diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7db974effdf3..2c3097d251b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5002,6 +5002,76 @@ 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/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) + '@wordpress/url': + specifier: 4.46.0 + version: 4.46.0 + 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/js-packages/components/changelog/add-jetpack-footer-show-default-links b/projects/js-packages/components/changelog/add-jetpack-footer-show-default-links new file mode 100644 index 000000000000..434f0812ca44 --- /dev/null +++ b/projects/js-packages/components/changelog/add-jetpack-footer-show-default-links @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +JetpackFooter: add a `showDefaultLinks` prop (default `true`) to opt out of the built-in Products/Help links. diff --git a/projects/js-packages/components/components/jetpack-footer/index.tsx b/projects/js-packages/components/components/jetpack-footer/index.tsx index 3c10a6d9cc29..08447fd7e6d6 100644 --- a/projects/js-packages/components/components/jetpack-footer/index.tsx +++ b/projects/js-packages/components/components/jetpack-footer/index.tsx @@ -24,10 +24,15 @@ declare global { * @param {JetpackFooterProps} props - Component properties. * @return {ReactNode} JetpackFooter component. */ -const JetpackFooter: FC< JetpackFooterProps > = ( { className, menu, ...otherProps } ) => { +const JetpackFooter: FC< JetpackFooterProps > = ( { + className, + menu, + showDefaultLinks = true, + ...otherProps +} ) => { let items: JetpackFooterMenuItem[] = []; - if ( ! isWpcomPlatformSite() && ! window?.JetpackNetworkAdminData ) { + if ( showDefaultLinks && ! isWpcomPlatformSite() && ! window?.JetpackNetworkAdminData ) { items = [ { label: __( 'Products', 'jetpack-components' ), diff --git a/projects/js-packages/components/components/jetpack-footer/types.ts b/projects/js-packages/components/components/jetpack-footer/types.ts index ca690da443d2..3f710593bbeb 100644 --- a/projects/js-packages/components/components/jetpack-footer/types.ts +++ b/projects/js-packages/components/components/jetpack-footer/types.ts @@ -16,4 +16,11 @@ export type JetpackFooterProps = { * Additional links to display in the footer. */ menu?: JetpackFooterMenuItem[]; + + /** + * Whether to include the default "Products" and "Help" links (shown on + * non-WordPress.com, non-network-admin contexts). Set to `false` for screens + * where those links aren't relevant. Defaults to `true`. + */ + showDefaultLinks?: boolean; }; 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/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. diff --git a/projects/plugins/beta/composer.json b/projects/plugins/beta/composer.json index 03b76d670983..d5dc4f51c7c9 100644 --- a/projects/plugins/beta/composer.json +++ b/projects/plugins/beta/composer.json @@ -9,8 +9,10 @@ }, "require": { "automattic/jetpack-admin-ui": "@dev", + "automattic/jetpack-assets": "@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..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": "b35c8f741ab239d96006476386767f79", + "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", @@ -377,6 +447,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", @@ -514,8 +642,10 @@ "minimum-stability": "dev", "stability-flags": { "automattic/jetpack-admin-ui": 20, + "automattic/jetpack-assets": 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/jetpack-beta.php b/projects/plugins/beta/jetpack-beta.php index 77e9f32d6e87..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' ); @@ -116,6 +117,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/package.json b/projects/plugins/beta/package.json new file mode 100644 index 000000000000..ef080f4d9f19 --- /dev/null +++ b/projects/plugins/beta/package.json @@ -0,0 +1,53 @@ +{ + "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] 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/icons": "13.1.0", + "@wordpress/ui": "0.13.0", + "@wordpress/url": "4.46.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/src/abilities/class-beta-abilities.php b/projects/plugins/beta/src/abilities/class-beta-abilities.php new file mode 100644 index 000000000000..7246e5cf2b45 --- /dev/null +++ b/projects/plugins/beta/src/abilities/class-beta-abilities.php @@ -0,0 +1,1152 @@ + 'Jetpack Beta', // Product name, not translated. + 'description' => __( 'Abilities provided by the Jetpack Beta Tester.', 'jetpack-beta' ), + ); + } + + /** + * {@inheritDoc} + * + * Returns all seven abilities: four read-only (list-plugins, get-plugin, + * get-settings, list-updates) and three write (activate-branch, + * update-settings, update-plugin). + */ + 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(), + 'jetpack-beta/list-updates' => self::spec_list_updates(), + 'jetpack-beta/update-plugin' => self::spec_update_plugin(), + ); + } + + /** + * 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, 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( + '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', + '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' ) ), + 'active_version_detail' => 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 }. `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( + 'type' => 'object', + 'properties' => array( + 'slug' => array( + 'type' => 'string', + 'description' => __( 'The WordPress plugin slug (e.g. "jetpack").', 'jetpack-beta' ), + ), + ), + 'required' => array( 'slug' ), + ), + 'output_schema' => self::plugin_view_schema(), + 'execute_callback' => array( __CLASS__, 'get_plugin' ), + 'permission_callback' => array( __CLASS__, 'can_view_plugin' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => false, + ), + '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, + // 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', + '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, + ), + ), + ); + } + + /** + * 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', + 'enum' => array( 'stable', 'trunk', 'rc', 'pr', 'release', 'unknown' ), + '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(), + 'reload' => array( 'type' => 'boolean' ), + ), + ), + '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 + // ------------------------------------------------------------------------- + + /** + * 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 { + // Bypass the cache on the explicit REST call so the on-demand list is fresh. + return self::build_plugin_list( true ); + } catch ( PluginDataException $e ) { + return new \WP_Error( 'plugin_data_error', $e->getMessage() ); + } + } + + /** + * Build the list-plugins payload. + * + * Shared by the `list-plugins` ability (fresh) and the admin page bootstrap, + * which preloads the list using cached data for an instant first paint. + * + * @param bool $bypass_cache Whether to bypass the plugins-list transient cache. + * @return array{plugins: array>} The list payload. + * @throws PluginDataException If the plugin list cannot be fetched. + */ + public static function build_plugin_list( bool $bypass_cache = false ): array { + $all_plugins = Plugin::get_all_plugins( $bypass_cache ); + + $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, + 'active_version_detail' => $active_version_detail, + 'manage_url' => Utils::admin_url( array( 'plugin' => $slug ) ), + ); + } + + return array( 'plugins' => $plugins ); + } + + /** + * Execute: get-plugin. + * + * 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 + */ + 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 ) + ); + } + + 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/class-wp-upgrader.php'; + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + + // install_and_activate() throws InvalidArgumentException for an unrecognized + // source. The schema enum rejects bad values first, but guard here too so a + // crafted request returns a clean WP_Error instead of an uncaught 500. + try { + $result = $plugin->install_and_activate( $source, $id ); + } catch ( \InvalidArgumentException $e ) { + return new \WP_Error( 'invalid_source', $e->getMessage() ); + } + if ( is_wp_error( $result ) ) { + return $result; + } + + // Re-resolve the plugin (bypassing the cache) to build the post-activation + // view. Guard against a failed refresh so we return a WP_Error instead of + // fataling on a null/exception inside build_plugin_view(). + try { + $refreshed = Plugin::get_plugin( $slug, true ); + } catch ( PluginDataException $e ) { + return new \WP_Error( 'plugin_data_error', $e->getMessage() ); + } + + if ( ! $refreshed ) { + return new \WP_Error( + 'unknown_plugin', + // translators: %s: Plugin slug. + sprintf( __( 'Plugin %s is not known.', 'jetpack-beta' ), $slug ) + ); + } + + $view = self::build_plugin_view( $refreshed ); + if ( is_wp_error( $view ) ) { + return $view; + } + + 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 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; + } + + /** + * 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) (bool) $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 ) { + // 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 ); + } 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' ) ) { + if ( $existing_branch ) { + $active_branch = $existing_branch; + } + } elseif ( $plugin->is_active( 'dev' ) ) { + $active_branch = $plugin->dev_info(); + if ( $active_branch && ! is_wp_error( $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 ); + + // These fragments are injected via dangerouslySetInnerHTML in the React UI, + // so sanitize them to post-safe HTML before returning to reduce XSS risk. + $to_test_html = is_string( $to_test_html ) ? wp_kses_post( $to_test_html ) : null; + $what_changed_html = is_string( $what_changed_html ) ? wp_kses_post( $what_changed_html ) : null; + + 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, + ); + } + + /** + * Build the current settings payload. + * + * 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 } + */ + private static function build_settings(): array { + 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' ), + ); + } + + /** + * 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' ) ), + 'pr' => array( 'type' => array( 'integer', 'null' ) ), + 'is_active' => array( 'type' => 'boolean' ), + ), + ), + ), + 'to_test_html' => array( 'type' => array( 'string', 'null' ) ), + 'what_changed_html' => array( 'type' => array( 'string', 'null' ) ), + ), + ); + } + + /** + * 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, + // 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, + ); + } + + /** + * 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' ), + ), + 'reload' => array( 'type' => 'boolean' ), + ), + ), + '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(); + + // Restrict updates to plugins Beta actually manages and that have a pending + // update, so this ability can't be used to drive arbitrary plugin updates. + try { + $needing = Utils::plugins_needing_update( true ); + } catch ( PluginDataException $e ) { + return new \WP_Error( 'plugin_data_error', $e->getMessage() ); + } + if ( ! isset( $needing[ $plugin_file ] ) ) { + return new \WP_Error( + 'unmanaged_plugin', + __( 'That plugin is not a Jetpack Beta managed update.', 'jetpack-beta' ) + ); + } + + $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'], + // Updating Jetpack Beta Tester itself replaces this plugin's own code, + // so the client must fully reload rather than soft-refresh the list. + 'reload' => self::is_self_file( $plugin_file ), + ); + } + + /** + * 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/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/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 */ 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 a3c981a85fc3..625079f9897f 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'; @@ -116,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 ); @@ -132,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'] - ); } /** @@ -214,21 +160,72 @@ 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' ); + + // Stable body class so the React layout styles (jetpack-admin-page-layout) + // can pin the header/footer and scroll the middle, independent of the + // hook-derived `{parent}_page_jetpack-beta` class. + add_filter( + 'admin_body_class', + static function ( $classes ) { + return trim( $classes . ' jetpack-beta-page' ); + } + ); + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $plugin_slug = isset( $_GET['plugin'] ) ? sanitize_text_field( wp_unslash( $_GET['plugin'] ) ) : null; + + // Resolve the human-readable plugin name up front (from cached data) so the + // React header/breadcrumb can render immediately, without waiting for the + // get-plugin ability to resolve. + $plugin_display_name = null; + if ( null !== $plugin_slug ) { + try { + $beta_plugin = Plugin::get_plugin( $plugin_slug ); + if ( $beta_plugin ) { + $plugin_display_name = $beta_plugin->get_name(); + } + } catch ( PluginDataException $e ) { + $plugin_display_name = null; + } + } + + // Preload the plugins list on the overview screen (cached data) so it + // renders instantly without waiting for the list-plugins ability. + $plugin_list = null; + if ( null === $plugin_slug ) { + try { + $payload = Abilities\Beta_Abilities::build_plugin_list(); + $plugin_list = $payload['plugins']; + } catch ( PluginDataException $e ) { + $plugin_list = null; + } + } + + 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' ), + 'plugin' => $plugin_slug, + 'pluginName' => $plugin_display_name, + 'plugins' => $plugin_list, + 'adminUrl' => Utils::admin_url(), + ), + JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP + ) . ';', + 'before' + ); } /** @@ -299,49 +296,30 @@ public static function to_test_content( Plugin $plugin ) { return array( Utils::render_markdown( $plugin, $wp_filesystem->get_contents( $file ) ), null ); } - /** Display autoupdate toggle */ + /** + * Display autoupdate toggle. + * + * @deprecated 4.3.0 The settings UI is now a React app backed by the Abilities API. + */ public static function show_toggle_autoupdates() { - $autoupdate = (bool) Utils::is_set_to_autoupdate(); - self::show_toggle( __( 'Autoupdates', 'jetpack-beta' ), 'autoupdates', $autoupdate ); + _deprecated_function( __METHOD__, '4.3.0' ); } - /** Display email notification toggle */ + /** + * Display email notification toggle. + * + * @deprecated 4.3.0 The settings UI is now a React app backed by the Abilities API. + */ 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 ); + _deprecated_function( __METHOD__, '4.3.0' ); } /** - * Display autoupdate and email notification toggles + * 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. + * @deprecated 4.3.0 The settings UI is now a React app backed by the Abilities API. */ - 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="" - > - - - - - /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, PluginUpdate, 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. + * + * @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 = () => + read< { plugins: PluginListItem[] } >( 'jetpack-beta/list-plugins' ); +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; 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[]; reload: boolean } >( + '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 new file mode 100644 index 000000000000..600e3facf0fc --- /dev/null +++ b/projects/plugins/beta/src/js/api/types.ts @@ -0,0 +1,72 @@ +/** + * 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; + /** Concrete underlying version for dev branches (the pretty version is just a channel label); null otherwise. */ + active_version_detail: 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; + /** GitHub PR number for feature-branch (`pr`) cards; null otherwise. */ + pr: number | 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; +}; + +export type Settings = { + autoupdates: boolean; + email_notifications: boolean; + skip_email: boolean; +}; + +export type PluginUpdate = { + plugin_file: string; + name: string; + new_version: string; +}; + +export type BetaBootstrap = { + apiRoot: string; + apiNonce: string; + plugin: string | null; + pluginName: string | null; + plugins: PluginListItem[] | null; + adminUrl: string; +}; + +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..c58fd7b38b9b --- /dev/null +++ b/projects/plugins/beta/src/js/app.tsx @@ -0,0 +1,47 @@ +/** + * Root App component — screen routing. + * + * Reads `window.JetpackBeta.plugin` to decide which screen to render: + * - 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 + */ + +import { AdminPage, JetpackFooter } 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 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 ( + + + + + ); +}; + +export default App; 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..b98b9e1dd0ab --- /dev/null +++ b/projects/plugins/beta/src/js/components/branch-card.tsx @@ -0,0 +1,115 @@ +/** + * 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, Notice, Stack, Text } from '@wordpress/ui'; +import { activateBranch, errorMessage } from '../api/abilities'; +import type { BranchCard as BranchCardType, PluginView } from '../api/types'; + +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; +}; + +/** + * Renders a branch as a compact list row with version label, active badge, and + * activate button. + * + * @param {Props} props - Component props. + * @return The branch row element. + */ +const BranchRow = ( { card, pluginSlug, onActivated, title }: Props ) => { + const [ busy, setBusy ] = useState( false ); + const [ error, setError ] = useState< string | null >( 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 ) { + return; + } + setBusy( true ); + 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 ) => { + setError( errorMessage( err, __( 'Could not activate branch.', 'jetpack-beta' ) ) ); + } ) + .finally( () => { + setBusy( false ); + } ); + // 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 ( +
+ + { error && ( + + { error } + + ) } + + + + { label } + { card.is_active && ( + { __( 'Active', 'jetpack-beta' ) } + ) } + + { detail && { detail } } + + { ! card.is_active && ( + + ) } + + +
+ ); +}; + +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 new file mode 100644 index 000000000000..ebb16a2809e2 --- /dev/null +++ b/projects/plugins/beta/src/js/components/branch-section.tsx @@ -0,0 +1,127 @@ +/** + * 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 { Card, Stack, Text } from '@wordpress/ui'; +import BranchRow 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 => { + // 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 ] ); + } + const numMatch = query.match( /^#?(\d+)$/ ); + if ( numMatch ) { + return Number( numMatch[ 1 ] ); + } + return null; +}; + +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`, + * 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. + */ +const BranchSection = ( { + title, + cards, + searchable = false, + pluginSlug, + onActivated, + searchPlaceholder, +}: Props ) => { + const [ query, setQuery ] = useState( '' ); + + if ( cards.length === 0 ) { + return null; + } + + const trimmedQuery = query.trim(); + const hasQuery = trimmedQuery !== ''; + + let filteredCards: BranchCardType[]; + if ( ! searchable ) { + 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 ) + ); + } else { + filteredCards = cards.filter( c => c.is_active ); + } + + return ( + + { searchable && ( + <> + }> + { title } + + + + ) } + { filteredCards.length > 0 && ( + + { filteredCards.map( card => ( + + ) ) } + + ) } + { searchable && hasQuery && filteredCards.length === 0 && ( + { __( 'No branches match your search.', 'jetpack-beta' ) } + ) } + + ); +}; + +export default BranchSection; 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..cc4624c995bf --- /dev/null +++ b/projects/plugins/beta/src/js/components/global-toggles.tsx @@ -0,0 +1,162 @@ +/** + * 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, Stack, Text } from '@wordpress/ui'; +import { errorMessage, getSettings, updateSettings } from '../api/abilities'; +import { Skeleton } from './skeleton'; +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 ) { + setFetchError( errorMessage( err, __( 'Could not load settings.', 'jetpack-beta' ) ) ); + setLoading( false ); + } + } ); + return () => { + cancelled = true; + }; + }, [] ); + + // 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, [ key ]: checked } ); + setUpdateError( null ); + setInFlight( key ); + updateSettings( { [ key ]: checked } ) + .then( setSettings ) + .catch( ( err: unknown ) => { + setSettings( previous ); + setUpdateError( errorMessage( err, failMessage ) ); + } ) + .finally( () => { + setInFlight( null ); + } ); + }, + [ inFlight, settings ] + ); + + const handleAutoupdates = useCallback( + ( checked: boolean ) => + applySetting( + 'autoupdates', + checked, + __( 'Could not save autoupdates setting.', 'jetpack-beta' ) + ), + [ applySetting ] + ); + + const handleEmailNotifications = useCallback( + ( checked: boolean ) => + applySetting( + 'email_notifications', + checked, + __( 'Could not save email notifications setting.', 'jetpack-beta' ) + ), + [ applySetting ] + ); + + if ( loading ) { + return ( + + + + + + + + + + + + ); + } + + if ( fetchError ) { + return ( + + { fetchError } + + ); + } + + if ( ! settings ) { + return null; + } + + const showEmailToggle = settings.autoupdates && ! settings.skip_email; + + return ( + + + + }> + { __( 'Settings', 'jetpack-beta' ) } + + { updateError && ( + + { updateError } + + ) } + + + { showEmailToggle && ( + + ) } + + + + + ); +}; + +export default GlobalToggles; 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..aa158b1ecdf7 --- /dev/null +++ b/projects/plugins/beta/src/js/components/markdown-panel.tsx @@ -0,0 +1,40 @@ +/** + * MarkdownPanel — a card that renders sanitized HTML content under a heading. + * + * 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 { Card, Stack, Text } from '@wordpress/ui'; + +type Props = { + title: string; + html: string; +}; + +/** + * Renders a card containing a heading and server-sanitized HTML content. + * + * @param {Props} props - Component props. + * @return The 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/components/skeleton.tsx b/projects/plugins/beta/src/js/components/skeleton.tsx new file mode 100644 index 000000000000..3837d8e92092 --- /dev/null +++ b/projects/plugins/beta/src/js/components/skeleton.tsx @@ -0,0 +1,67 @@ +/** + * Skeleton loading primitives. + * + * A shimmering placeholder block plus a card-shaped row skeleton, used in place + * of a spinner so the loading state mirrors the layout that's about to appear. + * + * @package + */ + +import { Card, Stack } from '@wordpress/ui'; + +type SkeletonProps = { + width?: string; + height?: string; +}; + +/** + * A single shimmering placeholder block. + * + * @param {SkeletonProps} props - Component props. + * @param {string} props.width - CSS width (default full width). + * @param {string} props.height - CSS height (default 1em). + * @return The skeleton block element. + */ +export const Skeleton = ( { width = '100%', height = '1em' }: SkeletonProps ) => ( +