From 32ef7646a212b2b1be685ba48c97ee093fc19f00 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 1 Jun 2026 11:38:49 -0700 Subject: [PATCH 01/53] Add design spec: modernize Jetpack Beta plugin UI Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-06-01-modernize-jetpack-beta-ui-design.md | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-01-modernize-jetpack-beta-ui-design.md diff --git a/docs/superpowers/specs/2026-06-01-modernize-jetpack-beta-ui-design.md b/docs/superpowers/specs/2026-06-01-modernize-jetpack-beta-ui-design.md new file mode 100644 index 000000000000..ea3e7e5310f1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-modernize-jetpack-beta-ui-design.md @@ -0,0 +1,135 @@ +# Modernize the Jetpack Beta plugin UI + +**Date:** 2026-06-01 +**Branch:** `update/modernize-jetpack-beta-ui` +**Status:** Approved design + +## Goal + +Replace the Jetpack Beta Tester admin UI with a modern React application built on the +**`@wordpress/ui`** design-system library, backed by **WordPress Abilities API** endpoints +for all reads and writes. + +Today the plugin is the only major Jetpack plugin with zero JS tooling: it is 100% +server-rendered PHP templates plus a 268-line vanilla `admin.js`, a 136-line `updates.js`, +and a hand-rolled **1299-line `admin.css`** that reimplements old Calypso "dops-" cards, +toggles, and search widgets. This work modernizes the front end and the data layer. + +## Boundaries + +**In scope** + +- The two admin screens: plugin-select landing and plugin-manage. +- Global toggles (autoupdates, email notifications). +- "Needed updates" surface, "To Test" / "What changed" panels, branch search/filter. +- Abilities API backend for all reads and the mutating actions. + +**Out of scope (remain PHP, unchanged)** + +- The `admin_notices` first-run banner on the plugins list page (`notice.template.php`). +- The WP-CLI command (`class-clicommand.php`). +- The install/download engine `Plugin::install_and_activate()` and the `Plugin`/`Utils` + data model — reused unchanged behind abilities. + +## Backend — Abilities API + +New `src/abilities/class-beta-abilities.php`, a `Beta_Abilities extends Registrar` class +mirroring `Automattic\Jetpack\Connection\Abilities\Connection_Abilities`. Category slug +`jetpack-beta`. Add `automattic/jetpack-wp-abilities: @dev` to `composer.json`. + +| Ability | Type | Input → Output | +|---|---|---| +| `jetpack-beta/list-plugins` | read | – → manageable plugins with active state + version | +| `jetpack-beta/get-plugin` | read | `{slug}` → branches (stable / rc / trunk + PR list + releases), active branch, currently-running, `is_mu_plugin`, to-test HTML, what-changed HTML, needed-updates | +| `jetpack-beta/get-settings` | read | – → `{autoupdates, email_notifications, skip_email}` | +| `jetpack-beta/activate-branch` | write | `{slug, source, id}` → result (wraps `install_and_activate`) | +| `jetpack-beta/update-settings` | write | `{autoupdates?, email_notifications?}` (partial) → new settings state | + +Notes: + +- **Permissions:** every `permission_callback` gates on `current_user_can('update_plugins')` + (the same capability the admin menu uses) and preserves the multisite / network-admin + redirects currently in `Admin::admin_page_load()`. +- **Markdown rendering** (Parsedown → `wp_kses_post`) stays server-side; the read abilities + return already-sanitized HTML strings for the "To Test" / "What changed" panels. +- **`update-settings`** is a single ability taking a partial settings object so the toggle + surface stays as one endpoint (rather than one ability per toggle). When autoupdates is + turned on it triggers `Hooks::maybe_schedule_autoupdate()` exactly as the current handler + does; `email_notifications` is ignored when `JETPACK_BETA_SKIP_EMAIL` is defined. +- **MCP exposure:** `meta.mcp.public = false` for all of them — installing arbitrary PR code + is sensitive and must not be an agent-callable tool. `meta.show_in_rest = true` so the + plugin's own React app can use the REST run route. +- **Annotations:** reads are `readonly: true, idempotent: true`; `activate-branch` is + `destructive: false` (installs code but is reversible) and not idempotent; + `update-settings` is a non-idempotent write. + +## Frontend — React app + +New `src/js/` tree, plus `package.json` and `webpack.config.js` using +`@automattic/jetpack-webpack-config` (same harness as the Protect plugin), building into +`build/`. Dependencies: `@wordpress/ui`, `@wordpress/element`, `@wordpress/api-fetch`, +`@wordpress/i18n`, `@automattic/jetpack-base-styles`, and `@wordpress/components` only if a +toggle/switch primitive is needed (see Risks). + +- **Entry / bootstrap:** `Admin::render()` prints a root `
`, enqueues the webpack build, + and localizes a small bootstrap object: REST root + nonce, the current `plugin` query-arg + slug, the user capability flag, and the **initial payload** for the requested screen so + there is no loading flash on first paint. Mutations and subsequent refreshes go through the + abilities run endpoint. +- **Routing:** client-side, driven by the `plugin` query arg. `PluginList` (landing) ↔ + `PluginManage`. Navigating updates the URL so links/back button keep working. +- **Component mapping (old → `@wordpress/ui`):** + - foldable "dops-card" → `Card` / `CollapsibleCard` + - branch cards → `Card` + `Badge` (active / stable / RC marker) + `Button` (Activate) + - `form-toggle` switches → toggle control (see Risks) + - dops-search → controlled text input filtering the PR / release lists client-side + (replaces the indexing logic in `admin.js`) + - "To Test" / "What changed" → `CollapsibleCard` rendering the sanitized HTML + - notices / errors → `Notice` +- **Action client:** thin wrapper over + `apiFetch({ path: '/wp-abilities/v1/abilities//run', method: 'POST', data })`. + - **Activate** shows a busy state on the clicked button (download can take 10-30s), then + refetches `get-plugin` to refresh active state. + - **Toggles** call `update-settings` optimistically and roll back on error. + +## Files + +**Add** +- `src/abilities/class-beta-abilities.php` +- `package.json`, `webpack.config.js`, and TS/babel config as needed +- `src/js/**` — app entry, `PluginList`, `PluginManage`, branch card, toggles, search, + abilities API client, styles (small SCSS for layout only; visuals come from `@wordpress/ui`) + +**Modify** +- `composer.json` — add `automattic/jetpack-wp-abilities` +- `src/class-admin.php` — render container + enqueue build + bootstrap; move + `to_test_content()` and toggle logic into abilities +- `jetpack-beta.php` — load/init abilities + +**Delete** +- `src/admin/plugin-select.template.php`, `plugin-manage.template.php`, + `branch-card.template.php`, `header.template.php`, `toggles.template.php`, + `show-needed-updates.template.php` +- `src/admin/admin.js`, `src/admin/updates.js`, `src/admin/admin.css` +- (Keep `notice.template.php` and `exception.template.php`.) + +## Testing & ship + +- PHP unit test for ability registration + permission gating, mirroring + `Connection_Abilities_Test`. (Per project convention, PHPUnit is run by CI, not locally.) +- `changelogger` entry. +- `pnpm jetpack build plugins/beta`, then rsync the built plugin to a fresh **Jurassic Ninja** + site (jetpack-test-jurassic-ninja skill), Jetpack-connect, and capture before/after + screenshots of both screens. + +## Risks & open questions + +- **Toggle primitive:** `@wordpress/ui` 0.13.0 is experimental and may not export a + toggle/switch. If it does not, use `ToggleControl` from `@wordpress/components` for the + toggles only; everything else stays on `@wordpress/ui`. Confirmed at build time. +- **Synchronous activate:** activation runs synchronously over REST (no async job queue). + Acceptable — it matches today's blocking redirect behavior — handled with a button-level + loading state and a generous request timeout. +- **Initial-data freshness:** bootstrapped payload is computed once per page load; remote + data (GitHub manifest, wporg versions) is already cached by `Plugin`, so this matches + current behavior. From ea073504b01ff3e8cfbee9fc15eba2849f616bcc Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 1 Jun 2026 11:42:45 -0700 Subject: [PATCH 02/53] Spec: use AdminPage (jetpack-components) for header/footer, matching Activity Log Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-06-01-modernize-jetpack-beta-ui-design.md | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-06-01-modernize-jetpack-beta-ui-design.md b/docs/superpowers/specs/2026-06-01-modernize-jetpack-beta-ui-design.md index ea3e7e5310f1..69e2e0373ed3 100644 --- a/docs/superpowers/specs/2026-06-01-modernize-jetpack-beta-ui-design.md +++ b/docs/superpowers/specs/2026-06-01-modernize-jetpack-beta-ui-design.md @@ -67,9 +67,34 @@ Notes: New `src/js/` tree, plus `package.json` and `webpack.config.js` using `@automattic/jetpack-webpack-config` (same harness as the Protect plugin), building into -`build/`. Dependencies: `@wordpress/ui`, `@wordpress/element`, `@wordpress/api-fetch`, -`@wordpress/i18n`, `@automattic/jetpack-base-styles`, and `@wordpress/components` only if a -toggle/switch primitive is needed (see Risks). +`build/`. Dependencies: `@automattic/jetpack-components` (page chrome — see Header & footer), +`@wordpress/ui`, `@wordpress/element`, `@wordpress/api-fetch`, `@wordpress/i18n`, +`@automattic/jetpack-base-styles`, and `@wordpress/components` only if a toggle/switch +primitive is needed (see Risks). + +### Header & footer (page chrome) + +Both screens are wrapped in **`AdminPage` from `@automattic/jetpack-components`**, the shared +Jetpack page chrome used by the Activity Log UI (`projects/packages/activity-log`), Protect, +and others. This is the canonical way to get a consistent header and footer and replaces the +plugin's bespoke `header.template.php` (a hand-inlined SVG masthead) — the plugin currently +has no footer at all. + +`AdminPage` provides: + +- **Header** — via `Page` from `@wordpress/admin-ui` + `JetpackLogo`: the Jetpack masthead + logo, a `title` ("Beta Tester"), a `subTitle`, header `actions`, and `breadcrumbs`. +- **Footer** — the standard `JetpackFooter` ("An Automattic Airline", Jetpack logo, module + label, a8c link). + +Mapping to the current UI: + +- Page `title` = "Beta Tester"; `subTitle` = short tagline. +- The plugin-manage screen's hand-rolled breadcrumb div ("Jetpack Beta Tester Home > Jetpack") + becomes the `AdminPage` `breadcrumbs` prop. +- `showFooter` defaults to `true` (Beta is a standalone wp-admin page, unlike the embedded + Activity Log which passes `showFooter={false}`). +- `AdminPage` wants `apiRoot` / `apiNonce`; these come from the same localized bootstrap. - **Entry / bootstrap:** `Admin::render()` prints a root `
`, enqueues the webpack build, and localizes a small bootstrap object: REST root + nonce, the current `plugin` query-arg From 07ce273b04f9c4f3095ec91b8c12f8109bad19d1 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 1 Jun 2026 11:48:07 -0700 Subject: [PATCH 03/53] Add implementation plan: modernize Jetpack Beta UI Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-01-modernize-jetpack-beta-ui.md | 436 ++++++++++++++++++ 1 file changed, 436 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-01-modernize-jetpack-beta-ui.md diff --git a/docs/superpowers/plans/2026-06-01-modernize-jetpack-beta-ui.md b/docs/superpowers/plans/2026-06-01-modernize-jetpack-beta-ui.md new file mode 100644 index 000000000000..4daee8a996f8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-modernize-jetpack-beta-ui.md @@ -0,0 +1,436 @@ +# Jetpack Beta UI Modernization — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the Jetpack Beta Tester admin UI (PHP templates + vanilla JS + 1299-line hand-rolled CSS) with a React app built on `@wordpress/ui` and the shared `@automattic/jetpack-components` `AdminPage` chrome, backed by WordPress Abilities API endpoints. + +**Architecture:** A `Beta_Abilities` registrar exposes reads (`list-plugins`, `get-plugin`, `get-settings`) and writes (`activate-branch`, `update-settings`) over the `wp-abilities/v1` REST run route. `Admin::render()` prints a root node, enqueues a webpack build, and localizes a bootstrap (REST root/nonce + current screen payload). The React app renders the two screens with `AdminPage` (header/footer/breadcrumbs) wrapping `@wordpress/ui` content, calling abilities via `@wordpress/api-fetch`. + +**Tech Stack:** PHP (WP Abilities API, `automattic/jetpack-wp-abilities`), React 18, `@wordpress/element`, `@wordpress/ui` 0.13.0, `@automattic/jetpack-components` (AdminPage/JetpackFooter), `@wordpress/api-fetch`, `@wordpress/i18n`, `@automattic/jetpack-webpack-config`. + +**Working dir for all paths:** `projects/plugins/beta/` + +**Spec:** `docs/superpowers/specs/2026-06-01-modernize-jetpack-beta-ui-design.md` + +--- + +## Key references (read before coding) + +- Abilities registrar base: `projects/packages/wp-abilities/src/class-registrar.php` +- Reference ability impl: `projects/packages/connection/src/abilities/class-connection-abilities.php` +- AdminPage chrome usage: `projects/packages/activity-log/src/js/components/ActivityLog/index.tsx` +- Webpack harness reference: `projects/plugins/protect/webpack.config.js` + `projects/plugins/protect/package.json` +- Data model: `projects/plugins/beta/src/class-plugin.php`, `src/class-utils.php`, `src/class-admin.php` +- Current screens being replaced: `src/admin/plugin-select.template.php`, `src/admin/plugin-manage.template.php`, `src/admin/branch-card.template.php`, `src/admin/toggles.template.php` + +--- + +## Phase 0 — Build scaffold & composer wiring + +### Task 1: Add JS build tooling + wp-abilities composer dep + +**Files:** +- Create: `package.json`, `webpack.config.js`, `tsconfig.json`, `babel.config.js` +- Modify: `composer.json`, `.gitignore` + +- [ ] **Step 1: Create `package.json`** (model on `projects/plugins/protect/package.json`) + +```json +{ + "private": true, + "name": "@automattic/jetpack-beta", + "version": "4.2.0", + "description": "Jetpack Beta Tester admin UI.", + "scripts": { + "build": "pnpm run clean && pnpm run build-client", + "build-client": "webpack", + "build-production": "NODE_ENV=production BABEL_ENV=production pnpm run build-client", + "clean": "rm -rf build/", + "typecheck": "tsgo --noEmit", + "watch": "pnpm run build && webpack watch" + }, + "dependencies": { + "@automattic/jetpack-base-styles": "workspace:*", + "@automattic/jetpack-components": "workspace:*", + "@wordpress/api-fetch": "7.46.0", + "@wordpress/components": "33.1.0", + "@wordpress/element": "6.46.0", + "@wordpress/i18n": "6.19.0", + "@wordpress/ui": "0.13.0" + }, + "devDependencies": { + "@automattic/jetpack-webpack-config": "workspace:*", + "webpack": "5.94.0", + "webpack-cli": "5.1.4" + } +} +``` + +Pin exact versions to whatever the monorepo currently resolves — copy the version strings from `projects/plugins/protect/package.json` rather than the literals above if they differ. + +- [ ] **Step 2: Create `webpack.config.js`** — copy `projects/plugins/protect/webpack.config.js` verbatim, then change `entry.index` to `'./src/js/index.tsx'`, keep `output.path` = `./build`, and set the `jetpackConfig` external `consumer_slug` to `'jetpack-beta'`. + +- [ ] **Step 3: Create `tsconfig.json` and `babel.config.js`** — copy from `projects/plugins/protect/` (same harness). Adjust `include` to `src/js`. + +- [ ] **Step 4: Add the abilities dependency to `composer.json`** under `require`, alphabetically among the `automattic/jetpack-*` entries: + +```json +"automattic/jetpack-wp-abilities": "@dev", +``` + +- [ ] **Step 5: Update `.gitignore`** — add `build/` and `node_modules/` if not present. + +- [ ] **Step 6: Install** — from repo root: + +```bash +pnpm jetpack install plugins/beta +``` + +Expected: composer pulls in `automattic/jetpack-wp-abilities` (visible in `vendor/`), pnpm links workspace deps. If it reports lockfile changes, run the suggested `jetpack install -r ...` once. + +- [ ] **Step 7: Commit** + +```bash +git add projects/plugins/beta/package.json projects/plugins/beta/webpack.config.js projects/plugins/beta/tsconfig.json projects/plugins/beta/babel.config.js projects/plugins/beta/composer.json projects/plugins/beta/composer.lock projects/plugins/beta/.gitignore pnpm-lock.yaml +git commit -m "Jetpack Beta: add JS build tooling and wp-abilities dependency" +``` + +--- + +## Phase 1 — Backend abilities + +### Task 2: `Beta_Abilities` registrar — read abilities + +**Files:** +- Create: `src/abilities/class-beta-abilities.php` +- Reference: `projects/packages/connection/src/abilities/class-connection-abilities.php` + +The class extends `Automattic\Jetpack\WP_Abilities\Registrar`, namespace `Automattic\JetpackBeta\Abilities`, category slug `jetpack-beta`. + +- [ ] **Step 1: Class skeleton + category + init override.** Override `init()` to register Beta's abilities **without** the global `jetpack_wp_abilities_enabled` gate (Beta's UI depends on them and Beta is not part of the staged Jetpack rollout). Keep the parent's per-item `should_register` behavior by reusing `register_category()`/`register_abilities()`. + +```php + 'Jetpack Beta', // Product name, not translated. + 'description' => __( 'Abilities provided by the Jetpack Beta Tester.', 'jetpack-beta' ), + ); + } + + public static function get_abilities(): array { + return array( + 'jetpack-beta/list-plugins' => self::spec_list_plugins(), + 'jetpack-beta/get-plugin' => self::spec_get_plugin(), + 'jetpack-beta/get-settings' => self::spec_get_settings(), + 'jetpack-beta/activate-branch' => self::spec_activate_branch(), + 'jetpack-beta/update-settings' => self::spec_update_settings(), + ); + } + + /** + * Shared permission check — mirrors the admin menu capability. + */ + public static function can_manage(): bool { + return current_user_can( 'update_plugins' ); + } +} +``` + +- [ ] **Step 2: `spec_list_plugins()` + `list_plugins()` execute callback.** Zero-arg read. Output: array of `{ slug, name, active_which (stable|dev|null), active_version (string|null), manage_url }`. Build by iterating `Plugin::get_all_plugins( true )` and replicating the active/version logic from `plugin-select.template.php` (`is_active('stable')` → `plugin_slug()`/`stable_pretty_version()`; `is_active('dev')` → `dev_plugin_slug()`/`dev_pretty_version()`; else inactive). `manage_url` = `Utils::admin_url( array( 'plugin' => $slug ) )`. + +Annotations: `readonly: true, idempotent: true`; `show_in_rest: true`; `mcp: { public: false }`. `permission_callback` = `array( __CLASS__, 'can_manage' )`. `input_schema` = empty object (`additionalProperties:false`). + +- [ ] **Step 3: `spec_get_plugin()` + `get_plugin()` execute callback.** Input: `{ slug: string }`. Reproduce the view-model that `plugin-manage.template.php` assembles, returning JSON instead of HTML: + - `name`, `is_mu_plugin`, `bug_report_url` + - `currently_running`: `{ which, source, id, version, pretty_version }` or null (from the `$active_branch`/`$version` logic) + - `sections`: an ordered list of branch cards, each `{ section: 'stable'|'rc'|'trunk'|'pr'|'release'|'existing', source, id, branch, version, pretty_version, is_active (bool) }`. Derive from `Plugin::source_info()` for stable/rc/trunk, `get_manifest()->pr` for PRs, and `get_wporg_data()->versions` (sorted via `Composer\Semver\Semver::rsort`) for releases — matching the template's ordering and the "fixup active branch" logic. + - `to_test_html` and `what_changed_html`: call the existing `Admin::to_test_content( $plugin )` (already returns sanitized HTML). + - `needed_updates`: whatever `show-needed-updates.template.php` computes (port that read). + - On unknown slug, return a `WP_Error( 'unknown_plugin', ... )` (the run controller surfaces it as an error response). + +Annotations same as list-plugins (readonly). `permission_callback` must also enforce the multisite/network-admin rule from `Admin::admin_page_load()` (deny + appropriate error when the managed plugin is network-activated and `! is_network_admin()`). + +- [ ] **Step 4: `spec_get_settings()` + `get_settings()`.** Zero-arg read → `{ autoupdates: bool, email_notifications: bool, skip_email: bool }` from `Utils::is_set_to_autoupdate()`, `Utils::is_set_to_email_notifications()`, and `defined('JETPACK_BETA_SKIP_EMAIL')`. + +- [ ] **Step 5: Run lint** on the new file: + +```bash +pnpm jetpack lint php --filename projects/plugins/beta/src/abilities/class-beta-abilities.php +``` + +Expected: no errors (fix phpcs spacing/escaping as needed). + +- [ ] **Step 6: Commit** + +```bash +git add projects/plugins/beta/src/abilities/class-beta-abilities.php +git commit -m "Jetpack Beta: add read abilities (list-plugins, get-plugin, get-settings)" +``` + +### Task 3: `Beta_Abilities` write abilities + wiring + +**Files:** +- Modify: `src/abilities/class-beta-abilities.php`, `jetpack-beta.php`, `src/class-admin.php` + +- [ ] **Step 1: `spec_activate_branch()` + `activate_branch()`.** Input: `{ slug, source, id }`. Resolve `Plugin::get_plugin( $slug )`, then call `$plugin->install_and_activate( $source, $id )` — the exact logic the nonce handler in `Admin::admin_page_load()` runs. Return `{ success: true, plugin: }` on success, or the `WP_Error` on failure. Annotations: `readonly:false, destructive:false, idempotent:false`; `show_in_rest:true`; `mcp:{ public:false }`. `permission_callback` = `can_manage` + the same network-admin guard as `get-plugin`. + +- [ ] **Step 2: `spec_update_settings()` + `update_settings()`.** Input: partial `{ autoupdates?: bool, email_notifications?: bool }`. For each provided key, replicate the toggle logic from `Admin::admin_page_load()`: + - `autoupdates`: `update_option( 'jp_beta_autoupdate', (int) $value )`; when newly enabled, call `Hooks::maybe_schedule_autoupdate()`. + - `email_notifications`: ignore when `JETPACK_BETA_SKIP_EMAIL` is defined; else `update_option( 'jp_beta_email_notifications', (int) $value )`. + Return the `get-settings` payload. Annotations: write, non-idempotent. + +- [ ] **Step 2b: Refactor (DRY).** Extract the option-mutation bodies from `Admin::admin_page_load()` into small static helpers (or call the ability execute methods) so the legacy GET handler and the ability share one implementation. Keep `admin_page_load()`'s nonce handling for any still-server-driven path, but the toggles/activate links are going away with the templates (Task 8) — leave `admin_page_load()` for the access-control redirect only. + +- [ ] **Step 3: Wire init in `jetpack-beta.php`.** After the autoloader require and `Hooks::setup();`, add: + +```php +add_action( 'plugins_loaded', array( Automattic\JetpackBeta\Abilities\Beta_Abilities::class, 'init' ), 20 ); +``` + +- [ ] **Step 4: Lint** + +```bash +pnpm jetpack lint php --filename projects/plugins/beta/src/abilities/class-beta-abilities.php --filename projects/plugins/beta/jetpack-beta.php --filename projects/plugins/beta/src/class-admin.php +``` + +- [ ] **Step 5: Commit** + +```bash +git add projects/plugins/beta/src/abilities/class-beta-abilities.php projects/plugins/beta/jetpack-beta.php projects/plugins/beta/src/class-admin.php +git commit -m "Jetpack Beta: add write abilities (activate-branch, update-settings) and wire init" +``` + +### Task 4: PHP unit test for abilities + +**Files:** +- Create: `tests/php/abilities/Beta_Abilities_Test.php` +- Reference: `projects/packages/connection/tests/php/abilities/Connection_Abilities_Test.php`, `projects/plugins/boost/tests/php/abilities/Boost_Abilities_Test.php` + +- [ ] **Step 1: Write tests** covering: (a) after firing `wp_abilities_api_categories_init` + `wp_abilities_api_init`, all five abilities are registered (`wp_get_ability( 'jetpack-beta/...' )` non-null); (b) each ability's `permission_callback` returns false for a subscriber and true for an `update_plugins`-capable user; (c) `get-settings` returns the expected shape. Follow the harness/bootstrap used by the reference tests. Per project convention, **do not run PHPUnit locally — CI runs it.** + +- [ ] **Step 2: Commit** + +```bash +git add projects/plugins/beta/tests/php/abilities/Beta_Abilities_Test.php +git commit -m "Jetpack Beta: add Beta_Abilities registration/permission tests" +``` + +--- + +## Phase 2 — React app + +### Task 5: App scaffold — bootstrap, AdminPage chrome, routing, API client + +**Files:** +- Create: `src/js/index.tsx`, `src/js/app.tsx`, `src/js/api/abilities.ts`, `src/js/api/types.ts`, `src/js/style.scss` +- Modify: `src/class-admin.php` (`render()` + `admin_enqueue_scripts()`) + +- [ ] **Step 1: PHP — `render()` prints root + bootstrap.** Replace the `require ...template.php` branches in `Admin::render()` with: print `
`, and enqueue/localize in `admin_enqueue_scripts()`. Read the build's `index.asset.php` for dependencies/version (standard Jetpack pattern — see how Protect enqueues `build/index.js`). Localize: + +```php +wp_localize_script( + 'jetpack-beta-app', + 'JetpackBeta', + array( + 'apiRoot' => esc_url_raw( rest_url() ), + 'apiNonce' => wp_create_nonce( 'wp_rest' ), + 'plugin' => isset( $_GET['plugin'] ) ? sanitize_text_field( wp_unslash( $_GET['plugin'] ) ) : null, + 'adminUrl' => Utils::admin_url(), + 'canManage'=> current_user_can( 'update_plugins' ), + ) +); +``` + +Drop the old `wp_enqueue_style('jetpack-beta-admin', 'admin/admin.css' ...)` and `admin.js` enqueues. + +- [ ] **Step 2: `src/js/api/abilities.ts`** — typed client over the run route: + +```ts +import apiFetch from '@wordpress/api-fetch'; + +const run = < T >( ability: string, data: Record< string, unknown > = {} ): Promise< T > => + apiFetch< T >( { + path: `/wp-abilities/v1/abilities/${ ability }/run`, + method: 'POST', + data, + } ); + +export const listPlugins = () => run< PluginListItem[] >( 'jetpack-beta/list-plugins' ); +export const getPlugin = ( slug: string ) => run< PluginView >( 'jetpack-beta/get-plugin', { slug } ); +export const getSettings = () => run< Settings >( 'jetpack-beta/get-settings' ); +export const activateBranch = ( slug: string, source: string, id: string ) => + run< { success: boolean; plugin: PluginView } >( 'jetpack-beta/activate-branch', { slug, source, id } ); +export const updateSettings = ( patch: Partial< Settings > ) => + run< Settings >( 'jetpack-beta/update-settings', patch ); +``` + +Define `PluginListItem`, `PluginView`, `BranchCard`, `Settings` in `src/js/api/types.ts` to match the ability output schemas from Tasks 2–3. + +- [ ] **Step 3: `src/js/index.tsx`** — set `apiFetch` root/nonce from `window.JetpackBeta`, then render `` into `#jetpack-beta-root`: + +```tsx +import apiFetch from '@wordpress/api-fetch'; +import { createRoot } from '@wordpress/element'; +import App from './app'; +import './style.scss'; + +const boot = window.JetpackBeta; +apiFetch.use( apiFetch.createRootURLMiddleware( boot.apiRoot ) ); +apiFetch.use( apiFetch.createNonceMiddleware( boot.apiNonce ) ); + +const el = document.getElementById( 'jetpack-beta-root' ); +if ( el ) { + createRoot( el ).render( ); +} +``` + +- [ ] **Step 4: `src/js/app.tsx`** — `AdminPage` shell + screen switch on `window.JetpackBeta.plugin`. Use `@automattic/jetpack-components` `AdminPage` with `title="Beta Tester"`, a `subTitle`, `apiRoot`/`apiNonce` from bootstrap, and `breadcrumbs` when on the manage screen. Render `` when no `plugin`, else ``. (Components built in Tasks 6–7; for this task stub them as `() => null` so it compiles.) + +- [ ] **Step 5: Typecheck + build** + +```bash +cd projects/plugins/beta && pnpm run build && pnpm run typecheck +``` + +Expected: `build/index.js` + `build/index.asset.php` produced, typecheck clean. **Verify `@wordpress/ui` exports a toggle/switch** (`Form`/Switch). If not, plan to import `ToggleControl` from `@wordpress/components` in Task 7. + +- [ ] **Step 6: Commit** + +```bash +git add projects/plugins/beta/src/js projects/plugins/beta/src/class-admin.php +git commit -m "Jetpack Beta: React app scaffold with AdminPage chrome and abilities client" +``` + +### Task 6: `PluginList` screen (landing) + +**Files:** +- Create: `src/js/screens/plugin-list.tsx`, `src/js/components/global-toggles.tsx` + +- [ ] **Step 1:** `PluginList` fetches `listPlugins()` on mount (loading + error states via `Notice`). Render each plugin as a `@wordpress/ui` `Card` row: name, current version/state (with a `Badge` for active dev/stable), and a "Manage" `Button` linking to `?page=jetpack-beta&plugin=`. Render `GlobalToggles` above the list. Match the data/labels from `plugin-select.template.php`. + +- [ ] **Step 2:** `GlobalToggles` fetches `getSettings()` and renders the Autoupdates + Email Notifications toggles (toggle primitive per Task 5 Step 5 finding). Calls `updateSettings()` optimistically, rolls back + shows a `Notice` on error. Hide the email toggle when `!autoupdates` or `skip_email` (mirrors `Admin::show_toggle_emails()`). + +- [ ] **Step 3: Build + typecheck + lint JS** + +```bash +cd projects/plugins/beta && pnpm run build && pnpm run typecheck && cd ../../.. && pnpm jetpack lint js projects/plugins/beta/src/js +``` + +- [ ] **Step 4: Commit** (`Jetpack Beta: implement plugin-list screen and global toggles`) + +### Task 7: `PluginManage` screen + +**Files:** +- Create: `src/js/screens/plugin-manage.tsx`, `src/js/components/branch-card.tsx`, `src/js/components/branch-section.tsx`, `src/js/components/markdown-panel.tsx` + +- [ ] **Step 1:** `PluginManage` fetches `getPlugin(slug)` (loading/error states). Layout, top → bottom, mirroring `plugin-manage.template.php`: + 1. "Currently Running" `Card` (when `currently_running`) + "Found a bug? Report it!" `Button` (`bug_report_url`). + 2. mu-plugin info `Notice` when `is_mu_plugin`. + 3. Branch sections via `BranchSection`: stable, rc, trunk, then PR section (with search) and Releases section (with search). + 4. "To Test" and "What changed" `CollapsibleCard`s via `MarkdownPanel` (renders sanitized HTML with `dangerouslySetInnerHTML`). +- [ ] **Step 2:** `BranchSection` renders a heading, an optional controlled search `` that filters its `BranchCard`s client-side by branch/version text (replaces `admin.js` indexing), and the list of cards. +- [ ] **Step 3:** `BranchCard` shows branch name/version, an active `Badge` when `is_active`, and an "Activate" `Button`. On click → `activateBranch(slug, source, id)` with a button-level busy state ("Activating…"); on success refetch `getPlugin(slug)` and update; on error show a `Notice` with the message. Mirror the label localization from the old `wp_localize_script` (`Activate`/`Activating…`/`Failed`). +- [ ] **Step 4:** Breadcrumbs — pass `[{ label: 'Jetpack Beta Tester', href: adminUrl }, { label: pluginName }]` to `AdminPage` (lift `pluginName` into `app.tsx` or render `AdminPage` inside the screen — follow the activity-log pattern). +- [ ] **Step 5: Build + typecheck + lint** (same commands as Task 6 Step 3). +- [ ] **Step 6: Commit** (`Jetpack Beta: implement plugin-manage screen with branch activation`) + +### Task 8: Remove legacy UI + +**Files:** +- Delete: `src/admin/plugin-select.template.php`, `src/admin/plugin-manage.template.php`, `src/admin/branch-card.template.php`, `src/admin/header.template.php`, `src/admin/toggles.template.php`, `src/admin/show-needed-updates.template.php`, `src/admin/admin.js`, `src/admin/updates.js`, `src/admin/admin.css` +- Keep: `src/admin/notice.template.php`, `src/admin/exception.template.php` +- Modify: `src/class-admin.php` + +- [ ] **Step 1: Delete** the files above with `git rm`. +- [ ] **Step 2: Clean `src/class-admin.php`** — remove now-dead methods/requires that referenced deleted templates (`to_test_content` stays — it's used by the ability; `show_toggle*` go; `render_banner` keeps `notice.template.php`). Ensure `render()`/`admin_enqueue_scripts()` only do the React path. Confirm `.gitattributes`/`.phpcs.dir.xml` don't reference removed files. +- [ ] **Step 3: Lint PHP** (`pnpm jetpack lint php --filename projects/plugins/beta/src/class-admin.php`). +- [ ] **Step 4: Commit** (`Jetpack Beta: remove legacy PHP templates, vanilla JS, and hand-rolled CSS`) + +### Task 9: Changelog + final validation + +- [ ] **Step 1: Changelog** — from repo root, use the jetpack-changelog skill / changelogger: + +```bash +cd projects/plugins/beta && composer changelog:add --no-interaction -- --type=changed --significance=minor --entry="Modernized the Beta Tester admin UI with a React interface built on the WordPress design system and the Abilities API." +``` + +(Adjust to the changelogger invocation the repo uses; verify a file lands in `projects/plugins/beta/changelog/`.) + +- [ ] **Step 2: Full validation** + +```bash +cd projects/plugins/beta && pnpm run build && pnpm run typecheck +cd ../../.. && pnpm jetpack lint js projects/plugins/beta/src/js && pnpm jetpack lint php --filename projects/plugins/beta/src/abilities/class-beta-abilities.php +``` + +- [ ] **Step 3: Commit** (`Jetpack Beta: add changelog entry`) + +--- + +## Phase 3 — Ship to Jurassic Ninja + +### Task 10: Deploy + screenshots + +- [ ] **Step 1:** Capture **before** screenshots of both screens on trunk (the current UI) for the PR — use the jetpack-screenshot skill or the existing `blaze-before.png` convention. +- [ ] **Step 2:** Build the plugin and provision/rsync to a fresh Jurassic Ninja site via the **jetpack-test-jurassic-ninja** skill; Jetpack-connect; force dev autoloading. +- [ ] **Step 3:** Capture **after** screenshots of the plugin-list and plugin-manage screens (header, footer, branch cards, toggles, activate flow). +- [ ] **Step 4:** Report the autologin URL and attach before/after screenshots. + +--- + +## Notes / risks (carried from spec) + +- **Toggle primitive:** confirm `@wordpress/ui` switch availability in Task 5 Step 5; fall back to `@wordpress/components` `ToggleControl` for toggles only. +- **Synchronous activate** over REST (10–30s) — handled with button busy state; matches current behavior. +- **`init()` bypasses the global rollout filter** deliberately (only Beta's own abilities register). +- **Network/multisite access control** from `Admin::admin_page_load()` must be preserved in the `get-plugin`/`activate-branch` permission callbacks. From 146bcf6cceaa68aea6a7f181f1b6c6b4e0b81a30 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 1 Jun 2026 11:53:20 -0700 Subject: [PATCH 04/53] Jetpack Beta: add JS build tooling and wp-abilities dependency Adds package.json, webpack.config.js, tsconfig.json, and babel.config.js modeled on projects/plugins/protect. Adds automattic/jetpack-wp-abilities to composer.json. Updates .gitignore to cover build/, node_modules/, .cache/. Co-Authored-By: Claude Opus 4.8 (1M context) --- pnpm-lock.yaml | 64 +++++++++++++++++++++++++ projects/plugins/beta/.gitignore | 3 ++ projects/plugins/beta/babel.config.js | 10 ++++ projects/plugins/beta/composer.json | 1 + projects/plugins/beta/composer.lock | 63 +++++++++++++++++++++++- projects/plugins/beta/package.json | 51 ++++++++++++++++++++ projects/plugins/beta/tsconfig.json | 9 ++++ projects/plugins/beta/webpack.config.js | 56 ++++++++++++++++++++++ 8 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 projects/plugins/beta/babel.config.js create mode 100644 projects/plugins/beta/package.json create mode 100644 projects/plugins/beta/tsconfig.json create mode 100644 projects/plugins/beta/webpack.config.js diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7db974effdf3..77cdecc21763 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5002,6 +5002,70 @@ importers: specifier: ^0.0.26 version: 0.0.26 + projects/plugins/beta: + dependencies: + '@automattic/jetpack-base-styles': + specifier: workspace:* + version: link:../../js-packages/base-styles + '@automattic/jetpack-components': + specifier: workspace:* + version: link:../../js-packages/components + '@wordpress/api-fetch': + specifier: 7.46.0 + version: 7.46.0 + '@wordpress/components': + specifier: 33.1.0 + version: 33.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/element': + specifier: 6.46.0 + version: 6.46.0 + '@wordpress/i18n': + specifier: 6.19.0 + version: 6.19.0 + '@wordpress/ui': + specifier: 0.13.0 + version: 0.13.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: + specifier: 18.3.1 + version: 18.3.1 + react-dom: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@automattic/jetpack-webpack-config': + specifier: workspace:* + version: link:../../js-packages/webpack-config + '@babel/core': + specifier: 7.29.0 + version: 7.29.0 + '@babel/preset-env': + specifier: 7.29.2 + version: 7.29.2(@babel/core@7.29.0) + '@babel/runtime': + specifier: 7.29.2 + version: 7.29.2 + '@types/react': + specifier: 18.3.28 + version: 18.3.28 + '@typescript/native-preview': + specifier: 7.0.0-dev.20260225.1 + version: 7.0.0-dev.20260225.1 + '@wordpress/browserslist-config': + specifier: 6.46.0 + version: 6.46.0 + sass-embedded: + specifier: 1.97.3 + version: 1.97.3 + sass-loader: + specifier: 16.0.5 + version: 16.0.5(sass-embedded@1.97.3)(webpack@5.105.2) + webpack: + specifier: 5.105.2 + version: 5.105.2(webpack-cli@6.0.1) + webpack-cli: + specifier: 6.0.1 + version: 6.0.1(webpack@5.105.2) + projects/plugins/boost: dependencies: '@automattic/jetpack-base-styles': diff --git a/projects/plugins/beta/.gitignore b/projects/plugins/beta/.gitignore index 57872d0f1e5f..a4dfd8410fbe 100644 --- a/projects/plugins/beta/.gitignore +++ b/projects/plugins/beta/.gitignore @@ -1 +1,4 @@ /vendor/ +/build/ +/node_modules/ +/.cache/ diff --git a/projects/plugins/beta/babel.config.js b/projects/plugins/beta/babel.config.js new file mode 100644 index 000000000000..20a740cb98d0 --- /dev/null +++ b/projects/plugins/beta/babel.config.js @@ -0,0 +1,10 @@ +const config = { + presets: [ + [ + '@automattic/jetpack-webpack-config/babel/preset', + { pluginReplaceTextdomain: { textdomain: 'jetpack-beta' } }, + ], + ], +}; + +module.exports = config; diff --git a/projects/plugins/beta/composer.json b/projects/plugins/beta/composer.json index 03b76d670983..8f1e4d437b3f 100644 --- a/projects/plugins/beta/composer.json +++ b/projects/plugins/beta/composer.json @@ -11,6 +11,7 @@ "automattic/jetpack-admin-ui": "@dev", "automattic/jetpack-autoloader": "@dev", "automattic/jetpack-logo": "@dev", + "automattic/jetpack-wp-abilities": "@dev", "composer/semver": "3.4.3", "erusev/parsedown": "1.7.4" }, diff --git a/projects/plugins/beta/composer.lock b/projects/plugins/beta/composer.lock index 690476fac8c1..4a8814941a2c 100644 --- a/projects/plugins/beta/composer.lock +++ b/projects/plugins/beta/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b35c8f741ab239d96006476386767f79", + "content-hash": "bcbf4552b109bb140ad39a6ea4d26c7d", "packages": [ { "name": "automattic/jetpack-admin-ui", @@ -377,6 +377,64 @@ "relative": true } }, + { + "name": "automattic/jetpack-wp-abilities", + "version": "dev-trunk", + "dist": { + "type": "path", + "url": "../../packages/wp-abilities", + "reference": "e71e43ab54aaf244b0cb2e29999da393164021f5" + }, + "require": { + "php": ">=7.2" + }, + "require-dev": { + "automattic/phpunit-select-config": "@dev", + "brain/monkey": "^2.6.2", + "yoast/phpunit-polyfills": "^4.0.0" + }, + "suggest": { + "automattic/jetpack-autoloader": "Allow for better interoperability with other plugins that use this package." + }, + "type": "jetpack-library", + "extra": { + "autotagger": true, + "mirror-repo": "Automattic/jetpack-wp-abilities", + "changelogger": { + "link-template": "https://github.com/automattic/jetpack-wp-abilities/compare/v${old}...v${new}" + }, + "branch-alias": { + "dev-trunk": "0.1.x-dev" + }, + "textdomain": "jetpack-wp-abilities", + "version-constants": { + "::PACKAGE_VERSION": "src/class-registrar.php" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "scripts": { + "phpunit": [ + "phpunit-select-config phpunit.#.xml.dist --colors=always" + ], + "test-coverage": [ + "php -dpcov.directory=. ./vendor/bin/phpunit-select-config phpunit.#.xml.dist --coverage-php \"$COVERAGE_DIR/php.cov\"" + ], + "test-php": [ + "@composer phpunit" + ] + }, + "license": [ + "GPL-2.0-or-later" + ], + "description": "Shared utilities for registering abilities with the WordPress Abilities API from Jetpack packages and plugins.", + "transport-options": { + "relative": true + } + }, { "name": "composer/semver", "version": "3.4.3", @@ -515,7 +573,8 @@ "stability-flags": { "automattic/jetpack-admin-ui": 20, "automattic/jetpack-autoloader": 20, - "automattic/jetpack-logo": 20 + "automattic/jetpack-logo": 20, + "automattic/jetpack-wp-abilities": 20 }, "prefer-stable": true, "prefer-lowest": false, diff --git a/projects/plugins/beta/package.json b/projects/plugins/beta/package.json new file mode 100644 index 000000000000..d110cd76aa36 --- /dev/null +++ b/projects/plugins/beta/package.json @@ -0,0 +1,51 @@ +{ + "private": true, + "name": "@automattic/jetpack-beta", + "description": "Serves beta and PR branches of Jetpack to a WordPress install near you!", + "homepage": "https://jetpack.com", + "bugs": { + "url": "https://github.com/Automattic/jetpack/labels/[Plugin] Jetpack Beta" + }, + "repository": { + "type": "git", + "url": "https://github.com/Automattic/jetpack.git", + "directory": "projects/plugins/beta" + }, + "license": "GPL-2.0-or-later", + "author": "Automattic", + "scripts": { + "build": "pnpm run clean && pnpm run build-client", + "build-client": "webpack", + "build-production": "NODE_ENV=production BABEL_ENV=production pnpm run build-client", + "clean": "rm -rf build/", + "typecheck": "tsgo --noEmit", + "watch": "pnpm run build && webpack watch" + }, + "browserslist": [ + "extends @wordpress/browserslist-config" + ], + "dependencies": { + "@automattic/jetpack-base-styles": "workspace:*", + "@automattic/jetpack-components": "workspace:*", + "@wordpress/api-fetch": "7.46.0", + "@wordpress/components": "33.1.0", + "@wordpress/element": "6.46.0", + "@wordpress/i18n": "6.19.0", + "@wordpress/ui": "0.13.0", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@automattic/jetpack-webpack-config": "workspace:*", + "@babel/core": "7.29.0", + "@babel/preset-env": "7.29.2", + "@babel/runtime": "7.29.2", + "@types/react": "18.3.28", + "@typescript/native-preview": "7.0.0-dev.20260225.1", + "@wordpress/browserslist-config": "6.46.0", + "sass-embedded": "1.97.3", + "sass-loader": "16.0.5", + "webpack": "5.105.2", + "webpack-cli": "6.0.1" + } +} diff --git a/projects/plugins/beta/tsconfig.json b/projects/plugins/beta/tsconfig.json new file mode 100644 index 000000000000..290c94ae163a --- /dev/null +++ b/projects/plugins/beta/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "jetpack-js-tools/tsconfig.base.json", + "include": [ "src/js" ], + "compilerOptions": { + "sourceMap": true, + "outDir": "./build/", + "target": "esnext" + } +} diff --git a/projects/plugins/beta/webpack.config.js b/projects/plugins/beta/webpack.config.js new file mode 100644 index 000000000000..f4e8a71b4be9 --- /dev/null +++ b/projects/plugins/beta/webpack.config.js @@ -0,0 +1,56 @@ +const path = require( 'path' ); +const jetpackWebpackConfig = require( '@automattic/jetpack-webpack-config/webpack' ); + +module.exports = [ + { + entry: { + index: './src/js/index.tsx', + }, + mode: jetpackWebpackConfig.mode, + devtool: jetpackWebpackConfig.devtool, + output: { + ...jetpackWebpackConfig.output, + path: path.resolve( './build' ), + }, + optimization: { + ...jetpackWebpackConfig.optimization, + }, + resolve: { + ...jetpackWebpackConfig.resolve, + }, + node: false, + plugins: [ ...jetpackWebpackConfig.StandardPlugins() ], + module: { + strictExportPresence: true, + rules: [ + // Transpile JavaScript + jetpackWebpackConfig.TranspileRule( { + exclude: /node_modules\//, + } ), + + // Transpile @automattic/jetpack-* in node_modules too. + jetpackWebpackConfig.TranspileRule( { + includeNodeModules: [ '@automattic/jetpack-' ], + } ), + + // Workarounds for non-extracted `@wordpress/*` packages. + ...jetpackWebpackConfig.BundledWpPkgsTranspileRules(), + + // Handle CSS. + jetpackWebpackConfig.CssRule( { + extensions: [ 'css', 'sass', 'scss' ], + extraLoaders: [ { loader: 'sass-loader', options: { api: 'modern-compiler' } } ], + } ), + + // Handle images. + jetpackWebpackConfig.FileRule(), + ], + }, + externals: { + ...jetpackWebpackConfig.externals, + jetpackConfig: JSON.stringify( { + consumer_slug: 'jetpack-beta', + } ), + }, + }, +]; From d474ec4c6d05fe173aea134d5e1a3c0cf8a6bb16 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 1 Jun 2026 11:59:09 -0700 Subject: [PATCH 05/53] Jetpack Beta: add read abilities (list-plugins, get-plugin, get-settings) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/abilities/class-beta-abilities.php | 590 ++++++++++++++++++ 1 file changed, 590 insertions(+) create mode 100644 projects/plugins/beta/src/abilities/class-beta-abilities.php diff --git a/projects/plugins/beta/src/abilities/class-beta-abilities.php b/projects/plugins/beta/src/abilities/class-beta-abilities.php new file mode 100644 index 000000000000..6eaca681ecc1 --- /dev/null +++ b/projects/plugins/beta/src/abilities/class-beta-abilities.php @@ -0,0 +1,590 @@ + 'Jetpack Beta', // Product name, not translated. + 'description' => __( 'Abilities provided by the Jetpack Beta Tester.', 'jetpack-beta' ), + ); + } + + /** + * {@inheritDoc} + * + * Returns the three read abilities. Task 3 will add write abilities + * (activate-branch, update-settings) to this same array. + */ + public static function get_abilities(): array { + return array( + 'jetpack-beta/list-plugins' => self::spec_list_plugins(), + 'jetpack-beta/get-plugin' => self::spec_get_plugin(), + 'jetpack-beta/get-settings' => self::spec_get_settings(), + ); + } + + /** + * Shared permission check — mirrors the admin menu capability. + * + * @return bool True when the current user can manage plugins. + */ + public static function can_manage(): bool { + return current_user_can( 'update_plugins' ); + } + + // ------------------------------------------------------------------------- + // Ability specs + // ------------------------------------------------------------------------- + + /** + * Spec: jetpack-beta/list-plugins. + * + * @return array + */ + private static function spec_list_plugins(): array { + return array( + 'label' => __( 'List Jetpack Beta plugins', 'jetpack-beta' ), + 'description' => __( + 'Return an array of all plugins known to the Jetpack Beta Tester, together with the currently-active branch and version for each. Shape: { plugins: [ { slug, name, active_which, active_version, manage_url } ] }. `active_which` is "stable", "dev", or null when the plugin is not active. `active_version` is the human-readable pretty version, or null when not active. `manage_url` is the wp-admin URL for the plugin\'s manage screen. Read-only and idempotent — safe to poll.', + 'jetpack-beta' + ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => new \stdClass(), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'plugins' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'slug' => array( 'type' => 'string' ), + 'name' => array( 'type' => 'string' ), + 'active_which' => array( 'type' => array( 'string', 'null' ) ), + 'active_version' => array( 'type' => array( 'string', 'null' ) ), + 'manage_url' => array( 'type' => 'string' ), + ), + ), + ), + ), + ), + 'execute_callback' => array( __CLASS__, 'list_plugins' ), + 'permission_callback' => array( __CLASS__, 'can_manage' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + 'mcp' => array( + 'public' => false, + ), + ), + ); + } + + /** + * Spec: jetpack-beta/get-plugin. + * + * @return array + */ + private static function spec_get_plugin(): array { + return array( + 'label' => __( 'Get Jetpack Beta plugin details', 'jetpack-beta' ), + 'description' => __( + 'Return the full view-model for a single plugin managed by Jetpack Beta Tester. Input: { slug }. Output: { name, is_mu_plugin, bug_report_url, currently_running, sections, to_test_html, what_changed_html, needed_updates }. `currently_running` is null when the plugin is not active. `sections` is an ordered array of branch cards (stable → rc → trunk → PRs → releases). `to_test_html` and `what_changed_html` are sanitized HTML strings or null. `needed_updates` is an array of plugin files that have pending updates. Read-only and idempotent — safe to poll.', + 'jetpack-beta' + ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'slug' => array( + 'type' => 'string', + 'description' => __( 'The WordPress plugin slug (e.g. "jetpack").', 'jetpack-beta' ), + ), + ), + 'required' => array( 'slug' ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'name' => array( 'type' => 'string' ), + 'is_mu_plugin' => array( 'type' => 'boolean' ), + 'bug_report_url' => array( 'type' => 'string' ), + 'currently_running' => array( + 'type' => array( 'object', 'null' ), + 'properties' => array( + 'which' => array( 'type' => array( 'string', 'null' ) ), + 'source' => array( 'type' => array( 'string', 'null' ) ), + 'id' => array( 'type' => array( 'string', 'null' ) ), + 'version' => array( 'type' => array( 'string', 'null' ) ), + 'pretty_version' => array( 'type' => array( 'string', 'null' ) ), + ), + ), + 'sections' => array( + 'type' => 'array', + 'items' => array( 'type' => 'object' ), + ), + 'to_test_html' => array( 'type' => array( 'string', 'null' ) ), + 'what_changed_html' => array( 'type' => array( 'string', 'null' ) ), + 'needed_updates' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + ), + ), + 'execute_callback' => array( __CLASS__, 'get_plugin' ), + 'permission_callback' => array( __CLASS__, 'can_view_plugin' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + 'mcp' => array( + 'public' => false, + ), + ), + ); + } + + /** + * Spec: jetpack-beta/get-settings. + * + * @return array + */ + private static function spec_get_settings(): array { + return array( + 'label' => __( 'Get Jetpack Beta settings', 'jetpack-beta' ), + 'description' => __( + 'Return the global settings for the Jetpack Beta Tester. Shape: { autoupdates, email_notifications, skip_email }. `autoupdates` is true when automatic background updates are enabled. `email_notifications` is true when email notifications for updates are enabled. `skip_email` is true when the JETPACK_BETA_SKIP_EMAIL constant is defined (e.g. on Atomic). Read-only and idempotent — safe to poll.', + 'jetpack-beta' + ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => new \stdClass(), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'autoupdates' => array( 'type' => 'boolean' ), + 'email_notifications' => array( 'type' => 'boolean' ), + 'skip_email' => array( 'type' => 'boolean' ), + ), + ), + 'execute_callback' => array( __CLASS__, 'get_settings' ), + 'permission_callback' => array( __CLASS__, 'can_manage' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + 'mcp' => array( + 'public' => false, + ), + ), + ); + } + + // ------------------------------------------------------------------------- + // Permission callbacks + // ------------------------------------------------------------------------- + + /** + * Permission check for get-plugin. + * + * Requires `can_manage()` and additionally enforces the same multisite / + * network-admin access-control rule as {@see Admin::admin_page_load()}: if + * the plugin being managed is network-activated (stable or dev file), the + * ability is denied outside of a network-admin context. + * + * @param array|null $input The input args passed to the ability. + * @return bool True when the current user is allowed to view the plugin. + */ + public static function can_view_plugin( $input ): bool { + if ( ! self::can_manage() ) { + return false; + } + + // Multisite network-activation access control. + if ( is_multisite() && ! is_network_admin() && isset( $input['slug'] ) ) { + try { + $plugin = Plugin::get_plugin( sanitize_key( $input['slug'] ) ); + } catch ( PluginDataException $e ) { + // Can't fetch plugin list — fail open so the execute callback + // can return a proper WP_Error with context. + return true; + } + + if ( $plugin && + ( is_plugin_active_for_network( $plugin->plugin_file() ) || + is_plugin_active_for_network( $plugin->dev_plugin_file() ) ) + ) { + return false; + } + } + + return true; + } + + // ------------------------------------------------------------------------- + // Execute callbacks + // ------------------------------------------------------------------------- + + /** + * Execute: list-plugins. + * + * Mirrors the active/version logic from plugin-select.template.php. + * + * @param array|null $input Ignored — zero-arg ability. + * @return array|\WP_Error + */ + public static function list_plugins( $input = null ) { + unset( $input ); + + try { + $all_plugins = Plugin::get_all_plugins( true ); + } catch ( PluginDataException $e ) { + return new \WP_Error( 'plugin_data_error', $e->getMessage() ); + } + + $plugins = array(); + foreach ( $all_plugins as $slug => $plugin ) { + if ( $plugin->is_active( 'stable' ) ) { + $active_which = 'stable'; + $active_version = $plugin->stable_pretty_version() ?? ''; + } elseif ( $plugin->is_active( 'dev' ) ) { + $active_which = 'dev'; + $active_version = $plugin->dev_pretty_version() ?? ''; + } else { + $active_which = null; + $active_version = null; + } + + $plugins[] = array( + 'slug' => $slug, + 'name' => $plugin->get_name(), + 'active_which' => $active_which, + 'active_version' => $active_version, + 'manage_url' => Utils::admin_url( array( 'plugin' => $slug ) ), + ); + } + + return array( 'plugins' => $plugins ); + } + + /** + * Execute: get-plugin. + * + * Reproduces the view-model built by plugin-manage.template.php as a JSON- + * serialisable array. The section ordering mirrors the template: + * existing (unknown) → stable → rc → trunk → PRs → releases. + * + * @param array|null $input Must contain 'slug'. + * @return array|\WP_Error + */ + public static function get_plugin( $input = null ) { + $slug = isset( $input['slug'] ) ? sanitize_key( $input['slug'] ) : ''; + if ( '' === $slug ) { + return new \WP_Error( 'missing_slug', __( 'A plugin slug is required.', 'jetpack-beta' ) ); + } + + try { + $plugin = Plugin::get_plugin( $slug, true ); + } catch ( PluginDataException $e ) { + return new \WP_Error( 'plugin_data_error', $e->getMessage() ); + } + + if ( ! $plugin ) { + return new \WP_Error( + 'unknown_plugin', + // translators: %s: Plugin slug. + sprintf( __( 'Plugin %s is not known.', 'jetpack-beta' ), $slug ) + ); + } + + try { + $manifest = $plugin->get_manifest( true ); + $wporg_data = $plugin->get_wporg_data( true ); + } catch ( PluginDataException $e ) { + return new \WP_Error( 'plugin_data_error', $e->getMessage() ); + } + + // ------------------------------------------------------------------ + // Replicate the existing_branch / active_branch logic from the template. + // ------------------------------------------------------------------ + $existing_branch = null; + if ( file_exists( $plugin->plugin_path() ) ) { + $tmp = get_plugin_data( $plugin->plugin_path(), false, false ); + $existing_branch = $plugin->source_info( 'release', $tmp['Version'] ); + if ( ! $existing_branch || is_wp_error( $existing_branch ) ) { + $existing_branch = (object) array( + 'which' => 'stable', + 'source' => 'unknown', + 'id' => $tmp['Version'], + 'version' => $tmp['Version'], + 'pretty_version' => $plugin->stable_pretty_version(), + ); + } + } + + $active_branch = (object) array( + 'which' => null, + 'source' => null, + 'id' => null, + ); + if ( $plugin->is_active( 'stable' ) ) { + $active_branch = $existing_branch; + } elseif ( $plugin->is_active( 'dev' ) ) { + $active_branch = $plugin->dev_info(); + if ( $active_branch ) { + $active_branch->which = 'dev'; + $active_branch->pretty_version = $plugin->dev_pretty_version(); + } else { + $tmp = get_plugin_data( $plugin->dev_plugin_path(), false, false ); + $active_branch = (object) array( + 'which' => 'dev', + 'source' => 'unknown', + 'id' => $tmp['Version'], + 'version' => $tmp['Version'], + 'pretty_version' => __( 'Unknown Development Version', 'jetpack-beta' ), + ); + } + } + + // ------------------------------------------------------------------ + // currently_running — null when the plugin is not active. + // ------------------------------------------------------------------ + $currently_running = null; + if ( null !== $active_branch->which ) { + $currently_running = array( + 'which' => $active_branch->which, + 'source' => $active_branch->source, + 'id' => $active_branch->id, + 'version' => $active_branch->version ?? null, + 'pretty_version' => $active_branch->pretty_version ?? null, + ); + } + + // ------------------------------------------------------------------ + // Build sections array — mirrors template order. + // ------------------------------------------------------------------ + $sections = array(); + + // Existing (unknown) stable version on disk. + if ( $existing_branch && 'unknown' === $existing_branch->source ) { + $sections[] = self::branch_to_section( $existing_branch, 'existing', $active_branch ); + } + + // Stable. + $branch = $plugin->source_info( 'stable', '' ); + if ( $branch && ! is_wp_error( $branch ) ) { + $section_item = self::branch_to_section( $branch, 'stable', $active_branch ); + + // Fixup active_branch so the active stable doesn't also render as active + // under releases below (mirrors the template's fixup block). + if ( $active_branch->source === $branch->source && $active_branch->id === $branch->id ) { + $active_branch->source = 'stable'; + $active_branch->id = ''; + } + + $sections[] = $section_item; + } + + // RC. + $branch = $plugin->source_info( 'rc', '' ); + if ( $branch && ! is_wp_error( $branch ) ) { + $sections[] = self::branch_to_section( $branch, 'rc', $active_branch ); + } + + // Trunk. + $branch = $plugin->source_info( 'trunk', '' ); + if ( $branch && ! is_wp_error( $branch ) ) { + $sections[] = self::branch_to_section( $branch, 'trunk', $active_branch ); + } + + // PRs. + if ( ! empty( $manifest->pr ) && (array) $manifest->pr ) { + foreach ( (array) $manifest->pr as $pr ) { + $branch = $plugin->source_info( 'pr', $pr->branch ); + if ( $branch && ! is_wp_error( $branch ) ) { + $sections[] = self::branch_to_section( $branch, 'pr', $active_branch ); + } + } + } elseif ( 'pr' === $active_branch->source ) { + // No PR list available but one is currently active — show it. + $sections[] = self::branch_to_section( $active_branch, 'pr', $active_branch ); + } + + // Releases — sorted newest-first with Semver::rsort(). + if ( ! empty( $wporg_data->versions ) && (array) $wporg_data->versions ) { + $versions = array_keys( (array) $wporg_data->versions ); + $versions = Semver::rsort( $versions ); + foreach ( $versions as $v ) { + $branch = $plugin->source_info( 'release', $v ); + if ( $branch && ! is_wp_error( $branch ) ) { + $sections[] = self::branch_to_section( $branch, 'release', $active_branch ); + } + } + } elseif ( 'release' === $active_branch->source && isset( $wporg_data->version ) && $wporg_data->version !== $active_branch->id ) { + // Old active release that no longer appears in wporg versions. + $sections[] = self::branch_to_section( $active_branch, 'release', $active_branch ); + } + + // ------------------------------------------------------------------ + // To-test / what-changed content. + // ------------------------------------------------------------------ + list( $to_test_html, $what_changed_html ) = Admin::to_test_content( $plugin ); + + // ------------------------------------------------------------------ + // Needed updates — port of show-needed-updates.template.php logic. + // ------------------------------------------------------------------ + $needed_updates = self::get_needed_updates_for_plugin( $plugin ); + + return array( + 'name' => $plugin->get_name(), + 'is_mu_plugin' => $plugin->is_mu_plugin(), + 'bug_report_url' => $plugin->bug_report_url(), + 'currently_running' => $currently_running, + 'sections' => $sections, + 'to_test_html' => $to_test_html, + 'what_changed_html' => $what_changed_html, + 'needed_updates' => $needed_updates, + ); + } + + /** + * Execute: get-settings. + * + * @param array|null $input Ignored — zero-arg ability. + * @return array + */ + public static function get_settings( $input = null ) { + unset( $input ); + + return array( + 'autoupdates' => (bool) Utils::is_set_to_autoupdate(), + 'email_notifications' => (bool) Utils::is_set_to_email_notifications(), + 'skip_email' => defined( 'JETPACK_BETA_SKIP_EMAIL' ), + ); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Convert a source_info object + section label into a section array item. + * + * @param object $branch The source_info object. + * @param string $section Section label: existing|stable|rc|trunk|pr|release. + * @param object $active_branch The currently-active branch object. + * @return array + */ + private static function branch_to_section( $branch, $section, $active_branch ): array { + $is_active = ( + null !== $active_branch->source && + $active_branch->source === $branch->source && + $active_branch->id === $branch->id + ); + + return array( + 'section' => $section, + 'source' => $branch->source, + 'id' => $branch->id, + 'branch' => $branch->branch ?? null, + 'version' => $branch->version ?? null, + 'pretty_version' => $branch->pretty_version ?? null, + 'is_active' => $is_active, + ); + } + + /** + * Return an array of plugin files that have pending updates, scoped to the + * given plugin (plus the Beta Tester itself). + * + * Mirrors the logic in show-needed-updates.template.php: calls + * `Utils::plugins_needing_update( true )` to include stable versions, then + * filters the result to only the files relevant to this plugin and the + * Jetpack Beta Tester itself. + * + * @param Plugin $plugin Plugin to check. + * @return string[] Plugin file paths that have available updates. + */ + private static function get_needed_updates_for_plugin( Plugin $plugin ): array { + try { + $updates = Utils::plugins_needing_update( true ); + } catch ( \Exception $e ) { + return array(); + } + + $relevant = array( + $plugin->plugin_file() => 1, + $plugin->dev_plugin_file() => 1, + JPBETA__PLUGIN_FOLDER . '/jetpack-beta.php' => 1, + ); + + return array_keys( array_intersect_key( $updates, $relevant ) ); + } +} From 836b21732c1d57783901d73517bcce719c480746 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 1 Jun 2026 12:08:02 -0700 Subject: [PATCH 06/53] Jetpack Beta: harden get-plugin null/WP_Error handling and fix ability metadata Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/abilities/class-beta-abilities.php | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/projects/plugins/beta/src/abilities/class-beta-abilities.php b/projects/plugins/beta/src/abilities/class-beta-abilities.php index 6eaca681ecc1..28f22ec475c8 100644 --- a/projects/plugins/beta/src/abilities/class-beta-abilities.php +++ b/projects/plugins/beta/src/abilities/class-beta-abilities.php @@ -65,8 +65,8 @@ public static function get_category_definition(): array { /** * {@inheritDoc} * - * Returns the three read abilities. Task 3 will add write abilities - * (activate-branch, update-settings) to this same array. + * Returns the three read abilities. Write abilities (activate-branch, + * update-settings) are added in a follow-up commit. */ public static function get_abilities(): array { return array( @@ -149,7 +149,7 @@ private static function spec_get_plugin(): array { return array( 'label' => __( 'Get Jetpack Beta plugin details', 'jetpack-beta' ), 'description' => __( - 'Return the full view-model for a single plugin managed by Jetpack Beta Tester. Input: { slug }. Output: { name, is_mu_plugin, bug_report_url, currently_running, sections, to_test_html, what_changed_html, needed_updates }. `currently_running` is null when the plugin is not active. `sections` is an ordered array of branch cards (stable → rc → trunk → PRs → releases). `to_test_html` and `what_changed_html` are sanitized HTML strings or null. `needed_updates` is an array of plugin files that have pending updates. Read-only and idempotent — safe to poll.', + 'Return the full view-model for a single plugin managed by Jetpack Beta Tester. Input: { slug }. Output: { name, is_mu_plugin, bug_report_url, currently_running, sections, to_test_html, what_changed_html, needed_updates }. `currently_running` is null when the plugin is not active. `sections` is an ordered array of branch cards (existing → stable → rc → trunk → PRs → releases). `to_test_html` and `what_changed_html` are sanitized HTML strings or null. `needed_updates` is an array of plugin files that have pending updates. Read-only — results are cached but may trigger background network refreshes.', 'jetpack-beta' ), 'input_schema' => array( @@ -180,7 +180,18 @@ private static function spec_get_plugin(): array { ), 'sections' => array( 'type' => 'array', - 'items' => array( 'type' => 'object' ), + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'section' => array( 'type' => 'string' ), + 'source' => array( 'type' => array( 'string', 'null' ) ), + 'id' => array( 'type' => array( 'string', 'null' ) ), + 'branch' => array( 'type' => array( 'string', 'null' ) ), + 'version' => array( 'type' => array( 'string', 'null' ) ), + 'pretty_version' => array( 'type' => array( 'string', 'null' ) ), + 'is_active' => array( 'type' => 'boolean' ), + ), + ), ), 'to_test_html' => array( 'type' => array( 'string', 'null' ) ), 'what_changed_html' => array( 'type' => array( 'string', 'null' ) ), @@ -196,7 +207,7 @@ private static function spec_get_plugin(): array { 'annotations' => array( 'readonly' => true, 'destructive' => false, - 'idempotent' => true, + 'idempotent' => false, ), 'show_in_rest' => true, 'mcp' => array( @@ -313,10 +324,10 @@ public static function list_plugins( $input = null ) { foreach ( $all_plugins as $slug => $plugin ) { if ( $plugin->is_active( 'stable' ) ) { $active_which = 'stable'; - $active_version = $plugin->stable_pretty_version() ?? ''; + $active_version = $plugin->stable_pretty_version() ?? null; } elseif ( $plugin->is_active( 'dev' ) ) { $active_which = 'dev'; - $active_version = $plugin->dev_pretty_version() ?? ''; + $active_version = $plugin->dev_pretty_version() ?? null; } else { $active_which = null; $active_version = null; @@ -395,10 +406,12 @@ public static function get_plugin( $input = null ) { 'id' => null, ); if ( $plugin->is_active( 'stable' ) ) { - $active_branch = $existing_branch; + if ( $existing_branch ) { + $active_branch = $existing_branch; + } } elseif ( $plugin->is_active( 'dev' ) ) { $active_branch = $plugin->dev_info(); - if ( $active_branch ) { + if ( $active_branch && ! is_wp_error( $active_branch ) ) { $active_branch->which = 'dev'; $active_branch->pretty_version = $plugin->dev_pretty_version(); } else { From 538ebf7d89f3337c7d1f1ea80c1e1545f0c55d76 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 1 Jun 2026 12:14:34 -0700 Subject: [PATCH 07/53] Jetpack Beta: add write abilities (activate-branch, update-settings) and wire init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add jetpack-beta/activate-branch and jetpack-beta/update-settings write abilities - Refactor get_plugin() → build_plugin_view() and get_settings() → build_settings() for DRY reuse - Extract plugin_view_schema() helper so the large schema literal lives in one place - Wire Beta_Abilities::init() into plugins_loaded at priority 20 in jetpack-beta.php - Require wp-admin includes inside activate_branch() for REST-context safety Co-Authored-By: Claude Opus 4.8 (1M context) --- projects/plugins/beta/jetpack-beta.php | 2 + .../src/abilities/class-beta-abilities.php | 349 +++++++++++++++--- 2 files changed, 295 insertions(+), 56 deletions(-) diff --git a/projects/plugins/beta/jetpack-beta.php b/projects/plugins/beta/jetpack-beta.php index 77e9f32d6e87..7e70d3959392 100644 --- a/projects/plugins/beta/jetpack-beta.php +++ b/projects/plugins/beta/jetpack-beta.php @@ -116,6 +116,8 @@ function jetpack_beta_admin_missing_autoloader() { Automattic\JetpackBeta\Hooks::setup(); +add_action( 'plugins_loaded', array( Automattic\JetpackBeta\Abilities\Beta_Abilities::class, 'init' ), 20 ); + register_activation_hook( __FILE__, array( Automattic\JetpackBeta\Hooks::class, 'activate' ) ); register_deactivation_hook( __FILE__, array( Automattic\JetpackBeta\Hooks::class, 'deactivate' ) ); diff --git a/projects/plugins/beta/src/abilities/class-beta-abilities.php b/projects/plugins/beta/src/abilities/class-beta-abilities.php index 28f22ec475c8..4b1658660e22 100644 --- a/projects/plugins/beta/src/abilities/class-beta-abilities.php +++ b/projects/plugins/beta/src/abilities/class-beta-abilities.php @@ -11,6 +11,7 @@ use Automattic\Jetpack\WP_Abilities\Registrar; use Automattic\JetpackBeta\Admin; +use Automattic\JetpackBeta\Hooks; use Automattic\JetpackBeta\Plugin; use Automattic\JetpackBeta\PluginDataException; use Automattic\JetpackBeta\Utils; @@ -65,14 +66,15 @@ public static function get_category_definition(): array { /** * {@inheritDoc} * - * Returns the three read abilities. Write abilities (activate-branch, - * update-settings) are added in a follow-up commit. + * Returns all five abilities: three read-only and two write. */ public static function get_abilities(): array { return array( - 'jetpack-beta/list-plugins' => self::spec_list_plugins(), - 'jetpack-beta/get-plugin' => self::spec_get_plugin(), - 'jetpack-beta/get-settings' => self::spec_get_settings(), + 'jetpack-beta/list-plugins' => self::spec_list_plugins(), + 'jetpack-beta/get-plugin' => self::spec_get_plugin(), + 'jetpack-beta/get-settings' => self::spec_get_settings(), + 'jetpack-beta/activate-branch' => self::spec_activate_branch(), + 'jetpack-beta/update-settings' => self::spec_update_settings(), ); } @@ -162,45 +164,7 @@ private static function spec_get_plugin(): array { ), 'required' => array( 'slug' ), ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'name' => array( 'type' => 'string' ), - 'is_mu_plugin' => array( 'type' => 'boolean' ), - 'bug_report_url' => array( 'type' => 'string' ), - 'currently_running' => array( - 'type' => array( 'object', 'null' ), - 'properties' => array( - 'which' => array( 'type' => array( 'string', 'null' ) ), - 'source' => array( 'type' => array( 'string', 'null' ) ), - 'id' => array( 'type' => array( 'string', 'null' ) ), - 'version' => array( 'type' => array( 'string', 'null' ) ), - 'pretty_version' => array( 'type' => array( 'string', 'null' ) ), - ), - ), - 'sections' => array( - 'type' => 'array', - 'items' => array( - 'type' => 'object', - 'properties' => array( - 'section' => array( 'type' => 'string' ), - 'source' => array( 'type' => array( 'string', 'null' ) ), - 'id' => array( 'type' => array( 'string', 'null' ) ), - 'branch' => array( 'type' => array( 'string', 'null' ) ), - 'version' => array( 'type' => array( 'string', 'null' ) ), - 'pretty_version' => array( 'type' => array( 'string', 'null' ) ), - 'is_active' => array( 'type' => 'boolean' ), - ), - ), - ), - 'to_test_html' => array( 'type' => array( 'string', 'null' ) ), - 'what_changed_html' => array( 'type' => array( 'string', 'null' ) ), - 'needed_updates' => array( - 'type' => 'array', - 'items' => array( 'type' => 'string' ), - ), - ), - ), + 'output_schema' => self::plugin_view_schema(), 'execute_callback' => array( __CLASS__, 'get_plugin' ), 'permission_callback' => array( __CLASS__, 'can_view_plugin' ), 'meta' => array( @@ -258,6 +222,110 @@ private static function spec_get_settings(): array { ); } + /** + * Spec: jetpack-beta/activate-branch. + * + * @return array + */ + private static function spec_activate_branch(): array { + return array( + 'label' => __( 'Activate a Jetpack Beta branch', 'jetpack-beta' ), + 'description' => __( + 'Download (if necessary) and activate a specific branch of a plugin managed by Jetpack Beta Tester. Input: { slug, source, id }. `source` is one of "stable", "trunk", "rc", "pr", "release", or "unknown". `id` is the branch name (for PRs) or version string (for releases); use an empty string for stable/rc/trunk. Returns { success, plugin } where `plugin` is the full plugin view-model (same shape as get-plugin). Not idempotent — will trigger a plugin deactivation/activation cycle even when the same branch is already active.', + 'jetpack-beta' + ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'slug' => array( + 'type' => 'string', + 'description' => __( 'The WordPress plugin slug (e.g. "jetpack").', 'jetpack-beta' ), + ), + 'source' => array( + 'type' => 'string', + 'description' => __( 'Branch source: "stable", "trunk", "rc", "pr", "release", or "unknown".', 'jetpack-beta' ), + ), + 'id' => array( + 'type' => 'string', + 'description' => __( 'Branch identifier: PR branch name, release version, or empty string for stable/rc/trunk.', 'jetpack-beta' ), + ), + ), + 'required' => array( 'slug', 'source', 'id' ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'plugin' => self::plugin_view_schema(), + ), + ), + 'execute_callback' => array( __CLASS__, 'activate_branch' ), + 'permission_callback' => array( __CLASS__, 'can_view_plugin' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + 'idempotent' => false, + ), + 'show_in_rest' => true, + 'mcp' => array( + 'public' => false, + ), + ), + ); + } + + /** + * Spec: jetpack-beta/update-settings. + * + * @return array + */ + private static function spec_update_settings(): array { + return array( + 'label' => __( 'Update Jetpack Beta settings', 'jetpack-beta' ), + 'description' => __( + 'Update one or more global settings for the Jetpack Beta Tester. Input: { autoupdates?, email_notifications? }. Omit a key to leave that setting unchanged. Returns the full settings object (same shape as get-settings) reflecting the new values.', + 'jetpack-beta' + ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'autoupdates' => array( + 'type' => 'boolean', + 'description' => __( 'Set to true to enable automatic background updates, false to disable.', 'jetpack-beta' ), + ), + 'email_notifications' => array( + 'type' => 'boolean', + 'description' => __( 'Set to true to enable update email notifications, false to disable. Has no effect when JETPACK_BETA_SKIP_EMAIL is defined.', 'jetpack-beta' ), + ), + ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'autoupdates' => array( 'type' => 'boolean' ), + 'email_notifications' => array( 'type' => 'boolean' ), + 'skip_email' => array( 'type' => 'boolean' ), + ), + ), + 'execute_callback' => array( __CLASS__, 'update_settings' ), + 'permission_callback' => array( __CLASS__, 'can_manage' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + 'idempotent' => false, + ), + 'show_in_rest' => true, + 'mcp' => array( + 'public' => false, + ), + ), + ); + } + // ------------------------------------------------------------------------- // Permission callbacks // ------------------------------------------------------------------------- @@ -348,9 +416,8 @@ public static function list_plugins( $input = null ) { /** * Execute: get-plugin. * - * Reproduces the view-model built by plugin-manage.template.php as a JSON- - * serialisable array. The section ordering mirrors the template: - * existing (unknown) → stable → rc → trunk → PRs → releases. + * Resolves the slug, validates the plugin exists, then delegates to + * {@see self::build_plugin_view()} for the actual payload construction. * * @param array|null $input Must contain 'slug'. * @return array|\WP_Error @@ -375,6 +442,129 @@ public static function get_plugin( $input = null ) { ); } + return self::build_plugin_view( $plugin ); + } + + /** + * Execute: get-settings. + * + * @param array|null $input Ignored — zero-arg ability. + * @return array + */ + public static function get_settings( $input = null ) { + unset( $input ); + return self::build_settings(); + } + + /** + * Execute: activate-branch. + * + * Resolves the plugin by slug, delegates the install + activation to + * {@see Plugin::install_and_activate()}, and returns the updated plugin + * view-model on success. + * + * The underlying install path uses {@see Plugin_Upgrader} which requires + * several wp-admin includes. These are loaded inline here — the same + * pattern used by the WP REST plugin-install endpoint. + * + * @param array|null $input Must contain 'slug', 'source', and 'id'. + * @return array|\WP_Error + */ + public static function activate_branch( $input = null ) { + $slug = isset( $input['slug'] ) ? sanitize_key( $input['slug'] ) : ''; + $source = isset( $input['source'] ) ? sanitize_text_field( $input['source'] ) : ''; + $id = isset( $input['id'] ) ? sanitize_text_field( $input['id'] ) : ''; + + if ( '' === $slug ) { + return new \WP_Error( 'missing_slug', __( 'A plugin slug is required.', 'jetpack-beta' ) ); + } + if ( '' === $source ) { + return new \WP_Error( 'missing_source', __( 'A branch source is required.', 'jetpack-beta' ) ); + } + + try { + $plugin = Plugin::get_plugin( $slug ); + } catch ( PluginDataException $e ) { + return new \WP_Error( 'plugin_data_error', $e->getMessage() ); + } + + if ( ! $plugin ) { + return new \WP_Error( + 'unknown_plugin', + // translators: %s: Plugin slug. + sprintf( __( 'Plugin %s is not known.', 'jetpack-beta' ), $slug ) + ); + } + + // The Plugin_Upgrader path (invoked by install_and_activate) requires + // these wp-admin includes. They are safe to require in a REST context — + // the WP core REST plugin-install endpoint does the same thing. + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/misc.php'; + require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + + $result = $plugin->install_and_activate( $source, $id ); + if ( is_wp_error( $result ) ) { + return $result; + } + + return array( + 'success' => true, + 'plugin' => self::build_plugin_view( $plugin ), + ); + } + + /** + * Execute: update-settings. + * + * Applies a partial update to the Beta Tester global settings. Only keys + * present in `$input` are changed; absent keys are left untouched. + * + * @param array|null $input May contain 'autoupdates' (bool) and/or 'email_notifications' (bool). + * @return array Updated settings (same shape as get-settings). + */ + public static function update_settings( $input = null ) { + if ( ! is_array( $input ) ) { + $input = array(); + } + + if ( array_key_exists( 'autoupdates', $input ) ) { + $value = (bool) $input['autoupdates']; + update_option( 'jp_beta_autoupdate', (int) $value ); + if ( $value ) { + Hooks::maybe_schedule_autoupdate(); + } + } + + if ( array_key_exists( 'email_notifications', $input ) ) { + if ( ! defined( 'JETPACK_BETA_SKIP_EMAIL' ) ) { + update_option( 'jp_beta_email_notifications', (int) $input['email_notifications'] ); + } + } + + return self::build_settings(); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Build the plugin view-model payload for a resolved Plugin object. + * + * Extracted from the original get_plugin() body so that both get_plugin() + * and activate_branch() can return an identical, DRY payload without + * duplicating the large template-mirroring logic. + * + * Reproduces the view-model built by plugin-manage.template.php as a JSON- + * serialisable array. The section ordering mirrors the template: + * existing (unknown) → stable → rc → trunk → PRs → releases. + * + * @param Plugin $plugin A fully resolved Plugin instance. + * @return array|\WP_Error The plugin view-model, or WP_Error on data failure. + */ + private static function build_plugin_view( Plugin $plugin ) { try { $manifest = $plugin->get_manifest( true ); $wporg_data = $plugin->get_wporg_data( true ); @@ -528,14 +718,14 @@ public static function get_plugin( $input = null ) { } /** - * Execute: get-settings. + * Build the current settings payload. * - * @param array|null $input Ignored — zero-arg ability. - * @return array + * Extracted so both get_settings() and update_settings() return an + * identical shape without duplicating the option reads. + * + * @return array { autoupdates: bool, email_notifications: bool, skip_email: bool } */ - public static function get_settings( $input = null ) { - unset( $input ); - + private static function build_settings(): array { return array( 'autoupdates' => (bool) Utils::is_set_to_autoupdate(), 'email_notifications' => (bool) Utils::is_set_to_email_notifications(), @@ -543,9 +733,56 @@ public static function get_settings( $input = null ) { ); } - // ------------------------------------------------------------------------- - // Helpers - // ------------------------------------------------------------------------- + /** + * JSON Schema object definition for a single plugin view-model. + * + * Shared between spec_get_plugin() (output_schema) and + * spec_activate_branch() (output_schema.properties.plugin) so the + * schema literal is defined in exactly one place. + * + * @return array JSON Schema object. + */ + private static function plugin_view_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'name' => array( 'type' => 'string' ), + 'is_mu_plugin' => array( 'type' => 'boolean' ), + 'bug_report_url' => array( 'type' => 'string' ), + 'currently_running' => array( + 'type' => array( 'object', 'null' ), + 'properties' => array( + 'which' => array( 'type' => array( 'string', 'null' ) ), + 'source' => array( 'type' => array( 'string', 'null' ) ), + 'id' => array( 'type' => array( 'string', 'null' ) ), + 'version' => array( 'type' => array( 'string', 'null' ) ), + 'pretty_version' => array( 'type' => array( 'string', 'null' ) ), + ), + ), + 'sections' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'section' => array( 'type' => 'string' ), + 'source' => array( 'type' => array( 'string', 'null' ) ), + 'id' => array( 'type' => array( 'string', 'null' ) ), + 'branch' => array( 'type' => array( 'string', 'null' ) ), + 'version' => array( 'type' => array( 'string', 'null' ) ), + 'pretty_version' => array( 'type' => array( 'string', 'null' ) ), + 'is_active' => array( 'type' => 'boolean' ), + ), + ), + ), + 'to_test_html' => array( 'type' => array( 'string', 'null' ) ), + 'what_changed_html' => array( 'type' => array( 'string', 'null' ) ), + 'needed_updates' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + ), + ); + } /** * Convert a source_info object + section label into a section array item. From ae7b67f4a8655038cdcfc8f2130ebaca1d43618c Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 1 Jun 2026 12:21:18 -0700 Subject: [PATCH 08/53] Jetpack Beta: refresh plugin view after activation and drop unused include Co-Authored-By: Claude Opus 4.8 (1M context) --- projects/plugins/beta/src/abilities/class-beta-abilities.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/projects/plugins/beta/src/abilities/class-beta-abilities.php b/projects/plugins/beta/src/abilities/class-beta-abilities.php index 4b1658660e22..d1fc53177ab6 100644 --- a/projects/plugins/beta/src/abilities/class-beta-abilities.php +++ b/projects/plugins/beta/src/abilities/class-beta-abilities.php @@ -500,7 +500,6 @@ public static function activate_branch( $input = null ) { // these wp-admin includes. They are safe to require in a REST context — // the WP core REST plugin-install endpoint does the same thing. require_once ABSPATH . 'wp-admin/includes/file.php'; - require_once ABSPATH . 'wp-admin/includes/misc.php'; require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; require_once ABSPATH . 'wp-admin/includes/plugin.php'; @@ -511,7 +510,7 @@ public static function activate_branch( $input = null ) { return array( 'success' => true, - 'plugin' => self::build_plugin_view( $plugin ), + 'plugin' => self::build_plugin_view( Plugin::get_plugin( $slug, true ) ), ); } From b293b165bd6074fe0b8e3c2ac4943aab76307336 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 1 Jun 2026 12:31:15 -0700 Subject: [PATCH 09/53] Jetpack Beta: React app scaffold with AdminPage chrome and abilities client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `JPBETA__PLUGIN_FILE` constant to `jetpack-beta.php` for use with `Assets::register_script`. - Add `automattic/jetpack-assets` to `composer.json` require (alphabetical order) and update `composer.lock`. - Add `use Automattic\Jetpack\Assets` to `class-admin.php`; replace legacy `wp_enqueue_style`/`wp_enqueue_script`/`wp_localize_script` with `Assets::register_script`/`Assets::enqueue_script` + `wp_add_inline_script` bootstrapping `window.JetpackBeta`. - Replace PHP template `require_once` calls in `render()` with `
`; preserve `PluginDataException` guard and network-admin `RuntimeException` path. - Create `src/js/api/types.ts`: full TypeScript types for all ability payloads plus `window.JetpackBeta` global declaration. - Create `src/js/api/abilities.ts`: typed `apiFetch` client for all five Beta abilities endpoints. - Create `src/js/index.tsx`: entry point — bootstraps `apiFetch` middleware then mounts `` via `createRoot`. - Create `src/js/app.tsx`: `AdminPage` shell routing between `` and `` based on `window.JetpackBeta.plugin`. - Create `src/js/screens/plugin-list.tsx` and `plugin-manage.tsx`: minimal type-correct stubs (replaced in Tasks 6–7). - Create `src/js/style.scss`: minimal root layout rule. Co-Authored-By: Claude Opus 4.8 (1M context) --- projects/plugins/beta/composer.json | 1 + projects/plugins/beta/composer.lock | 73 ++++++++++++++++++- projects/plugins/beta/jetpack-beta.php | 1 + projects/plugins/beta/src/class-admin.php | 56 ++++++++------ projects/plugins/beta/src/js/api/abilities.ts | 25 +++++++ projects/plugins/beta/src/js/api/types.ts | 62 ++++++++++++++++ projects/plugins/beta/src/js/app.tsx | 38 ++++++++++ projects/plugins/beta/src/js/index.tsx | 21 ++++++ .../beta/src/js/screens/plugin-list.tsx | 18 +++++ .../beta/src/js/screens/plugin-manage.tsx | 24 ++++++ projects/plugins/beta/src/js/style.scss | 11 +++ 11 files changed, 305 insertions(+), 25 deletions(-) create mode 100644 projects/plugins/beta/src/js/api/abilities.ts create mode 100644 projects/plugins/beta/src/js/api/types.ts create mode 100644 projects/plugins/beta/src/js/app.tsx create mode 100644 projects/plugins/beta/src/js/index.tsx create mode 100644 projects/plugins/beta/src/js/screens/plugin-list.tsx create mode 100644 projects/plugins/beta/src/js/screens/plugin-manage.tsx create mode 100644 projects/plugins/beta/src/js/style.scss diff --git a/projects/plugins/beta/composer.json b/projects/plugins/beta/composer.json index 8f1e4d437b3f..d5dc4f51c7c9 100644 --- a/projects/plugins/beta/composer.json +++ b/projects/plugins/beta/composer.json @@ -9,6 +9,7 @@ }, "require": { "automattic/jetpack-admin-ui": "@dev", + "automattic/jetpack-assets": "@dev", "automattic/jetpack-autoloader": "@dev", "automattic/jetpack-logo": "@dev", "automattic/jetpack-wp-abilities": "@dev", diff --git a/projects/plugins/beta/composer.lock b/projects/plugins/beta/composer.lock index 4a8814941a2c..12c6197dddd8 100644 --- a/projects/plugins/beta/composer.lock +++ b/projects/plugins/beta/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "bcbf4552b109bb140ad39a6ea4d26c7d", + "content-hash": "d3ed8813c6d6d4597f32863db79188e8", "packages": [ { "name": "automattic/jetpack-admin-ui", @@ -83,6 +83,76 @@ "relative": true } }, + { + "name": "automattic/jetpack-assets", + "version": "dev-trunk", + "dist": { + "type": "path", + "url": "../../packages/assets", + "reference": "a4c3991ea97f5705663221c8ebff9758b46fd683" + }, + "require": { + "automattic/jetpack-constants": "@dev", + "automattic/jetpack-status": "@dev", + "php": ">=7.2" + }, + "require-dev": { + "automattic/phpunit-select-config": "@dev", + "brain/monkey": "^2.6.2", + "wikimedia/testing-access-wrapper": "^1.0 || ^2.0 || ^3.0 || ^4.0", + "yoast/phpunit-polyfills": "^4.0.0" + }, + "suggest": { + "automattic/jetpack-autoloader": "Allow for better interoperability with other plugins that use this package." + }, + "type": "jetpack-library", + "extra": { + "autotagger": true, + "mirror-repo": "Automattic/jetpack-assets", + "textdomain": "jetpack-assets", + "changelogger": { + "link-template": "https://github.com/Automattic/jetpack-assets/compare/v${old}...v${new}" + }, + "branch-alias": { + "dev-trunk": "4.3.x-dev" + } + }, + "autoload": { + "files": [ + "actions.php" + ], + "classmap": [ + "src/" + ] + }, + "scripts": { + "build-development": [ + "pnpm run build" + ], + "build-production": [ + "pnpm run build-production" + ], + "phpunit": [ + "phpunit-select-config phpunit.#.xml.dist --colors=always" + ], + "test-coverage": [ + "pnpm concurrently --names php,js 'php -dpcov.directory=. ./vendor/bin/phpunit-select-config phpunit.#.xml.dist --coverage-php \"$COVERAGE_DIR/php.cov\"' 'pnpm:test-coverage'" + ], + "test-js": [ + "pnpm run test" + ], + "test-php": [ + "@composer phpunit" + ] + }, + "license": [ + "GPL-2.0-or-later" + ], + "description": "Asset management utilities for Jetpack ecosystem packages", + "transport-options": { + "relative": true + } + }, { "name": "automattic/jetpack-autoloader", "version": "dev-trunk", @@ -572,6 +642,7 @@ "minimum-stability": "dev", "stability-flags": { "automattic/jetpack-admin-ui": 20, + "automattic/jetpack-assets": 20, "automattic/jetpack-autoloader": 20, "automattic/jetpack-logo": 20, "automattic/jetpack-wp-abilities": 20 diff --git a/projects/plugins/beta/jetpack-beta.php b/projects/plugins/beta/jetpack-beta.php index 7e70d3959392..70a9c12a1bd7 100644 --- a/projects/plugins/beta/jetpack-beta.php +++ b/projects/plugins/beta/jetpack-beta.php @@ -34,6 +34,7 @@ } define( 'JPBETA__PLUGIN_FOLDER', dirname( plugin_basename( __FILE__ ) ) ); +define( 'JPBETA__PLUGIN_FILE', __FILE__ ); define( 'JPBETA_VERSION', '4.2.0' ); define( 'JETPACK_BETA_PLUGINS_URL', 'https://betadownload.jetpack.me/plugins.json' ); diff --git a/projects/plugins/beta/src/class-admin.php b/projects/plugins/beta/src/class-admin.php index a3c981a85fc3..b5823463b771 100644 --- a/projects/plugins/beta/src/class-admin.php +++ b/projects/plugins/beta/src/class-admin.php @@ -8,6 +8,7 @@ namespace Automattic\JetpackBeta; use Automattic\Jetpack\Admin_UI\Admin_Menu; +use Automattic\Jetpack\Assets; /** * Handles the Jetpack Beta plugin Admin functions. @@ -86,20 +87,17 @@ public static function render() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $plugin_name = isset( $_GET['plugin'] ) ? filter_var( wp_unslash( $_GET['plugin'] ) ) : null; - if ( null === $plugin_name ) { - require_once __DIR__ . '/admin/plugin-select.template.php'; - return; - } - - $plugin = Plugin::get_plugin( $plugin_name, true ); - if ( ! $plugin ) { - throw new PluginDataException( - // translators: %s: Requested plugin slug. - sprintf( __( 'Plugin %s is not known.', 'jetpack-beta' ), $plugin_name ) - ); + if ( null !== $plugin_name ) { + $plugin = Plugin::get_plugin( $plugin_name, true ); + if ( ! $plugin ) { + throw new PluginDataException( + // translators: %s: Requested plugin slug. + sprintf( __( 'Plugin %s is not known.', 'jetpack-beta' ), $plugin_name ) + ); + } } - require_once __DIR__ . '/admin/plugin-manage.template.php'; + echo '
'; } catch ( PluginDataException $exception ) { ob_clean(); require_once __DIR__ . '/admin/exception.template.php'; @@ -214,21 +212,31 @@ public static function admin_enqueue_scripts( $hookname ) { return; } - wp_enqueue_style( 'jetpack-beta-admin', plugins_url( 'admin/admin.css', __FILE__ ), array(), JPBETA_VERSION ); - wp_enqueue_script( 'jetpack-admin-js', plugins_url( 'admin/admin.js', __FILE__ ), array(), JPBETA_VERSION, true ); - wp_localize_script( - 'jetpack-admin-js', - 'JetpackBeta', + Assets::register_script( + 'jetpack-beta-app', + 'build/index.js', + JPBETA__PLUGIN_FILE, array( - 'activate' => __( 'Activate', 'jetpack-beta' ), - 'activating' => __( 'Activating...', 'jetpack-beta' ), - 'update' => __( 'Update', 'jetpack-beta' ), - 'updating' => __( 'Updating...', 'jetpack-beta' ), - 'failed' => __( 'Failed', 'jetpack-beta' ), - // translators: %s: Error message. - 'failedmsg' => __( 'Update failed: %s', 'jetpack-beta' ), + 'in_footer' => true, + 'textdomain' => 'jetpack-beta', ) ); + Assets::enqueue_script( 'jetpack-beta-app' ); + wp_add_inline_script( + 'jetpack-beta-app', + 'window.JetpackBeta = ' . wp_json_encode( + array( + 'apiRoot' => esc_url_raw( rest_url() ), + 'apiNonce' => wp_create_nonce( 'wp_rest' ), + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + 'plugin' => isset( $_GET['plugin'] ) ? sanitize_text_field( wp_unslash( $_GET['plugin'] ) ) : null, + 'adminUrl' => Utils::admin_url(), + 'canManage' => current_user_can( 'update_plugins' ), + ), + JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP + ) . ';', + 'before' + ); } /** diff --git a/projects/plugins/beta/src/js/api/abilities.ts b/projects/plugins/beta/src/js/api/abilities.ts new file mode 100644 index 000000000000..220aa673ef95 --- /dev/null +++ b/projects/plugins/beta/src/js/api/abilities.ts @@ -0,0 +1,25 @@ +/** + * Typed client for Jetpack Beta WP Abilities API endpoints. + * + * @package + */ + +import apiFetch from '@wordpress/api-fetch'; +import type { PluginListItem, PluginView, Settings } from './types'; + +const run = < T >( ability: string, data: Record< string, unknown > = {} ): Promise< T > => + apiFetch< T >( { path: `/wp-abilities/v1/abilities/${ ability }/run`, method: 'POST', data } ); + +export const listPlugins = () => + run< { plugins: PluginListItem[] } >( 'jetpack-beta/list-plugins' ); +export const getPlugin = ( slug: string ) => + run< PluginView >( 'jetpack-beta/get-plugin', { slug } ); +export const getSettings = () => run< Settings >( 'jetpack-beta/get-settings' ); +export const activateBranch = ( slug: string, source: string, id: string ) => + run< { success: boolean; plugin: PluginView } >( 'jetpack-beta/activate-branch', { + slug, + source, + id, + } ); +export const updateSettings = ( patch: Partial< Settings > ) => + run< Settings >( 'jetpack-beta/update-settings', patch as Record< string, unknown > ); diff --git a/projects/plugins/beta/src/js/api/types.ts b/projects/plugins/beta/src/js/api/types.ts new file mode 100644 index 000000000000..98b0e1702448 --- /dev/null +++ b/projects/plugins/beta/src/js/api/types.ts @@ -0,0 +1,62 @@ +/** + * TypeScript types matching the Jetpack Beta WP Abilities API payloads. + * + * @package + */ + +export type PluginListItem = { + slug: string; + name: string; + active_which: 'stable' | 'dev' | null; + active_version: string | null; + manage_url: string; +}; + +export type BranchCard = { + section: string; + source: string | null; + id: string | null; + branch: string | null; + version: string | null; + pretty_version: string | null; + is_active: boolean; +}; + +export type CurrentlyRunning = { + which: string | null; + source: string | null; + id: string | null; + version: string | null; + pretty_version: string | null; +}; + +export type PluginView = { + name: string; + is_mu_plugin: boolean; + bug_report_url: string; + currently_running: CurrentlyRunning | null; + sections: BranchCard[]; + to_test_html: string | null; + what_changed_html: string | null; + needed_updates: string[]; +}; + +export type Settings = { + autoupdates: boolean; + email_notifications: boolean; + skip_email: boolean; +}; + +export type BetaBootstrap = { + apiRoot: string; + apiNonce: string; + plugin: string | null; + adminUrl: string; + canManage: boolean; +}; + +declare global { + interface Window { + JetpackBeta: BetaBootstrap; + } +} diff --git a/projects/plugins/beta/src/js/app.tsx b/projects/plugins/beta/src/js/app.tsx new file mode 100644 index 000000000000..171c7017a5d3 --- /dev/null +++ b/projects/plugins/beta/src/js/app.tsx @@ -0,0 +1,38 @@ +/** + * Root App component — AdminPage shell with screen routing. + * + * Reads `window.JetpackBeta.plugin` to decide which screen to render: + * - null → PluginList (all plugins overview) + * - string → PluginManage (single-plugin manage view) + * + * @package + */ + +import { AdminPage } from '@automattic/jetpack-components'; +import { __ } from '@wordpress/i18n'; +import PluginList from './screens/plugin-list'; +import PluginManage from './screens/plugin-manage'; + +const boot = window.JetpackBeta; + +/** + * App component. + * + * @return The AdminPage shell with the active screen. + */ +const App = () => { + const plugin = boot.plugin; + + return ( + + { plugin === null ? : } + + ); +}; + +export default App; diff --git a/projects/plugins/beta/src/js/index.tsx b/projects/plugins/beta/src/js/index.tsx new file mode 100644 index 000000000000..206844ea9a22 --- /dev/null +++ b/projects/plugins/beta/src/js/index.tsx @@ -0,0 +1,21 @@ +/** + * Entry point for the Jetpack Beta Tester React app. + * + * Bootstraps apiFetch middleware from window.JetpackBeta then mounts the app. + * + * @package + */ + +import apiFetch from '@wordpress/api-fetch'; +import { createRoot } from '@wordpress/element'; +import App from './app'; +import './style.scss'; + +const boot = window.JetpackBeta; +apiFetch.use( apiFetch.createRootURLMiddleware( boot.apiRoot ) ); +apiFetch.use( apiFetch.createNonceMiddleware( boot.apiNonce ) ); + +const el = document.getElementById( 'jetpack-beta-root' ); +if ( el ) { + createRoot( el ).render( ); +} diff --git a/projects/plugins/beta/src/js/screens/plugin-list.tsx b/projects/plugins/beta/src/js/screens/plugin-list.tsx new file mode 100644 index 000000000000..be855519e67d --- /dev/null +++ b/projects/plugins/beta/src/js/screens/plugin-list.tsx @@ -0,0 +1,18 @@ +/** + * PluginList screen stub — replaced in Task 6. + * + * @package + */ + +import { __ } from '@wordpress/i18n'; + +/** + * Stub component for the plugin list screen. + * + * @return The stub element. + */ +const PluginList = () => { + return

{ __( 'Loading…', 'jetpack-beta' ) }

; +}; + +export default PluginList; diff --git a/projects/plugins/beta/src/js/screens/plugin-manage.tsx b/projects/plugins/beta/src/js/screens/plugin-manage.tsx new file mode 100644 index 000000000000..74142f15aff5 --- /dev/null +++ b/projects/plugins/beta/src/js/screens/plugin-manage.tsx @@ -0,0 +1,24 @@ +/** + * PluginManage screen stub — replaced in Task 7. + * + * @package + */ + +import { __ } from '@wordpress/i18n'; + +type Props = { + slug: string; +}; + +/** + * Stub component for the plugin manage screen. + * + * @param {Props} _props - Component props (unused in stub). + * @return The stub element. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const PluginManage = ( _props: Props ) => { + return

{ __( 'Loading…', 'jetpack-beta' ) }

; +}; + +export default PluginManage; diff --git a/projects/plugins/beta/src/js/style.scss b/projects/plugins/beta/src/js/style.scss new file mode 100644 index 000000000000..57485d26d150 --- /dev/null +++ b/projects/plugins/beta/src/js/style.scss @@ -0,0 +1,11 @@ +/** + * Jetpack Beta Tester — app styles. + * + * @package automattic/jetpack-beta + */ + +#jetpack-beta-root { + display: flex; + flex-direction: column; + min-height: 100%; +} From 8c524cab317cc6c7277a15cabea38c38b03fb28d Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 1 Jun 2026 12:43:33 -0700 Subject: [PATCH 10/53] Jetpack Beta: implement plugin-list screen and global toggles Co-Authored-By: Claude Opus 4.8 (1M context) --- .../beta/src/js/components/global-toggles.tsx | 158 ++++++++++++++++++ .../beta/src/js/screens/plugin-list.tsx | 118 ++++++++++++- 2 files changed, 272 insertions(+), 4 deletions(-) create mode 100644 projects/plugins/beta/src/js/components/global-toggles.tsx diff --git a/projects/plugins/beta/src/js/components/global-toggles.tsx b/projects/plugins/beta/src/js/components/global-toggles.tsx new file mode 100644 index 000000000000..88f6c9faf480 --- /dev/null +++ b/projects/plugins/beta/src/js/components/global-toggles.tsx @@ -0,0 +1,158 @@ +/** + * GlobalToggles — settings panel for autoupdates and email notifications. + * + * Fetches current settings on mount and lets the user toggle them optimistically, + * rolling back on error and surfacing a Notice when something goes wrong. + * + * @package + */ + +import { ToggleControl } from '@wordpress/components'; +import { useCallback, useEffect, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Card, Notice } from '@wordpress/ui'; +import { getSettings, updateSettings } from '../api/abilities'; +import type { Settings } from '../api/types'; + +type InFlight = 'autoupdates' | 'email_notifications' | null; + +/** + * Settings panel that exposes the autoupdates and email notification toggles. + * + * @return The settings card element, a loading placeholder, or an error notice. + */ +const GlobalToggles = () => { + const [ settings, setSettings ] = useState< Settings | null >( null ); + const [ loading, setLoading ] = useState( true ); + const [ fetchError, setFetchError ] = useState< string | null >( null ); + const [ updateError, setUpdateError ] = useState< string | null >( null ); + const [ inFlight, setInFlight ] = useState< InFlight >( null ); + + useEffect( () => { + let cancelled = false; + getSettings() + .then( data => { + if ( ! cancelled ) { + setSettings( data ); + setLoading( false ); + } + } ) + .catch( ( err: unknown ) => { + if ( ! cancelled ) { + const msg = + err && typeof err === 'object' && 'message' in err && typeof err.message === 'string' + ? err.message + : __( 'Could not load settings.', 'jetpack-beta' ); + setFetchError( msg ); + setLoading( false ); + } + } ); + return () => { + cancelled = true; + }; + }, [] ); + + const handleAutoupdates = useCallback( + ( checked: boolean ) => { + if ( ! settings ) { + return; + } + const previous = settings; + setSettings( { ...settings, autoupdates: checked } ); + setUpdateError( null ); + setInFlight( 'autoupdates' ); + updateSettings( { autoupdates: checked } ) + .then( updated => { + setSettings( updated ); + } ) + .catch( ( err: unknown ) => { + setSettings( previous ); + const msg = + err && typeof err === 'object' && 'message' in err && typeof err.message === 'string' + ? err.message + : __( 'Could not save autoupdates setting.', 'jetpack-beta' ); + setUpdateError( msg ); + } ) + .finally( () => { + setInFlight( null ); + } ); + }, + [ settings ] + ); + + const handleEmailNotifications = useCallback( + ( checked: boolean ) => { + if ( ! settings ) { + return; + } + const previous = settings; + setSettings( { ...settings, email_notifications: checked } ); + setUpdateError( null ); + setInFlight( 'email_notifications' ); + updateSettings( { email_notifications: checked } ) + .then( updated => { + setSettings( updated ); + } ) + .catch( ( err: unknown ) => { + setSettings( previous ); + const msg = + err && typeof err === 'object' && 'message' in err && typeof err.message === 'string' + ? err.message + : __( 'Could not save email notifications setting.', 'jetpack-beta' ); + setUpdateError( msg ); + } ) + .finally( () => { + setInFlight( null ); + } ); + }, + [ settings ] + ); + + if ( loading ) { + return null; + } + + if ( fetchError ) { + return ( + + { fetchError } + + ); + } + + if ( ! settings ) { + return null; + } + + const showEmailToggle = settings.autoupdates && ! settings.skip_email; + + return ( + + + { updateError && ( + + { updateError } + + ) } + + { showEmailToggle && ( + + ) } + + + ); +}; + +export default GlobalToggles; diff --git a/projects/plugins/beta/src/js/screens/plugin-list.tsx b/projects/plugins/beta/src/js/screens/plugin-list.tsx index be855519e67d..6d3b4fca059f 100644 --- a/projects/plugins/beta/src/js/screens/plugin-list.tsx +++ b/projects/plugins/beta/src/js/screens/plugin-list.tsx @@ -1,18 +1,128 @@ /** - * PluginList screen stub — replaced in Task 6. + * PluginList screen — shows all managed plugins with their active branch/version + * and a "Manage" link, plus the global settings toggles. * * @package */ +import { Spinner } from '@wordpress/components'; +import { useEffect, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +import { Badge, Button, Card, Notice, Stack, Text } from '@wordpress/ui'; +import { listPlugins } from '../api/abilities'; +import GlobalToggles from '../components/global-toggles'; +import type { PluginListItem } from '../api/types'; /** - * Stub component for the plugin list screen. + * Derive the display version string and badge label for a plugin row. * - * @return The stub element. + * @param plugin - The plugin list item. + * @return Version text and optional badge label. + */ +const pluginStatus = ( + plugin: PluginListItem +): { versionText: string; badgeLabel: string | null } => { + if ( plugin.active_which === 'dev' ) { + return { + versionText: plugin.active_version ?? '', + badgeLabel: __( 'Dev', 'jetpack-beta' ), + }; + } + if ( plugin.active_which === 'stable' ) { + return { + versionText: plugin.active_version ?? '', + badgeLabel: __( 'Stable', 'jetpack-beta' ), + }; + } + return { + versionText: __( 'Plugin is not active', 'jetpack-beta' ), + badgeLabel: null, + }; +}; + +/** + * PluginList screen component. + * + * Fetches all managed plugins on mount and renders a card per plugin alongside + * the GlobalToggles settings panel. + * + * @return The plugin list screen element. */ const PluginList = () => { - return

{ __( 'Loading…', 'jetpack-beta' ) }

; + const [ plugins, setPlugins ] = useState< PluginListItem[] | null >( null ); + const [ loading, setLoading ] = useState( true ); + const [ error, setError ] = useState< string | null >( null ); + + useEffect( () => { + let cancelled = false; + listPlugins() + .then( data => { + if ( ! cancelled ) { + setPlugins( data.plugins ); + setLoading( false ); + } + } ) + .catch( ( err: unknown ) => { + if ( ! cancelled ) { + const msg = + err && typeof err === 'object' && 'message' in err && typeof err.message === 'string' + ? err.message + : __( 'Could not load plugins.', 'jetpack-beta' ); + setError( msg ); + setLoading( false ); + } + } ); + return () => { + cancelled = true; + }; + }, [] ); + + return ( + + + { loading && } + { error && ( + + { error } + + ) } + { plugins && + plugins.map( plugin => { + const { versionText, badgeLabel } = pluginStatus( plugin ); + return ( + + + + + + { plugin.name } + + + { versionText } + { badgeLabel && ( + + { badgeLabel } + + ) } + + + + + + + ); + } ) } + + ); }; export default PluginList; From b0e1778b2df52d4989ccc3b2c5939d2de27b8cfa Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 1 Jun 2026 12:50:12 -0700 Subject: [PATCH 11/53] Jetpack Beta: serialize settings toggles and show loading spinner Co-Authored-By: Claude Opus 4.8 (1M context) --- .../beta/src/js/components/global-toggles.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/projects/plugins/beta/src/js/components/global-toggles.tsx b/projects/plugins/beta/src/js/components/global-toggles.tsx index 88f6c9faf480..66014b97b0fc 100644 --- a/projects/plugins/beta/src/js/components/global-toggles.tsx +++ b/projects/plugins/beta/src/js/components/global-toggles.tsx @@ -7,7 +7,7 @@ * @package */ -import { ToggleControl } from '@wordpress/components'; +import { Spinner, ToggleControl } from '@wordpress/components'; import { useCallback, useEffect, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { Card, Notice } from '@wordpress/ui'; @@ -54,6 +54,9 @@ const GlobalToggles = () => { const handleAutoupdates = useCallback( ( checked: boolean ) => { + if ( inFlight !== null ) { + return; + } if ( ! settings ) { return; } @@ -77,11 +80,15 @@ const GlobalToggles = () => { setInFlight( null ); } ); }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- inFlight read only guards re-entrancy; stale closure is safe because inFlight is set before any await [ settings ] ); const handleEmailNotifications = useCallback( ( checked: boolean ) => { + if ( inFlight !== null ) { + return; + } if ( ! settings ) { return; } @@ -105,11 +112,12 @@ const GlobalToggles = () => { setInFlight( null ); } ); }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- inFlight read only guards re-entrancy; stale closure is safe because inFlight is set before any await [ settings ] ); if ( loading ) { - return null; + return ; } if ( fetchError ) { From 0717b11f6713b38791661f02e2751248fa8f1f37 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 1 Jun 2026 12:57:43 -0700 Subject: [PATCH 12/53] Jetpack Beta: implement plugin-manage screen with branch activation Adds the PluginManage screen (branch picker) and supporting components: markdown-panel, branch-card, branch-section. The manage screen owns its own AdminPage wrapper so it can inject a plugin-name breadcrumb after the async getPlugin() resolves. app.tsx routes list vs manage accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) --- projects/plugins/beta/src/js/app.tsx | 16 +- .../beta/src/js/components/branch-card.tsx | 88 +++++++ .../beta/src/js/components/branch-section.tsx | 82 +++++++ .../beta/src/js/components/markdown-panel.tsx | 36 +++ .../beta/src/js/screens/plugin-manage.tsx | 224 +++++++++++++++++- 5 files changed, 434 insertions(+), 12 deletions(-) create mode 100644 projects/plugins/beta/src/js/components/branch-card.tsx create mode 100644 projects/plugins/beta/src/js/components/branch-section.tsx create mode 100644 projects/plugins/beta/src/js/components/markdown-panel.tsx diff --git a/projects/plugins/beta/src/js/app.tsx b/projects/plugins/beta/src/js/app.tsx index 171c7017a5d3..9a1b937c13ed 100644 --- a/projects/plugins/beta/src/js/app.tsx +++ b/projects/plugins/beta/src/js/app.tsx @@ -1,9 +1,9 @@ /** - * Root App component — AdminPage shell with screen routing. + * Root App component — screen routing. * * Reads `window.JetpackBeta.plugin` to decide which screen to render: - * - null → PluginList (all plugins overview) - * - string → PluginManage (single-plugin manage view) + * - null → PluginList wrapped in AdminPage (all plugins overview) + * - string → PluginManage (single-plugin manage view, owns its own AdminPage so it can supply a breadcrumb once the plugin name is known) * * @package */ @@ -18,11 +18,17 @@ const boot = window.JetpackBeta; /** * App component. * - * @return The AdminPage shell with the active screen. + * @return The active screen, wrapped in AdminPage where appropriate. */ const App = () => { const plugin = boot.plugin; + // The manage screen owns its own AdminPage so it can inject a breadcrumb + // once it knows the plugin name (fetched asynchronously). + if ( plugin !== null ) { + return ; + } + return ( { apiRoot={ boot.apiRoot } apiNonce={ boot.apiNonce } > - { plugin === null ? : } + ); }; diff --git a/projects/plugins/beta/src/js/components/branch-card.tsx b/projects/plugins/beta/src/js/components/branch-card.tsx new file mode 100644 index 000000000000..9a84ff98fa1a --- /dev/null +++ b/projects/plugins/beta/src/js/components/branch-card.tsx @@ -0,0 +1,88 @@ +/** + * BranchCard — displays a single branch with its version label and an Activate button. + * + * @package + */ + +import { useCallback, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Badge, Button, Card, Notice, Stack, Text } from '@wordpress/ui'; +import { activateBranch } from '../api/abilities'; +import type { BranchCard as BranchCardType, PluginView } from '../api/types'; + +type Props = { + card: BranchCardType; + pluginSlug: string; + onActivated: ( view: PluginView ) => void; +}; + +/** + * Renders a branch card with version label, active badge, and activate button. + * + * @param {Props} props - Component props. + * @return The branch card element. + */ +const BranchCard = ( { card, pluginSlug, onActivated }: Props ) => { + const [ busy, setBusy ] = useState( false ); + const [ error, setError ] = useState< string | null >( null ); + + const label = card.pretty_version ?? card.branch ?? card.version ?? ''; + + const handleActivate = useCallback( () => { + if ( busy ) { + return; + } + setBusy( true ); + setError( null ); + activateBranch( pluginSlug, card.source ?? '', card.id ?? '' ) + .then( result => { + onActivated( result.plugin ); + } ) + .catch( ( err: unknown ) => { + const msg = + err && typeof err === 'object' && 'message' in err && typeof err.message === 'string' + ? err.message + : __( 'Could not activate branch.', 'jetpack-beta' ); + setError( msg ); + } ) + .finally( () => { + setBusy( false ); + } ); + // eslint-disable-next-line react-hooks/exhaustive-deps -- busy guards re-entrancy; stale closure is safe because busy is set before any await + }, [ card.id, card.source, onActivated, pluginSlug ] ); + + return ( + + + { error && ( + + { error } + + ) } + + + { label } + { card.is_active && ( + { __( 'Active', 'jetpack-beta' ) } + ) } + + { ! card.is_active && ( + + ) } + + + + ); +}; + +export default BranchCard; diff --git a/projects/plugins/beta/src/js/components/branch-section.tsx b/projects/plugins/beta/src/js/components/branch-section.tsx new file mode 100644 index 000000000000..99b73e53409b --- /dev/null +++ b/projects/plugins/beta/src/js/components/branch-section.tsx @@ -0,0 +1,82 @@ +/** + * BranchSection — renders a group of branch cards with an optional search filter. + * + * @package + */ + +import { SearchControl } from '@wordpress/components'; +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Stack, Text } from '@wordpress/ui'; +import BranchCard from './branch-card'; +import type { BranchCard as BranchCardType, PluginView } from '../api/types'; + +type Props = { + title: string; + cards: BranchCardType[]; + searchable?: boolean; + pluginSlug: string; + onActivated: ( view: PluginView ) => void; + searchPlaceholder?: string; +}; + +/** + * Renders a labeled section of branch cards, with optional client-side search filtering. + * + * When `searchable` is true, a SearchControl is shown that filters cards by matching + * the query (case-insensitive) against `pretty_version`, `branch`, and `version`. + * + * @param {Props} props - Component props. + * @return The branch section element, or null if there are no cards. + */ +const BranchSection = ( { + title, + cards, + searchable = false, + pluginSlug, + onActivated, + searchPlaceholder, +}: Props ) => { + const [ query, setQuery ] = useState( '' ); + + if ( cards.length === 0 ) { + return null; + } + + const filteredCards = + searchable && query.trim() !== '' + ? cards.filter( card => { + const q = query.toLowerCase(); + return ( + ( card.pretty_version?.toLowerCase().includes( q ) ?? false ) || + ( card.branch?.toLowerCase().includes( q ) ?? false ) || + ( card.version?.toLowerCase().includes( q ) ?? false ) + ); + } ) + : cards; + + return ( + + { title } + { searchable && ( + + ) } + { filteredCards.map( ( card, index ) => ( + + ) ) } + + ); +}; + +export default BranchSection; diff --git a/projects/plugins/beta/src/js/components/markdown-panel.tsx b/projects/plugins/beta/src/js/components/markdown-panel.tsx new file mode 100644 index 000000000000..e20752f8dfc1 --- /dev/null +++ b/projects/plugins/beta/src/js/components/markdown-panel.tsx @@ -0,0 +1,36 @@ +/** + * MarkdownPanel — a collapsible card that renders sanitized HTML content. + * + * The HTML content is already sanitized server-side (via Parsedown + wp_kses) + * before being passed through the API, so dangerouslySetInnerHTML is safe here. + * + * @package + */ + +import { CollapsibleCard } from '@wordpress/ui'; + +type Props = { + title: string; + html: string; +}; + +/** + * Renders a collapsible card containing server-sanitized HTML content. + * + * @param {Props} props - Component props. + * @return The collapsible card element. + */ +const MarkdownPanel = ( { title, html }: Props ) => { + return ( + + { title } + + { /* HTML is sanitized server-side via Parsedown + wp_kses before API delivery */ } + { /* eslint-disable-next-line react/no-danger */ } +
+ + + ); +}; + +export default MarkdownPanel; diff --git a/projects/plugins/beta/src/js/screens/plugin-manage.tsx b/projects/plugins/beta/src/js/screens/plugin-manage.tsx index 74142f15aff5..8de08409f0f2 100644 --- a/projects/plugins/beta/src/js/screens/plugin-manage.tsx +++ b/projects/plugins/beta/src/js/screens/plugin-manage.tsx @@ -1,24 +1,234 @@ /** - * PluginManage screen stub — replaced in Task 7. + * PluginManage screen — per-plugin branch picker. + * + * Fetches the plugin view on mount and renders: + * - Global settings toggles (unless it's a mu-plugin) + * - Currently-running card with bug report link + * - Branch sections (existing, stable, rc, trunk, pr, release) + * - "To Test" and "What changed" collapsible panels * * @package */ +import { AdminPage } from '@automattic/jetpack-components'; +import { Spinner } from '@wordpress/components'; +import { useCallback, useEffect, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +import { Button, Card, Notice, Stack, Text } from '@wordpress/ui'; +import { getPlugin } from '../api/abilities'; +import BranchSection from '../components/branch-section'; +import GlobalToggles from '../components/global-toggles'; +import MarkdownPanel from '../components/markdown-panel'; +import type { BranchCard as BranchCardType, PluginView } from '../api/types'; type Props = { slug: string; }; +const boot = window.JetpackBeta; + +/** + * Section definitions in display order. + * Searchable sections have a search placeholder matching the original PHP template. + */ +const SECTION_CONFIG: Array< { + key: string; + title: string; + searchable?: boolean; + searchPlaceholder?: string; +} > = [ + { key: 'existing', title: __( 'Existing', 'jetpack-beta' ) }, + { key: 'stable', title: __( 'Latest Stable', 'jetpack-beta' ) }, + { key: 'rc', title: __( 'Release Candidate', 'jetpack-beta' ) }, + { key: 'trunk', title: __( 'Bleeding Edge', 'jetpack-beta' ) }, + { + key: 'pr', + title: __( 'Feature Branches', 'jetpack-beta' ), + searchable: true, + searchPlaceholder: __( 'Search for a Feature Branch', 'jetpack-beta' ), + }, + { + key: 'release', + title: __( 'Released Versions', 'jetpack-beta' ), + searchable: true, + searchPlaceholder: __( 'Search for a release', 'jetpack-beta' ), + }, +]; + +/** + * Group the flat sections array from the API into a map keyed by section name. + * + * @param sections - The flat list of branch cards from the API. + * @return A map from section key to its branch cards. + */ +const groupSections = ( sections: BranchCardType[] ): Map< string, BranchCardType[] > => { + const map = new Map< string, BranchCardType[] >(); + for ( const card of sections ) { + const existing = map.get( card.section ); + if ( existing ) { + existing.push( card ); + } else { + map.set( card.section, [ card ] ); + } + } + return map; +}; + +/** + * Render a simple breadcrumb ReactNode: "Jetpack Beta Tester › Plugin Name". + * + * @param pluginName - The current plugin name, or null while loading. + * @return The breadcrumb element. + */ +const renderBreadcrumbs = ( pluginName: string | null ) => ( + + { __( 'Jetpack Beta Tester', 'jetpack-beta' ) } + { pluginName && ( + <> + + { pluginName } + + ) } + +); + /** - * Stub component for the plugin manage screen. + * PluginManage screen component. + * + * Fetches the plugin view on mount and renders branch cards for each section, + * with search filtering for PR and release branches. * - * @param {Props} _props - Component props (unused in stub). - * @return The stub element. + * @param {Props} props - Component props. + * @return The manage screen element. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const PluginManage = ( _props: Props ) => { - return

{ __( 'Loading…', 'jetpack-beta' ) }

; +const PluginManage = ( { slug }: Props ) => { + const [ view, setView ] = useState< PluginView | null >( null ); + const [ loading, setLoading ] = useState( true ); + const [ error, setError ] = useState< string | null >( null ); + + useEffect( () => { + let cancelled = false; + setLoading( true ); + setError( null ); + getPlugin( slug ) + .then( data => { + if ( ! cancelled ) { + setView( data ); + setLoading( false ); + } + } ) + .catch( ( err: unknown ) => { + if ( ! cancelled ) { + const msg = + err && typeof err === 'object' && 'message' in err && typeof err.message === 'string' + ? err.message + : __( 'Could not load plugin.', 'jetpack-beta' ); + setError( msg ); + setLoading( false ); + } + } ); + return () => { + cancelled = true; + }; + }, [ slug ] ); + + const handleActivated = useCallback( ( updated: PluginView ) => { + setView( updated ); + }, [] ); + + const pluginName = view?.name ?? null; + const sectionMap = view ? groupSections( view.sections ) : new Map< string, BranchCardType[] >(); + + return ( + + { loading && } + { error && ( + + { error } + + ) } + { view && ( + + { ! view.is_mu_plugin && } + + { view.is_mu_plugin && ( + + + { __( + 'This plugin will be installed as a mu-plugin. See the documentation for details on what this entails.', + 'jetpack-beta' + ) } + + + ) } + + { view.currently_running && ( + + + + + + + { view.name } { __( '— Currently Running', 'jetpack-beta' ) } + + + + { view.currently_running.pretty_version ?? + view.currently_running.version ?? + '' } + + + + + + + ) } + + { SECTION_CONFIG.map( ( { key, title, searchable, searchPlaceholder } ) => ( + + ) ) } + + { view.to_test_html && ( + + ) } + + { view.what_changed_html && ( + + ) } + + ) } + + ); }; export default PluginManage; From 5be2f8dbd87a262e6a74e77b8866929be7d3d999 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 1 Jun 2026 13:13:37 -0700 Subject: [PATCH 13/53] Jetpack Beta: fix manage-screen header, default branch visibility, and mu-plugin notice Co-Authored-By: Claude Opus 4.8 (1M context) --- .../beta/src/js/components/branch-card.tsx | 2 +- .../beta/src/js/components/branch-section.tsx | 36 ++++++++++++------- .../beta/src/js/screens/plugin-manage.tsx | 13 +++++-- 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/projects/plugins/beta/src/js/components/branch-card.tsx b/projects/plugins/beta/src/js/components/branch-card.tsx index 9a84ff98fa1a..59b4002822e0 100644 --- a/projects/plugins/beta/src/js/components/branch-card.tsx +++ b/projects/plugins/beta/src/js/components/branch-card.tsx @@ -48,7 +48,7 @@ const BranchCard = ( { card, pluginSlug, onActivated }: Props ) => { .finally( () => { setBusy( false ); } ); - // eslint-disable-next-line react-hooks/exhaustive-deps -- busy guards re-entrancy; stale closure is safe because busy is set before any await + // eslint-disable-next-line react-hooks/exhaustive-deps -- re-entry is prevented by `disabled={ busy }` on the Button; the in-handler `if (busy) return` is a secondary guard only }, [ card.id, card.source, onActivated, pluginSlug ] ); return ( diff --git a/projects/plugins/beta/src/js/components/branch-section.tsx b/projects/plugins/beta/src/js/components/branch-section.tsx index 99b73e53409b..2148c6f84c41 100644 --- a/projects/plugins/beta/src/js/components/branch-section.tsx +++ b/projects/plugins/beta/src/js/components/branch-section.tsx @@ -43,17 +43,24 @@ const BranchSection = ( { return null; } - const filteredCards = - searchable && query.trim() !== '' - ? cards.filter( card => { - const q = query.toLowerCase(); - return ( - ( card.pretty_version?.toLowerCase().includes( q ) ?? false ) || - ( card.branch?.toLowerCase().includes( q ) ?? false ) || - ( card.version?.toLowerCase().includes( q ) ?? false ) - ); - } ) - : cards; + const trimmedQuery = query.trim(); + const hasQuery = trimmedQuery !== ''; + + let filteredCards: BranchCardType[]; + if ( ! searchable ) { + filteredCards = cards; + } else if ( hasQuery ) { + filteredCards = cards.filter( card => { + const q = trimmedQuery.toLowerCase(); + return ( + ( card.pretty_version?.toLowerCase().includes( q ) ?? false ) || + ( card.branch?.toLowerCase().includes( q ) ?? false ) || + ( card.version?.toLowerCase().includes( q ) ?? false ) + ); + } ); + } else { + filteredCards = cards.filter( c => c.is_active ); + } return ( @@ -67,14 +74,17 @@ const BranchSection = ( { onChange={ setQuery } /> ) } - { filteredCards.map( ( card, index ) => ( + { filteredCards.map( card => ( ) ) } + { searchable && hasQuery && filteredCards.length === 0 && ( + { __( 'No branches match your search.', 'jetpack-beta' ) } + ) } ); }; diff --git a/projects/plugins/beta/src/js/screens/plugin-manage.tsx b/projects/plugins/beta/src/js/screens/plugin-manage.tsx index 8de08409f0f2..01d0e22bc610 100644 --- a/projects/plugins/beta/src/js/screens/plugin-manage.tsx +++ b/projects/plugins/beta/src/js/screens/plugin-manage.tsx @@ -141,8 +141,7 @@ const PluginManage = ( { slug }: Props ) => { return ( { { view.is_mu_plugin && ( + { __( 'This plugin will be installed as a mu-plugin. See', 'jetpack-beta' ) }{ ' ' } + + { __( 'the documentation', 'jetpack-beta' ) } + { ' ' } { __( - 'This plugin will be installed as a mu-plugin. See the documentation for details on what this entails.', + "for details on what this entails, particularly if you're newly installing a stable version.", 'jetpack-beta' ) } From 184b219cfde88086092269a6b7545f3e2a8c67cc Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 1 Jun 2026 13:17:33 -0700 Subject: [PATCH 14/53] Jetpack Beta: remove legacy PHP templates, vanilla JS, and hand-rolled CSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The React app (Tasks 5–7) fully replaces the old PHP-template/vanilla-JS UI. Delete the six dead templates, admin.js, updates.js, and admin.css. Strip the now-dead GET-action handlers (activate-branch, toggle-autoupdates, toggle-email-notifications) and the helper methods they used (is_toggle_action, show_toggle, show_toggle_autoupdates, show_toggle_emails) from class-admin.php, while keeping the multisite network-admin access-control redirect, render(), render_banner(), to_test_content(), admin_enqueue_scripts(), and plugin_action_links() intact. Co-Authored-By: Claude Opus 4.8 (1M context) --- projects/plugins/beta/src/admin/admin.css | 1299 ----------------- projects/plugins/beta/src/admin/admin.js | 268 ---- .../beta/src/admin/branch-card.template.php | 106 -- .../beta/src/admin/header.template.php | 33 - .../beta/src/admin/plugin-manage.template.php | 332 ----- .../beta/src/admin/plugin-select.template.php | 78 - .../admin/show-needed-updates.template.php | 99 -- .../beta/src/admin/toggles.template.php | 20 - projects/plugins/beta/src/admin/updates.js | 136 -- projects/plugins/beta/src/class-admin.php | 100 +- 10 files changed, 1 insertion(+), 2470 deletions(-) delete mode 100644 projects/plugins/beta/src/admin/admin.css delete mode 100644 projects/plugins/beta/src/admin/admin.js delete mode 100644 projects/plugins/beta/src/admin/branch-card.template.php delete mode 100644 projects/plugins/beta/src/admin/header.template.php delete mode 100644 projects/plugins/beta/src/admin/plugin-manage.template.php delete mode 100644 projects/plugins/beta/src/admin/plugin-select.template.php delete mode 100644 projects/plugins/beta/src/admin/show-needed-updates.template.php delete mode 100644 projects/plugins/beta/src/admin/toggles.template.php delete mode 100644 projects/plugins/beta/src/admin/updates.js diff --git a/projects/plugins/beta/src/admin/admin.css b/projects/plugins/beta/src/admin/admin.css deleted file mode 100644 index e89cc1eeb39f..000000000000 --- a/projects/plugins/beta/src/admin/admin.css +++ /dev/null @@ -1,1299 +0,0 @@ -.toplevel_page_jetpack-beta, -.jetpack_page_jetpack-beta { - background: #f3f6f8; -} - -.toplevel_page_jetpack-beta #wpcontent, -.jetpack_page_jetpack-beta #wpcontent { - padding-left: 0; -} - -html { - overflow: -moz-scrollbars-vertical; - overflow-y: scroll; -} - -.highlight { - background: #e9eff3; -} - -.jetpack-beta-logo { - width: 10.375rem; - height: 1.8125rem; - fill: #72af3a; -} - -.jetpack-beta__bleeding-edge-head { - background-color: #fff; - text-align: center; - box-shadow: 0 1px 0 rgba(200, 215, 225, 0.5), 0 1px 2px #e9eff3; - padding: 0 1.25rem; -} - -.jetpack-beta__bleeding-edge-head a { - text-decoration: none; -} - -.jetpack-beta__wrap { - margin-bottom: 32px; -} - -.jetpack-beta__wrap h2 { - font-weight: 400; - margin-bottom: 8px; -} - -.jetpack-beta__update-needed .dops-card { - border-left: 3px solid #f0b849; -} - -.jetpack-beta__update-needed .dops-card.is-error { - border-left: 3px solid #f04949; -} - -.jetpack-beta__bleeding-edge-head span { - font-weight: 300; - display: inline-block; - font-size: 20px; - vertical-align: super; - color: #a8bece; - line-height: 25px; - position: relative; - top: -5px; - left: 5px; -} - -.jetpack-beta__bleeding-edge-head .jetpack-beta-container { - padding: 11px 0 6px; -} - -#jetpack-beta-tester__is-mu-plugin.dops-card { - border-left: 3px solid #f0b849; - padding-top: 1px; - padding-bottom: 1px; - margin-top: 20px; -} - -.jetpack-beta-container { - margin: 0 auto; - text-align: left; - max-width: 45rem; - padding: 1.5rem; -} - -.jetpack-beta-container img { - width: 100%; - height: auto; -} - -.to-test { - margin-top: 16px; -} - -/* Card */ -.dops-card { - position: relative; - margin: 0 auto 0.625rem auto; - padding: 1rem; - box-sizing: border-box; - background: #fff; - box-shadow: 0 0 0 1px rgba(200, 215, 225, 0.5), 0 1px 2px #e9eff3; -} - -.dops-card::after { - content: "."; - display: block; - height: 0; - clear: both; - visibility: hidden; -} - -@media ( min-width: 481px ) { - - .dops-card { - margin-bottom: 1rem; - padding: 1.5rem; - } -} - -.dops-card.is-compact { - margin-bottom: 1px; -} - -@media ( min-width: 481px ) { - - .dops-card.is-compact { - margin-bottom: 1px; - padding: 1rem 1.5rem; - } -} - -.dops-card-title { - background-color: #f6f7f7; - color: #000; - font-family: Helvetica, Arial, sans-serif; - font-size: 12px; - font-weight: 400; - text-transform: uppercase; - border-bottom: 1px solid #dcdcde; - padding: 10px 16px; - margin: 0; -} - -@media ( min-width: 481px ) { - - .dops-card-title { - padding: 16px 24px; - } -} - -.dops-card-title .dops-card-meta { - color: #a7aaad; - float: right; -} - -.dops-card-section { - font-size: 14px; - padding: 16px; - border-bottom: 1px solid #dcdcde; -} - -.dops-card-section::after { - content: "."; - display: block; - height: 0; - clear: both; - visibility: hidden; -} - -@media ( min-width: 481px ) { - - .dops-card-section { - padding: 24px; - } -} - -.dops-card-section:last-child { - border-bottom: none; -} - -.dops-card-section .dops-card-section-label { - color: #000; - font-size: 12px; - font-weight: 400; - text-transform: uppercase; -} - -.dops-card-section .dops-card-section-orient-vertical .dops-card-section-label { - margin-bottom: 10px; -} - -.dops-card-section .dops-card-section-orient-horizontal .dops-card-section-label { - float: left; - width: 30%; -} - -@media all and ( max-width: 590px ) { - - .dops-card-section .dops-card-section-orient-horizontal .dops-card-section-label { - margin-bottom: 10px; - float: none; - width: 100%; - } -} - -.dops-card-section .dops-card-section-orient-horizontal .dops-card-section-content { - float: right; - width: 70%; -} - -@media all and ( max-width: 590px ) { - - .dops-card-section .dops-card-section-orient-horizontal .dops-card-section-content { - float: none; - width: 100%; - } -} - -.dops-card-footer { - background: #f6f7f7; - padding: 15px 20px; -} - -.dops-card-icon { - float: right; - text-transform: capitalize; -} - -.dops-card-icon .genericon { - border-radius: 50%; - width: 16px; - height: 16px; - margin-right: 10px; - color: #fff; - background: #81bf16; -} - -.dops-button { - background: #fff; - border-color: #c8d7e1; - border-style: solid; - border-width: 1px 1px 2px; - color: #2e4453; - cursor: pointer; - display: inline-block; - margin: 0; - outline: 0; - overflow: hidden; - font-size: 14px; - font-weight: 500; - text-overflow: ellipsis; - text-decoration: none; - vertical-align: top; - box-sizing: border-box; - line-height: 21px; - border-radius: 4px; - padding: 7px 14px 9px; - -webkit-appearance: none; - -moz-appearance: none; - min-width: 100px; - text-align: center; - appearance: none; -} - -.dops-button:hover { - border-color: #a8bece; - color: #2e4453; -} - -.dops-button:active { - border-width: 2px 1px 1px; -} - -.dops-button:visited { - color: #2e4453; -} - -.dops-button[disabled]:active, -.dops-button:disabled:active { - border-width: 1px 1px 2px; -} - -.dops-button:focus { - outline: 0; - border-color: #00aadc; - box-shadow: 0 0 0 2px #78dcfa; -} - -.dops-button.is-compact { - padding: 7px; - color: #668eaa; - font-size: 11px; - line-height: 1; - text-transform: uppercase; -} - -.dops-button.is-disabled, -.dops-button[disabled], -.dops-button:disabled { - color: #e9eff3 !important; - background: #fff !important; - border-color: #e9eff3 !important; - cursor: default !important; -} - -.dops-button.is-compact:disabled { - color: #e9eff3; -} - -.dops-button.is-compact .gridicon { - top: 4px; - margin-top: -8px; -} - -.dops-button.is-compact .gridicons-plus-small { - margin-left: -4px; -} - -.dops-button.is-compact .gridicons-plus-small:last-of-type { - margin-left: 0; -} - -.dops-button.is-compact .gridicons-plus-small + .gridicon { - margin-left: -4px; -} - -.dops-button.hidden { - display: none; -} - -.dops-button .gridicon { - position: relative; - top: 4px; - margin-top: -2px; - width: 18px; - height: 18px; -} - -.dops-button.is-primary { - background: #00aadc; - border-color: #0087be; - color: #fff; -} - -.dops-button.is-primary:hover, -.dops-button.is-primary:focus { - border-color: #005082; - color: #fff; -} - -.dops-button.is-primary[disabled], -.dops-button.is-primary:disabled { - background: #bceefd; - border-color: #8cc9e2; - color: #fff; -} - -.dops-button.is-primary.is-compact { - color: #fff; -} - -.dops-button.is-error { - background: #dc0000; - border-color: #be0000; -} - -.dops-button.is-error:hover, -.dops-button.is-error:focus { - border-color: #820000; -} - -.dops-button.is-error[disabled], -.dops-button.is-error:disabled { - background: #fdbcbc; - border-color: #e28c8c; -} - -@keyframes appear { - - 0% { - opacity: 0; - } - - 100% { - opacity: 1; - } -} - -/** - * Toggle - */ -.form-toggle__label { - text-decoration: none; - color: #124964; - margin-left: 10px; -} - -.form-toggle__label:focus { - box-shadow: none; -} - -.form-toggle-explanation { - text-transform: uppercase; - color: #87a6bc; - font-size: 11px; - line-height: 20px; - vertical-align: top; -} - -.form-toggle__switch { - margin-top: 2px; - border-radius: 8px; - width: 24px; - height: 16px; - background: #a8bece; - display: inline-block; -} - -.form-toggle__switch::after { - left: 2px; - top: 2px; - border-radius: 50%; - background: #fff; - transition: all 0.2s ease; - width: 12px; - height: 12px; - position: relative; - display: block; - content: ""; -} - -.form-toggle__label:active .form-toggle__switch, -.form-toggle__label.is-active .form-toggle__switch { - background: #00aadc; -} - -.form-toggle__label:active .form-toggle__switch::after, -.form-toggle__label.is-active .form-toggle__switch::after { - left: 10px; -} - -.form-toggle__label.is-active:active .form-toggle__switch { - background: #a8bece; -} - -.form-toggle__label.is-active:active .form-toggle__switch::after { - left: 2px; -} - -.dops-foldable-card.dops-card { - position: relative; - transition: margin 0.15s linear; - padding: 0; -} - -.dops-foldable-card.dops-card::after { - content: "."; - display: block; - height: 0; - clear: both; - visibility: hidden; -} - -.dops-foldable-card.dops-card.is-expanded { - margin-bottom: 8px; -} - -.dops-foldable-card.dops-card .is-clickable { - cursor: pointer; -} - -.dops-foldable-card__header { - min-height: 64px; - width: 100%; - padding: 16px; - box-sizing: border-box; - display: flex; - -ms-flex-align: center; - align-items: center; - -ms-flex-pack: justify; - justify-content: space-between; - position: relative; -} - -.dops-foldable-card__header.has-border .dops-foldable-card__summary, -.dops-foldable-card__header.has-border .dops-foldable-card__summary_expanded { - margin-right: 0; -} - -.dops-foldable-card__header.has-border .dops-foldable-card__expand { - border-left: 1px #f3f6f8 solid; -} - -.dops-foldable-card.is-compact .dops-foldable-card__header { - padding: 8px 16px; - min-height: 40px; -} - -.dops-foldable-card.is-expanded .dops-foldable-card__header { - margin-bottom: 0; - height: inherit; - min-height: 64px; -} - -.dops-foldable-card.is-expanded.is-compact .dops-foldable-card__header { - min-height: 40px; -} - -.dops-foldable-card.is-disabled .dops-foldable-card__header { - opacity: 0.2; -} - -.dops-foldable-card__action { - position: absolute; - top: 0; - right: 0; - height: 100%; - background: none; - border: 0; -} - -.dops-foldable-card.is-expanded .dops-foldable-card__action { - height: 100%; -} - -.dops-foldable-card.is-disabled .dops-foldable-card__action { - cursor: default; -} - -.accessible-focus .dops-foldable-card__action:focus { - outline: thin dotted; -} - -button.dops-foldable-card__action { - cursor: pointer; - outline: 0; -} - -.dops-foldable-card__main { - max-width: calc(100% - 36px); - display: block; - -ms-flex-align: center; - align-items: center; - width: 100%; - margin-right: 5px; - flex: 1 1; -} - -.dops-foldable-card__secondary { - display: flex; - -ms-flex-align: center; - align-items: center; - flex: 0 1; - -ms-flex-pack: end; - justify-content: flex-end; -} - -.dops-foldable-card__expand { - width: 48px; -} - -.dops-foldable-card__expand .gridicon { - fill: #87a6bc; - display: flex; - -ms-flex-align: center; - align-items: center; - width: 100%; - vertical-align: middle; - transition: transform 0.15s cubic-bezier(0.175, 0.885, 0.32, 1.275), color 0.2s ease-in; -} - -.dops-foldable-card.is-expanded .dops-foldable-card__expand .gridicon { - transform: rotate(180deg); -} - -.dops-foldable-card__expand .gridicon:hover { - fill: #87a6bc; -} - -.dops-foldable-card__expand:focus .gridicon, -.dops-foldable-card__expand:hover .gridicon { - fill: #00aadc; -} - -.dops-foldable-card__header-text { - font-size: 1.125rem; - width: 100%; - color: #1d2327; -} - -.dops-foldable-card__header-text small { - font-size: 0.8rem; -} - -.dops-foldable-card__subheader { - margin-top: 0.125rem; - margin-bottom: 0.125rem; - font-size: 0.875rem; - color: #4f748e; -} - -.dops-foldable-card__content { - display: none; -} - -.dops-foldable-card__content pre code { - display: block; - overflow: auto; -} - -.dops-foldable-card.is-expanded .dops-foldable-card__content { - display: block; - padding: 16px; - border-top: 1px solid #f3f6f8; -} - -.dops-foldable-card.is-compact .dops-foldable-card.is-expanded .dops-foldable-card__content { - padding: 8px; -} - -.dops-foldable-card.is-expanded .dops-foldable-card__content p:first-child { - margin-top: 0; -} - -.dops-foldable-card.is-expanded .dops-foldable-card__content p:last-child { - margin-bottom: 0; -} - -.dops-foldable-card__summary, -.dops-foldable-card__summary_expanded { - margin-right: 40px; - color: #87a6bc; - font-size: 12px; - transition: opacity 0.2s linear; - display: inline-block; -} - -.dops-foldable-card.has-expanded-summary .dops-foldable-card__summary, -.dops-foldable-card.has-expanded-summary .dops-foldable-card__summary_expanded { - transition: none; - flex: 2; - text-align: right; -} - -@media ( max-width: 480px ) { - - .dops-foldable-card__summary, - .dops-foldable-card__summary_expanded { - display: none; - } -} - -.dops-foldable-card__summary { - opacity: 1; - display: inline-block; -} - -.dops-foldable-card.is-expanded .dops-foldable-card__summary { - display: none; -} - -.has-expanded-summary .dops-foldable-card.is-expanded .dops-foldable-card__summary { - display: none; -} - -.dops-foldable-card__summary_expanded { - display: none; -} - -.dops-foldable-card.is-expanded .dops-foldable-card__summary_expanded { - display: inline-block; -} - -/** - * @component Search - */ -.dops-search { - margin-bottom: 24px; - width: 60px; - height: 51px; - position: relative; - z-index: 22; -} - -@media ( max-width: 660px ) { - - .dops-search { - width: 50px; - } -} - -.dops-search .dops-search-open__icon { - position: absolute; - top: 50%; - margin-top: -12px; - width: 60px; - z-index: 20; - color: #0087be; -} - -.dops-accessible-focus .dops-search .dops-search-open__icon:focus { - outline: dotted 1px #0087be; -} - -@media ( max-width: 660px ) { - - .dops-search .dops-search-open__icon { - width: 50px; - } -} - -.dops-search .dops-search-open__icon:hover { - color: #3d596d; -} - -.dops-search .dops-search-close__icon { - position: absolute; - bottom: 0; - top: 50%; - right: 0; - margin-top: -12px; - width: 60px; - cursor: pointer; - z-index: 20; - color: #3d596d; - display: none; - opacity: 0; - transition: opacity 0.2s ease-in; -} - -.dops-accessible-focus .dops-search .dops-search-close__icon:focus { - outline: dotted 1px #0087be; -} - -.dops-search .dops-search-close__icon::before { - position: absolute; - left: 0; - right: 0; - top: 50%; - margin-top: -8px; - font-size: 16px; - text-align: center; -} - -@media ( max-width: 660px ) { - - .dops-search .dops-search-close__icon::before { - font-size: 14px; - margin-top: -7px; - } -} - -@media ( max-width: 660px ) { - - .dops-search .dops-search-close__icon { - width: 50px; - } -} - -.dops-search.is-pinned { - margin-bottom: 0; - height: auto; - position: absolute; - bottom: 0; - top: 0; - right: 0; - z-index: 170; -} - -.dops-search.is-pinned .dops-search-open__icon { - right: 0; -} - -.dops-search.is-pinned .dops-search__input[type="search"] { - height: 100%; -} - -.dops-search__input[type="search"] { - width: 100%; - display: none; - position: absolute; - z-index: 10; - top: 0; - margin: 0; - padding: 0 50px 0 60px; - border: none; - background: #fff; - height: 51px; - -moz-appearance: none; - appearance: none; - box-sizing: border-box; - -webkit-appearance: none; - box-shadow: none; -} - -@media ( max-width: 660px ) { - - .dops-search__input[type="search"] { - opacity: 0; - left: 0; - padding-left: 50px; - } -} - -.dops-search__input[type="search"]::-webkit-search-cancel-button { - -webkit-appearance: none; -} - -.dops-search__input[type="search"]:focus { - box-shadow: none; - border: none; -} - -.dops-search.is-open { - margin-right: 0 !important; - width: 100%; -} - -.dops-search.is-open .dops-search-open__icon { - color: #3d596d; - left: 0; -} - -.dops-search.is-open .dops-search-close__icon { - display: inline-block; -} - -.dops-search.is-open .dops-search__input, -.dops-search.is-open .dops-search-close__icon { - opacity: 1; -} - -.dops-search.is-open .dops-search__input { - display: block; -} - -.dops-search .dops-spinner { - display: none; - position: absolute; - top: 50%; - left: 30px; - transform: translate(-50%, -50%); -} - -@media ( max-width: 660px ) { - - .dops-search .dops-spinner { - left: 25px; - } -} - -.dops-search.is-searching .dops-search-open__icon { - display: none; -} - -.dops-search.is-searching .dops-spinner { - display: block; - z-index: 20; -} - -@media ( max-width: 660px ) { - - .animating.dops-search-opening .dops-search input { - opacity: 1; - } -} - -/** - * Section Nav - */ -.dops-section-nav { - height: 35px; - position: relative; - width: 100%; - padding: 0; - margin: 0 0 1px 0; - background: #fff; - box-sizing: border-box; - box-shadow: 0 0 0 1px rgba(200, 215, 225, 0.5), 0 1px 2px #e9eff3; -} - -.dops-section-nav.is-empty .dops-section-nav__panel { - visibility: hidden; -} - -@media ( max-width: 480px ) { - - .dops-section-nav.is-open { - box-shadow: 0 0 0 1px #87a6bc, 0 2px 4px #c8d7e1; - } -} - -@media ( min-width: 481px ) { - - .dops-section-nav.has-pinned-items { - padding-right: 60px; - } -} - -@media ( min-width: 481px ) and ( max-width: 660px ) { - - .dops-section-nav.has-pinned-items { - padding-right: 50px; - } -} - -@media ( max-width: 660px ) { - - .dops-section-nav { - height: 46px; - margin-bottom: 9px; - } -} - -.dops-section-nav__mobile-header { - display: flex; - padding: 15px; - font-size: 14px; - line-height: 16px; - color: #2e4453; - font-weight: 600; - cursor: pointer; -} - -.dops-section-nav__mobile-header::after { - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - display: inline-block; - vertical-align: middle; - font: 400 16px/1 dashicons; - content: "\F347"; - line-height: 16px; - color: rgba(135, 166, 188, 0.5); -} - -.dops-section-nav.is-open .dops-section-nav__mobile-header::after { - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - display: inline-block; - vertical-align: middle; - font: 400 16px/1 dashicons; - content: "\F343"; -} - -.dops-section-nav.has-pinned-items .dops-section-nav__mobile-header { - padding-right: 50px; -} - -.dops-section-nav.has-pinned-items .dops-section-nav__mobile-header::after { - margin-left: 8px; -} - -@media ( min-width: 481px ) { - - .dops-section-nav__mobile-header { - display: none; - } -} - -.dops-section-nav__mobile-header-text { - width: 0; - flex: 1 0 auto; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} - -.dops-section-nav__mobile-header-text small { - margin-left: 5px; - font-size: 11px; - color: #87a6bc; - font-weight: 600; - text-transform: uppercase; -} - -.dops-section-nav.has-pinned-items .dops-section-nav__mobile-header-text { - width: auto; - flex: 0 1 auto; -} - -.dops-section-nav__panel { - box-sizing: border-box; - width: 100%; -} - -@media ( max-width: 480px ) { - - .dops-section-nav.is-open .dops-section-nav__panel { - padding-bottom: 15px; - border-top: solid 1px #c8d7e1; - background: linear-gradient(to bottom, #f3f6f8 0%, #fff 4px); - } -} - -@media ( min-width: 481px ) { - - .dops-section-nav__panel { - display: flex; - -ms-flex-align: center; - align-items: center; - } - - .dops-section-nav__panel:first-child { - width: 0; - flex: 1 0 auto; - } -} - -.dops-section-nav-group { - position: relative; - margin-top: 16px; - padding-top: 16px; - border-top: solid 1px #c8d7e1; -} - -.dops-section-nav-group:first-child { - padding-top: 0; - border-top: none; -} - -@media ( max-width: 480px ) { - - .dops-section-nav-group { - display: none; - } - - .dops-section-nav.is-open .dops-section-nav-group { - display: block; - } -} - -@media ( min-width: 481px ) { - - .dops-section-nav-group { - margin-top: 0; - padding-top: 0; - border-top: none; - } - - .dops-section-nav-group:first-child { - display: flex; - width: 0; - flex: 1 0 auto; - } -} - -.dops-section-nav__button { - width: 100%; - margin-top: 24px; -} - -.dops-section-nav__hr { - background: #e9eff3; -} - -.dops-section-nav-group__label { - display: none; - margin-bottom: 8px; - padding: 0 15px; - font-size: 11px; - color: #87a6bc; - font-weight: 600; - text-transform: uppercase; - line-height: 12px; -} - -@media ( max-width: 480px ) { - - .has-siblings .dops-section-nav-group__label { - display: block; - } -} - -.dops-section-nav-group__label-text { - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; -} - -.dops-section-nav-tab .count { - margin-left: 8px; -} - -@media ( min-width: 481px ) { - - .dops-section-nav-tabs { - width: 0; - flex: 1 0 auto; - } - - .dops-section-nav-tabs.is-dropdown { - position: relative; - width: auto; - flex: 0 1 auto; - margin: 8px; - } -} - -.dops-section-nav-tabs__list { - margin: 0; - list-style: none; -} - -@media ( min-width: 481px ) { - - .dops-section-nav-tabs__list { - display: flex; - width: 100%; - overflow: hidden; - } - - .is-dropdown .dops-section-nav-tabs__list { - display: none; - } -} - -.dops-section-nav-tab { - margin-bottom: 0; -} - -@media ( min-width: 481px ) { - - .dops-section-nav-tab { - width: auto; - flex: none; - border-bottom: 2px solid transparent; - border-top: none; - text-align: center; - } - - .dops-section-nav-tab.is-selected { - border-bottom-color: #2e4453; - } -} - -.dops-section-nav-tab__link, -.dops-section-nav-tab__text { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} - -.dops-section-nav-tab__link { - display: flex; - -ms-flex-align: center; - align-items: center; - box-sizing: border-box; - padding: 15px; - width: 100%; - font-size: 14px; - font-weight: 600; - line-height: 18px; - color: #2e4453; - cursor: pointer; -} - -.dops-section-nav-tab__link:visited { - color: #2e4453; -} - -.dops-section-nav-tab__link[disabled], -.notouch .dops-section-nav-tab__link[disabled]:hover { - color: #e9eff3; - cursor: default; -} - -.is-selected .dops-section-nav-tab__link { - color: #fff; - background-color: #00aadc; -} - -.dops-section-nav-tab__link:focus { - outline: none; - box-shadow: none; -} - -.accessible-focus .dops-section-nav-tab__link:focus { - outline: solid #87a6bc 1px; -} - -.is-external .dops-section-nav-tab__link::after { - font-size: 18px; - padding-left: 2px; -} - -.notouch .dops-section-nav-tab__link:hover { - color: #00aadc; -} - -.notouch .is-selected .dops-section-nav-tab__link:hover { - color: #fff; -} - -@media ( min-width: 481px ) { - - .dops-section-nav-tab__link { - display: block; - width: auto; - padding: 16px 16px 14px 16px; - color: #0087be; - font-weight: 400; - } - - .dops-section-nav-tab__link:visited { - color: #0087be; - } - - .is-selected .dops-section-nav-tab__link { - color: #2e4453; - background-color: transparent; - } - - .is-selected .dops-section-nav-tab__link::after { - display: none; - } - - .notouch .is-selected .dops-section-nav-tab__link:hover { - color: #2e4453; - } -} - -.dops-section-nav-tab__text { - display: block; - flex: 1 0 auto; - width: 0; - color: inherit; -} - -@media ( min-width: 481px ) { - - .dops-section-nav-tab__text { - display: inline; - flex: none; - width: auto; - } -} - -.dops-section-nav-tabs__dropdown { - position: relative; - z-index: 3; - width: 100%; -} - -.dops-section-nav-tabs__dropdown.is-open { - z-index: 4; -} - -.dops-section-nav-tabs__dropdown .dops-select-dropdown__container { - position: static; -} - -.dops-section-nav__segmented .dops-segmented-control { - margin: 0 15px; -} - -.dops-section-nav__segmented .dops-segmented-control__link { - padding: 3px 16px 5px; -} - -@media ( max-width: 480px ) { - - .dops-section-nav .dops-search.is-pinned { - height: 46px; - } -} - -.jpbeta-file-list { - display: inline-block; - white-space: nowrap; -} - -.jpbeta-file-list > li > .container { - width: 100%; - box-sizing: border-box; - display: flex; - -ms-flex-align: center; - align-items: center; - -ms-flex-pack: justify; - justify-content: space-between; -} - -.jpbeta-file-list > li > .container > span { - flex: 0 0 auto; -} - -.branch-card.branch-card-hide { - display: none; -} - -.branch-card.branch-card-active.branch-card-hide { - display: block; -} - -.branch-card-active .activate-branch { - display: none; -} - -.branch-card-active .dops-foldable-card__secondary .dops-foldable-card__summary::before { - content: attr(data-active); -} diff --git a/projects/plugins/beta/src/admin/admin.js b/projects/plugins/beta/src/admin/admin.js deleted file mode 100644 index f945bf62a7b0..000000000000 --- a/projects/plugins/beta/src/admin/admin.js +++ /dev/null @@ -1,268 +0,0 @@ -( function () { - // Elements - let prs = document.getElementById( 'section-pr' )?.querySelectorAll( '.branch-card' ); - if ( ! prs ) { - return; // Return early if on main plugin selection screen. - } - const releases = document.getElementById( 'section-releases' ).querySelectorAll( '.branch-card' ); - const search_input_prs = document.getElementById( 'search-component-prs' ); - const search_input_releases = document.getElementById( 'search-component-releases' ); - const search_close_link_prs = document.getElementById( 'search-component-prs-close' ); - const search_close_link_releases = document.getElementById( 'search-component-releases-close' ); - const activate_links = document.querySelectorAll( '.activate-branch' ); - const toggle_links = document.querySelectorAll( '.form-toggle__label' ); - - const pr_index = []; - const release_index = []; - const each = Array.prototype.forEach; - let clicked_activate = false; - let clicked_toggle = false; - - // Build index of prs - each.call( prs, function ( element, index ) { - hide( element ); - element.querySelector( '.activate-branch' ).setAttribute( 'data-index', index ); - pr_index[ index ] = { - header: element.querySelector( '.branch-card-header' ).textContent, - key: element.getAttribute( 'data-pr' ), - element: element, - }; - } ); - - // Build index of releases - each.call( releases, function ( element, index ) { - hide( element ); - element.querySelector( '.activate-branch' ).setAttribute( 'data-index', index ); - release_index[ index ] = { - header: element.querySelector( '.branch-card-header' ).textContent, - key: element.getAttribute( 'data-release' ), - element: element, - }; - } ); - - search_input_listener( search_input_prs ); - search_input_listener( search_input_releases ); - /** - * Attaches keyup event listener to the search inputs. - * - * @param {object} input_area - Search input DOM Element object. - */ - function search_input_listener( input_area ) { - if ( ! input_area ) { - return; - } - - input_area.addEventListener( 'keyup', function ( event ) { - const section_id = event.srcElement.id; - const search_for = pr_to_header( input_area.value ); - const index = 'search-component-releases' === section_id ? release_index : pr_index; - - if ( ! search_for ) { - if ( input_area.id === 'search-component-prs' ) { - each.call( prs, hide ); - hide( search_close_link_prs ); - } - - if ( input_area.id === 'search-component-releases' ) { - each.call( releases, hide ); - hide( search_close_link_releases ); - } - - return; - } - - if ( input_area.id === 'search-component-prs' ) { - show( search_close_link_prs ); - } - - if ( input_area.id === 'search-component-releases' ) { - show( search_close_link_releases ); - } - - index.forEach( show_found.bind( this, search_for, section_id ) ); - } ); - } - - /** - * Displays matching search results. - * - * @param {string} search_for - Search input term. - * @param {string} section - Which search section to display result in (pr/release). - * @param {object} found - A matching prs or releases array item from the search. - */ - function show_found( search_for, section, found ) { - const element = found.element; - const header_text = /^ *[0-9]+ *$/.test( search_for ) ? `${ found.key }` : found.header; - const class_selector = '.branch-card-header'; - - const found_position = header_text.toLowerCase().indexOf( search_for.toLowerCase() ); - if ( -1 === found_position ) { - hide( element ); - return; - } - - element.querySelector( class_selector ).innerHTML = highlight_word( search_for, header_text ); - show( element ); - } - - hide_search_close_link( search_close_link_prs ); - hide_search_close_link( search_close_link_releases ); - /** - * Attaches click event listener that controls hiding search results and clearing search inputs. - * Also handles hiding the close search icon when search input is empty. - * - * @param {object} section - DOM Element object for a close search icon. - */ - function hide_search_close_link( section ) { - if ( ! section ) { - return; - } - - hide( section ); - section.addEventListener( 'click', function ( event ) { - if ( section.id === 'search-component-prs-close' ) { - each.call( prs, hide ); - hide( section ); - search_input_prs.value = ''; - } - - if ( section.id === 'search-component-releases-close' ) { - each.call( releases, hide ); - hide( section ); - search_input_releases.value = ''; - } - - event.preventDefault(); - } ); - } - - // Attach click event listeners to all of the 'Activate' links. - each.call( activate_links, function ( element ) { - element.addEventListener( 'click', activate_link_click.bind( this, element ) ); - } ); - /** - * Handles click event for the 'Activate' links. - * - * @param {object} element - The 'Activate' link element being clicked. - */ - function activate_link_click( element ) { - if ( clicked_activate ) { - return; - } - if ( element.textContent === window.JetpackBeta.activate ) { - element.parentNode.textContent = window.JetpackBeta.activating; - } else { - element.parentNode.textContent = window.JetpackBeta.updating; - } - - const index = parseInt( element.getAttribute( 'data-index' ) ); - - prs = Array.prototype.filter.call( prs, function ( pr, i ) { - return index === i ? false : true; - } ); - disable_activate_branch_links(); - trackEvent( element ); - clicked_activate = true; - } - - /** - * Disables the 'Activate' links. - */ - function disable_activate_branch_links() { - each.call( activate_links, function ( element ) { - element.addEventListener( 'click', function ( event ) { - event.preventDefault(); - } ); - element.removeEventListener( 'click', activate_link_click.bind( this, element ) ); - element.classList.add( 'is-disabled' ); - } ); - } - - // Attaches click event listener to all toggle links. - each.call( toggle_links, function ( element ) { - element.addEventListener( 'click', toggle_link_click.bind( this, element ) ); - } ); - /** - * Handles click event for one of the toggle links (e.g. Autoupdates switch). - * - * @param {object} element - The toggle link element being clicked. - */ - function toggle_link_click( element ) { - if ( clicked_toggle ) { - return; - } - clicked_toggle = true; - element.classList.toggle( 'is-active' ); - trackEvent( element ); - } - - // Helper functions - - /** - * Massage search input to match pr/release 'header'. - * - * @param {string} search - The raw search input text. - * @return {string} The massaged search string. - */ - function pr_to_header( search ) { - return search - .replace( /\//g, ' / ' ) - .replace( /-/g, ' ' ) - .replace( / +/g, ' ' ) - .toLowerCase() - .trim(); - } - - /** - * Highlights text in search results matching the search input text. - * - * @param {string} word - The search input term. - * @param {string} phrase - The full pr/release header text. - * @return {string} Search result with span wrapping matching word (search input) for styling. - */ - function highlight_word( word, phrase ) { - // Escape special regex characters in the search word - const escapedWord = word.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ); - // Create a case-insensitive regex to find all occurrences in the phrase - const regex = new RegExp( escapedWord, 'gi' ); - // Replace with the matched text (preserving original case) wrapped in a span - return phrase.replace( regex, function ( match ) { - return '' + match + ''; - } ); - } - - /** - * Sets an element to display:none - * - * @param {object} element - DOM Element object. - */ - function hide( element ) { - element.classList.add( 'branch-card-hide' ); - } - - /** - * Unsets/clears an element's display value. - * - * @param {object} element - DOM Element object. - */ - function show( element ) { - element.classList.remove( 'branch-card-hide' ); - } - - /** - * Track user event such as a click on a button or a link. - * - * @param {string} element - Element that was clicked. - */ - function trackEvent( element ) { - // Do not track anything if TOS have not been accepted yet and the file isn't enqueued. - if ( ! window.jpTracksAJAX || 'function' !== typeof window.jpTracksAJAX.record_ajax_event ) { - return; - } - - const eventName = element.getAttribute( 'data-jptracks-name' ); - const eventProp = element.getAttribute( 'data-jptracks-prop' ); - - window.jpTracksAJAX.record_ajax_event( eventName, 'click', eventProp ); - } -} )(); diff --git a/projects/plugins/beta/src/admin/branch-card.template.php b/projects/plugins/beta/src/admin/branch-card.template.php deleted file mode 100644 index 1f0954230b4c..000000000000 --- a/projects/plugins/beta/src/admin/branch-card.template.php +++ /dev/null @@ -1,106 +0,0 @@ -which ? $plugin->dev_plugin_slug() : $plugin->plugin_slug(); - $classes = array( 'dops-foldable-card', 'has-expanded-summary', 'dops-card', 'branch-card' ); - $data_attr = ''; - $more_info = array(); - if ( isset( $branch->pr ) && is_int( $branch->pr ) ) { - $data_attr = sprintf( 'data-pr="%s"', esc_attr( $branch->pr ) ); - // translators: Translates the `More info` link. %1$s: URL. %2$s: PR number. - $more_info[] = sprintf( __( 'more info #%2$s', 'jetpack-beta' ), $branch->plugin_url, $branch->pr ); - } elseif ( 'release' === $branch->source ) { - $data_attr = sprintf( 'data-release="%s"', esc_attr( $branch->version ) ); - $more_info[] = sprintf( - // translators: Which release is being selected. - __( 'Public release (%1$s) available on WordPress.org', 'jetpack-beta' ), - esc_html( $branch->version ), - esc_attr( $branch->version ) - ); - } elseif ( 'rc' === $branch->source || 'trunk' === $branch->source || 'unknown' === $branch->source && $branch->version ) { - $more_info[] = sprintf( - // translators: %s: Version number. - __( 'Version %s', 'jetpack-beta' ), - $branch->version - ); - } - - if ( isset( $branch->update_date ) ) { - // translators: %s is how long ago the branch was updated. - $more_info[] = sprintf( __( 'last updated %s ago', 'jetpack-beta' ), human_time_diff( strtotime( $branch->update_date ) ) ); - } - - $activate_url = wp_nonce_url( - Utils::admin_url( - array( - 'activate-branch' => "{$branch->source}:{$branch->id}", - 'plugin' => $plugin->plugin_slug(), - ) - ), - 'activate_branch' - ); - - if ( $active_branch->source === $branch->source && $active_branch->id === $branch->id ) { - $classes[] = 'branch-card-active'; - } - if ( 'unknown' === $branch->source ) { - if ( $branch->id === 'deactivate' ) { - $classes[] = 'deactivate-mu-plugin'; - $classes[] = 'deactivate-mu-plugin-' . $plugin->plugin_slug(); - } else { - $classes[] = 'existing-branch-for-' . $plugin->plugin_slug(); - } - } - if ( empty( $branch->is_last ) ) { - $classes[] = 'is-compact'; - } - - // Needs to match what core's wp_ajax_update_plugin() will return. - // phpcs:ignore WordPress.WP.I18n.MissingTranslatorsComment, WordPress.WP.I18n.TextDomainMismatch - $updater_version = sprintf( __( 'Version %s', 'default' ), $branch->version ); - - if ( $branch->source === 'unknown' && $branch->id === 'deactivate' ) { - $active_text = __( 'Inactive', 'jetpack-beta' ); - $activate_text = __( 'Deactivate', 'jetpack-beta' ); - } else { - $active_text = __( 'Active', 'jetpack-beta' ); - $activate_text = __( 'Activate', 'jetpack-beta' ); - } - - ?> -
class="" data-slug="" data-updater-version=""> -
- -
-
pretty_version ); ?>
-
- -
-
-
- - - - - -
-
- - diff --git a/projects/plugins/beta/src/admin/plugin-manage.template.php b/projects/plugins/beta/src/admin/plugin-manage.template.php deleted file mode 100644 index 44c62c6a764e..000000000000 --- a/projects/plugins/beta/src/admin/plugin-manage.template.php +++ /dev/null @@ -1,332 +0,0 @@ -get_manifest( true ); -$wporg_data = $plugin->get_wporg_data( true ); - -$existing_branch = null; -if ( file_exists( $plugin->plugin_path() ) ) { - $tmp = get_plugin_data( $plugin->plugin_path(), false, false ); - $existing_branch = $plugin->source_info( 'release', $tmp['Version'] ); - if ( ! $existing_branch || is_wp_error( $existing_branch ) ) { - $existing_branch = (object) array( - 'which' => 'stable', - 'source' => 'unknown', - 'id' => $tmp['Version'], - 'version' => $tmp['Version'], - 'pretty_version' => $plugin->stable_pretty_version(), - ); - } -} - -$active_branch = (object) array( - 'which' => null, - 'source' => null, - 'id' => null, -); -$version = null; -$verslug = ''; -if ( $plugin->is_active( 'stable' ) ) { - $active_branch = $existing_branch; - $verslug = $plugin->plugin_slug(); - $version = $active_branch->pretty_version; -} elseif ( $plugin->is_active( 'dev' ) ) { - $active_branch = $plugin->dev_info(); - if ( $active_branch ) { - $active_branch->which = 'dev'; - $active_branch->pretty_version = $plugin->dev_pretty_version(); - } else { - $tmp = get_plugin_data( $plugin->dev_plugin_path(), false, false ); - $active_branch = (object) array( - 'which' => 'dev', - 'source' => 'unknown', - 'id' => $tmp['Version'], - 'version' => $tmp['Version'], - 'pretty_version' => __( 'Unknown Development Version', 'jetpack-beta' ), - ); - } - $verslug = $plugin->dev_plugin_slug(); - $version = $active_branch->pretty_version . ' | ' . $active_branch->version; -} - -?> - -
-
- - - -  > get_name() ); ?> -
- - is_mu_plugin() ) { - require __DIR__ . '/toggles.template.php'; - } - ?> - - - is_mu_plugin() ) { - $url = sprintf( 'https://github.com/Automattic/jetpack-beta/blob/%s/docs/mu-plugin-info.md', rawurlencode( str_ends_with( JPBETA_VERSION, '-alpha' ) ? 'HEAD' : 'v' . JPBETA_VERSION ) ); - ?> -
-

get_name() ); ?> will be installed as a mu-plugin. See the documentation for details on what this entails, particularly if you're newly installing a stable version.

-
- - - -
-
- - - get_name() ); ?> - Currently Running - - -
-
-

-
-
-
-
- -
-
-
-
- - - - - -
-
- -
- source ) { - $branch = clone $existing_branch; - $branch->pretty_version = __( 'Existing Version', 'jetpack-beta' ); - require __DIR__ . '/branch-card.template.php'; - } - if ( $plugin->is_mu_plugin() && $active_branch && $active_branch->which === 'dev' && ! $existing_branch ) { - // This is a bit of a cheat. Telling it to activate an "unknown" existing stable version when there is no - // existing stable version has the effect of deactivating the plugin. This saves us having to write a special handler - // for mu-plugin deactivation. - $branch = (object) array( - 'which' => 'stable', - 'source' => 'unknown', - 'id' => 'deactivate', // Arbitrary, unused. - 'version' => '', - 'pretty_version' => 'Deactivate mu-plugin', - ); - require __DIR__ . '/branch-card.template.php'; - } - ?> - source_info( 'stable', '' ); - if ( $branch && ! is_wp_error( $branch ) ) { - $branch->pretty_version = __( 'Latest Stable', 'jetpack-beta' ); - require __DIR__ . '/branch-card.template.php'; - - // Fixup `$active_branch` so it doesn't show up as "active" under releases below. - if ( $active_branch->source === $branch->source && $active_branch->id === $branch->id ) { - $active_branch->source = 'stable'; - $active_branch->id = ''; - } - } - ?> - source_info( 'rc', '' ); - if ( $branch && ! is_wp_error( $branch ) ) { - require __DIR__ . '/branch-card.template.php'; - } - ?> - source_info( 'trunk', '' ); - if ( $branch && ! is_wp_error( $branch ) ) { - require __DIR__ . '/branch-card.template.php'; - } - ?> - - pr ) || ! (array) $manifest->pr ) { ?> -
- source ) { - $branch = clone $active_branch; - $branch->pretty_version = $branch->branch; - require __DIR__ . '/branch-card.template.php'; - } - ?> -
- -
-
-
- -
-
-
-
- pr; - end( $pr_list ); - $last = key( $pr_list ); - foreach ( $pr_list as $k => $pr ) { - $branch = $plugin->source_info( 'pr', $pr->branch ); - if ( $branch && ! is_wp_error( $branch ) ) { - // Add spaces around the branch name for historical reasons. - $branch->pretty_version = strtr( - $branch->branch, - array( - '/' => ' / ', - '-' => ' ', - ) - ); - $branch->is_last = $k === $last; - require __DIR__ . '/branch-card.template.php'; - } - } - ?> -
- - - versions ) || ! (array) $wporg_data->versions ) { ?> -
- source && $wporg_data->version !== $active_branch->id ) { - $branch = $active_branch; - require __DIR__ . '/branch-card.template.php'; - } - ?> -
- -
-
-
- -
-
-
-
- versions ); - $versions = Semver::rsort( $versions ); - end( $versions ); - $last = key( $versions ); - foreach ( $versions as $k => $v ) { - $branch = $plugin->source_info( 'release', $v ); - if ( $branch && ! is_wp_error( $branch ) ) { - unset( $branch->updated_date ); - $branch->pretty_version = $branch->version; - $branch->is_last = $k === $last; - require __DIR__ . '/branch-card.template.php'; - } - } - ?> -
- -
- - -
-
- -
-
-
-
-
-
- -
-
- -
-
- -
-
-
-
-
-
- -
-
- -
diff --git a/projects/plugins/beta/src/admin/plugin-select.template.php b/projects/plugins/beta/src/admin/plugin-select.template.php deleted file mode 100644 index b6015e20a6e1..000000000000 --- a/projects/plugins/beta/src/admin/plugin-select.template.php +++ /dev/null @@ -1,78 +0,0 @@ - - - -
- - - - -
- $plugin ) { - $classes = array( 'dops-foldable-card', 'has-expanded-summary', 'dops-card' ); - if ( $plugin->is_active( 'stable' ) ) { - $classes[] = 'plugin-stable'; - $verslug = $plugin->plugin_slug(); - $version = $plugin->stable_pretty_version() ?? ''; - } elseif ( $plugin->is_active( 'dev' ) ) { - $classes[] = 'plugin-dev'; - $verslug = $plugin->dev_plugin_slug(); - $version = $plugin->dev_pretty_version() ?? ''; - } else { - $classes[] = 'plugin-inactive'; - $verslug = ''; - $version = __( 'Plugin is not active', 'jetpack-beta' ); - } - $classes[] = 'is-compact'; - - $url = Utils::admin_url( - array( - 'plugin' => $slug, - ) - ); - - ?> -
-
- -
-
get_name() ); ?>
-
-
-
- - - - - -
-
- -
diff --git a/projects/plugins/beta/src/admin/show-needed-updates.template.php b/projects/plugins/beta/src/admin/show-needed-updates.template.php deleted file mode 100644 index 8fdd0471f6e6..000000000000 --- a/projects/plugins/beta/src/admin/show-needed-updates.template.php +++ /dev/null @@ -1,99 +0,0 @@ -plugin_file() => 1, - $plugin->dev_plugin_file() => 1, - JPBETA__PLUGIN_FOLDER . '/jetpack-beta.php' => 1, - ) - ); - } - if ( ! $updates ) { - return; - } - - wp_enqueue_script( 'jetpack-beta-updates', plugins_url( 'updates.js', __FILE__ ), array( 'jquery', 'updates' ), JPBETA_VERSION, true ); - wp_localize_script( - 'jetpack-beta-updates', - 'JetpackBetaUpdates', - array( - 'activate' => __( 'Activate', 'jetpack-beta' ), - 'activating' => __( 'Activating...', 'jetpack-beta' ), - 'updating' => __( 'Updating...', 'jetpack-beta' ), - 'leaving' => __( 'Don\'t go Plugin is still installing!', 'jetpack-beta' ), - ) - ); - // Junk needed by core's 'updates' JS. - wp_print_admin_notice_templates(); - wp_localize_script( - 'updates', - '_wpUpdatesItemCounts', - array( - 'totals' => wp_get_update_data(), - ) - ); - - ?> -
-

- $update ) { - $slug = dirname( $file ); - $isdev = false; - if ( JPBETA__PLUGIN_FOLDER === $slug ) { - // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - $name = $update->Name; - } else { - if ( str_ends_with( $slug, '-dev' ) ) { - $isdev = true; - $slug = substr( $slug, 0, -4 ); - } - $plugin = Plugin::get_plugin( $slug ); - $name = $plugin->get_name() . ' | ' . ( $isdev ? $plugin->dev_pretty_version() : $plugin->stable_pretty_version() ); - } - - $url = wp_nonce_url( self_admin_url( 'update.php?action=upgrade-plugin&plugin=' . rawurlencode( $file ) ), 'upgrade-plugin_' . $file ); - - // translators: %s: Version number. - $sub_header = sprintf( __( 'Version %s is available', 'jetpack-beta' ), $update->update->new_version ); - - ?> -
" data-plugin=""> -
- -
-
-
-
-
- - - - - -
-
- -
- - - - - diff --git a/projects/plugins/beta/src/admin/updates.js b/projects/plugins/beta/src/admin/updates.js deleted file mode 100644 index c809bd1ac0f6..000000000000 --- a/projects/plugins/beta/src/admin/updates.js +++ /dev/null @@ -1,136 +0,0 @@ -/* global jQuery, wp, JetpackBeta */ -/** - * Update message hooks. - * - * @param {jQuery} $ - jQuery object. - * @param {object} wp - WP object. - * @param {object} i18n - I18n data. - */ -( function ( $, wp, i18n ) { - const $updateNotices = $( '.jetpack-beta__update-needed' ), - $document = $( document ); - - /** - * Success handler for plugin updates. - * - * @param {object} response - Response object. - */ - function onSuccess( response ) { - // Too bad we can't just use wp.updates.updatePluginSuccess(), but it assumes it's on one of core's pages. - const $adminBarUpdates = $( '#wp-admin-bar-updates' ); - $adminBarUpdates.removeClass( 'spin' ); - - $updateNotices.find( '[data-plugin="' + response.plugin + '"]' ).remove(); - if ( $updateNotices.find( '[data-plugin]' ).length <= 0 ) { - $updateNotices.remove(); - } - - // Update any version strings that are flagged as being for this slug. - $( '[data-jpbeta-version-for="' + response.slug + '"]' ).text( response.newVersion ); - // Clear the "active" indicator on all branch cards, then try to set it on a card for the new version. - const $active = $( '.branch-card-active[data-slug="' + response.slug + '"]' ); - if ( $active.length ) { - $active.removeClass( 'branch-card-active' ); - $( - '.branch-card[data-slug="' + - response.slug + - '"][data-updater-version="' + - response.newVersion + - '"]' - ) - .first() - .addClass( 'branch-card-active' ); - } - // Delete the "Existing Version" branch card for the slug, if any, because we just updated it to - // some release version card. - $( '.existing-branch-for-' + response.slug ).remove(); - - wp.a11y.speak( wp.i18n.__( 'Update completed successfully.', 'jetpack-beta' ) ); - wp.updates.decrementCount( 'plugin' ); - $document.trigger( 'wp-plugin-update-success', response ); - } - - /** - * Error handler for plugin updates. - * - * @param {object} response - Response object. - */ - function onError( response ) { - // Too bad we can't just use wp.updates.updatePluginError(), but it assumes it's on one of core's pages. - if ( ! wp.updates.isValidResponse( response, 'update' ) ) { - return; - } - - if ( wp.updates.maybeHandleCredentialError( response, 'update-plugin' ) ) { - return; - } - - const $adminBarUpdates = $( '#wp-admin-bar-updates' ); - - let $notice; - if ( response.plugin ) { - $notice = $updateNotices.find( '[data-plugin="' + response.plugin + '"]' ); - } else { - $notice = $updateNotices.find( '[data-slug="' + response.slug + '"]' ); - } - const $button = $notice.find( '.update-branch' ); - - // eslint-disable-next-line @wordpress/valid-sprintf - const errorMessage = wp.i18n.sprintf( i18n.failedmsg, response.errorMessage ); - - $notice.addClass( 'is-error' ); - $button.removeClass( 'is-disabled' ).addClass( 'is-error' ); - $button.prop( 'disabled', false ); - $button.text( i18n.failed ); - $notice.find( '.error-message' ).remove(); - $notice - .find( '.dops-foldable-card__main' ) - .first() - .append( $( '
' ).html( errorMessage ) ); - - $adminBarUpdates.removeClass( 'spin' ); - - wp.a11y.speak( errorMessage, 'assertive' ); - - $document.trigger( 'wp-plugin-update-error', response ); - } - - /** - * Click handler for plugin updates in Jetpack Beta update notices. - * - * @param {Event} event - Event interface. - */ - $updateNotices.on( 'click', '[data-plugin] .update-branch', function ( event ) { - const $button = $( event.target ); - - event.preventDefault(); - - if ( $button.hasClass( 'is-disabled' ) ) { - return; - } - - const $notice = $button.parents( '.dops-card' ), - $adminBarUpdates = $( '#wp-admin-bar-updates' ); - - $notice.removeClass( 'is-error' ); - $notice.find( '.error-message' ).remove(); - $button.removeClass( 'is-error' ).addClass( 'is-disabled' ); - $button.prop( 'disabled', true ); - $button.text( i18n.updating ); - - wp.updates.maybeRequestFilesystemCredentials( event ); - - // Too bad we can't just call wp.updates.updatePlugin(), but it assumes it's on one of core's pages. - $adminBarUpdates.addClass( 'spin' ); - - const args = { - plugin: $notice.data( 'plugin' ), - slug: $notice.data( 'slug' ), - success: onSuccess, - error: onError, - }; - - $document.trigger( 'wp-plugin-updating', args ); - wp.updates.ajax( 'update-plugin', args ); - } ); -} )( jQuery, wp, JetpackBeta ); diff --git a/projects/plugins/beta/src/class-admin.php b/projects/plugins/beta/src/class-admin.php index b5823463b771..d842e2baeed1 100644 --- a/projects/plugins/beta/src/class-admin.php +++ b/projects/plugins/beta/src/class-admin.php @@ -114,10 +114,9 @@ public static function render() { public static function admin_page_load() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $plugin_name = isset( $_GET['plugin'] ) ? filter_var( wp_unslash( $_GET['plugin'] ) ) : null; - $plugin = null; // If a plugin is specified, check that it's valid. - // This comes before the nonce check for the access control. + // This comes before any redirect for the access control. if ( null !== $plugin_name ) { $plugin = Plugin::get_plugin( $plugin_name ); @@ -130,57 +129,6 @@ public static function admin_page_load() { exit( 0 ); } } - - // No nonce? Nothing else to do. - if ( ! isset( $_GET['_wpnonce'] ) ) { - return; - } - - // Install and activate Jetpack Version. - if ( - wp_verify_nonce( $_GET['_wpnonce'], 'activate_branch' ) && // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- WP core doesn't pre-sanitize nonces either. - isset( $_GET['activate-branch'] ) && $plugin - ) { - list( $source, $id ) = explode( ':', filter_var( wp_unslash( $_GET['activate-branch'] ) ), 2 ); - $res = $plugin->install_and_activate( $source, $id ); - if ( is_wp_error( $res ) ) { - // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - wp_die( $res ); - } - } - - // Toggle autoupdates. - if ( self::is_toggle_action( 'autoupdates' ) ) { - $autoupdate = (bool) Utils::is_set_to_autoupdate(); - update_option( 'jp_beta_autoupdate', (int) ! $autoupdate ); - - if ( Utils::is_set_to_autoupdate() ) { - Hooks::maybe_schedule_autoupdate(); - } - } - - // Toggle email notifications. - if ( self::is_toggle_action( 'email_notifications' ) ) { - $enable_email_notifications = (bool) Utils::is_set_to_email_notifications(); - update_option( 'jp_beta_email_notifications', (int) ! $enable_email_notifications ); - } - - wp_safe_redirect( Utils::admin_url( $plugin ? array( 'plugin' => $plugin_name ) : array() ) ); - exit( 0 ); - } - - /** - * Checks if autoupdates and email notifications are toggled. - * - * @param string $option - Which option is being toggled. - */ - private static function is_toggle_action( $option ) { - return ( - isset( $_GET['_wpnonce'] ) && - wp_verify_nonce( $_GET['_wpnonce'], "enable_$option" ) && // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- WP core doesn't pre-sanitize nonces either. - isset( $_GET['_action'] ) && - "toggle_enable_$option" === $_GET['_action'] - ); } /** @@ -306,50 +254,4 @@ public static function to_test_content( Plugin $plugin ) { } return array( Utils::render_markdown( $plugin, $wp_filesystem->get_contents( $file ) ), null ); } - - /** Display autoupdate toggle */ - public static function show_toggle_autoupdates() { - $autoupdate = (bool) Utils::is_set_to_autoupdate(); - self::show_toggle( __( 'Autoupdates', 'jetpack-beta' ), 'autoupdates', $autoupdate ); - } - - /** Display email notification toggle */ - public static function show_toggle_emails() { - if ( ! Utils::is_set_to_autoupdate() || defined( 'JETPACK_BETA_SKIP_EMAIL' ) ) { - return; - } - $email_notification = (bool) Utils::is_set_to_email_notifications(); - self::show_toggle( __( 'Email Notifications', 'jetpack-beta' ), 'email_notifications', $email_notification ); - } - - /** - * Display autoupdate and email notification toggles - * - * @param string $name name of toggle. - * @param string $option Which toggle (autoupdates, email_notification). - * @param bool $value If toggle is active or not. - */ - public static function show_toggle( $name, $option, $value ) { - $query = array( - '_action' => "toggle_enable_$option", - ); - // phpcs:ignore WordPress.Security.NonceVerification.Recommended - if ( isset( $_GET['plugin'] ) ) { - // phpcs:ignore WordPress.Security.NonceVerification.Recommended - $query['plugin'] = filter_var( wp_unslash( $_GET['plugin'] ) ); - } - - ?> - " - class="form-toggle__label " - data-jptracks-name="jetpack_beta_toggle_" - data-jptracks-prop="" - > - - - - - Date: Mon, 1 Jun 2026 13:18:23 -0700 Subject: [PATCH 15/53] Jetpack Beta: drop stale template reference in notice doc comment Co-Authored-By: Claude Opus 4.8 (1M context) --- projects/plugins/beta/src/admin/notice.template.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/plugins/beta/src/admin/notice.template.php b/projects/plugins/beta/src/admin/notice.template.php index b041f1973822..67a73c897eff 100644 --- a/projects/plugins/beta/src/admin/notice.template.php +++ b/projects/plugins/beta/src/admin/notice.template.php @@ -2,7 +2,7 @@ /** * Jetpack Beta wp-admin page notice. * - * @html-template \Automattic\JetpackBeta\Admin::render_banner -- Also from render() via plugin-select.template.php or plugin-manage.template.php + * @html-template \Automattic\JetpackBeta\Admin::render_banner * @package automattic/jetpack-beta */ From 1e0e5dcc5ad0ee53468dc931ec86d3013932470a Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 1 Jun 2026 13:20:36 -0700 Subject: [PATCH 16/53] Jetpack Beta: add changelog entry for the UI modernization Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plugins/beta/changelog/update-modernize-jetpack-beta-ui | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 projects/plugins/beta/changelog/update-modernize-jetpack-beta-ui diff --git a/projects/plugins/beta/changelog/update-modernize-jetpack-beta-ui b/projects/plugins/beta/changelog/update-modernize-jetpack-beta-ui new file mode 100644 index 000000000000..61eb929a1f81 --- /dev/null +++ b/projects/plugins/beta/changelog/update-modernize-jetpack-beta-ui @@ -0,0 +1,4 @@ +Significance: minor +Type: changed + +Modernized the Beta Tester admin interface with a React UI built on the WordPress design system, backed by the WordPress Abilities API. From 4aef4ce092080299814b7dfb793f59e6135540e9 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 1 Jun 2026 13:33:12 -0700 Subject: [PATCH 17/53] Jetpack Beta: fix abilities REST integration (GET reads, input envelope, admin includes) - Read abilities are called over GET with an `input` query envelope; writes POST with an `input` body envelope, matching the wp-abilities/v1 run controller. - Add a default empty-object input to the zero-arg read abilities so GET requests (which cannot encode an empty object) pass input validation. - Load wp-admin/includes/file.php + plugin.php in build_plugin_view() since the REST run endpoint executes outside wp-admin (WP_Filesystem/get_plugin_data). - Add @wordpress/url dependency for addQueryArgs. Co-Authored-By: Claude Opus 4.8 (1M context) --- pnpm-lock.yaml | 3 ++ projects/plugins/beta/package.json | 1 + .../src/abilities/class-beta-abilities.php | 16 +++++++ projects/plugins/beta/src/js/api/abilities.ts | 44 ++++++++++++++++--- 4 files changed, 57 insertions(+), 7 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77cdecc21763..4d80504e30a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5025,6 +5025,9 @@ importers: '@wordpress/ui': specifier: 0.13.0 version: 0.13.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/url': + specifier: 4.46.0 + version: 4.46.0 react: specifier: 18.3.1 version: 18.3.1 diff --git a/projects/plugins/beta/package.json b/projects/plugins/beta/package.json index d110cd76aa36..b8c60a229097 100644 --- a/projects/plugins/beta/package.json +++ b/projects/plugins/beta/package.json @@ -32,6 +32,7 @@ "@wordpress/element": "6.46.0", "@wordpress/i18n": "6.19.0", "@wordpress/ui": "0.13.0", + "@wordpress/url": "4.46.0", "react": "18.3.1", "react-dom": "18.3.1" }, diff --git a/projects/plugins/beta/src/abilities/class-beta-abilities.php b/projects/plugins/beta/src/abilities/class-beta-abilities.php index d1fc53177ab6..d16c44e6ca8a 100644 --- a/projects/plugins/beta/src/abilities/class-beta-abilities.php +++ b/projects/plugins/beta/src/abilities/class-beta-abilities.php @@ -107,6 +107,11 @@ private static function spec_list_plugins(): array { 'type' => 'object', 'properties' => new \stdClass(), 'additionalProperties' => false, + // Zero-argument ability. The REST run endpoint calls read-only + // abilities over GET, which cannot encode an empty object in the + // query string, so input arrives as null. Default to an empty + // object so input validation (type: object) passes. + 'default' => array(), ), 'output_schema' => array( 'type' => 'object', @@ -197,6 +202,11 @@ private static function spec_get_settings(): array { 'type' => 'object', 'properties' => new \stdClass(), 'additionalProperties' => false, + // Zero-argument ability. The REST run endpoint calls read-only + // abilities over GET, which cannot encode an empty object in the + // query string, so input arrives as null. Default to an empty + // object so input validation (type: object) passes. + 'default' => array(), ), 'output_schema' => array( 'type' => 'object', @@ -564,6 +574,12 @@ public static function update_settings( $input = null ) { * @return array|\WP_Error The plugin view-model, or WP_Error on data failure. */ private static function build_plugin_view( Plugin $plugin ) { + // The Abilities REST run endpoint executes outside wp-admin, so the + // admin include files are not loaded. to_test_content()/dev_info() + // rely on WP_Filesystem() and get_plugin_data(); load them explicitly. + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + try { $manifest = $plugin->get_manifest( true ); $wporg_data = $plugin->get_wporg_data( true ); diff --git a/projects/plugins/beta/src/js/api/abilities.ts b/projects/plugins/beta/src/js/api/abilities.ts index 220aa673ef95..5e2892ac4713 100644 --- a/projects/plugins/beta/src/js/api/abilities.ts +++ b/projects/plugins/beta/src/js/api/abilities.ts @@ -1,25 +1,55 @@ /** * Typed client for Jetpack Beta WP Abilities API endpoints. * + * The WP Abilities API run controller (`wp-abilities/v1/abilities//run`) + * dispatches by HTTP method based on the ability's annotations: read-only + * abilities MUST be called with GET, updates with POST. In both cases the + * ability input is wrapped in an `input` envelope (query param for GET, JSON + * body for POST). The response is the bare ability output. + * * @package */ import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; import type { PluginListItem, PluginView, Settings } from './types'; -const run = < T >( ability: string, data: Record< string, unknown > = {} ): Promise< T > => - apiFetch< T >( { path: `/wp-abilities/v1/abilities/${ ability }/run`, method: 'POST', data } ); +const path = ( ability: string ) => `/wp-abilities/v1/abilities/${ ability }/run`; + +/** + * Call a read-only ability via GET. Any input is passed in the `input` query + * envelope; zero-argument abilities are called with a bare GET. + * + * @param {string} ability - Ability id. + * @param {object} input - Optional input arguments. + * @return {Promise} The ability output. + */ +const read = < T >( ability: string, input?: Record< string, unknown > ): Promise< T > => + apiFetch< T >( { + path: input ? addQueryArgs( path( ability ), { input } ) : path( ability ), + method: 'GET', + } ); + +/** + * Call an updating ability via POST. Input is wrapped in the `input` envelope. + * + * @param {string} ability - Ability id. + * @param {object} input - Input arguments. + * @return {Promise} The ability output. + */ +const write = < T >( ability: string, input: Record< string, unknown > ): Promise< T > => + apiFetch< T >( { path: path( ability ), method: 'POST', data: { input } } ); export const listPlugins = () => - run< { plugins: PluginListItem[] } >( 'jetpack-beta/list-plugins' ); + read< { plugins: PluginListItem[] } >( 'jetpack-beta/list-plugins' ); export const getPlugin = ( slug: string ) => - run< PluginView >( 'jetpack-beta/get-plugin', { slug } ); -export const getSettings = () => run< Settings >( 'jetpack-beta/get-settings' ); + read< PluginView >( 'jetpack-beta/get-plugin', { slug } ); +export const getSettings = () => read< Settings >( 'jetpack-beta/get-settings' ); export const activateBranch = ( slug: string, source: string, id: string ) => - run< { success: boolean; plugin: PluginView } >( 'jetpack-beta/activate-branch', { + write< { success: boolean; plugin: PluginView } >( 'jetpack-beta/activate-branch', { slug, source, id, } ); export const updateSettings = ( patch: Partial< Settings > ) => - run< Settings >( 'jetpack-beta/update-settings', patch as Record< string, unknown > ); + write< Settings >( 'jetpack-beta/update-settings', patch as Record< string, unknown > ); From 10db3c5067eb875aa2f4019207989c809037c177 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 1 Jun 2026 13:35:50 -0700 Subject: [PATCH 18/53] Jetpack Beta: mark anchor-rendered buttons as non-native for correct semantics Co-Authored-By: Claude Opus 4.8 (1M context) --- projects/plugins/beta/src/js/screens/plugin-list.tsx | 1 + projects/plugins/beta/src/js/screens/plugin-manage.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/projects/plugins/beta/src/js/screens/plugin-list.tsx b/projects/plugins/beta/src/js/screens/plugin-list.tsx index 6d3b4fca059f..3b381a109c7d 100644 --- a/projects/plugins/beta/src/js/screens/plugin-list.tsx +++ b/projects/plugins/beta/src/js/screens/plugin-list.tsx @@ -112,6 +112,7 @@ const PluginList = () => { variant="outline" tone="neutral" size="compact" + nativeButton={ false } render={ } > { __( 'Manage', 'jetpack-beta' ) } diff --git a/projects/plugins/beta/src/js/screens/plugin-manage.tsx b/projects/plugins/beta/src/js/screens/plugin-manage.tsx index 01d0e22bc610..4448905cc0f7 100644 --- a/projects/plugins/beta/src/js/screens/plugin-manage.tsx +++ b/projects/plugins/beta/src/js/screens/plugin-manage.tsx @@ -195,6 +195,7 @@ const PluginManage = ( { slug }: Props ) => { variant="outline" tone="neutral" size="compact" + nativeButton={ false } render={ Date: Mon, 1 Jun 2026 13:41:57 -0700 Subject: [PATCH 19/53] Jetpack Beta: lay out global toggles on a single row Co-Authored-By: Claude Opus 4.8 (1M context) --- .../beta/src/js/components/global-toggles.tsx | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/projects/plugins/beta/src/js/components/global-toggles.tsx b/projects/plugins/beta/src/js/components/global-toggles.tsx index 66014b97b0fc..8c8f0ff00072 100644 --- a/projects/plugins/beta/src/js/components/global-toggles.tsx +++ b/projects/plugins/beta/src/js/components/global-toggles.tsx @@ -10,7 +10,7 @@ import { Spinner, ToggleControl } from '@wordpress/components'; import { useCallback, useEffect, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -import { Card, Notice } from '@wordpress/ui'; +import { Card, Notice, Stack } from '@wordpress/ui'; import { getSettings, updateSettings } from '../api/abilities'; import type { Settings } from '../api/types'; @@ -142,22 +142,24 @@ const GlobalToggles = () => { { updateError } ) } - - { showEmailToggle && ( + - ) } + { showEmailToggle && ( + + ) } + ); From 1e4d1e4dde5b30990ddee7fc4dee754b0e9f2bc7 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 1 Jun 2026 13:50:57 -0700 Subject: [PATCH 20/53] Jetpack Beta: contained layout, clickable plugin cards with chevron, settings heading - Wrap the plugin list in a Container/Col column (matching the My Jetpack module layout). - Replace the per-row Manage button with a chevron; the whole card is now a keyboard-accessible button that navigates to the plugin's manage screen. - Add a Settings heading above the global toggles. Co-Authored-By: Claude Opus 4.8 (1M context) --- pnpm-lock.yaml | 3 + projects/plugins/beta/package.json | 1 + .../beta/src/js/components/global-toggles.tsx | 43 +++--- .../beta/src/js/screens/plugin-list.tsx | 127 +++++++++++------- projects/plugins/beta/src/js/style.scss | 12 ++ 5 files changed, 118 insertions(+), 68 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d80504e30a0..2c3097d251b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5022,6 +5022,9 @@ importers: '@wordpress/i18n': specifier: 6.19.0 version: 6.19.0 + '@wordpress/icons': + specifier: 13.1.0 + version: 13.1.0(react@18.3.1) '@wordpress/ui': specifier: 0.13.0 version: 0.13.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) diff --git a/projects/plugins/beta/package.json b/projects/plugins/beta/package.json index b8c60a229097..f6c414c1272f 100644 --- a/projects/plugins/beta/package.json +++ b/projects/plugins/beta/package.json @@ -31,6 +31,7 @@ "@wordpress/components": "33.1.0", "@wordpress/element": "6.46.0", "@wordpress/i18n": "6.19.0", + "@wordpress/icons": "13.1.0", "@wordpress/ui": "0.13.0", "@wordpress/url": "4.46.0", "react": "18.3.1", diff --git a/projects/plugins/beta/src/js/components/global-toggles.tsx b/projects/plugins/beta/src/js/components/global-toggles.tsx index 8c8f0ff00072..6609817de575 100644 --- a/projects/plugins/beta/src/js/components/global-toggles.tsx +++ b/projects/plugins/beta/src/js/components/global-toggles.tsx @@ -10,7 +10,7 @@ import { Spinner, ToggleControl } from '@wordpress/components'; import { useCallback, useEffect, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -import { Card, Notice, Stack } from '@wordpress/ui'; +import { Card, Notice, Stack, Text } from '@wordpress/ui'; import { getSettings, updateSettings } from '../api/abilities'; import type { Settings } from '../api/types'; @@ -137,28 +137,31 @@ const GlobalToggles = () => { return ( - { updateError && ( - - { updateError } - - ) } - - - { showEmailToggle && ( + + { __( 'Settings', 'jetpack-beta' ) } + { updateError && ( + + { updateError } + + ) } + - ) } + { showEmailToggle && ( + + ) } + diff --git a/projects/plugins/beta/src/js/screens/plugin-list.tsx b/projects/plugins/beta/src/js/screens/plugin-list.tsx index 3b381a109c7d..179c2e7f1624 100644 --- a/projects/plugins/beta/src/js/screens/plugin-list.tsx +++ b/projects/plugins/beta/src/js/screens/plugin-list.tsx @@ -5,13 +5,16 @@ * @package */ +import { Col, Container } from '@automattic/jetpack-components'; import { Spinner } from '@wordpress/components'; -import { useEffect, useState } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; -import { Badge, Button, Card, Notice, Stack, Text } from '@wordpress/ui'; +import { useCallback, useEffect, useState } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { Icon, chevronRight } from '@wordpress/icons'; +import { Badge, Card, Notice, Stack, Text } from '@wordpress/ui'; import { listPlugins } from '../api/abilities'; import GlobalToggles from '../components/global-toggles'; import type { PluginListItem } from '../api/types'; +import type { KeyboardEvent } from 'react'; /** * Derive the display version string and badge label for a plugin row. @@ -40,6 +43,64 @@ const pluginStatus = ( }; }; +/** + * A single clickable plugin card. The whole card navigates to the plugin's + * manage screen; the chevron is a visual affordance only. + * + * @param props - Component props. + * @param props.plugin - The plugin to render. + * @return The plugin card element. + */ +const PluginCard = ( { plugin }: { plugin: PluginListItem } ) => { + const { versionText, badgeLabel } = pluginStatus( plugin ); + + const goToManage = useCallback( () => { + window.location.href = plugin.manage_url; + }, [ plugin.manage_url ] ); + + const onKeyDown = useCallback( + ( event: KeyboardEvent ) => { + if ( event.key === 'Enter' || event.key === ' ' ) { + event.preventDefault(); + goToManage(); + } + }, + [ goToManage ] + ); + + return ( + + + + + { plugin.name } + + { versionText } + { badgeLabel && ( + + { badgeLabel } + + ) } + + + + + + + ); +}; + /** * PluginList screen component. * @@ -78,51 +139,21 @@ const PluginList = () => { }, [] ); return ( - - - { loading && } - { error && ( - - { error } - - ) } - { plugins && - plugins.map( plugin => { - const { versionText, badgeLabel } = pluginStatus( plugin ); - return ( - - - - - - { plugin.name } - - - { versionText } - { badgeLabel && ( - - { badgeLabel } - - ) } - - - - - - - ); - } ) } - + + + + + { loading && } + { error && ( + + { error } + + ) } + { plugins && + plugins.map( plugin => ) } + + + ); }; diff --git a/projects/plugins/beta/src/js/style.scss b/projects/plugins/beta/src/js/style.scss index 57485d26d150..61e791ed832f 100644 --- a/projects/plugins/beta/src/js/style.scss +++ b/projects/plugins/beta/src/js/style.scss @@ -9,3 +9,15 @@ flex-direction: column; min-height: 100%; } + +// Whole plugin card acts as a button linking to its manage screen. +.jetpack-beta-plugin-card { + cursor: pointer; + transition: box-shadow 0.1s ease-in-out, border-color 0.1s ease-in-out; + + &:hover, + &:focus-visible { + border-color: var(--wp-admin-theme-color, #3858e9); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + } +} From c8ba54f9e2ce4caf087170ce2c652b795de12420 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 1 Jun 2026 13:53:37 -0700 Subject: [PATCH 21/53] Jetpack Beta: constrain the manage screen to the same fixed-width container as the list Co-Authored-By: Claude Opus 4.8 (1M context) --- .../beta/src/js/screens/plugin-manage.tsx | 181 +++++++++--------- 1 file changed, 94 insertions(+), 87 deletions(-) diff --git a/projects/plugins/beta/src/js/screens/plugin-manage.tsx b/projects/plugins/beta/src/js/screens/plugin-manage.tsx index 4448905cc0f7..fb6e8f45feb0 100644 --- a/projects/plugins/beta/src/js/screens/plugin-manage.tsx +++ b/projects/plugins/beta/src/js/screens/plugin-manage.tsx @@ -10,7 +10,7 @@ * @package */ -import { AdminPage } from '@automattic/jetpack-components'; +import { AdminPage, Col, Container } from '@automattic/jetpack-components'; import { Spinner } from '@wordpress/components'; import { useCallback, useEffect, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; @@ -146,95 +146,102 @@ const PluginManage = ( { slug }: Props ) => { apiNonce={ boot.apiNonce } breadcrumbs={ renderBreadcrumbs( pluginName ) } > - { loading && } - { error && ( - - { error } - - ) } - { view && ( - - { ! view.is_mu_plugin && } - - { view.is_mu_plugin && ( - - - { __( 'This plugin will be installed as a mu-plugin. See', 'jetpack-beta' ) }{ ' ' } - - { __( 'the documentation', 'jetpack-beta' ) } - { ' ' } - { __( - "for details on what this entails, particularly if you're newly installing a stable version.", - 'jetpack-beta' - ) } - + + + { loading && } + { error && ( + + { error } ) } - - { view.currently_running && ( - - - - - - - { view.name } { __( '— Currently Running', 'jetpack-beta' ) } - - - - { view.currently_running.pretty_version ?? - view.currently_running.version ?? - '' } - - - - - - - ) } - - { SECTION_CONFIG.map( ( { key, title, searchable, searchPlaceholder } ) => ( - - ) ) } - - { view.to_test_html && ( - - ) } - - { view.what_changed_html && ( - + { view && ( + + { ! view.is_mu_plugin && } + + { view.is_mu_plugin && ( + + + { __( 'This plugin will be installed as a mu-plugin. See', 'jetpack-beta' ) }{ ' ' } + + { __( 'the documentation', 'jetpack-beta' ) } + { ' ' } + { __( + "for details on what this entails, particularly if you're newly installing a stable version.", + 'jetpack-beta' + ) } + + + ) } + + { view.currently_running && ( + + + + + + + { view.name } { __( '— Currently Running', 'jetpack-beta' ) } + + + + { view.currently_running.pretty_version ?? + view.currently_running.version ?? + '' } + + + + + + + ) } + + { SECTION_CONFIG.map( ( { key, title, searchable, searchPlaceholder } ) => ( + + ) ) } + + { view.to_test_html && ( + + ) } + + { view.what_changed_html && ( + + ) } + ) } - - ) } + + ); }; From 7c09965f71f09465cbbfdfbb38dafee61a426497 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 1 Jun 2026 13:59:23 -0700 Subject: [PATCH 22/53] Jetpack Beta: render manage breadcrumb with the admin-ui Breadcrumbs markup Use the same @wordpress/ui primitives, '/' separator and h1 current item as @wordpress/admin-ui's Breadcrumbs, relabeled 'Beta Tester'. Links via a real anchor since this admin page has no TanStack router for the component's RouterLink. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../beta/src/js/screens/plugin-manage.tsx | 30 ++++++++++++++----- projects/plugins/beta/src/js/style.scss | 6 ++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/projects/plugins/beta/src/js/screens/plugin-manage.tsx b/projects/plugins/beta/src/js/screens/plugin-manage.tsx index fb6e8f45feb0..0c2ad6b03513 100644 --- a/projects/plugins/beta/src/js/screens/plugin-manage.tsx +++ b/projects/plugins/beta/src/js/screens/plugin-manage.tsx @@ -14,7 +14,7 @@ import { AdminPage, Col, Container } from '@automattic/jetpack-components'; import { Spinner } from '@wordpress/components'; import { useCallback, useEffect, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -import { Button, Card, Notice, Stack, Text } from '@wordpress/ui'; +import { Button, Card, Link, Notice, Stack, Text } from '@wordpress/ui'; import { getPlugin } from '../api/abilities'; import BranchSection from '../components/branch-section'; import GlobalToggles from '../components/global-toggles'; @@ -75,21 +75,37 @@ const groupSections = ( sections: BranchCardType[] ): Map< string, BranchCardTyp }; /** - * Render a simple breadcrumb ReactNode: "Jetpack Beta Tester › Plugin Name". + * Render the breadcrumb trail: "Beta Tester / Plugin Name". + * + * Mirrors the markup of `@wordpress/admin-ui`'s `Breadcrumbs` (the component the + * My Jetpack screens use) — same `@wordpress/ui` primitives, `/` separator, and + * an `h1` current item — but links with a real anchor instead of the TanStack + * router `Link` that component depends on, since this admin page has no router. * * @param pluginName - The current plugin name, or null while loading. * @return The breadcrumb element. */ const renderBreadcrumbs = ( pluginName: string | null ) => ( - - { __( 'Jetpack Beta Tester', 'jetpack-beta' ) } + } + > + }> + { __( 'Beta Tester', 'jetpack-beta' ) } + { pluginName && ( <> - - { pluginName } + + }> + { pluginName } + ) } - + ); /** diff --git a/projects/plugins/beta/src/js/style.scss b/projects/plugins/beta/src/js/style.scss index 61e791ed832f..5ee8c6dafd66 100644 --- a/projects/plugins/beta/src/js/style.scss +++ b/projects/plugins/beta/src/js/style.scss @@ -10,6 +10,12 @@ min-height: 100%; } +// Muted "/" separator in the manage-screen breadcrumb. +.jetpack-beta-breadcrumb-separator { + color: var(--wpds-color-stroke-surface-neutral, #dbdbdb); + user-select: none; +} + // Whole plugin card acts as a button linking to its manage screen. .jetpack-beta-plugin-card { cursor: pointer; From 61b68ffc22484bf514dc7edf21c56a3b64618107 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 1 Jun 2026 14:04:07 -0700 Subject: [PATCH 23/53] Jetpack Beta: collapse single-branch sections into one card Drop the redundant uppercase heading for the fixed branches (Latest Stable, Release Candidate, Bleeding Edge); the card now carries the section name as its label with the version as a sub-line only when it differs. Searchable sections (Feature Branches, Released Versions) keep their heading and search. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../beta/src/js/components/branch-card.tsx | 27 ++++++++++++++----- .../beta/src/js/components/branch-section.tsx | 19 +++++++------ 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/projects/plugins/beta/src/js/components/branch-card.tsx b/projects/plugins/beta/src/js/components/branch-card.tsx index 59b4002822e0..acd88502f839 100644 --- a/projects/plugins/beta/src/js/components/branch-card.tsx +++ b/projects/plugins/beta/src/js/components/branch-card.tsx @@ -14,6 +14,12 @@ type Props = { card: BranchCardType; pluginSlug: string; onActivated: ( view: PluginView ) => void; + /** + * Optional primary label. When set (used for the fixed single-branch + * sections), it replaces the standalone section heading and the branch's + * own version is shown as a secondary line only when it adds information. + */ + title?: string; }; /** @@ -22,11 +28,15 @@ type Props = { * @param {Props} props - Component props. * @return The branch card element. */ -const BranchCard = ( { card, pluginSlug, onActivated }: Props ) => { +const BranchCard = ( { card, pluginSlug, onActivated, title }: Props ) => { const [ busy, setBusy ] = useState( false ); const [ error, setError ] = useState< string | null >( null ); - const label = card.pretty_version ?? card.branch ?? card.version ?? ''; + const version = card.pretty_version ?? card.branch ?? card.version ?? ''; + const label = title ?? version; + // Only show the version as a secondary line when a title is given and the + // version actually differs from it (avoids "Release Candidate / Release Candidate"). + const detail = title && version && version !== title ? version : null; const handleActivate = useCallback( () => { if ( busy ) { @@ -60,11 +70,14 @@ const BranchCard = ( { card, pluginSlug, onActivated }: Props ) => { ) } - - { label } - { card.is_active && ( - { __( 'Active', 'jetpack-beta' ) } - ) } + + + { label } + { card.is_active && ( + { __( 'Active', 'jetpack-beta' ) } + ) } + + { detail && { detail } } { ! card.is_active && ( - - - - - ) } + + + ) } - { SECTION_CONFIG.map( ( { key, title, searchable, searchPlaceholder } ) => ( - - ) ) } + { SECTION_CONFIG.map( ( { key, title, searchable, searchPlaceholder } ) => ( + + ) ) } - { view.to_test_html && ( - - ) } + { view.to_test_html && ( + + ) } - { view.what_changed_html && ( - - ) } - - ) } - - + { view.what_changed_html && ( + + ) } + + ) } + + +
); diff --git a/projects/plugins/beta/src/js/style.scss b/projects/plugins/beta/src/js/style.scss index 2fc62b475048..17b51494abca 100644 --- a/projects/plugins/beta/src/js/style.scss +++ b/projects/plugins/beta/src/js/style.scss @@ -48,6 +48,15 @@ body.jetpack-beta-page { user-select: none; } +// wordpress.org plugin icon shown on each list row. +.jetpack-beta-plugin-icon { + width: 40px; + height: 40px; + flex-shrink: 0; + border-radius: 4px; + object-fit: contain; +} + // Whole plugin card acts as a button linking to its manage screen. .jetpack-beta-plugin-card { cursor: pointer; From ec54198eeeb1e1c32caac15e9f90f1bd87d32774 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 1 Jun 2026 14:38:31 -0700 Subject: [PATCH 29/53] Jetpack Beta: show global settings only on the overview screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The autoupdate and email-notification settings are site-wide Beta plugin options, not per-plugin, so the toggles no longer appear on each plugin's manage screen — only on the overview where they apply. Co-Authored-By: Claude Opus 4.8 (1M context) --- projects/plugins/beta/src/js/screens/plugin-manage.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/projects/plugins/beta/src/js/screens/plugin-manage.tsx b/projects/plugins/beta/src/js/screens/plugin-manage.tsx index 61ee5646a66a..f005129845e0 100644 --- a/projects/plugins/beta/src/js/screens/plugin-manage.tsx +++ b/projects/plugins/beta/src/js/screens/plugin-manage.tsx @@ -17,7 +17,6 @@ import { Button, Card, Link, Notice, Stack, Text } from '@wordpress/ui'; import { getPlugin } from '../api/abilities'; import BranchSection from '../components/branch-section'; import Footer from '../components/footer'; -import GlobalToggles from '../components/global-toggles'; import MarkdownPanel from '../components/markdown-panel'; import { CardRowSkeleton } from '../components/skeleton'; import type { BranchCard as BranchCardType, PluginView } from '../api/types'; @@ -184,8 +183,6 @@ const PluginManage = ( { slug }: Props ) => { ) } { view && ( - { ! view.is_mu_plugin && } - { view.is_mu_plugin && ( From 4b3f980634b6cd41ee0681c0b36f56ba28917ca3 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 1 Jun 2026 14:41:38 -0700 Subject: [PATCH 30/53] Jetpack Beta: fall back to a generic plugin icon for non-wporg plugins Plugins without wordpress.org assets now render a generic plugin icon in a neutral box instead of hiding the image, so every list row stays aligned. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../beta/src/js/screens/plugin-list.tsx | 44 +++++++++++-------- projects/plugins/beta/src/js/style.scss | 9 ++++ 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/projects/plugins/beta/src/js/screens/plugin-list.tsx b/projects/plugins/beta/src/js/screens/plugin-list.tsx index 421e62cdb628..1b5a0cc939b8 100644 --- a/projects/plugins/beta/src/js/screens/plugin-list.tsx +++ b/projects/plugins/beta/src/js/screens/plugin-list.tsx @@ -8,13 +8,13 @@ import { Col, Container } from '@automattic/jetpack-components'; import { useCallback, useEffect, useState } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; -import { Icon, chevronRight } from '@wordpress/icons'; +import { Icon, chevronRight, plugins as pluginsIcon } from '@wordpress/icons'; import { Badge, Card, Notice, Stack, Text } from '@wordpress/ui'; import { listPlugins } from '../api/abilities'; import GlobalToggles from '../components/global-toggles'; import { CardRowSkeleton } from '../components/skeleton'; import type { PluginListItem } from '../api/types'; -import type { KeyboardEvent, SyntheticEvent } from 'react'; +import type { KeyboardEvent } from 'react'; /** * Derive the display version string and badge label for a plugin row. @@ -43,16 +43,6 @@ const pluginStatus = ( }; }; -/** - * Hide a plugin icon that fails to load (e.g. an unpublished plugin with no - * wordpress.org assets) so a broken-image glyph never shows. - * - * @param event - The image error event. - */ -const hideBrokenIcon = ( event: SyntheticEvent< HTMLImageElement > ) => { - event.currentTarget.style.display = 'none'; -}; - /** * A single clickable plugin card. The whole card navigates to the plugin's * manage screen; the chevron is a visual affordance only. @@ -63,6 +53,13 @@ const hideBrokenIcon = ( event: SyntheticEvent< HTMLImageElement > ) => { */ const PluginCard = ( { plugin }: { plugin: PluginListItem } ) => { const { versionText, badgeLabel } = pluginStatus( plugin ); + // Plugins without wordpress.org assets (unpublished betas) fall back to a + // generic plugin icon so every row stays visually aligned. + const [ iconFailed, setIconFailed ] = useState( false ); + + const onIconError = useCallback( () => { + setIconFailed( true ); + }, [] ); const goToManage = useCallback( () => { window.location.href = plugin.manage_url; @@ -94,13 +91,22 @@ const PluginCard = ( { plugin }: { plugin: PluginListItem } ) => { - + { iconFailed ? ( + + ) : ( + + ) } { plugin.name } diff --git a/projects/plugins/beta/src/js/style.scss b/projects/plugins/beta/src/js/style.scss index 17b51494abca..bbd0a7a535b9 100644 --- a/projects/plugins/beta/src/js/style.scss +++ b/projects/plugins/beta/src/js/style.scss @@ -57,6 +57,15 @@ body.jetpack-beta-page { object-fit: contain; } +// Generic fallback for plugins without wordpress.org assets. +.jetpack-beta-plugin-icon--fallback { + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--wpds-color-bg-surface-neutral-weak, #f0f0f0); + color: var(--wpds-color-fg-interactive-neutral-weak, #50575e); +} + // Whole plugin card acts as a button linking to its manage screen. .jetpack-beta-plugin-card { cursor: pointer; From 2bdc2b3558fa29e5af6eec2419a6bf747e24741a Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 1 Jun 2026 14:49:38 -0700 Subject: [PATCH 31/53] Jetpack Beta: fix horizontal scroll on mobile Replace the @automattic/jetpack-components grid Container/Col (whose gutter negative margins rendered wider than narrow viewports) with a simple responsive max-width content column, and let the settings toggles wrap. No more horizontal scrollbar on mobile. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../beta/src/js/components/global-toggles.tsx | 4 +- .../beta/src/js/screens/plugin-list.tsx | 33 ++- .../beta/src/js/screens/plugin-manage.tsx | 188 +++++++++--------- projects/plugins/beta/src/js/style.scss | 9 + 4 files changed, 119 insertions(+), 115 deletions(-) diff --git a/projects/plugins/beta/src/js/components/global-toggles.tsx b/projects/plugins/beta/src/js/components/global-toggles.tsx index 7668fb29b306..9639e1602e96 100644 --- a/projects/plugins/beta/src/js/components/global-toggles.tsx +++ b/projects/plugins/beta/src/js/components/global-toggles.tsx @@ -123,7 +123,7 @@ const GlobalToggles = () => { - + @@ -157,7 +157,7 @@ const GlobalToggles = () => { { updateError } ) } - + { return ( // Full-width scroll container so the scrollbar sits at the page edge; the - // inner Container keeps the content at a centered, fixed width. + // inner content div keeps everything at a centered, responsive fixed width.
- - - - - { loading && - Array.from( { length: 6 } ).map( ( _, index ) => ) } - { error && ( - - { error } - - ) } - { plugins && - plugins.map( plugin => ) } - - - +
+ + + { loading && + Array.from( { length: 6 } ).map( ( _, index ) => ) } + { error && ( + + { error } + + ) } + { plugins && + plugins.map( plugin => ) } + +
); }; diff --git a/projects/plugins/beta/src/js/screens/plugin-manage.tsx b/projects/plugins/beta/src/js/screens/plugin-manage.tsx index f005129845e0..4c14f7d89c27 100644 --- a/projects/plugins/beta/src/js/screens/plugin-manage.tsx +++ b/projects/plugins/beta/src/js/screens/plugin-manage.tsx @@ -10,7 +10,7 @@ * @package */ -import { AdminPage, Col, Container } from '@automattic/jetpack-components'; +import { AdminPage } from '@automattic/jetpack-components'; import { useCallback, useEffect, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { Button, Card, Link, Notice, Stack, Text } from '@wordpress/ui'; @@ -167,106 +167,104 @@ const PluginManage = ( { slug }: Props ) => { unwrapped >
- - - { loading && ( - - { Array.from( { length: 4 } ).map( ( _, index ) => ( - - ) ) } - - ) } - { error && ( - - { error } - - ) } - { view && ( - - { view.is_mu_plugin && ( - - - { __( 'This plugin will be installed as a mu-plugin. See', 'jetpack-beta' ) }{ ' ' } - - { __( 'the documentation', 'jetpack-beta' ) } - { ' ' } - { __( - "for details on what this entails, particularly if you're newly installing a stable version.", - 'jetpack-beta' - ) } - - - ) } +
+ { loading && ( + + { Array.from( { length: 4 } ).map( ( _, index ) => ( + + ) ) } + + ) } + { error && ( + + { error } + + ) } + { view && ( + + { view.is_mu_plugin && ( + + + { __( 'This plugin will be installed as a mu-plugin. See', 'jetpack-beta' ) }{ ' ' } + + { __( 'the documentation', 'jetpack-beta' ) } + { ' ' } + { __( + "for details on what this entails, particularly if you're newly installing a stable version.", + 'jetpack-beta' + ) } + + + ) } - { view.currently_running && ( - - - - - - - { view.name } { __( '— Currently Running', 'jetpack-beta' ) } - - - - { view.currently_running.pretty_version ?? - view.currently_running.version ?? - '' } + { view.currently_running && ( + + + + + + + { view.name } { __( '— Currently Running', 'jetpack-beta' ) } - - + + + { view.currently_running.pretty_version ?? + view.currently_running.version ?? + '' } + - - - ) } + + + + + ) } - { SECTION_CONFIG.map( ( { key, title, searchable, searchPlaceholder } ) => ( - - ) ) } + { SECTION_CONFIG.map( ( { key, title, searchable, searchPlaceholder } ) => ( + + ) ) } - { view.to_test_html && ( - - ) } + { view.to_test_html && ( + + ) } - { view.what_changed_html && ( - - ) } - - ) } - - + { view.what_changed_html && ( + + ) } + + ) } +
diff --git a/projects/plugins/beta/src/js/style.scss b/projects/plugins/beta/src/js/style.scss index bbd0a7a535b9..7e1aca8d1a07 100644 --- a/projects/plugins/beta/src/js/style.scss +++ b/projects/plugins/beta/src/js/style.scss @@ -48,6 +48,15 @@ body.jetpack-beta-page { user-select: none; } +// Centered, responsive content column inside the full-width scroll area. +.jetpack-beta-content { + box-sizing: border-box; + width: 100%; + max-width: 760px; + margin: 0 auto; + padding: var(--wpds-dimension-padding-2xl, 24px); +} + // wordpress.org plugin icon shown on each list row. .jetpack-beta-plugin-icon { width: 40px; From bdc635eca8717f29244964f172296cd1310b54d6 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 1 Jun 2026 14:57:05 -0700 Subject: [PATCH 32/53] Jetpack Beta: add breathing room between the last list item and the footer The shared layout mixin capped the content column to the viewport so its bottom padding was clipped mid-scroll and the last card sat flush against the footer. Opt our simple stacked content out of that cap (flex: 0 0 auto) so it sizes to its height and the bottom padding becomes real spacing above the footer. Co-Authored-By: Claude Opus 4.8 (1M context) --- projects/plugins/beta/src/js/style.scss | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/projects/plugins/beta/src/js/style.scss b/projects/plugins/beta/src/js/style.scss index 7e1aca8d1a07..71bcd693cc34 100644 --- a/projects/plugins/beta/src/js/style.scss +++ b/projects/plugins/beta/src/js/style.scss @@ -55,6 +55,14 @@ body.jetpack-beta-page { max-width: 760px; margin: 0 auto; padding: var(--wpds-dimension-padding-2xl, 24px); + + // The shared admin-page-layout mixin makes the scroll child fill and clip to + // the viewport (so internal-scroll content like DataViews can manage its own + // overflow). Our content is a simple stacked list, so opt out: size to the + // content height and let .jetpack-beta-scroll do the scrolling. This keeps + // the bottom padding as real breathing room above the pinned footer instead + // of clipping it mid-scroll. + flex: 0 0 auto !important; } // wordpress.org plugin icon shown on each list row. From 8de4c44b7c051a615bb08362dda651ab66fc9c52 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 1 Jun 2026 16:02:09 -0700 Subject: [PATCH 33/53] =?UTF-8?q?Jetpack=20Beta:=20simplify=20=E2=80=94=20?= =?UTF-8?q?shared=20error=20helper,=20dedup=20toggle=20handlers,=20drop=20?= =?UTF-8?q?dead=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract a shared errorMessage() helper; replace the 6 duplicated unknown-error message guards across the React components with it. - Collapse the two near-identical settings toggle handlers into one parameterized applySetting() flow. - Memoize groupSections() and hoist the search toLowerCase() out of the per-card filter callback. - Remove dead code: the never-rendered needed_updates payload (and its per-load plugins_needing_update() wp.org network call) and the unused canManage bootstrap field. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/abilities/class-beta-abilities.php | 40 +-------- projects/plugins/beta/src/class-admin.php | 1 - projects/plugins/beta/src/js/api/abilities.ts | 13 +++ projects/plugins/beta/src/js/api/types.ts | 2 - .../beta/src/js/components/branch-card.tsx | 8 +- .../beta/src/js/components/branch-section.tsx | 9 +-- .../beta/src/js/components/global-toggles.tsx | 81 +++++++------------ .../beta/src/js/screens/plugin-list.tsx | 8 +- .../beta/src/js/screens/plugin-manage.tsx | 15 ++-- 9 files changed, 58 insertions(+), 119 deletions(-) diff --git a/projects/plugins/beta/src/abilities/class-beta-abilities.php b/projects/plugins/beta/src/abilities/class-beta-abilities.php index 7031087d39b8..0156aa2ab269 100644 --- a/projects/plugins/beta/src/abilities/class-beta-abilities.php +++ b/projects/plugins/beta/src/abilities/class-beta-abilities.php @@ -156,7 +156,7 @@ private static function spec_get_plugin(): array { return array( 'label' => __( 'Get Jetpack Beta plugin details', 'jetpack-beta' ), 'description' => __( - 'Return the full view-model for a single plugin managed by Jetpack Beta Tester. Input: { slug }. Output: { name, is_mu_plugin, bug_report_url, currently_running, sections, to_test_html, what_changed_html, needed_updates }. `currently_running` is null when the plugin is not active. `sections` is an ordered array of branch cards (existing → stable → rc → trunk → PRs → releases). `to_test_html` and `what_changed_html` are sanitized HTML strings or null. `needed_updates` is an array of plugin files that have pending updates. Read-only — results are cached but may trigger background network refreshes.', + 'Return the full view-model for a single plugin managed by Jetpack Beta Tester. Input: { slug }. Output: { name, is_mu_plugin, bug_report_url, currently_running, sections, to_test_html, what_changed_html }. `currently_running` is null when the plugin is not active. `sections` is an ordered array of branch cards (existing → stable → rc → trunk → PRs → releases). `to_test_html` and `what_changed_html` are sanitized HTML strings or null. Read-only — results are cached but may trigger background network refreshes.', 'jetpack-beta' ), 'input_schema' => array( @@ -730,11 +730,6 @@ private static function build_plugin_view( Plugin $plugin ) { // ------------------------------------------------------------------ list( $to_test_html, $what_changed_html ) = Admin::to_test_content( $plugin ); - // ------------------------------------------------------------------ - // Needed updates — port of show-needed-updates.template.php logic. - // ------------------------------------------------------------------ - $needed_updates = self::get_needed_updates_for_plugin( $plugin ); - return array( 'name' => $plugin->get_name(), 'is_mu_plugin' => $plugin->is_mu_plugin(), @@ -743,7 +738,6 @@ private static function build_plugin_view( Plugin $plugin ) { 'sections' => $sections, 'to_test_html' => $to_test_html, 'what_changed_html' => $what_changed_html, - 'needed_updates' => $needed_updates, ); } @@ -806,10 +800,6 @@ private static function plugin_view_schema(): array { ), 'to_test_html' => array( 'type' => array( 'string', 'null' ) ), 'what_changed_html' => array( 'type' => array( 'string', 'null' ) ), - 'needed_updates' => array( - 'type' => 'array', - 'items' => array( 'type' => 'string' ), - ), ), ); } @@ -839,32 +829,4 @@ private static function branch_to_section( $branch, $section, $active_branch ): 'is_active' => $is_active, ); } - - /** - * Return an array of plugin files that have pending updates, scoped to the - * given plugin (plus the Beta Tester itself). - * - * Mirrors the logic in show-needed-updates.template.php: calls - * `Utils::plugins_needing_update( true )` to include stable versions, then - * filters the result to only the files relevant to this plugin and the - * Jetpack Beta Tester itself. - * - * @param Plugin $plugin Plugin to check. - * @return string[] Plugin file paths that have available updates. - */ - private static function get_needed_updates_for_plugin( Plugin $plugin ): array { - try { - $updates = Utils::plugins_needing_update( true ); - } catch ( \Exception $e ) { - return array(); - } - - $relevant = array( - $plugin->plugin_file() => 1, - $plugin->dev_plugin_file() => 1, - JPBETA__PLUGIN_FOLDER . '/jetpack-beta.php' => 1, - ); - - return array_keys( array_intersect_key( $updates, $relevant ) ); - } } diff --git a/projects/plugins/beta/src/class-admin.php b/projects/plugins/beta/src/class-admin.php index 33b10d08e5b8..52b3f0b8811c 100644 --- a/projects/plugins/beta/src/class-admin.php +++ b/projects/plugins/beta/src/class-admin.php @@ -221,7 +221,6 @@ static function ( $classes ) { 'pluginName' => $plugin_display_name, 'plugins' => $plugin_list, 'adminUrl' => Utils::admin_url(), - 'canManage' => current_user_can( 'update_plugins' ), ), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';', diff --git a/projects/plugins/beta/src/js/api/abilities.ts b/projects/plugins/beta/src/js/api/abilities.ts index 5e2892ac4713..4e5b0c515792 100644 --- a/projects/plugins/beta/src/js/api/abilities.ts +++ b/projects/plugins/beta/src/js/api/abilities.ts @@ -16,6 +16,19 @@ import type { PluginListItem, PluginView, Settings } from './types'; const path = ( ability: string ) => `/wp-abilities/v1/abilities/${ ability }/run`; +/** + * Extract a human-readable message from an unknown thrown value, falling back to + * a provided default. Used to surface ability/apiFetch failures in the UI. + * + * @param error - The caught value. + * @param fallback - Message to use when none can be derived. + * @return The error message, or the fallback. + */ +export const errorMessage = ( error: unknown, fallback: string ): string => + error && typeof error === 'object' && 'message' in error && typeof error.message === 'string' + ? error.message + : fallback; + /** * Call a read-only ability via GET. Any input is passed in the `input` query * envelope; zero-argument abilities are called with a bare GET. diff --git a/projects/plugins/beta/src/js/api/types.ts b/projects/plugins/beta/src/js/api/types.ts index a3dc53323152..03d27d95e0d8 100644 --- a/projects/plugins/beta/src/js/api/types.ts +++ b/projects/plugins/beta/src/js/api/types.ts @@ -38,7 +38,6 @@ export type PluginView = { sections: BranchCard[]; to_test_html: string | null; what_changed_html: string | null; - needed_updates: string[]; }; export type Settings = { @@ -54,7 +53,6 @@ export type BetaBootstrap = { pluginName: string | null; plugins: PluginListItem[] | null; adminUrl: string; - canManage: boolean; }; declare global { diff --git a/projects/plugins/beta/src/js/components/branch-card.tsx b/projects/plugins/beta/src/js/components/branch-card.tsx index acd88502f839..fb53ebfd7c3d 100644 --- a/projects/plugins/beta/src/js/components/branch-card.tsx +++ b/projects/plugins/beta/src/js/components/branch-card.tsx @@ -7,7 +7,7 @@ import { useCallback, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { Badge, Button, Card, Notice, Stack, Text } from '@wordpress/ui'; -import { activateBranch } from '../api/abilities'; +import { activateBranch, errorMessage } from '../api/abilities'; import type { BranchCard as BranchCardType, PluginView } from '../api/types'; type Props = { @@ -49,11 +49,7 @@ const BranchCard = ( { card, pluginSlug, onActivated, title }: Props ) => { onActivated( result.plugin ); } ) .catch( ( err: unknown ) => { - const msg = - err && typeof err === 'object' && 'message' in err && typeof err.message === 'string' - ? err.message - : __( 'Could not activate branch.', 'jetpack-beta' ); - setError( msg ); + setError( errorMessage( err, __( 'Could not activate branch.', 'jetpack-beta' ) ) ); } ) .finally( () => { setBusy( false ); diff --git a/projects/plugins/beta/src/js/components/branch-section.tsx b/projects/plugins/beta/src/js/components/branch-section.tsx index 78e1c6085434..387b777d7869 100644 --- a/projects/plugins/beta/src/js/components/branch-section.tsx +++ b/projects/plugins/beta/src/js/components/branch-section.tsx @@ -50,14 +50,13 @@ const BranchSection = ( { if ( ! searchable ) { filteredCards = cards; } else if ( hasQuery ) { - filteredCards = cards.filter( card => { - const q = trimmedQuery.toLowerCase(); - return ( + const q = trimmedQuery.toLowerCase(); + filteredCards = cards.filter( + card => ( card.pretty_version?.toLowerCase().includes( q ) ?? false ) || ( card.branch?.toLowerCase().includes( q ) ?? false ) || ( card.version?.toLowerCase().includes( q ) ?? false ) - ); - } ); + ); } else { filteredCards = cards.filter( c => c.is_active ); } diff --git a/projects/plugins/beta/src/js/components/global-toggles.tsx b/projects/plugins/beta/src/js/components/global-toggles.tsx index 9639e1602e96..6ed9cb8d4463 100644 --- a/projects/plugins/beta/src/js/components/global-toggles.tsx +++ b/projects/plugins/beta/src/js/components/global-toggles.tsx @@ -11,7 +11,7 @@ import { ToggleControl } from '@wordpress/components'; import { useCallback, useEffect, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { Card, Notice, Stack, Text } from '@wordpress/ui'; -import { getSettings, updateSettings } from '../api/abilities'; +import { errorMessage, getSettings, updateSettings } from '../api/abilities'; import { Skeleton } from './skeleton'; import type { Settings } from '../api/types'; @@ -40,11 +40,7 @@ const GlobalToggles = () => { } ) .catch( ( err: unknown ) => { if ( ! cancelled ) { - const msg = - err && typeof err === 'object' && 'message' in err && typeof err.message === 'string' - ? err.message - : __( 'Could not load settings.', 'jetpack-beta' ); - setFetchError( msg ); + setFetchError( errorMessage( err, __( 'Could not load settings.', 'jetpack-beta' ) ) ); setLoading( false ); } } ); @@ -53,29 +49,22 @@ const GlobalToggles = () => { }; }, [] ); - const handleAutoupdates = useCallback( - ( checked: boolean ) => { - if ( inFlight !== null ) { - return; - } - if ( ! settings ) { + // Both toggles share one optimistic-update flow, differing only by which + // setting key they write and the error message on failure. + const applySetting = useCallback( + ( key: 'autoupdates' | 'email_notifications', checked: boolean, failMessage: string ) => { + if ( inFlight !== null || ! settings ) { return; } const previous = settings; - setSettings( { ...settings, autoupdates: checked } ); + setSettings( { ...settings, [ key ]: checked } ); setUpdateError( null ); - setInFlight( 'autoupdates' ); - updateSettings( { autoupdates: checked } ) - .then( updated => { - setSettings( updated ); - } ) + setInFlight( key ); + updateSettings( { [ key ]: checked } ) + .then( setSettings ) .catch( ( err: unknown ) => { setSettings( previous ); - const msg = - err && typeof err === 'object' && 'message' in err && typeof err.message === 'string' - ? err.message - : __( 'Could not save autoupdates setting.', 'jetpack-beta' ); - setUpdateError( msg ); + setUpdateError( errorMessage( err, failMessage ) ); } ) .finally( () => { setInFlight( null ); @@ -85,36 +74,24 @@ const GlobalToggles = () => { [ settings ] ); + const handleAutoupdates = useCallback( + ( checked: boolean ) => + applySetting( + 'autoupdates', + checked, + __( 'Could not save autoupdates setting.', 'jetpack-beta' ) + ), + [ applySetting ] + ); + const handleEmailNotifications = useCallback( - ( checked: boolean ) => { - if ( inFlight !== null ) { - return; - } - if ( ! settings ) { - return; - } - const previous = settings; - setSettings( { ...settings, email_notifications: checked } ); - setUpdateError( null ); - setInFlight( 'email_notifications' ); - updateSettings( { email_notifications: checked } ) - .then( updated => { - setSettings( updated ); - } ) - .catch( ( err: unknown ) => { - setSettings( previous ); - const msg = - err && typeof err === 'object' && 'message' in err && typeof err.message === 'string' - ? err.message - : __( 'Could not save email notifications setting.', 'jetpack-beta' ); - setUpdateError( msg ); - } ) - .finally( () => { - setInFlight( null ); - } ); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- inFlight read only guards re-entrancy; stale closure is safe because inFlight is set before any await - [ settings ] + ( checked: boolean ) => + applySetting( + 'email_notifications', + checked, + __( 'Could not save email notifications setting.', 'jetpack-beta' ) + ), + [ applySetting ] ); if ( loading ) { diff --git a/projects/plugins/beta/src/js/screens/plugin-list.tsx b/projects/plugins/beta/src/js/screens/plugin-list.tsx index 820dabe41c00..9248d8bee588 100644 --- a/projects/plugins/beta/src/js/screens/plugin-list.tsx +++ b/projects/plugins/beta/src/js/screens/plugin-list.tsx @@ -9,7 +9,7 @@ import { useCallback, useEffect, useState } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { Icon, chevronRight, plugins as pluginsIcon } from '@wordpress/icons'; import { Badge, Card, Notice, Stack, Text } from '@wordpress/ui'; -import { listPlugins } from '../api/abilities'; +import { errorMessage, listPlugins } from '../api/abilities'; import GlobalToggles from '../components/global-toggles'; import { CardRowSkeleton } from '../components/skeleton'; import type { PluginListItem } from '../api/types'; @@ -194,11 +194,7 @@ const PluginList = () => { } ) .catch( ( err: unknown ) => { if ( ! cancelled ) { - const msg = - err && typeof err === 'object' && 'message' in err && typeof err.message === 'string' - ? err.message - : __( 'Could not load plugins.', 'jetpack-beta' ); - setError( msg ); + setError( errorMessage( err, __( 'Could not load plugins.', 'jetpack-beta' ) ) ); setLoading( false ); } } ); diff --git a/projects/plugins/beta/src/js/screens/plugin-manage.tsx b/projects/plugins/beta/src/js/screens/plugin-manage.tsx index 4c14f7d89c27..b3c9b9e82a8b 100644 --- a/projects/plugins/beta/src/js/screens/plugin-manage.tsx +++ b/projects/plugins/beta/src/js/screens/plugin-manage.tsx @@ -11,10 +11,10 @@ */ import { AdminPage } from '@automattic/jetpack-components'; -import { useCallback, useEffect, useState } from '@wordpress/element'; +import { useCallback, useEffect, useMemo, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { Button, Card, Link, Notice, Stack, Text } from '@wordpress/ui'; -import { getPlugin } from '../api/abilities'; +import { errorMessage, getPlugin } from '../api/abilities'; import BranchSection from '../components/branch-section'; import Footer from '../components/footer'; import MarkdownPanel from '../components/markdown-panel'; @@ -135,11 +135,7 @@ const PluginManage = ( { slug }: Props ) => { } ) .catch( ( err: unknown ) => { if ( ! cancelled ) { - const msg = - err && typeof err === 'object' && 'message' in err && typeof err.message === 'string' - ? err.message - : __( 'Could not load plugin.', 'jetpack-beta' ); - setError( msg ); + setError( errorMessage( err, __( 'Could not load plugin.', 'jetpack-beta' ) ) ); setLoading( false ); } } ); @@ -155,7 +151,10 @@ const PluginManage = ( { slug }: Props ) => { // Prefer the name from the bootstrap so the header renders immediately, then // keep using it once the full view has loaded. const pluginName = view?.name ?? boot.pluginName ?? null; - const sectionMap = view ? groupSections( view.sections ) : new Map< string, BranchCardType[] >(); + const sectionMap = useMemo( + () => ( view ? groupSections( view.sections ) : new Map< string, BranchCardType[] >() ), + [ view ] + ); return ( Date: Mon, 1 Jun 2026 16:04:37 -0700 Subject: [PATCH 34/53] Remove internal planning docs from the branch Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-01-modernize-jetpack-beta-ui.md | 436 ------------------ ...-06-01-modernize-jetpack-beta-ui-design.md | 160 ------- 2 files changed, 596 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-01-modernize-jetpack-beta-ui.md delete mode 100644 docs/superpowers/specs/2026-06-01-modernize-jetpack-beta-ui-design.md diff --git a/docs/superpowers/plans/2026-06-01-modernize-jetpack-beta-ui.md b/docs/superpowers/plans/2026-06-01-modernize-jetpack-beta-ui.md deleted file mode 100644 index 4daee8a996f8..000000000000 --- a/docs/superpowers/plans/2026-06-01-modernize-jetpack-beta-ui.md +++ /dev/null @@ -1,436 +0,0 @@ -# Jetpack Beta UI Modernization — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the Jetpack Beta Tester admin UI (PHP templates + vanilla JS + 1299-line hand-rolled CSS) with a React app built on `@wordpress/ui` and the shared `@automattic/jetpack-components` `AdminPage` chrome, backed by WordPress Abilities API endpoints. - -**Architecture:** A `Beta_Abilities` registrar exposes reads (`list-plugins`, `get-plugin`, `get-settings`) and writes (`activate-branch`, `update-settings`) over the `wp-abilities/v1` REST run route. `Admin::render()` prints a root node, enqueues a webpack build, and localizes a bootstrap (REST root/nonce + current screen payload). The React app renders the two screens with `AdminPage` (header/footer/breadcrumbs) wrapping `@wordpress/ui` content, calling abilities via `@wordpress/api-fetch`. - -**Tech Stack:** PHP (WP Abilities API, `automattic/jetpack-wp-abilities`), React 18, `@wordpress/element`, `@wordpress/ui` 0.13.0, `@automattic/jetpack-components` (AdminPage/JetpackFooter), `@wordpress/api-fetch`, `@wordpress/i18n`, `@automattic/jetpack-webpack-config`. - -**Working dir for all paths:** `projects/plugins/beta/` - -**Spec:** `docs/superpowers/specs/2026-06-01-modernize-jetpack-beta-ui-design.md` - ---- - -## Key references (read before coding) - -- Abilities registrar base: `projects/packages/wp-abilities/src/class-registrar.php` -- Reference ability impl: `projects/packages/connection/src/abilities/class-connection-abilities.php` -- AdminPage chrome usage: `projects/packages/activity-log/src/js/components/ActivityLog/index.tsx` -- Webpack harness reference: `projects/plugins/protect/webpack.config.js` + `projects/plugins/protect/package.json` -- Data model: `projects/plugins/beta/src/class-plugin.php`, `src/class-utils.php`, `src/class-admin.php` -- Current screens being replaced: `src/admin/plugin-select.template.php`, `src/admin/plugin-manage.template.php`, `src/admin/branch-card.template.php`, `src/admin/toggles.template.php` - ---- - -## Phase 0 — Build scaffold & composer wiring - -### Task 1: Add JS build tooling + wp-abilities composer dep - -**Files:** -- Create: `package.json`, `webpack.config.js`, `tsconfig.json`, `babel.config.js` -- Modify: `composer.json`, `.gitignore` - -- [ ] **Step 1: Create `package.json`** (model on `projects/plugins/protect/package.json`) - -```json -{ - "private": true, - "name": "@automattic/jetpack-beta", - "version": "4.2.0", - "description": "Jetpack Beta Tester admin UI.", - "scripts": { - "build": "pnpm run clean && pnpm run build-client", - "build-client": "webpack", - "build-production": "NODE_ENV=production BABEL_ENV=production pnpm run build-client", - "clean": "rm -rf build/", - "typecheck": "tsgo --noEmit", - "watch": "pnpm run build && webpack watch" - }, - "dependencies": { - "@automattic/jetpack-base-styles": "workspace:*", - "@automattic/jetpack-components": "workspace:*", - "@wordpress/api-fetch": "7.46.0", - "@wordpress/components": "33.1.0", - "@wordpress/element": "6.46.0", - "@wordpress/i18n": "6.19.0", - "@wordpress/ui": "0.13.0" - }, - "devDependencies": { - "@automattic/jetpack-webpack-config": "workspace:*", - "webpack": "5.94.0", - "webpack-cli": "5.1.4" - } -} -``` - -Pin exact versions to whatever the monorepo currently resolves — copy the version strings from `projects/plugins/protect/package.json` rather than the literals above if they differ. - -- [ ] **Step 2: Create `webpack.config.js`** — copy `projects/plugins/protect/webpack.config.js` verbatim, then change `entry.index` to `'./src/js/index.tsx'`, keep `output.path` = `./build`, and set the `jetpackConfig` external `consumer_slug` to `'jetpack-beta'`. - -- [ ] **Step 3: Create `tsconfig.json` and `babel.config.js`** — copy from `projects/plugins/protect/` (same harness). Adjust `include` to `src/js`. - -- [ ] **Step 4: Add the abilities dependency to `composer.json`** under `require`, alphabetically among the `automattic/jetpack-*` entries: - -```json -"automattic/jetpack-wp-abilities": "@dev", -``` - -- [ ] **Step 5: Update `.gitignore`** — add `build/` and `node_modules/` if not present. - -- [ ] **Step 6: Install** — from repo root: - -```bash -pnpm jetpack install plugins/beta -``` - -Expected: composer pulls in `automattic/jetpack-wp-abilities` (visible in `vendor/`), pnpm links workspace deps. If it reports lockfile changes, run the suggested `jetpack install -r ...` once. - -- [ ] **Step 7: Commit** - -```bash -git add projects/plugins/beta/package.json projects/plugins/beta/webpack.config.js projects/plugins/beta/tsconfig.json projects/plugins/beta/babel.config.js projects/plugins/beta/composer.json projects/plugins/beta/composer.lock projects/plugins/beta/.gitignore pnpm-lock.yaml -git commit -m "Jetpack Beta: add JS build tooling and wp-abilities dependency" -``` - ---- - -## Phase 1 — Backend abilities - -### Task 2: `Beta_Abilities` registrar — read abilities - -**Files:** -- Create: `src/abilities/class-beta-abilities.php` -- Reference: `projects/packages/connection/src/abilities/class-connection-abilities.php` - -The class extends `Automattic\Jetpack\WP_Abilities\Registrar`, namespace `Automattic\JetpackBeta\Abilities`, category slug `jetpack-beta`. - -- [ ] **Step 1: Class skeleton + category + init override.** Override `init()` to register Beta's abilities **without** the global `jetpack_wp_abilities_enabled` gate (Beta's UI depends on them and Beta is not part of the staged Jetpack rollout). Keep the parent's per-item `should_register` behavior by reusing `register_category()`/`register_abilities()`. - -```php - 'Jetpack Beta', // Product name, not translated. - 'description' => __( 'Abilities provided by the Jetpack Beta Tester.', 'jetpack-beta' ), - ); - } - - public static function get_abilities(): array { - return array( - 'jetpack-beta/list-plugins' => self::spec_list_plugins(), - 'jetpack-beta/get-plugin' => self::spec_get_plugin(), - 'jetpack-beta/get-settings' => self::spec_get_settings(), - 'jetpack-beta/activate-branch' => self::spec_activate_branch(), - 'jetpack-beta/update-settings' => self::spec_update_settings(), - ); - } - - /** - * Shared permission check — mirrors the admin menu capability. - */ - public static function can_manage(): bool { - return current_user_can( 'update_plugins' ); - } -} -``` - -- [ ] **Step 2: `spec_list_plugins()` + `list_plugins()` execute callback.** Zero-arg read. Output: array of `{ slug, name, active_which (stable|dev|null), active_version (string|null), manage_url }`. Build by iterating `Plugin::get_all_plugins( true )` and replicating the active/version logic from `plugin-select.template.php` (`is_active('stable')` → `plugin_slug()`/`stable_pretty_version()`; `is_active('dev')` → `dev_plugin_slug()`/`dev_pretty_version()`; else inactive). `manage_url` = `Utils::admin_url( array( 'plugin' => $slug ) )`. - -Annotations: `readonly: true, idempotent: true`; `show_in_rest: true`; `mcp: { public: false }`. `permission_callback` = `array( __CLASS__, 'can_manage' )`. `input_schema` = empty object (`additionalProperties:false`). - -- [ ] **Step 3: `spec_get_plugin()` + `get_plugin()` execute callback.** Input: `{ slug: string }`. Reproduce the view-model that `plugin-manage.template.php` assembles, returning JSON instead of HTML: - - `name`, `is_mu_plugin`, `bug_report_url` - - `currently_running`: `{ which, source, id, version, pretty_version }` or null (from the `$active_branch`/`$version` logic) - - `sections`: an ordered list of branch cards, each `{ section: 'stable'|'rc'|'trunk'|'pr'|'release'|'existing', source, id, branch, version, pretty_version, is_active (bool) }`. Derive from `Plugin::source_info()` for stable/rc/trunk, `get_manifest()->pr` for PRs, and `get_wporg_data()->versions` (sorted via `Composer\Semver\Semver::rsort`) for releases — matching the template's ordering and the "fixup active branch" logic. - - `to_test_html` and `what_changed_html`: call the existing `Admin::to_test_content( $plugin )` (already returns sanitized HTML). - - `needed_updates`: whatever `show-needed-updates.template.php` computes (port that read). - - On unknown slug, return a `WP_Error( 'unknown_plugin', ... )` (the run controller surfaces it as an error response). - -Annotations same as list-plugins (readonly). `permission_callback` must also enforce the multisite/network-admin rule from `Admin::admin_page_load()` (deny + appropriate error when the managed plugin is network-activated and `! is_network_admin()`). - -- [ ] **Step 4: `spec_get_settings()` + `get_settings()`.** Zero-arg read → `{ autoupdates: bool, email_notifications: bool, skip_email: bool }` from `Utils::is_set_to_autoupdate()`, `Utils::is_set_to_email_notifications()`, and `defined('JETPACK_BETA_SKIP_EMAIL')`. - -- [ ] **Step 5: Run lint** on the new file: - -```bash -pnpm jetpack lint php --filename projects/plugins/beta/src/abilities/class-beta-abilities.php -``` - -Expected: no errors (fix phpcs spacing/escaping as needed). - -- [ ] **Step 6: Commit** - -```bash -git add projects/plugins/beta/src/abilities/class-beta-abilities.php -git commit -m "Jetpack Beta: add read abilities (list-plugins, get-plugin, get-settings)" -``` - -### Task 3: `Beta_Abilities` write abilities + wiring - -**Files:** -- Modify: `src/abilities/class-beta-abilities.php`, `jetpack-beta.php`, `src/class-admin.php` - -- [ ] **Step 1: `spec_activate_branch()` + `activate_branch()`.** Input: `{ slug, source, id }`. Resolve `Plugin::get_plugin( $slug )`, then call `$plugin->install_and_activate( $source, $id )` — the exact logic the nonce handler in `Admin::admin_page_load()` runs. Return `{ success: true, plugin: }` on success, or the `WP_Error` on failure. Annotations: `readonly:false, destructive:false, idempotent:false`; `show_in_rest:true`; `mcp:{ public:false }`. `permission_callback` = `can_manage` + the same network-admin guard as `get-plugin`. - -- [ ] **Step 2: `spec_update_settings()` + `update_settings()`.** Input: partial `{ autoupdates?: bool, email_notifications?: bool }`. For each provided key, replicate the toggle logic from `Admin::admin_page_load()`: - - `autoupdates`: `update_option( 'jp_beta_autoupdate', (int) $value )`; when newly enabled, call `Hooks::maybe_schedule_autoupdate()`. - - `email_notifications`: ignore when `JETPACK_BETA_SKIP_EMAIL` is defined; else `update_option( 'jp_beta_email_notifications', (int) $value )`. - Return the `get-settings` payload. Annotations: write, non-idempotent. - -- [ ] **Step 2b: Refactor (DRY).** Extract the option-mutation bodies from `Admin::admin_page_load()` into small static helpers (or call the ability execute methods) so the legacy GET handler and the ability share one implementation. Keep `admin_page_load()`'s nonce handling for any still-server-driven path, but the toggles/activate links are going away with the templates (Task 8) — leave `admin_page_load()` for the access-control redirect only. - -- [ ] **Step 3: Wire init in `jetpack-beta.php`.** After the autoloader require and `Hooks::setup();`, add: - -```php -add_action( 'plugins_loaded', array( Automattic\JetpackBeta\Abilities\Beta_Abilities::class, 'init' ), 20 ); -``` - -- [ ] **Step 4: Lint** - -```bash -pnpm jetpack lint php --filename projects/plugins/beta/src/abilities/class-beta-abilities.php --filename projects/plugins/beta/jetpack-beta.php --filename projects/plugins/beta/src/class-admin.php -``` - -- [ ] **Step 5: Commit** - -```bash -git add projects/plugins/beta/src/abilities/class-beta-abilities.php projects/plugins/beta/jetpack-beta.php projects/plugins/beta/src/class-admin.php -git commit -m "Jetpack Beta: add write abilities (activate-branch, update-settings) and wire init" -``` - -### Task 4: PHP unit test for abilities - -**Files:** -- Create: `tests/php/abilities/Beta_Abilities_Test.php` -- Reference: `projects/packages/connection/tests/php/abilities/Connection_Abilities_Test.php`, `projects/plugins/boost/tests/php/abilities/Boost_Abilities_Test.php` - -- [ ] **Step 1: Write tests** covering: (a) after firing `wp_abilities_api_categories_init` + `wp_abilities_api_init`, all five abilities are registered (`wp_get_ability( 'jetpack-beta/...' )` non-null); (b) each ability's `permission_callback` returns false for a subscriber and true for an `update_plugins`-capable user; (c) `get-settings` returns the expected shape. Follow the harness/bootstrap used by the reference tests. Per project convention, **do not run PHPUnit locally — CI runs it.** - -- [ ] **Step 2: Commit** - -```bash -git add projects/plugins/beta/tests/php/abilities/Beta_Abilities_Test.php -git commit -m "Jetpack Beta: add Beta_Abilities registration/permission tests" -``` - ---- - -## Phase 2 — React app - -### Task 5: App scaffold — bootstrap, AdminPage chrome, routing, API client - -**Files:** -- Create: `src/js/index.tsx`, `src/js/app.tsx`, `src/js/api/abilities.ts`, `src/js/api/types.ts`, `src/js/style.scss` -- Modify: `src/class-admin.php` (`render()` + `admin_enqueue_scripts()`) - -- [ ] **Step 1: PHP — `render()` prints root + bootstrap.** Replace the `require ...template.php` branches in `Admin::render()` with: print `
`, and enqueue/localize in `admin_enqueue_scripts()`. Read the build's `index.asset.php` for dependencies/version (standard Jetpack pattern — see how Protect enqueues `build/index.js`). Localize: - -```php -wp_localize_script( - 'jetpack-beta-app', - 'JetpackBeta', - array( - 'apiRoot' => esc_url_raw( rest_url() ), - 'apiNonce' => wp_create_nonce( 'wp_rest' ), - 'plugin' => isset( $_GET['plugin'] ) ? sanitize_text_field( wp_unslash( $_GET['plugin'] ) ) : null, - 'adminUrl' => Utils::admin_url(), - 'canManage'=> current_user_can( 'update_plugins' ), - ) -); -``` - -Drop the old `wp_enqueue_style('jetpack-beta-admin', 'admin/admin.css' ...)` and `admin.js` enqueues. - -- [ ] **Step 2: `src/js/api/abilities.ts`** — typed client over the run route: - -```ts -import apiFetch from '@wordpress/api-fetch'; - -const run = < T >( ability: string, data: Record< string, unknown > = {} ): Promise< T > => - apiFetch< T >( { - path: `/wp-abilities/v1/abilities/${ ability }/run`, - method: 'POST', - data, - } ); - -export const listPlugins = () => run< PluginListItem[] >( 'jetpack-beta/list-plugins' ); -export const getPlugin = ( slug: string ) => run< PluginView >( 'jetpack-beta/get-plugin', { slug } ); -export const getSettings = () => run< Settings >( 'jetpack-beta/get-settings' ); -export const activateBranch = ( slug: string, source: string, id: string ) => - run< { success: boolean; plugin: PluginView } >( 'jetpack-beta/activate-branch', { slug, source, id } ); -export const updateSettings = ( patch: Partial< Settings > ) => - run< Settings >( 'jetpack-beta/update-settings', patch ); -``` - -Define `PluginListItem`, `PluginView`, `BranchCard`, `Settings` in `src/js/api/types.ts` to match the ability output schemas from Tasks 2–3. - -- [ ] **Step 3: `src/js/index.tsx`** — set `apiFetch` root/nonce from `window.JetpackBeta`, then render `` into `#jetpack-beta-root`: - -```tsx -import apiFetch from '@wordpress/api-fetch'; -import { createRoot } from '@wordpress/element'; -import App from './app'; -import './style.scss'; - -const boot = window.JetpackBeta; -apiFetch.use( apiFetch.createRootURLMiddleware( boot.apiRoot ) ); -apiFetch.use( apiFetch.createNonceMiddleware( boot.apiNonce ) ); - -const el = document.getElementById( 'jetpack-beta-root' ); -if ( el ) { - createRoot( el ).render( ); -} -``` - -- [ ] **Step 4: `src/js/app.tsx`** — `AdminPage` shell + screen switch on `window.JetpackBeta.plugin`. Use `@automattic/jetpack-components` `AdminPage` with `title="Beta Tester"`, a `subTitle`, `apiRoot`/`apiNonce` from bootstrap, and `breadcrumbs` when on the manage screen. Render `` when no `plugin`, else ``. (Components built in Tasks 6–7; for this task stub them as `() => null` so it compiles.) - -- [ ] **Step 5: Typecheck + build** - -```bash -cd projects/plugins/beta && pnpm run build && pnpm run typecheck -``` - -Expected: `build/index.js` + `build/index.asset.php` produced, typecheck clean. **Verify `@wordpress/ui` exports a toggle/switch** (`Form`/Switch). If not, plan to import `ToggleControl` from `@wordpress/components` in Task 7. - -- [ ] **Step 6: Commit** - -```bash -git add projects/plugins/beta/src/js projects/plugins/beta/src/class-admin.php -git commit -m "Jetpack Beta: React app scaffold with AdminPage chrome and abilities client" -``` - -### Task 6: `PluginList` screen (landing) - -**Files:** -- Create: `src/js/screens/plugin-list.tsx`, `src/js/components/global-toggles.tsx` - -- [ ] **Step 1:** `PluginList` fetches `listPlugins()` on mount (loading + error states via `Notice`). Render each plugin as a `@wordpress/ui` `Card` row: name, current version/state (with a `Badge` for active dev/stable), and a "Manage" `Button` linking to `?page=jetpack-beta&plugin=`. Render `GlobalToggles` above the list. Match the data/labels from `plugin-select.template.php`. - -- [ ] **Step 2:** `GlobalToggles` fetches `getSettings()` and renders the Autoupdates + Email Notifications toggles (toggle primitive per Task 5 Step 5 finding). Calls `updateSettings()` optimistically, rolls back + shows a `Notice` on error. Hide the email toggle when `!autoupdates` or `skip_email` (mirrors `Admin::show_toggle_emails()`). - -- [ ] **Step 3: Build + typecheck + lint JS** - -```bash -cd projects/plugins/beta && pnpm run build && pnpm run typecheck && cd ../../.. && pnpm jetpack lint js projects/plugins/beta/src/js -``` - -- [ ] **Step 4: Commit** (`Jetpack Beta: implement plugin-list screen and global toggles`) - -### Task 7: `PluginManage` screen - -**Files:** -- Create: `src/js/screens/plugin-manage.tsx`, `src/js/components/branch-card.tsx`, `src/js/components/branch-section.tsx`, `src/js/components/markdown-panel.tsx` - -- [ ] **Step 1:** `PluginManage` fetches `getPlugin(slug)` (loading/error states). Layout, top → bottom, mirroring `plugin-manage.template.php`: - 1. "Currently Running" `Card` (when `currently_running`) + "Found a bug? Report it!" `Button` (`bug_report_url`). - 2. mu-plugin info `Notice` when `is_mu_plugin`. - 3. Branch sections via `BranchSection`: stable, rc, trunk, then PR section (with search) and Releases section (with search). - 4. "To Test" and "What changed" `CollapsibleCard`s via `MarkdownPanel` (renders sanitized HTML with `dangerouslySetInnerHTML`). -- [ ] **Step 2:** `BranchSection` renders a heading, an optional controlled search `` that filters its `BranchCard`s client-side by branch/version text (replaces `admin.js` indexing), and the list of cards. -- [ ] **Step 3:** `BranchCard` shows branch name/version, an active `Badge` when `is_active`, and an "Activate" `Button`. On click → `activateBranch(slug, source, id)` with a button-level busy state ("Activating…"); on success refetch `getPlugin(slug)` and update; on error show a `Notice` with the message. Mirror the label localization from the old `wp_localize_script` (`Activate`/`Activating…`/`Failed`). -- [ ] **Step 4:** Breadcrumbs — pass `[{ label: 'Jetpack Beta Tester', href: adminUrl }, { label: pluginName }]` to `AdminPage` (lift `pluginName` into `app.tsx` or render `AdminPage` inside the screen — follow the activity-log pattern). -- [ ] **Step 5: Build + typecheck + lint** (same commands as Task 6 Step 3). -- [ ] **Step 6: Commit** (`Jetpack Beta: implement plugin-manage screen with branch activation`) - -### Task 8: Remove legacy UI - -**Files:** -- Delete: `src/admin/plugin-select.template.php`, `src/admin/plugin-manage.template.php`, `src/admin/branch-card.template.php`, `src/admin/header.template.php`, `src/admin/toggles.template.php`, `src/admin/show-needed-updates.template.php`, `src/admin/admin.js`, `src/admin/updates.js`, `src/admin/admin.css` -- Keep: `src/admin/notice.template.php`, `src/admin/exception.template.php` -- Modify: `src/class-admin.php` - -- [ ] **Step 1: Delete** the files above with `git rm`. -- [ ] **Step 2: Clean `src/class-admin.php`** — remove now-dead methods/requires that referenced deleted templates (`to_test_content` stays — it's used by the ability; `show_toggle*` go; `render_banner` keeps `notice.template.php`). Ensure `render()`/`admin_enqueue_scripts()` only do the React path. Confirm `.gitattributes`/`.phpcs.dir.xml` don't reference removed files. -- [ ] **Step 3: Lint PHP** (`pnpm jetpack lint php --filename projects/plugins/beta/src/class-admin.php`). -- [ ] **Step 4: Commit** (`Jetpack Beta: remove legacy PHP templates, vanilla JS, and hand-rolled CSS`) - -### Task 9: Changelog + final validation - -- [ ] **Step 1: Changelog** — from repo root, use the jetpack-changelog skill / changelogger: - -```bash -cd projects/plugins/beta && composer changelog:add --no-interaction -- --type=changed --significance=minor --entry="Modernized the Beta Tester admin UI with a React interface built on the WordPress design system and the Abilities API." -``` - -(Adjust to the changelogger invocation the repo uses; verify a file lands in `projects/plugins/beta/changelog/`.) - -- [ ] **Step 2: Full validation** - -```bash -cd projects/plugins/beta && pnpm run build && pnpm run typecheck -cd ../../.. && pnpm jetpack lint js projects/plugins/beta/src/js && pnpm jetpack lint php --filename projects/plugins/beta/src/abilities/class-beta-abilities.php -``` - -- [ ] **Step 3: Commit** (`Jetpack Beta: add changelog entry`) - ---- - -## Phase 3 — Ship to Jurassic Ninja - -### Task 10: Deploy + screenshots - -- [ ] **Step 1:** Capture **before** screenshots of both screens on trunk (the current UI) for the PR — use the jetpack-screenshot skill or the existing `blaze-before.png` convention. -- [ ] **Step 2:** Build the plugin and provision/rsync to a fresh Jurassic Ninja site via the **jetpack-test-jurassic-ninja** skill; Jetpack-connect; force dev autoloading. -- [ ] **Step 3:** Capture **after** screenshots of the plugin-list and plugin-manage screens (header, footer, branch cards, toggles, activate flow). -- [ ] **Step 4:** Report the autologin URL and attach before/after screenshots. - ---- - -## Notes / risks (carried from spec) - -- **Toggle primitive:** confirm `@wordpress/ui` switch availability in Task 5 Step 5; fall back to `@wordpress/components` `ToggleControl` for toggles only. -- **Synchronous activate** over REST (10–30s) — handled with button busy state; matches current behavior. -- **`init()` bypasses the global rollout filter** deliberately (only Beta's own abilities register). -- **Network/multisite access control** from `Admin::admin_page_load()` must be preserved in the `get-plugin`/`activate-branch` permission callbacks. diff --git a/docs/superpowers/specs/2026-06-01-modernize-jetpack-beta-ui-design.md b/docs/superpowers/specs/2026-06-01-modernize-jetpack-beta-ui-design.md deleted file mode 100644 index 69e2e0373ed3..000000000000 --- a/docs/superpowers/specs/2026-06-01-modernize-jetpack-beta-ui-design.md +++ /dev/null @@ -1,160 +0,0 @@ -# Modernize the Jetpack Beta plugin UI - -**Date:** 2026-06-01 -**Branch:** `update/modernize-jetpack-beta-ui` -**Status:** Approved design - -## Goal - -Replace the Jetpack Beta Tester admin UI with a modern React application built on the -**`@wordpress/ui`** design-system library, backed by **WordPress Abilities API** endpoints -for all reads and writes. - -Today the plugin is the only major Jetpack plugin with zero JS tooling: it is 100% -server-rendered PHP templates plus a 268-line vanilla `admin.js`, a 136-line `updates.js`, -and a hand-rolled **1299-line `admin.css`** that reimplements old Calypso "dops-" cards, -toggles, and search widgets. This work modernizes the front end and the data layer. - -## Boundaries - -**In scope** - -- The two admin screens: plugin-select landing and plugin-manage. -- Global toggles (autoupdates, email notifications). -- "Needed updates" surface, "To Test" / "What changed" panels, branch search/filter. -- Abilities API backend for all reads and the mutating actions. - -**Out of scope (remain PHP, unchanged)** - -- The `admin_notices` first-run banner on the plugins list page (`notice.template.php`). -- The WP-CLI command (`class-clicommand.php`). -- The install/download engine `Plugin::install_and_activate()` and the `Plugin`/`Utils` - data model — reused unchanged behind abilities. - -## Backend — Abilities API - -New `src/abilities/class-beta-abilities.php`, a `Beta_Abilities extends Registrar` class -mirroring `Automattic\Jetpack\Connection\Abilities\Connection_Abilities`. Category slug -`jetpack-beta`. Add `automattic/jetpack-wp-abilities: @dev` to `composer.json`. - -| Ability | Type | Input → Output | -|---|---|---| -| `jetpack-beta/list-plugins` | read | – → manageable plugins with active state + version | -| `jetpack-beta/get-plugin` | read | `{slug}` → branches (stable / rc / trunk + PR list + releases), active branch, currently-running, `is_mu_plugin`, to-test HTML, what-changed HTML, needed-updates | -| `jetpack-beta/get-settings` | read | – → `{autoupdates, email_notifications, skip_email}` | -| `jetpack-beta/activate-branch` | write | `{slug, source, id}` → result (wraps `install_and_activate`) | -| `jetpack-beta/update-settings` | write | `{autoupdates?, email_notifications?}` (partial) → new settings state | - -Notes: - -- **Permissions:** every `permission_callback` gates on `current_user_can('update_plugins')` - (the same capability the admin menu uses) and preserves the multisite / network-admin - redirects currently in `Admin::admin_page_load()`. -- **Markdown rendering** (Parsedown → `wp_kses_post`) stays server-side; the read abilities - return already-sanitized HTML strings for the "To Test" / "What changed" panels. -- **`update-settings`** is a single ability taking a partial settings object so the toggle - surface stays as one endpoint (rather than one ability per toggle). When autoupdates is - turned on it triggers `Hooks::maybe_schedule_autoupdate()` exactly as the current handler - does; `email_notifications` is ignored when `JETPACK_BETA_SKIP_EMAIL` is defined. -- **MCP exposure:** `meta.mcp.public = false` for all of them — installing arbitrary PR code - is sensitive and must not be an agent-callable tool. `meta.show_in_rest = true` so the - plugin's own React app can use the REST run route. -- **Annotations:** reads are `readonly: true, idempotent: true`; `activate-branch` is - `destructive: false` (installs code but is reversible) and not idempotent; - `update-settings` is a non-idempotent write. - -## Frontend — React app - -New `src/js/` tree, plus `package.json` and `webpack.config.js` using -`@automattic/jetpack-webpack-config` (same harness as the Protect plugin), building into -`build/`. Dependencies: `@automattic/jetpack-components` (page chrome — see Header & footer), -`@wordpress/ui`, `@wordpress/element`, `@wordpress/api-fetch`, `@wordpress/i18n`, -`@automattic/jetpack-base-styles`, and `@wordpress/components` only if a toggle/switch -primitive is needed (see Risks). - -### Header & footer (page chrome) - -Both screens are wrapped in **`AdminPage` from `@automattic/jetpack-components`**, the shared -Jetpack page chrome used by the Activity Log UI (`projects/packages/activity-log`), Protect, -and others. This is the canonical way to get a consistent header and footer and replaces the -plugin's bespoke `header.template.php` (a hand-inlined SVG masthead) — the plugin currently -has no footer at all. - -`AdminPage` provides: - -- **Header** — via `Page` from `@wordpress/admin-ui` + `JetpackLogo`: the Jetpack masthead - logo, a `title` ("Beta Tester"), a `subTitle`, header `actions`, and `breadcrumbs`. -- **Footer** — the standard `JetpackFooter` ("An Automattic Airline", Jetpack logo, module - label, a8c link). - -Mapping to the current UI: - -- Page `title` = "Beta Tester"; `subTitle` = short tagline. -- The plugin-manage screen's hand-rolled breadcrumb div ("Jetpack Beta Tester Home > Jetpack") - becomes the `AdminPage` `breadcrumbs` prop. -- `showFooter` defaults to `true` (Beta is a standalone wp-admin page, unlike the embedded - Activity Log which passes `showFooter={false}`). -- `AdminPage` wants `apiRoot` / `apiNonce`; these come from the same localized bootstrap. - -- **Entry / bootstrap:** `Admin::render()` prints a root `
`, enqueues the webpack build, - and localizes a small bootstrap object: REST root + nonce, the current `plugin` query-arg - slug, the user capability flag, and the **initial payload** for the requested screen so - there is no loading flash on first paint. Mutations and subsequent refreshes go through the - abilities run endpoint. -- **Routing:** client-side, driven by the `plugin` query arg. `PluginList` (landing) ↔ - `PluginManage`. Navigating updates the URL so links/back button keep working. -- **Component mapping (old → `@wordpress/ui`):** - - foldable "dops-card" → `Card` / `CollapsibleCard` - - branch cards → `Card` + `Badge` (active / stable / RC marker) + `Button` (Activate) - - `form-toggle` switches → toggle control (see Risks) - - dops-search → controlled text input filtering the PR / release lists client-side - (replaces the indexing logic in `admin.js`) - - "To Test" / "What changed" → `CollapsibleCard` rendering the sanitized HTML - - notices / errors → `Notice` -- **Action client:** thin wrapper over - `apiFetch({ path: '/wp-abilities/v1/abilities//run', method: 'POST', data })`. - - **Activate** shows a busy state on the clicked button (download can take 10-30s), then - refetches `get-plugin` to refresh active state. - - **Toggles** call `update-settings` optimistically and roll back on error. - -## Files - -**Add** -- `src/abilities/class-beta-abilities.php` -- `package.json`, `webpack.config.js`, and TS/babel config as needed -- `src/js/**` — app entry, `PluginList`, `PluginManage`, branch card, toggles, search, - abilities API client, styles (small SCSS for layout only; visuals come from `@wordpress/ui`) - -**Modify** -- `composer.json` — add `automattic/jetpack-wp-abilities` -- `src/class-admin.php` — render container + enqueue build + bootstrap; move - `to_test_content()` and toggle logic into abilities -- `jetpack-beta.php` — load/init abilities - -**Delete** -- `src/admin/plugin-select.template.php`, `plugin-manage.template.php`, - `branch-card.template.php`, `header.template.php`, `toggles.template.php`, - `show-needed-updates.template.php` -- `src/admin/admin.js`, `src/admin/updates.js`, `src/admin/admin.css` -- (Keep `notice.template.php` and `exception.template.php`.) - -## Testing & ship - -- PHP unit test for ability registration + permission gating, mirroring - `Connection_Abilities_Test`. (Per project convention, PHPUnit is run by CI, not locally.) -- `changelogger` entry. -- `pnpm jetpack build plugins/beta`, then rsync the built plugin to a fresh **Jurassic Ninja** - site (jetpack-test-jurassic-ninja skill), Jetpack-connect, and capture before/after - screenshots of both screens. - -## Risks & open questions - -- **Toggle primitive:** `@wordpress/ui` 0.13.0 is experimental and may not export a - toggle/switch. If it does not, use `ToggleControl` from `@wordpress/components` for the - toggles only; everything else stays on `@wordpress/ui`. Confirmed at build time. -- **Synchronous activate:** activation runs synchronously over REST (no async job queue). - Acceptable — it matches today's blocking redirect behavior — handled with a button-level - loading state and a generous request timeout. -- **Initial-data freshness:** bootstrapped payload is computed once per page load; remote - data (GitHub manifest, wporg versions) is already cached by `Plugin`, so this matches - current behavior. From 6f31ea33102e589ab96a1251c92cc2fa8f931ce4 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 1 Jun 2026 16:46:12 -0700 Subject: [PATCH 35/53] Jetpack Beta: restore the "updates available" UI Re-adds the ability to update an installed branch when a newer build is available (a feature the old PHP UI had via show-needed-updates.template.php). - Backend: jetpack-beta/list-updates (managed plugins with a newer build) and jetpack-beta/update-plugin (runs the upgrader for a plugin file). Both gate on update_plugins; not exposed to MCP. - Frontend: an UpdatesPanel ('Updates available' cards with an Update button + busy state) on the overview screen and, scoped to the plugin, on each manage screen; refreshes the view after an in-place update. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/abilities/class-beta-abilities.php | 222 ++++++++++++++++++ projects/plugins/beta/src/js/api/abilities.ts | 8 +- projects/plugins/beta/src/js/api/types.ts | 6 + .../beta/src/js/components/updates-panel.tsx | 153 ++++++++++++ .../beta/src/js/screens/plugin-list.tsx | 2 + .../beta/src/js/screens/plugin-manage.tsx | 193 ++++++++------- 6 files changed, 493 insertions(+), 91 deletions(-) create mode 100644 projects/plugins/beta/src/js/components/updates-panel.tsx diff --git a/projects/plugins/beta/src/abilities/class-beta-abilities.php b/projects/plugins/beta/src/abilities/class-beta-abilities.php index 0156aa2ab269..299c96fad56b 100644 --- a/projects/plugins/beta/src/abilities/class-beta-abilities.php +++ b/projects/plugins/beta/src/abilities/class-beta-abilities.php @@ -75,6 +75,8 @@ public static function get_abilities(): array { 'jetpack-beta/get-settings' => self::spec_get_settings(), 'jetpack-beta/activate-branch' => self::spec_activate_branch(), 'jetpack-beta/update-settings' => self::spec_update_settings(), + 'jetpack-beta/list-updates' => self::spec_list_updates(), + 'jetpack-beta/update-plugin' => self::spec_update_plugin(), ); } @@ -829,4 +831,224 @@ private static function branch_to_section( $branch, $section, $active_branch ): 'is_active' => $is_active, ); } + + /** + * Spec: jetpack-beta/list-updates. + * + * @return array The ability spec. + */ + private static function spec_list_updates(): array { + return array( + 'label' => __( 'List available Jetpack Beta plugin updates', 'jetpack-beta' ), + 'description' => __( + 'Return the managed plugins that have a newer build available. Optional input { slug } scopes the result to a single plugin (plus the Beta Tester itself); omit it for every managed plugin. Output: { updates: [ { plugin_file, name, new_version } ] }. Read-only, but refreshes WordPress.org/Beta update data so it is not a pure cache read.', + 'jetpack-beta' + ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'slug' => array( 'type' => 'string' ), + ), + 'additionalProperties' => false, + 'default' => array(), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'updates' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'plugin_file' => array( 'type' => 'string' ), + 'name' => array( 'type' => 'string' ), + 'new_version' => array( 'type' => 'string' ), + ), + ), + ), + ), + ), + 'execute_callback' => array( __CLASS__, 'list_updates' ), + 'permission_callback' => array( __CLASS__, 'can_manage' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => false, + ), + 'show_in_rest' => true, + 'mcp' => array( 'public' => false ), + ), + ); + } + + /** + * Spec: jetpack-beta/update-plugin. + * + * @return array The ability spec. + */ + private static function spec_update_plugin(): array { + return array( + 'label' => __( 'Update a Jetpack Beta plugin to its newest build', 'jetpack-beta' ), + 'description' => __( + 'Run the plugin updater for a single plugin file (as reported by list-updates) to install its newest available build. Input: { plugin_file }. Output: { success, updates } where `updates` is the refreshed list-updates payload.', + 'jetpack-beta' + ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'plugin_file' => array( 'type' => 'string' ), + ), + 'required' => array( 'plugin_file' ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'updates' => array( + 'type' => 'array', + 'items' => array( 'type' => 'object' ), + ), + ), + ), + 'execute_callback' => array( __CLASS__, 'update_plugin' ), + 'permission_callback' => array( __CLASS__, 'can_manage' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + 'idempotent' => false, + ), + 'show_in_rest' => true, + 'mcp' => array( 'public' => false ), + ), + ); + } + + /** + * Execute: list-updates. + * + * @param array|null $input Optional `{ slug }` to scope the result. + * @return array|\WP_Error The updates payload, or WP_Error on data failure. + */ + public static function list_updates( $input = null ) { + $slug = isset( $input['slug'] ) && '' !== $input['slug'] ? sanitize_key( $input['slug'] ) : null; + + try { + return self::build_updates_list( $slug ); + } catch ( PluginDataException $e ) { + return new \WP_Error( 'plugin_data_error', $e->getMessage() ); + } + } + + /** + * Execute: update-plugin. + * + * @param array|null $input `{ plugin_file }` of the plugin to update. + * @return array|\WP_Error `{ success, updates }`, or WP_Error on failure. + */ + public static function update_plugin( $input = null ) { + $plugin_file = isset( $input['plugin_file'] ) ? sanitize_text_field( wp_unslash( $input['plugin_file'] ) ) : ''; + if ( '' === $plugin_file ) { + return new \WP_Error( 'missing_plugin_file', __( 'A plugin file is required.', 'jetpack-beta' ) ); + } + + // The Abilities REST run endpoint executes outside wp-admin, so load the + // upgrader/update includes the same way core's plugin-update endpoint does. + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + require_once ABSPATH . 'wp-admin/includes/update.php'; + require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; + + // Make sure the available-update data (including Beta's injected builds) is current. + wp_clean_plugins_cache(); + wp_update_plugins(); + + $skin = new \WP_Ajax_Upgrader_Skin(); + $upgrader = new \Plugin_Upgrader( $skin ); + $result = $upgrader->upgrade( $plugin_file ); + + if ( is_wp_error( $result ) ) { + return $result; + } + if ( is_wp_error( $skin->result ) ) { + return $skin->result; + } + if ( ! $result ) { + $errors = $skin->get_errors(); + if ( is_wp_error( $errors ) && $errors->has_errors() ) { + return $errors; + } + return new \WP_Error( 'update_failed', __( 'The plugin update did not complete.', 'jetpack-beta' ) ); + } + + try { + $updates = self::build_updates_list(); + } catch ( PluginDataException $e ) { + $updates = array( 'updates' => array() ); + } + + return array( + 'success' => true, + 'updates' => $updates['updates'], + ); + } + + /** + * Build the list-updates payload: managed plugins with a newer build available. + * + * Ports show-needed-updates.template.php — `Utils::plugins_needing_update( true )` + * filtered (when `$slug` is given) to that plugin's files plus the Beta Tester. + * + * @param string|null $slug Optional plugin slug to scope the result. + * @return array{updates: array>} The updates payload. + * @throws PluginDataException If the plugin list cannot be fetched. + */ + private static function build_updates_list( ?string $slug = null ): array { + $updates = Utils::plugins_needing_update( true ); + + if ( null !== $slug ) { + $plugin = Plugin::get_plugin( $slug ); + if ( $plugin ) { + $updates = array_intersect_key( + $updates, + array( + $plugin->plugin_file() => 1, + $plugin->dev_plugin_file() => 1, + JPBETA__PLUGIN_FOLDER . '/jetpack-beta.php' => 1, + ) + ); + } + } + + $list = array(); + foreach ( $updates as $file => $update ) { + $dir = dirname( $file ); + + if ( JPBETA__PLUGIN_FOLDER === $dir ) { + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- WP plugin-data header. + $name = $update->Name; + } else { + $is_dev = str_ends_with( $dir, '-dev' ); + $plugin_slug = $is_dev ? substr( $dir, 0, -4 ) : $dir; + $plugin = Plugin::get_plugin( $plugin_slug ); + if ( $plugin ) { + $version = $is_dev ? $plugin->dev_pretty_version() : $plugin->stable_pretty_version(); + $name = $plugin->get_name() . ' | ' . $version; + } else { + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- WP plugin-data header. + $name = $update->Name; + } + } + + $list[] = array( + 'plugin_file' => $file, + 'name' => $name, + 'new_version' => $update->update->new_version ?? '', + ); + } + + return array( 'updates' => $list ); + } } diff --git a/projects/plugins/beta/src/js/api/abilities.ts b/projects/plugins/beta/src/js/api/abilities.ts index 4e5b0c515792..f0f9473b6ff5 100644 --- a/projects/plugins/beta/src/js/api/abilities.ts +++ b/projects/plugins/beta/src/js/api/abilities.ts @@ -12,7 +12,7 @@ import apiFetch from '@wordpress/api-fetch'; import { addQueryArgs } from '@wordpress/url'; -import type { PluginListItem, PluginView, Settings } from './types'; +import type { PluginListItem, PluginUpdate, PluginView, Settings } from './types'; const path = ( ability: string ) => `/wp-abilities/v1/abilities/${ ability }/run`; @@ -66,3 +66,9 @@ export const activateBranch = ( slug: string, source: string, id: string ) => } ); export const updateSettings = ( patch: Partial< Settings > ) => write< Settings >( 'jetpack-beta/update-settings', patch as Record< string, unknown > ); +export const listUpdates = ( slug?: string ) => + read< { updates: PluginUpdate[] } >( 'jetpack-beta/list-updates', slug ? { slug } : undefined ); +export const updatePlugin = ( pluginFile: string ) => + write< { success: boolean; updates: PluginUpdate[] } >( 'jetpack-beta/update-plugin', { + plugin_file: pluginFile, + } ); diff --git a/projects/plugins/beta/src/js/api/types.ts b/projects/plugins/beta/src/js/api/types.ts index 03d27d95e0d8..8fa0fb092704 100644 --- a/projects/plugins/beta/src/js/api/types.ts +++ b/projects/plugins/beta/src/js/api/types.ts @@ -46,6 +46,12 @@ export type Settings = { skip_email: boolean; }; +export type PluginUpdate = { + plugin_file: string; + name: string; + new_version: string; +}; + export type BetaBootstrap = { apiRoot: string; apiNonce: string; diff --git a/projects/plugins/beta/src/js/components/updates-panel.tsx b/projects/plugins/beta/src/js/components/updates-panel.tsx new file mode 100644 index 000000000000..bdb0314e126a --- /dev/null +++ b/projects/plugins/beta/src/js/components/updates-panel.tsx @@ -0,0 +1,153 @@ +/** + * UpdatesPanel — lists managed plugins that have a newer build available and + * lets the user update each one in place. + * + * Renders nothing when there are no updates. Optionally scoped to a single + * plugin via `slug` (used on the manage screen). + * + * @package + */ + +import { useCallback, useEffect, useState } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { Badge, Button, Card, Notice, Stack, Text } from '@wordpress/ui'; +import { errorMessage, listUpdates, updatePlugin } from '../api/abilities'; +import type { PluginUpdate } from '../api/types'; + +type RowProps = { + update: PluginUpdate; + busy: boolean; + disabled: boolean; + onUpdate: ( pluginFile: string ) => void; +}; + +/** + * A single "update available" row with its Update button. + * + * @param {RowProps} props - Component props. + * @return The row element. + */ +const UpdateRow = ( { update, busy, disabled, onUpdate }: RowProps ) => { + const handle = useCallback( + () => onUpdate( update.plugin_file ), + [ onUpdate, update.plugin_file ] + ); + + return ( + + + { update.name } + + { sprintf( + /* translators: %s: version number. */ + __( 'Version %s is available', 'jetpack-beta' ), + update.new_version + ) } + + + + + ); +}; + +type Props = { + slug?: string; + onUpdated?: () => void; +}; + +/** + * Updates-available panel. + * + * @param {Props} props - Component props. + * @return The panel element, or null when there are no updates. + */ +const UpdatesPanel = ( { slug, onUpdated }: Props ) => { + const [ updates, setUpdates ] = useState< PluginUpdate[] | null >( null ); + const [ error, setError ] = useState< string | null >( null ); + const [ busyFile, setBusyFile ] = useState< string | null >( null ); + + useEffect( () => { + let cancelled = false; + listUpdates( slug ) + .then( data => { + if ( ! cancelled ) { + setUpdates( data.updates ); + } + } ) + .catch( ( err: unknown ) => { + if ( ! cancelled ) { + setError( errorMessage( err, __( 'Could not check for updates.', 'jetpack-beta' ) ) ); + } + } ); + return () => { + cancelled = true; + }; + }, [ slug ] ); + + const handleUpdate = useCallback( + ( pluginFile: string ) => { + if ( busyFile !== null ) { + return; + } + setBusyFile( pluginFile ); + setError( null ); + updatePlugin( pluginFile ) + .then( data => { + setUpdates( data.updates ); + onUpdated?.(); + } ) + .catch( ( err: unknown ) => { + setError( errorMessage( err, __( 'Could not update the plugin.', 'jetpack-beta' ) ) ); + } ) + .finally( () => { + setBusyFile( null ); + } ); + }, + [ busyFile, onUpdated ] + ); + + // Render nothing until we know there is at least one update to offer. + if ( ! error && ( ! updates || updates.length === 0 ) ) { + return null; + } + + return ( + + + + + { __( 'Updates available', 'jetpack-beta' ) } + { updates && updates.length > 0 && ( + { String( updates.length ) } + ) } + + { error && ( + + { error } + + ) } + { updates?.map( update => ( + + ) ) } + + + + ); +}; + +export default UpdatesPanel; diff --git a/projects/plugins/beta/src/js/screens/plugin-list.tsx b/projects/plugins/beta/src/js/screens/plugin-list.tsx index 9248d8bee588..08b48e562e29 100644 --- a/projects/plugins/beta/src/js/screens/plugin-list.tsx +++ b/projects/plugins/beta/src/js/screens/plugin-list.tsx @@ -12,6 +12,7 @@ import { Badge, Card, Notice, Stack, Text } from '@wordpress/ui'; import { errorMessage, listPlugins } from '../api/abilities'; import GlobalToggles from '../components/global-toggles'; import { CardRowSkeleton } from '../components/skeleton'; +import UpdatesPanel from '../components/updates-panel'; import type { PluginListItem } from '../api/types'; import type { KeyboardEvent } from 'react'; @@ -211,6 +212,7 @@ const PluginList = () => {
+ { loading && Array.from( { length: 6 } ).map( ( _, index ) => ) } { error && ( diff --git a/projects/plugins/beta/src/js/screens/plugin-manage.tsx b/projects/plugins/beta/src/js/screens/plugin-manage.tsx index b3c9b9e82a8b..c4a6fb33fcf1 100644 --- a/projects/plugins/beta/src/js/screens/plugin-manage.tsx +++ b/projects/plugins/beta/src/js/screens/plugin-manage.tsx @@ -19,6 +19,7 @@ import BranchSection from '../components/branch-section'; import Footer from '../components/footer'; import MarkdownPanel from '../components/markdown-panel'; import { CardRowSkeleton } from '../components/skeleton'; +import UpdatesPanel from '../components/updates-panel'; import type { BranchCard as BranchCardType, PluginView } from '../api/types'; type Props = { @@ -148,6 +149,15 @@ const PluginManage = ( { slug }: Props ) => { setView( updated ); }, [] ); + // Refresh the view after an in-place update so the running version updates. + const handleUpdated = useCallback( () => { + getPlugin( slug ) + .then( setView ) + .catch( () => { + // Leave the current view in place; the update itself succeeded. + } ); + }, [ slug ] ); + // Prefer the name from the bootstrap so the header renders immediately, then // keep using it once the full view has loaded. const pluginName = view?.name ?? boot.pluginName ?? null; @@ -167,102 +177,105 @@ const PluginManage = ( { slug }: Props ) => { >
- { loading && ( - - { Array.from( { length: 4 } ).map( ( _, index ) => ( - - ) ) } - - ) } - { error && ( - - { error } - - ) } - { view && ( - - { view.is_mu_plugin && ( - - - { __( 'This plugin will be installed as a mu-plugin. See', 'jetpack-beta' ) }{ ' ' } - - { __( 'the documentation', 'jetpack-beta' ) } - { ' ' } - { __( - "for details on what this entails, particularly if you're newly installing a stable version.", - 'jetpack-beta' - ) } - - - ) } + + + { loading && ( + + { Array.from( { length: 4 } ).map( ( _, index ) => ( + + ) ) } + + ) } + { error && ( + + { error } + + ) } + { view && ( + + { view.is_mu_plugin && ( + + + { __( 'This plugin will be installed as a mu-plugin. See', 'jetpack-beta' ) }{ ' ' } + + { __( 'the documentation', 'jetpack-beta' ) } + { ' ' } + { __( + "for details on what this entails, particularly if you're newly installing a stable version.", + 'jetpack-beta' + ) } + + + ) } - { view.currently_running && ( - - - - - - - { view.name } { __( '— Currently Running', 'jetpack-beta' ) } + { view.currently_running && ( + + + + + + + { view.name } { __( '— Currently Running', 'jetpack-beta' ) } + + + + { view.currently_running.pretty_version ?? + view.currently_running.version ?? + '' } - - - { view.currently_running.pretty_version ?? - view.currently_running.version ?? - '' } - + + - - - - - ) } + + + ) } - { SECTION_CONFIG.map( ( { key, title, searchable, searchPlaceholder } ) => ( - - ) ) } + { SECTION_CONFIG.map( ( { key, title, searchable, searchPlaceholder } ) => ( + + ) ) } - { view.to_test_html && ( - - ) } + { view.to_test_html && ( + + ) } - { view.what_changed_html && ( - - ) } - - ) } + { view.what_changed_html && ( + + ) } + + ) } +
-
+ ); }; diff --git a/projects/plugins/beta/src/js/style.scss b/projects/plugins/beta/src/js/style.scss index 73ef8889b598..3d49928e38ea 100644 --- a/projects/plugins/beta/src/js/style.scss +++ b/projects/plugins/beta/src/js/style.scss @@ -114,21 +114,6 @@ body.jetpack-beta-page { } } -// Page footer (Jetpack mark + Automattic byline), no product/help links. -.jetpack-beta-footer { - border-top: 1px solid var(--wpds-color-stroke-surface-neutral-weak, #e0e0e0); - box-sizing: border-box; - padding: var(--wpds-dimension-padding-xl, 24px) var(--wpds-dimension-padding-2xl, 24px); - width: 100%; - - .jetpack-beta-footer__a8c { - - svg { - fill: var(--wpds-color-fg-interactive-neutral-weak, #50575e); - } - - @media (min-width: 480px) { - margin-inline-start: auto; - } - } -} +// The footer is the shared `@automattic/jetpack-components` JetpackFooter +// (rendered with `showDefaultLinks={ false }`); it brings its own styles, so no +// Beta-specific footer CSS is needed here. From 01ecc9f89854a9cc763b1d2a70e339c005d02ce9 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Tue, 2 Jun 2026 10:09:47 -0700 Subject: [PATCH 42/53] Jetpack Beta: make the plugin list a compact single-card list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the stack of separate plugin cards with one bordered @wordpress/ui Card whose rows are link items separated by hairline dividers — tighter and matching the Social-style list. (@wordpress/ui has no list/item primitive, and @wordpress/components' ItemGroup/Item is an experimental API blocked by @wordpress/no-unsafe-wp-apis, so the list is composed from Card + rows.) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../beta/src/js/screens/plugin-list.tsx | 90 ++++++++++--------- projects/plugins/beta/src/js/style.scss | 41 +++++---- 2 files changed, 74 insertions(+), 57 deletions(-) diff --git a/projects/plugins/beta/src/js/screens/plugin-list.tsx b/projects/plugins/beta/src/js/screens/plugin-list.tsx index b48006a8741d..343972b84205 100644 --- a/projects/plugins/beta/src/js/screens/plugin-list.tsx +++ b/projects/plugins/beta/src/js/screens/plugin-list.tsx @@ -43,13 +43,13 @@ const pluginStatus = ( }; /** - * A single plugin card. The whole card is a link to the plugin's manage screen - * (so it behaves like a real link — middle/cmd-click, copy URL, etc.); the - * chevron is a visual affordance only. + * A single plugin row inside the ItemGroup list. The whole row is a link to the + * plugin's manage screen (so it behaves like a real link — middle/cmd-click, + * copy URL, etc.); the chevron is a visual affordance only. * * @param props - Component props. * @param props.plugin - The plugin to render. - * @return The plugin card element. + * @return The plugin row element. */ const PluginCard = ( { plugin }: { plugin: PluginListItem } ) => { const { versionText, badgeLabel } = pluginStatus( plugin ); @@ -62,50 +62,53 @@ const PluginCard = ( { plugin }: { plugin: PluginListItem } ) => { }, [] ); return ( - } > - - - - { iconFailed ? ( - - ) : ( - - ) } - - { plugin.name } - - { versionText } - { badgeLabel && ( - - { badgeLabel } - - ) } - + + + { iconFailed ? ( + + ) : ( + + ) } + + { plugin.name } + + { versionText } + { badgeLabel && ( + + { badgeLabel } + + ) } - - - + + + ); }; @@ -204,8 +207,13 @@ const PluginList = () => { { error } ) } - { plugins && - plugins.map( plugin => ) } + { plugins && plugins.length > 0 && ( + + { plugins.map( plugin => ( + + ) ) } + + ) }
diff --git a/projects/plugins/beta/src/js/style.scss b/projects/plugins/beta/src/js/style.scss index 3d49928e38ea..aa64aab14450 100644 --- a/projects/plugins/beta/src/js/style.scss +++ b/projects/plugins/beta/src/js/style.scss @@ -83,15 +83,19 @@ body.jetpack-beta-page { color: var(--wpds-color-fg-interactive-neutral-weak, #50575e); } -// The whole plugin card is a link to its manage screen, but it should read as a -// card — not a text link. Inherit the normal admin text color and drop the -// underline (on the anchor and its descendants). -.jetpack-beta-plugin-card { - color: inherit; - cursor: pointer; - text-decoration: none; - transition: box-shadow 0.1s ease-in-out, border-color 0.1s ease-in-out; +// The plugin list is one bordered @wordpress/ui Card (no per-row gaps); each +// row is a link separated by a hairline divider. `overflow: hidden` clips the +// row hover backgrounds to the card's rounded corners. +.jetpack-beta-plugin-list { + overflow: hidden; +} +.jetpack-beta-plugin-row { + box-sizing: border-box; + display: block; + padding: var(--wpds-dimension-padding-lg, 16px) var(--wpds-dimension-padding-2xl, 24px); + + &, &:hover, &:focus, &:active, @@ -100,17 +104,22 @@ body.jetpack-beta-page { text-decoration: none; } - &:hover, - &:focus-visible { - border-color: var(--wp-admin-theme-color, #3858e9); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + &:not(:last-child) { + border-block-end: 1px solid var(--wpds-color-stroke-surface-neutral-weak, #e0e0e0); + } + + &:hover { + background: var(--wpds-color-bg-surface-neutral-weak, #f6f7f7); } - // Provide our own focus indicator (blue border + shadow above) and suppress - // the UA/admin outline, which is rectangular and would square off the card's - // rounded corners on focus. + // Inset focus ring so it stays inside the card's rounded corners. &:focus-visible { - outline: none; + outline: 2px solid var(--wp-admin-theme-color, #3858e9); + outline-offset: -2px; + } + + .jetpack-beta-plugin-row__inner { + width: 100%; } } From 2203265dfc3c30f8c426d5f42615a38bc5c33979 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Tue, 2 Jun 2026 10:51:21 -0700 Subject: [PATCH 43/53] Jetpack Beta: let feature-branch search match PR numbers and GitHub URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the GitHub PR number on feature-branch cards (new `pr` field from branch_to_section + schema + type), and extend the Feature Branches search to accept a pasted pull-request URL (…/pull/12345) or a bare/`#`-prefixed PR number in addition to branch text — making branches easier to find. Updated the search placeholder to advertise it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/abilities/class-beta-abilities.php | 4 +++ projects/plugins/beta/src/js/api/types.ts | 2 ++ .../beta/src/js/components/branch-section.tsx | 27 ++++++++++++++++++- .../beta/src/js/screens/plugin-manage.tsx | 2 +- 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/projects/plugins/beta/src/abilities/class-beta-abilities.php b/projects/plugins/beta/src/abilities/class-beta-abilities.php index 14f4520d1348..d78f604be7ac 100644 --- a/projects/plugins/beta/src/abilities/class-beta-abilities.php +++ b/projects/plugins/beta/src/abilities/class-beta-abilities.php @@ -833,6 +833,7 @@ private static function plugin_view_schema(): array { 'branch' => array( 'type' => array( 'string', 'null' ) ), 'version' => array( 'type' => array( 'string', 'null' ) ), 'pretty_version' => array( 'type' => array( 'string', 'null' ) ), + 'pr' => array( 'type' => array( 'integer', 'null' ) ), 'is_active' => array( 'type' => 'boolean' ), ), ), @@ -865,6 +866,9 @@ private static function branch_to_section( $branch, $section, $active_branch ): 'branch' => $branch->branch ?? null, 'version' => $branch->version ?? null, 'pretty_version' => $branch->pretty_version ?? null, + // PR branches carry the GitHub PR number; surfaced so the UI search + // can match a pasted PR number or pull-request URL. + 'pr' => isset( $branch->pr ) ? (int) $branch->pr : null, 'is_active' => $is_active, ); } diff --git a/projects/plugins/beta/src/js/api/types.ts b/projects/plugins/beta/src/js/api/types.ts index 8fa0fb092704..397d547e2bb8 100644 --- a/projects/plugins/beta/src/js/api/types.ts +++ b/projects/plugins/beta/src/js/api/types.ts @@ -19,6 +19,8 @@ export type BranchCard = { branch: string | null; version: string | null; pretty_version: string | null; + /** GitHub PR number for feature-branch (`pr`) cards; null otherwise. */ + pr: number | null; is_active: boolean; }; diff --git a/projects/plugins/beta/src/js/components/branch-section.tsx b/projects/plugins/beta/src/js/components/branch-section.tsx index ae007440a578..1745d1a4e57e 100644 --- a/projects/plugins/beta/src/js/components/branch-section.tsx +++ b/projects/plugins/beta/src/js/components/branch-section.tsx @@ -11,6 +11,28 @@ import { Stack, Text } from '@wordpress/ui'; import BranchCard from './branch-card'; import type { BranchCard as BranchCardType, PluginView } from '../api/types'; +/** + * Extract a GitHub PR number from a search query, so the feature-branch search + * accepts a pasted pull-request URL or a bare PR number — not just branch text. + * + * Matches: `https://github.com/owner/repo/pull/12345` (with optional + * trailing path/query/hash), `#12345`, or `12345`. + * + * @param query - The trimmed search query. + * @return The PR number, or null if the query isn't a PR reference. + */ +const extractPrNumber = ( query: string ): number | null => { + const urlMatch = query.match( /github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/i ); + if ( urlMatch ) { + return Number( urlMatch[ 1 ] ); + } + const numMatch = query.match( /^#?(\d+)$/ ); + if ( numMatch ) { + return Number( numMatch[ 1 ] ); + } + return null; +}; + type Props = { title: string; cards: BranchCardType[]; @@ -24,7 +46,8 @@ type Props = { * Renders a labeled section of branch cards, with optional client-side search filtering. * * When `searchable` is true, a SearchControl is shown that filters cards by matching - * the query (case-insensitive) against `pretty_version`, `branch`, and `version`. + * the query (case-insensitive) against `pretty_version`, `branch`, and `version`, + * and — for feature branches — by GitHub PR number or a pasted pull-request URL. * * @param {Props} props - Component props. * @return The branch section element, or null if there are no cards. @@ -51,8 +74,10 @@ const BranchSection = ( { filteredCards = cards; } else if ( hasQuery ) { const q = trimmedQuery.toLowerCase(); + const prNumber = extractPrNumber( trimmedQuery ); filteredCards = cards.filter( card => + ( prNumber !== null && card.pr === prNumber ) || ( card.pretty_version?.toLowerCase().includes( q ) ?? false ) || ( card.branch?.toLowerCase().includes( q ) ?? false ) || ( card.version?.toLowerCase().includes( q ) ?? false ) diff --git a/projects/plugins/beta/src/js/screens/plugin-manage.tsx b/projects/plugins/beta/src/js/screens/plugin-manage.tsx index 36bf6fc2447d..10b62b1d047e 100644 --- a/projects/plugins/beta/src/js/screens/plugin-manage.tsx +++ b/projects/plugins/beta/src/js/screens/plugin-manage.tsx @@ -52,7 +52,7 @@ const SECTION_CONFIG: Array< { key: 'pr', title: __( 'Feature Branches', 'jetpack-beta' ), searchable: true, - searchPlaceholder: __( 'Search for a Feature Branch', 'jetpack-beta' ), + searchPlaceholder: __( 'Search by name, PR number, or GitHub URL', 'jetpack-beta' ), }, { key: 'release', From f9e910d8e7d756a72c68760972d651ad8925fe12 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Tue, 2 Jun 2026 10:54:56 -0700 Subject: [PATCH 44/53] Jetpack Beta: round the plugin row focus ring to match the card The focus indicator was a square `outline`, which cut straight across the card's rounded top/bottom corners on the first/last rows. Switch to an inset box-shadow (follows border-radius) and round the first/last rows to the card's `--wpds-border-radius-lg`, so the focus ring follows the rounded corners. Co-Authored-By: Claude Opus 4.8 (1M context) --- projects/plugins/beta/src/js/style.scss | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/projects/plugins/beta/src/js/style.scss b/projects/plugins/beta/src/js/style.scss index aa64aab14450..8144aa39db9d 100644 --- a/projects/plugins/beta/src/js/style.scss +++ b/projects/plugins/beta/src/js/style.scss @@ -112,10 +112,23 @@ body.jetpack-beta-page { background: var(--wpds-color-bg-surface-neutral-weak, #f6f7f7); } - // Inset focus ring so it stays inside the card's rounded corners. + // Match the card's rounded corners on the first/last rows so the focus ring + // follows them instead of cutting square across the top/bottom of the card. + &:first-child { + border-start-start-radius: var(--wpds-border-radius-lg, 8px); + border-start-end-radius: var(--wpds-border-radius-lg, 8px); + } + + &:last-child { + border-end-start-radius: var(--wpds-border-radius-lg, 8px); + border-end-end-radius: var(--wpds-border-radius-lg, 8px); + } + + // Use an inset box-shadow (it follows border-radius, unlike outline) so the + // focus ring has rounded corners on the first/last rows. &:focus-visible { - outline: 2px solid var(--wp-admin-theme-color, #3858e9); - outline-offset: -2px; + outline: none; + box-shadow: inset 0 0 0 2px var(--wp-admin-theme-color, #3858e9); } .jetpack-beta-plugin-row__inner { From 7df30695253d5dd73ad1c013230958e0bd32e64d Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Tue, 2 Jun 2026 10:59:29 -0700 Subject: [PATCH 45/53] Jetpack Beta: show pending-update notices as info instead of warning Switch the updates Notice from intent="warning" (orange) to intent="info"; it stays non-dismissable. Co-Authored-By: Claude Opus 4.8 (1M context) --- projects/plugins/beta/src/js/components/updates-panel.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/projects/plugins/beta/src/js/components/updates-panel.tsx b/projects/plugins/beta/src/js/components/updates-panel.tsx index 32f9e3fdccfe..88a3d108a35c 100644 --- a/projects/plugins/beta/src/js/components/updates-panel.tsx +++ b/projects/plugins/beta/src/js/components/updates-panel.tsx @@ -2,9 +2,9 @@ * UpdatesPanel — surfaces managed plugins that have a newer build available and * lets the user update each one in place. * - * Each pending update is shown as a non-dismissable warning (orange) Notice so - * it clearly stands out. Renders nothing when there are no updates. Optionally - * scoped to a single plugin via `slug` (used on the manage screen). + * Each pending update is shown as a non-dismissable info Notice. Renders nothing + * when there are no updates. Optionally scoped to a single plugin via `slug` + * (used on the manage screen). * * @package */ @@ -35,7 +35,7 @@ const UpdateRow = ( { update, busy, disabled, onUpdate }: RowProps ) => { ); return ( - + { update.name } { sprintf( From f2f90ad8fa2de77d364068f56b572435a238b095 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Tue, 2 Jun 2026 12:18:09 -0700 Subject: [PATCH 46/53] Jetpack Beta: make the branch picker a compact list like the plugin list Generalize the compact-list styles (jetpack-beta-list / -list-row) and reuse them for the branch picker: the simple sections (Existing, Latest Stable, Release Candidate, Bleeding Edge) collapse into one bordered card of divider-separated rows, and the searchable Feature Branches / Released Versions sections render their results in the same compact card under their search box. Branch cards become rows (no per-branch Card/gap). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../beta/src/js/components/branch-card.tsx | 23 +++---- .../beta/src/js/components/branch-section.tsx | 26 ++++---- .../beta/src/js/screens/plugin-list.tsx | 4 +- .../beta/src/js/screens/plugin-manage.tsx | 50 ++++++++++++---- projects/plugins/beta/src/js/style.scss | 60 +++++++++++-------- 5 files changed, 103 insertions(+), 60 deletions(-) diff --git a/projects/plugins/beta/src/js/components/branch-card.tsx b/projects/plugins/beta/src/js/components/branch-card.tsx index fb53ebfd7c3d..41276560d391 100644 --- a/projects/plugins/beta/src/js/components/branch-card.tsx +++ b/projects/plugins/beta/src/js/components/branch-card.tsx @@ -1,12 +1,14 @@ /** - * BranchCard — displays a single branch with its version label and an Activate button. + * BranchRow — a single branch as a compact list row with its version label and + * an Activate button (or an Active badge). Rendered inside a `.jetpack-beta-list` + * card so branches stack tightly, matching the plugin list. * * @package */ import { useCallback, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -import { Badge, Button, Card, Notice, Stack, Text } from '@wordpress/ui'; +import { Badge, Button, Notice, Stack, Text } from '@wordpress/ui'; import { activateBranch, errorMessage } from '../api/abilities'; import type { BranchCard as BranchCardType, PluginView } from '../api/types'; @@ -23,12 +25,13 @@ type Props = { }; /** - * Renders a branch card with version label, active badge, and activate button. + * Renders a branch as a compact list row with version label, active badge, and + * activate button. * * @param {Props} props - Component props. - * @return The branch card element. + * @return The branch row element. */ -const BranchCard = ( { card, pluginSlug, onActivated, title }: Props ) => { +const BranchRow = ( { card, pluginSlug, onActivated, title }: Props ) => { const [ busy, setBusy ] = useState( false ); const [ error, setError ] = useState< string | null >( null ); @@ -58,8 +61,8 @@ const BranchCard = ( { card, pluginSlug, onActivated, title }: Props ) => { }, [ card.id, card.source, onActivated, pluginSlug ] ); return ( - - +
+ { error && ( { error } @@ -89,9 +92,9 @@ const BranchCard = ( { card, pluginSlug, onActivated, title }: Props ) => { ) } - - + +
); }; -export default BranchCard; +export default BranchRow; diff --git a/projects/plugins/beta/src/js/components/branch-section.tsx b/projects/plugins/beta/src/js/components/branch-section.tsx index 1745d1a4e57e..6d5c0cbeeb99 100644 --- a/projects/plugins/beta/src/js/components/branch-section.tsx +++ b/projects/plugins/beta/src/js/components/branch-section.tsx @@ -7,8 +7,8 @@ import { SearchControl } from '@wordpress/components'; import { useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -import { Stack, Text } from '@wordpress/ui'; -import BranchCard from './branch-card'; +import { Card, Stack, Text } from '@wordpress/ui'; +import BranchRow from './branch-card'; import type { BranchCard as BranchCardType, PluginView } from '../api/types'; /** @@ -102,15 +102,19 @@ const BranchSection = ( { /> ) } - { filteredCards.map( card => ( - - ) ) } + { filteredCards.length > 0 && ( + + { filteredCards.map( card => ( + + ) ) } + + ) } { searchable && hasQuery && filteredCards.length === 0 && ( { __( 'No branches match your search.', 'jetpack-beta' ) } ) } diff --git a/projects/plugins/beta/src/js/screens/plugin-list.tsx b/projects/plugins/beta/src/js/screens/plugin-list.tsx index 343972b84205..ff4aacf87c92 100644 --- a/projects/plugins/beta/src/js/screens/plugin-list.tsx +++ b/projects/plugins/beta/src/js/screens/plugin-list.tsx @@ -63,7 +63,7 @@ const PluginCard = ( { plugin }: { plugin: PluginListItem } ) => { return ( {
) } { plugins && plugins.length > 0 && ( - + { plugins.map( plugin => ( ) ) } diff --git a/projects/plugins/beta/src/js/screens/plugin-manage.tsx b/projects/plugins/beta/src/js/screens/plugin-manage.tsx index 10b62b1d047e..56cd85fc3cfb 100644 --- a/projects/plugins/beta/src/js/screens/plugin-manage.tsx +++ b/projects/plugins/beta/src/js/screens/plugin-manage.tsx @@ -22,6 +22,7 @@ import { import { __, sprintf } from '@wordpress/i18n'; import { Button, Card, Link, Notice, Stack, Text } from '@wordpress/ui'; import { errorMessage, getPlugin } from '../api/abilities'; +import BranchRow from '../components/branch-card'; import BranchSection from '../components/branch-section'; import MarkdownPanel from '../components/markdown-panel'; import { CardRowSkeleton } from '../components/skeleton'; @@ -185,6 +186,17 @@ const PluginManage = ( { slug }: Props ) => { [ view ] ); + // The simple (non-searchable) sections collapse into a single compact list + // card, each row labeled by its section title. The searchable sections keep + // their own search box and compact list below. + const simpleRows = useMemo( + () => + SECTION_CONFIG.filter( s => ! s.searchable ).flatMap( s => + ( sectionMap.get( s.key ) ?? [] ).map( card => ( { card, title: s.title } ) ) + ), + [ sectionMap ] + ); + return ( { ) } - { SECTION_CONFIG.map( ( { key, title, searchable, searchPlaceholder } ) => ( - - ) ) } + { simpleRows.length > 0 && ( + + { simpleRows.map( ( { card, title } ) => ( + + ) ) } + + ) } + + { SECTION_CONFIG.filter( s => s.searchable ).map( + ( { key, title, searchPlaceholder } ) => ( + + ) + ) } { view.to_test_html && ( Date: Tue, 2 Jun 2026 12:26:29 -0700 Subject: [PATCH 47/53] Jetpack Beta: unify row :focus with :focus-visible and compact the skeleton - Plugin row links now show the same inset focus ring for :focus as for :focus-visible, overriding the wp-admin default that rendered only a blue bottom border on click. - Loading skeleton is now a single compact list card of divider-separated skeleton rows (ListSkeleton), matching the loaded list instead of a stack of separate cards. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../beta/src/js/components/skeleton.tsx | 46 +++++++++++++------ .../beta/src/js/screens/plugin-list.tsx | 5 +- .../beta/src/js/screens/plugin-manage.tsx | 10 +--- projects/plugins/beta/src/js/style.scss | 7 ++- 4 files changed, 43 insertions(+), 25 deletions(-) diff --git a/projects/plugins/beta/src/js/components/skeleton.tsx b/projects/plugins/beta/src/js/components/skeleton.tsx index 0540251285aa..3837d8e92092 100644 --- a/projects/plugins/beta/src/js/components/skeleton.tsx +++ b/projects/plugins/beta/src/js/components/skeleton.tsx @@ -27,21 +27,41 @@ export const Skeleton = ( { width = '100%', height = '1em' }: SkeletonProps ) => ); /** - * A card-shaped skeleton mirroring a plugin / branch row: a couple of text lines - * on the left and a small action placeholder on the right. + * A single skeleton row mirroring a plugin / branch row: a couple of text lines + * on the left and a small action placeholder on the right. Rendered inside a + * `ListSkeleton` so it matches the compact list layout. * - * @return The card row skeleton element. + * @return The skeleton row element. */ -export const CardRowSkeleton = () => ( - - - - - - - - +const RowSkeleton = () => ( +
+ + + + - + + +
+); + +/** + * A compact list skeleton: one bordered card of divider-separated skeleton rows, + * mirroring the loaded plugin/branch list so the loading state matches its shape. + * + * @param {object} props - Component props. + * @param {number} props.rows - Number of skeleton rows to render (default 5). + * @return The list skeleton element. + */ +export const ListSkeleton = ( { rows = 5 }: { rows?: number } ) => ( + ); diff --git a/projects/plugins/beta/src/js/screens/plugin-list.tsx b/projects/plugins/beta/src/js/screens/plugin-list.tsx index ff4aacf87c92..739ca90eafaf 100644 --- a/projects/plugins/beta/src/js/screens/plugin-list.tsx +++ b/projects/plugins/beta/src/js/screens/plugin-list.tsx @@ -11,7 +11,7 @@ import { Icon, chevronRight, plugins as pluginsIcon } from '@wordpress/icons'; import { Badge, Card, Notice, Stack, Text } from '@wordpress/ui'; import { errorMessage, listPlugins } from '../api/abilities'; import GlobalToggles from '../components/global-toggles'; -import { CardRowSkeleton } from '../components/skeleton'; +import { ListSkeleton } from '../components/skeleton'; import UpdatesPanel from '../components/updates-panel'; import type { PluginListItem } from '../api/types'; @@ -200,8 +200,7 @@ const PluginList = () => { - { loading && - Array.from( { length: 6 } ).map( ( _, index ) => ) } + { loading && } { error && ( { error } diff --git a/projects/plugins/beta/src/js/screens/plugin-manage.tsx b/projects/plugins/beta/src/js/screens/plugin-manage.tsx index 56cd85fc3cfb..904256b1ade1 100644 --- a/projects/plugins/beta/src/js/screens/plugin-manage.tsx +++ b/projects/plugins/beta/src/js/screens/plugin-manage.tsx @@ -25,7 +25,7 @@ import { errorMessage, getPlugin } from '../api/abilities'; import BranchRow from '../components/branch-card'; import BranchSection from '../components/branch-section'; import MarkdownPanel from '../components/markdown-panel'; -import { CardRowSkeleton } from '../components/skeleton'; +import { ListSkeleton } from '../components/skeleton'; import UpdatesPanel from '../components/updates-panel'; import type { BranchCard as BranchCardType, PluginView } from '../api/types'; @@ -210,13 +210,7 @@ const PluginManage = ( { slug }: Props ) => {
- { loading && ( - - { Array.from( { length: 4 } ).map( ( _, index ) => ( - - ) ) } - - ) } + { loading && } { error && ( { error } diff --git a/projects/plugins/beta/src/js/style.scss b/projects/plugins/beta/src/js/style.scss index 1285e9febfe2..f27a4c391e70 100644 --- a/projects/plugins/beta/src/js/style.scss +++ b/projects/plugins/beta/src/js/style.scss @@ -116,7 +116,8 @@ body.jetpack-beta-page { // Keep the row content (label on the left, action/chevron on the right) full // width within the row. .jetpack-beta-plugin-row__inner, -.jetpack-beta-branch-row__inner { +.jetpack-beta-branch-row__inner, +.jetpack-beta-skeleton-row__inner { width: 100%; } @@ -138,6 +139,10 @@ body.jetpack-beta-page { background: var(--wpds-color-bg-surface-neutral-weak, #f6f7f7); } + // Use the same inset ring for :focus and :focus-visible so a mouse click and + // keyboard focus look identical (and override the wp-admin default anchor + // focus, which otherwise shows only a blue bottom border). + &:focus, &:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--wp-admin-theme-color, #3858e9); From 2a98039185e05f55744d7a7cd278a421316fbcff Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Tue, 2 Jun 2026 12:38:46 -0700 Subject: [PATCH 48/53] Jetpack Beta: add a welcome/intro card to the overview screen Restore the "Welcome to Jetpack Beta Tester" intro (lost when the legacy notice template was removed) as a React card at the top of the overview, reusing the original copy with emphasis via createInterpolateElement. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../beta/src/js/components/welcome.tsx | 53 +++++++++++++++++++ .../beta/src/js/screens/plugin-list.tsx | 2 + 2 files changed, 55 insertions(+) create mode 100644 projects/plugins/beta/src/js/components/welcome.tsx diff --git a/projects/plugins/beta/src/js/components/welcome.tsx b/projects/plugins/beta/src/js/components/welcome.tsx new file mode 100644 index 000000000000..a1b01bd3e4eb --- /dev/null +++ b/projects/plugins/beta/src/js/components/welcome.tsx @@ -0,0 +1,53 @@ +/** + * Welcome — intro card shown at the top of the plugin overview, explaining what + * the Beta Tester does. + * + * @package + */ + +import { createInterpolateElement } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Card, Stack, Text } from '@wordpress/ui'; + +/** + * Welcome / intro card. + * + * @return The welcome card element. + */ +const Welcome = () => ( + + + + }> + { __( 'Welcome to Jetpack Beta Tester', 'jetpack-beta' ) } + + }> + { __( + 'Thank you for helping to test our plugins! We appreciate your time and effort.', + 'jetpack-beta' + ) } + + }> + { createInterpolateElement( + __( + 'When you select a branch to test, Jetpack Beta Tester will install and activate it on your behalf and keep it up to date. When you are finished testing, you can switch back to the current version by selecting Latest Stable.', + 'jetpack-beta' + ), + { em: } + ) } + + }> + { createInterpolateElement( + __( + "Not sure where to start? If you select Bleeding Edge, you'll get all the cool new features we're planning to ship in our next release.", + 'jetpack-beta' + ), + { em: } + ) } + + + + +); + +export default Welcome; diff --git a/projects/plugins/beta/src/js/screens/plugin-list.tsx b/projects/plugins/beta/src/js/screens/plugin-list.tsx index 739ca90eafaf..2c4dd02f1c6f 100644 --- a/projects/plugins/beta/src/js/screens/plugin-list.tsx +++ b/projects/plugins/beta/src/js/screens/plugin-list.tsx @@ -13,6 +13,7 @@ import { errorMessage, listPlugins } from '../api/abilities'; import GlobalToggles from '../components/global-toggles'; import { ListSkeleton } from '../components/skeleton'; import UpdatesPanel from '../components/updates-panel'; +import Welcome from '../components/welcome'; import type { PluginListItem } from '../api/types'; /** @@ -198,6 +199,7 @@ const PluginList = () => {
+ { loading && } From 6901a1ec7fadb1b218ba3b85e83880ea1ed89153 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Tue, 2 Jun 2026 13:31:49 -0700 Subject: [PATCH 49/53] =?UTF-8?q?Jetpack=20Beta:=20address=20anomiex=20rev?= =?UTF-8?q?iew=20=E2=80=94=20self-reload=20+=20drop=20phan=20suppression?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reload the page after activating or updating Jetpack Beta Tester itself: the activate-branch / update-plugin abilities now return a `reload` flag (true when the affected plugin is Beta itself), and the React UI does a full window.location.reload() instead of a soft view refresh, since the running app's own code was just swapped. - Remove the @phan-file-suppress for the Abilities API symbols; the minimum tested WordPress is 6.9 (which ships the Abilities API), so it's unused. Phan passes without it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/abilities/class-beta-abilities.php | 21 ++++++++++++++++-- projects/plugins/beta/src/js/api/abilities.ts | 22 ++++++++++++------- .../beta/src/js/components/branch-card.tsx | 6 +++++ .../beta/src/js/components/updates-panel.tsx | 6 +++++ 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/projects/plugins/beta/src/abilities/class-beta-abilities.php b/projects/plugins/beta/src/abilities/class-beta-abilities.php index d78f604be7ac..5ca9b9e79900 100644 --- a/projects/plugins/beta/src/abilities/class-beta-abilities.php +++ b/projects/plugins/beta/src/abilities/class-beta-abilities.php @@ -5,8 +5,6 @@ * @package automattic/jetpack-beta */ -// @phan-file-suppress PhanUndeclaredFunction, PhanUndeclaredClassMethod @phan-suppress-current-line UnusedSuppression -- Abilities API added in WP 6.9; suppressions needed for older-WP compatibility runs. - namespace Automattic\JetpackBeta\Abilities; use Automattic\Jetpack\WP_Abilities\Registrar; @@ -273,6 +271,7 @@ private static function spec_activate_branch(): array { 'properties' => array( 'success' => array( 'type' => 'boolean' ), 'plugin' => self::plugin_view_schema(), + 'reload' => array( 'type' => 'boolean' ), ), ), 'execute_callback' => array( __CLASS__, 'activate_branch' ), @@ -570,9 +569,23 @@ public static function activate_branch( $input = null ) { return array( 'success' => true, 'plugin' => $view, + // Activating a branch of Jetpack Beta Tester itself swaps this plugin's + // own PHP/JS out from under the running React app, so the client must do + // a full page reload rather than a soft view refresh. + 'reload' => self::is_self( $refreshed ), ); } + /** + * Whether the given plugin is the Jetpack Beta Tester plugin itself. + * + * @param Plugin $plugin A resolved Plugin instance. + * @return bool + */ + private static function is_self( Plugin $plugin ): bool { + return plugin_basename( JPBETA__PLUGIN_FILE ) === $plugin->plugin_file(); + } + /** * Execute: update-settings. * @@ -951,6 +964,7 @@ private static function spec_update_plugin(): array { 'type' => 'array', 'items' => array( 'type' => 'object' ), ), + 'reload' => array( 'type' => 'boolean' ), ), ), 'execute_callback' => array( __CLASS__, 'update_plugin' ), @@ -1047,6 +1061,9 @@ public static function update_plugin( $input = null ) { return array( 'success' => true, 'updates' => $updates['updates'], + // Updating Jetpack Beta Tester itself replaces this plugin's own code, + // so the client must fully reload rather than soft-refresh the list. + 'reload' => plugin_basename( JPBETA__PLUGIN_FILE ) === $plugin_file, ); } diff --git a/projects/plugins/beta/src/js/api/abilities.ts b/projects/plugins/beta/src/js/api/abilities.ts index f0f9473b6ff5..a6de99816c58 100644 --- a/projects/plugins/beta/src/js/api/abilities.ts +++ b/projects/plugins/beta/src/js/api/abilities.ts @@ -59,16 +59,22 @@ export const getPlugin = ( slug: string ) => read< PluginView >( 'jetpack-beta/get-plugin', { slug } ); export const getSettings = () => read< Settings >( 'jetpack-beta/get-settings' ); export const activateBranch = ( slug: string, source: string, id: string ) => - write< { success: boolean; plugin: PluginView } >( 'jetpack-beta/activate-branch', { - slug, - source, - id, - } ); + write< { success: boolean; plugin: PluginView; reload: boolean } >( + 'jetpack-beta/activate-branch', + { + slug, + source, + id, + } + ); export const updateSettings = ( patch: Partial< Settings > ) => write< Settings >( 'jetpack-beta/update-settings', patch as Record< string, unknown > ); export const listUpdates = ( slug?: string ) => read< { updates: PluginUpdate[] } >( 'jetpack-beta/list-updates', slug ? { slug } : undefined ); export const updatePlugin = ( pluginFile: string ) => - write< { success: boolean; updates: PluginUpdate[] } >( 'jetpack-beta/update-plugin', { - plugin_file: pluginFile, - } ); + write< { success: boolean; updates: PluginUpdate[]; reload: boolean } >( + 'jetpack-beta/update-plugin', + { + plugin_file: pluginFile, + } + ); diff --git a/projects/plugins/beta/src/js/components/branch-card.tsx b/projects/plugins/beta/src/js/components/branch-card.tsx index 41276560d391..63eaeea60ce6 100644 --- a/projects/plugins/beta/src/js/components/branch-card.tsx +++ b/projects/plugins/beta/src/js/components/branch-card.tsx @@ -49,6 +49,12 @@ const BranchRow = ( { card, pluginSlug, onActivated, title }: Props ) => { setError( null ); activateBranch( pluginSlug, card.source ?? '', card.id ?? '' ) .then( result => { + // Activating Jetpack Beta Tester itself swaps this app's own code; + // reload so the freshly-activated version takes over. + if ( result.reload ) { + window.location.reload(); + return; + } onActivated( result.plugin ); } ) .catch( ( err: unknown ) => { diff --git a/projects/plugins/beta/src/js/components/updates-panel.tsx b/projects/plugins/beta/src/js/components/updates-panel.tsx index 88a3d108a35c..ee42cc1dd17c 100644 --- a/projects/plugins/beta/src/js/components/updates-panel.tsx +++ b/projects/plugins/beta/src/js/components/updates-panel.tsx @@ -101,6 +101,12 @@ const UpdatesPanel = ( { slug, onUpdated }: Props ) => { setError( null ); updatePlugin( pluginFile ) .then( data => { + // Updating Jetpack Beta Tester itself replaces this app's own + // code; reload so the new version takes over. + if ( data.reload ) { + window.location.reload(); + return; + } setUpdates( data.updates ); onUpdated?.(); } ) From 3d020ffc157453e470103dfa88f1b58f42d3ad4d Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Tue, 2 Jun 2026 13:48:13 -0700 Subject: [PATCH 50/53] Jetpack Beta: show the concrete version on Bleeding Edge / Release Candidate rows Their pretty version is just the label ("Bleeding Edge" / "Release Candidate"), so the secondary version line was suppressed. Fall back to the raw `version` string so each branch row shows which build it points at (the version you'd run). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../beta/src/js/components/branch-card.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/projects/plugins/beta/src/js/components/branch-card.tsx b/projects/plugins/beta/src/js/components/branch-card.tsx index 63eaeea60ce6..b98b9e1dd0ab 100644 --- a/projects/plugins/beta/src/js/components/branch-card.tsx +++ b/projects/plugins/beta/src/js/components/branch-card.tsx @@ -35,11 +35,20 @@ const BranchRow = ( { card, pluginSlug, onActivated, title }: Props ) => { const [ busy, setBusy ] = useState( false ); const [ error, setError ] = useState< string | null >( null ); - const version = card.pretty_version ?? card.branch ?? card.version ?? ''; - const label = title ?? version; - // Only show the version as a secondary line when a title is given and the - // version actually differs from it (avoids "Release Candidate / Release Candidate"). - const detail = title && version && version !== title ? version : null; + const prettyVersion = card.pretty_version ?? card.branch ?? card.version ?? ''; + const label = title ?? prettyVersion; + // Secondary line shows the concrete version. Prefer the pretty version when it + // already names the version (i.e. differs from the section title); otherwise + // fall back to the raw version — e.g. "Bleeding Edge" / "Release Candidate", + // whose pretty version is just the label, still show which build they point at. + let detail: string | null = null; + if ( title ) { + if ( prettyVersion && prettyVersion !== title ) { + detail = prettyVersion; + } else if ( card.version ) { + detail = card.version; + } + } const handleActivate = useCallback( () => { if ( busy ) { From c606c9fabd06e98cf1eb3ab62e7259e0b67fe0b7 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Tue, 2 Jun 2026 13:54:33 -0700 Subject: [PATCH 51/53] Jetpack Beta: show the concrete dev version on plugin list rows too For plugins running a dev branch the list showed only the channel label ("Bleeding Edge" / "Release Candidate"). Surface the underlying version via a new `active_version_detail` field on the list item (from dev_info()) and render it as a secondary line, matching the manage screen. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/abilities/class-beta-abilities.php | 32 ++++++++++++------- projects/plugins/beta/src/js/api/types.ts | 2 ++ .../beta/src/js/screens/plugin-list.tsx | 10 ++++-- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/projects/plugins/beta/src/abilities/class-beta-abilities.php b/projects/plugins/beta/src/abilities/class-beta-abilities.php index 5ca9b9e79900..151b69b90cbc 100644 --- a/projects/plugins/beta/src/abilities/class-beta-abilities.php +++ b/projects/plugins/beta/src/abilities/class-beta-abilities.php @@ -102,7 +102,7 @@ private static function spec_list_plugins(): array { return array( 'label' => __( 'List Jetpack Beta plugins', 'jetpack-beta' ), 'description' => __( - 'Return an array of all plugins known to the Jetpack Beta Tester, together with the currently-active branch and version for each. Shape: { plugins: [ { slug, name, active_which, active_version, manage_url } ] }. `active_which` is "stable", "dev", or null when the plugin is not active. `active_version` is the human-readable pretty version, or null when not active. `manage_url` is the wp-admin URL for the plugin\'s manage screen. Read-only and idempotent — safe to poll.', + 'Return an array of all plugins known to the Jetpack Beta Tester, together with the currently-active branch and version for each. Shape: { plugins: [ { slug, name, active_which, active_version, active_version_detail, manage_url } ] }. `active_which` is "stable", "dev", or null when the plugin is not active. `active_version` is the human-readable pretty version (a channel label like "Bleeding Edge" for dev branches), or null when not active. `active_version_detail` is the concrete underlying version for dev branches, or null. `manage_url` is the wp-admin URL for the plugin\'s manage screen. Read-only and idempotent — safe to poll.', 'jetpack-beta' ), 'input_schema' => array( @@ -123,11 +123,12 @@ private static function spec_list_plugins(): array { 'items' => array( 'type' => 'object', 'properties' => array( - 'slug' => array( 'type' => 'string' ), - 'name' => array( 'type' => 'string' ), - 'active_which' => array( 'type' => array( 'string', 'null' ) ), - 'active_version' => array( 'type' => array( 'string', 'null' ) ), - 'manage_url' => array( 'type' => 'string' ), + 'slug' => array( 'type' => 'string' ), + 'name' => array( 'type' => 'string' ), + 'active_which' => array( 'type' => array( 'string', 'null' ) ), + 'active_version' => array( 'type' => array( 'string', 'null' ) ), + 'active_version_detail' => array( 'type' => array( 'string', 'null' ) ), + 'manage_url' => array( 'type' => 'string' ), ), ), ), @@ -419,23 +420,32 @@ public static function build_plugin_list( bool $bypass_cache = false ): array { $plugins = array(); foreach ( $all_plugins as $slug => $plugin ) { + $active_version_detail = null; if ( $plugin->is_active( 'stable' ) ) { $active_which = 'stable'; $active_version = $plugin->stable_pretty_version(); } elseif ( $plugin->is_active( 'dev' ) ) { $active_which = 'dev'; $active_version = $plugin->dev_pretty_version(); + // dev_pretty_version() is just a channel label ("Bleeding Edge", + // "Release Candidate", "Feature Branch: …"), so surface the concrete + // running version separately the way the manage screen does. + $dev_info = $plugin->dev_info(); + if ( $dev_info && ! is_wp_error( $dev_info ) && isset( $dev_info->version ) ) { + $active_version_detail = $dev_info->version; + } } else { $active_which = null; $active_version = null; } $plugins[] = array( - 'slug' => $slug, - 'name' => $plugin->get_name(), - 'active_which' => $active_which, - 'active_version' => $active_version, - 'manage_url' => Utils::admin_url( array( 'plugin' => $slug ) ), + 'slug' => $slug, + 'name' => $plugin->get_name(), + 'active_which' => $active_which, + 'active_version' => $active_version, + 'active_version_detail' => $active_version_detail, + 'manage_url' => Utils::admin_url( array( 'plugin' => $slug ) ), ); } diff --git a/projects/plugins/beta/src/js/api/types.ts b/projects/plugins/beta/src/js/api/types.ts index 397d547e2bb8..600e3facf0fc 100644 --- a/projects/plugins/beta/src/js/api/types.ts +++ b/projects/plugins/beta/src/js/api/types.ts @@ -9,6 +9,8 @@ export type PluginListItem = { name: string; active_which: 'stable' | 'dev' | null; active_version: string | null; + /** Concrete underlying version for dev branches (the pretty version is just a channel label); null otherwise. */ + active_version_detail: string | null; manage_url: string; }; diff --git a/projects/plugins/beta/src/js/screens/plugin-list.tsx b/projects/plugins/beta/src/js/screens/plugin-list.tsx index 2c4dd02f1c6f..79d52bf75287 100644 --- a/projects/plugins/beta/src/js/screens/plugin-list.tsx +++ b/projects/plugins/beta/src/js/screens/plugin-list.tsx @@ -24,21 +24,26 @@ import type { PluginListItem } from '../api/types'; */ const pluginStatus = ( plugin: PluginListItem -): { versionText: string; badgeLabel: string | null } => { +): { versionText: string; versionDetail: string | null; badgeLabel: string | null } => { if ( plugin.active_which === 'dev' ) { return { versionText: plugin.active_version ?? '', + // The pretty version is just a channel label ("Bleeding Edge" etc.), so + // show the concrete running version too — matching the manage screen. + versionDetail: plugin.active_version_detail ?? null, badgeLabel: __( 'Dev', 'jetpack-beta' ), }; } if ( plugin.active_which === 'stable' ) { return { versionText: plugin.active_version ?? '', + versionDetail: null, badgeLabel: __( 'Stable', 'jetpack-beta' ), }; } return { versionText: __( 'Plugin is not active', 'jetpack-beta' ), + versionDetail: null, badgeLabel: null, }; }; @@ -53,7 +58,7 @@ const pluginStatus = ( * @return The plugin row element. */ const PluginCard = ( { plugin }: { plugin: PluginListItem } ) => { - const { versionText, badgeLabel } = pluginStatus( plugin ); + const { versionText, versionDetail, badgeLabel } = pluginStatus( plugin ); // Plugins without wordpress.org assets (unpublished betas) fall back to a // generic plugin icon so every row stays visually aligned. const [ iconFailed, setIconFailed ] = useState( false ); @@ -105,6 +110,7 @@ const PluginCard = ( { plugin }: { plugin: PluginListItem } ) => { ) } + { versionDetail && { versionDetail } } From 72d9a76b4643c58c08450bb3c06823e85f67646c Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Tue, 2 Jun 2026 14:37:43 -0700 Subject: [PATCH 52/53] Jetpack Beta: drop the localStorage plugin-list cache The server bootstrap (window.JetpackBeta.plugins) is localized on every page load and already provides the instant-paint preload, so the localStorage layer was redundant (and a prior staleness hazard). Keep the stale-while-revalidate shape with the bootstrap as the cache: paint from boot.plugins, then revalidate against the cache-bypassing list-plugins ability. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../beta/src/js/screens/plugin-list.tsx | 56 ++++--------------- 1 file changed, 10 insertions(+), 46 deletions(-) diff --git a/projects/plugins/beta/src/js/screens/plugin-list.tsx b/projects/plugins/beta/src/js/screens/plugin-list.tsx index 79d52bf75287..3474afc6308a 100644 --- a/projects/plugins/beta/src/js/screens/plugin-list.tsx +++ b/projects/plugins/beta/src/js/screens/plugin-list.tsx @@ -120,74 +120,39 @@ const PluginCard = ( { plugin }: { plugin: PluginListItem } ) => { }; const boot = window.JetpackBeta; -const CACHE_KEY = 'jetpack-beta-plugins'; - -/** - * Read the remembered plugin list from localStorage. - * - * @return The cached plugin list, or null when absent/unreadable. - */ -const readCachedPlugins = (): PluginListItem[] | null => { - try { - const raw = window.localStorage.getItem( CACHE_KEY ); - return raw ? ( JSON.parse( raw ) as PluginListItem[] ) : null; - } catch { - return null; - } -}; - -/** - * Remember the plugin list in localStorage for instant subsequent loads. - * - * @param plugins - The plugin list to cache. - */ -const writeCachedPlugins = ( plugins: PluginListItem[] ) => { - try { - window.localStorage.setItem( CACHE_KEY, JSON.stringify( plugins ) ); - } catch { - // Ignore storage failures (private mode, quota) — the list still renders. - } -}; /** * PluginList screen component. * * Renders a card per managed plugin alongside the GlobalToggles settings panel. - * The list is preloaded from the page bootstrap (or a localStorage cache) so it - * paints instantly. It then revalidates against the list-plugins ability in the - * background (stale-while-revalidate) and reconciles, so a remembered list that - * has since changed doesn't stay stale. + * The list is preloaded from the page bootstrap (`window.JetpackBeta.plugins`, + * cached data the server localizes on each load) so it paints instantly, then + * revalidates against the (cache-bypassing) list-plugins ability and reconciles + * — stale-while-revalidate, with the server bootstrap as the cache. * * @return The plugin list screen element. */ const PluginList = () => { - const preloaded = boot.plugins ?? readCachedPlugins(); - const [ plugins, setPlugins ] = useState< PluginListItem[] | null >( preloaded ); - const [ loading, setLoading ] = useState( preloaded === null ); + const [ plugins, setPlugins ] = useState< PluginListItem[] | null >( boot.plugins ); + const [ loading, setLoading ] = useState( boot.plugins === null ); const [ error, setError ] = useState< string | null >( null ); useEffect( () => { - // Remember the freshest list (the bootstrap preload) for next time. - if ( boot.plugins ) { - writeCachedPlugins( boot.plugins ); - } - - // Always revalidate against the server. The preloaded/cached list keeps - // painting instantly; this refreshes it in the background and reconciles. + // Revalidate against fresh server data; the bootstrap preload keeps painting + // instantly in the meantime. let cancelled = false; listPlugins() .then( data => { if ( ! cancelled ) { setPlugins( data.plugins ); - writeCachedPlugins( data.plugins ); setLoading( false ); } } ) .catch( ( err: unknown ) => { if ( ! cancelled ) { // Only surface an error when there's nothing to show; otherwise - // keep the cached/preloaded list rather than replacing it. - if ( preloaded === null ) { + // keep the bootstrap preload rather than replacing it. + if ( boot.plugins === null ) { setError( errorMessage( err, __( 'Could not load plugins.', 'jetpack-beta' ) ) ); } setLoading( false ); @@ -196,7 +161,6 @@ const PluginList = () => { return () => { cancelled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- one-time mount effect; `preloaded`/`boot` are stable for the life of the page }, [] ); return ( From db251a0ab8dbd344728bb238fde0f4661e9b424a Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Wed, 3 Jun 2026 12:44:45 -0700 Subject: [PATCH 53/53] Jetpack Beta: fix self-reload for the -dev build; review nits - is_self/update-plugin reload now match both the stable (jetpack-beta/) and dev (jetpack-beta-dev/) file forms via a shared is_self_file() helper. Previously is_self() compared the running file (which is the -dev path when a dev build of Beta is active) against Plugin::plugin_file() (always the stable path), so activating/updating Beta while running its own dev build skipped the reload. - branch-section: anchor the GitHub PR-URL regex to a host boundary so lookalike hosts (evilgithub.com) don't match. - plugin-manage: guard handleActivated's setView with the mounted ref, matching handleUpdated. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/abilities/class-beta-abilities.php | 20 +++++++++++++++++-- .../beta/src/js/components/branch-section.tsx | 4 +++- .../beta/src/js/screens/plugin-manage.tsx | 4 +++- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/projects/plugins/beta/src/abilities/class-beta-abilities.php b/projects/plugins/beta/src/abilities/class-beta-abilities.php index 151b69b90cbc..7246e5cf2b45 100644 --- a/projects/plugins/beta/src/abilities/class-beta-abilities.php +++ b/projects/plugins/beta/src/abilities/class-beta-abilities.php @@ -593,7 +593,23 @@ public static function activate_branch( $input = null ) { * @return bool */ private static function is_self( Plugin $plugin ): bool { - return plugin_basename( JPBETA__PLUGIN_FILE ) === $plugin->plugin_file(); + return self::is_self_file( $plugin->plugin_file() ); + } + + /** + * Whether a `folder/file.php` path belongs to the Jetpack Beta Tester plugin + * itself — either the stable (`jetpack-beta/…`) or dev (`jetpack-beta-dev/…`) + * build. Beta can manage/update itself and may be running from either folder, + * so both forms count: activating or updating it swaps this app's own code and + * the client must do a full page reload. + * + * @param string $plugin_file A `folder/file.php` plugin path. + * @return bool + */ + private static function is_self_file( string $plugin_file ): bool { + $main = basename( JPBETA__PLUGIN_FILE ); + $stable = preg_replace( '/-dev$/', '', JPBETA__PLUGIN_FOLDER ); + return "{$stable}/{$main}" === $plugin_file || "{$stable}-dev/{$main}" === $plugin_file; } /** @@ -1073,7 +1089,7 @@ public static function update_plugin( $input = null ) { 'updates' => $updates['updates'], // Updating Jetpack Beta Tester itself replaces this plugin's own code, // so the client must fully reload rather than soft-refresh the list. - 'reload' => plugin_basename( JPBETA__PLUGIN_FILE ) === $plugin_file, + 'reload' => self::is_self_file( $plugin_file ), ); } diff --git a/projects/plugins/beta/src/js/components/branch-section.tsx b/projects/plugins/beta/src/js/components/branch-section.tsx index 6d5c0cbeeb99..ebb16a2809e2 100644 --- a/projects/plugins/beta/src/js/components/branch-section.tsx +++ b/projects/plugins/beta/src/js/components/branch-section.tsx @@ -22,7 +22,9 @@ import type { BranchCard as BranchCardType, PluginView } from '../api/types'; * @return The PR number, or null if the query isn't a PR reference. */ const extractPrNumber = ( query: string ): number | null => { - const urlMatch = query.match( /github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/i ); + // Require a host boundary before "github.com" so lookalike hosts + // (e.g. evilgithub.com) don't match. + const urlMatch = query.match( /(?:^|\/\/|\.)github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/i ); if ( urlMatch ) { return Number( urlMatch[ 1 ] ); } diff --git a/projects/plugins/beta/src/js/screens/plugin-manage.tsx b/projects/plugins/beta/src/js/screens/plugin-manage.tsx index 904256b1ade1..2bbb93c570d3 100644 --- a/projects/plugins/beta/src/js/screens/plugin-manage.tsx +++ b/projects/plugins/beta/src/js/screens/plugin-manage.tsx @@ -162,7 +162,9 @@ const PluginManage = ( { slug }: Props ) => { }, [ slug ] ); const handleActivated = useCallback( ( updated: PluginView ) => { - setView( updated ); + if ( mounted.current ) { + setView( updated ); + } }, [] ); // Refresh the view after an in-place update so the running version updates.