diff --git a/projects/packages/jetpack-mu-wpcom/changelog/fix-paypal-180-flag-review-fixes b/projects/packages/jetpack-mu-wpcom/changelog/fix-paypal-180-flag-review-fixes new file mode 100644 index 000000000000..6c644b542e0b --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/fix-paypal-180-flag-review-fixes @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +PayPal Payment Buttons: only register the onboarding endpoint while the API-managed buttons flag is on. diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-paypal-onboarding.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-paypal-onboarding.php index c74c71cc54ae..97a5e30b6ee1 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-paypal-onboarding.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-paypal-onboarding.php @@ -16,6 +16,7 @@ use Automattic\Jetpack\Connection\Manager as Connection_Manager; use Automattic\Jetpack\Constants; +use Automattic\Jetpack\Feature_Flags\Feature_Flags; if ( ! defined( 'ABSPATH' ) ) { exit( 0 ); @@ -126,6 +127,12 @@ public function __construct() { * Register REST API routes. */ public function register_routes() { + // Hard-coded: mu-wpcom cannot reach PayPal_Payment_Buttons::API_MANAGED_BUTTONS_FLAG. + // Unregistered here, so only a `jetpack_feature_flag_enabled_*` filter flips it on wpcom. + if ( ! Feature_Flags::is_enabled( 'paypal-payments-api-managed-buttons' ) ) { + return; + } + register_rest_route( $this->namespace, $this->rest_base . '/signup-link', diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-endpoints/WPCOM_REST_API_V2_Endpoint_PayPal_Onboarding_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-endpoints/WPCOM_REST_API_V2_Endpoint_PayPal_Onboarding_Test.php index 865c291cd623..7546962e6514 100644 --- a/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-endpoints/WPCOM_REST_API_V2_Endpoint_PayPal_Onboarding_Test.php +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-endpoints/WPCOM_REST_API_V2_Endpoint_PayPal_Onboarding_Test.php @@ -187,6 +187,38 @@ private function token_response() { * not collide with the editor-facing wpcom/v2/paypal/onboarding/signup-link * that the paypal-payments package registers on these same hosts. */ + /** + * The flag is unregistered here, so this also covers the unregistered-default path: + * only a `jetpack_feature_flag_enabled_*` filter can turn the route on. + */ + public function test_no_route_is_registered_while_the_flag_is_off() { + $routes = $this->build_routes(); + + $this->assertArrayNotHasKey( '/wpcom/v2/paypal/platform/signup-link', $routes ); + } + + public function test_the_route_is_registered_once_the_flag_is_on() { + add_filter( 'jetpack_feature_flag_enabled_paypal-payments-api-managed-buttons', '__return_true' ); + + $routes = $this->build_routes(); + + remove_all_filters( 'jetpack_feature_flag_enabled_paypal-payments-api-managed-buttons' ); + + $this->assertArrayHasKey( '/wpcom/v2/paypal/platform/signup-link', $routes ); + } + + /** + * Rebuild the route table from a fresh REST server, the way the package suite does. + */ + private function build_routes() { + global $wp_rest_server; + $wp_rest_server = null; + + $routes = rest_get_server()->get_routes(); + + return $routes; + } + public function test_endpoint_is_registered_under_the_platform_path() { $this->assertNotFalse( has_action( 'rest_api_init', array( $this->endpoint, 'register_routes' ) ), diff --git a/projects/packages/paypal-payments/.gitattributes b/projects/packages/paypal-payments/.gitattributes index 777808f25452..b7e06c1834bc 100644 --- a/projects/packages/paypal-payments/.gitattributes +++ b/projects/packages/paypal-payments/.gitattributes @@ -12,3 +12,5 @@ webpack.config.blocks.js production-exclude /src/**/*.scss production-exclude /src/**/*.jsx production-exclude /src/paypal-payment-buttons/**/*.js production-exclude +/docs/** production-exclude +DEVELOPMENT.md production-exclude diff --git a/projects/packages/paypal-payments/DEVELOPMENT.md b/projects/packages/paypal-payments/DEVELOPMENT.md index 71991ac5a894..2dc20d9e97f3 100644 --- a/projects/packages/paypal-payments/DEVELOPMENT.md +++ b/projects/packages/paypal-payments/DEVELOPMENT.md @@ -61,6 +61,25 @@ jp docker up -d && jp docker install Credentials are encrypted with `AUTH_KEY`, so a `wp-config.php` still carrying the `put your unique phrase here` placeholder will refuse to store them — generate real salts first. Then connect with sandbox credentials from [developer.paypal.com](https://developer.paypal.com/dashboard/applications/sandbox); the admin screen is at **Jetpack → Payment Links**, or **Settings → Payment Links** when Jetpack is not active. +### Turning on the API-managed buttons + +The API-managed flow — the connection wizard, the `wpcom/v2/paypal/*` REST routes, and the Payment Links admin page — ships behind the `paypal-payments-api-managed-buttons` feature flag, off by default. While it is off the block shows the paste-code editor, and a button created through the API keeps rendering but is read-only in the editor. + +On Jurassic Ninja, the Companion plugin toggles it: + +```bash +wp companion feature-flag enable paypal-payments-api-managed-buttons +wp companion feature-flag list # read the `effective` column +``` + +In `jp docker`, or anywhere without Companion, force it from an mu-plugin: + +```php +add_filter( 'jetpack_feature_flag_enabled_paypal-payments-api-managed-buttons', '__return_true' ); +``` + +On WordPress.com Simple and Atomic, Automatticians can flip it under **Tools → Feature Flags**. + ## Options and Transients | Key | Kind | Holds | diff --git a/projects/packages/paypal-payments/changelog/add-paypal-api-managed-buttons-flag b/projects/packages/paypal-payments/changelog/add-paypal-api-managed-buttons-flag new file mode 100644 index 000000000000..42da48a5eb2f --- /dev/null +++ b/projects/packages/paypal-payments/changelog/add-paypal-api-managed-buttons-flag @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Add a feature flag for the API-managed payment buttons; the block keeps the paste-code editor while it is off. diff --git a/projects/packages/paypal-payments/changelog/add-paypal-payment-buttons-v2 b/projects/packages/paypal-payments/changelog/add-paypal-payment-buttons-v2 index 1232e0e08dd3..0f00330f4047 100644 --- a/projects/packages/paypal-payments/changelog/add-paypal-payment-buttons-v2 +++ b/projects/packages/paypal-payments/changelog/add-paypal-payment-buttons-v2 @@ -1,4 +1,4 @@ Significance: minor Type: added -Add API-driven payment buttons: connect a PayPal account from WordPress, create and manage payment links without leaving the editor, choose a Button, Link, or QR format, and pick a Light, Auto, or Dark style preset. +Add API-managed payment buttons behind a feature flag that is not yet enabled. Once it is on, you can connect a PayPal account from WordPress, create and manage payment links without leaving the editor, choose a Button, Link, or QR format, and pick a Light, Auto, or Dark style preset. diff --git a/projects/packages/paypal-payments/composer.json b/projects/packages/paypal-payments/composer.json index 83ae449556a0..2d031e50f712 100644 --- a/projects/packages/paypal-payments/composer.json +++ b/projects/packages/paypal-payments/composer.json @@ -8,6 +8,7 @@ "automattic/jetpack-assets": "@dev", "automattic/jetpack-plans": "@dev", "automattic/jetpack-connection": "@dev", + "automattic/jetpack-feature-flags": "@dev", "automattic/jetpack-blocks": "@dev", "automattic/jetpack-status": "@dev" }, diff --git a/projects/packages/paypal-payments/docs/test_plan.md b/projects/packages/paypal-payments/docs/test_plan.md index 388f01202a19..8e2242bb190c 100644 --- a/projects/packages/paypal-payments/docs/test_plan.md +++ b/projects/packages/paypal-payments/docs/test_plan.md @@ -194,7 +194,7 @@ Items marked 🔧 Manual are genuinely manual-only (accessibility, live PayPal A | Install standalone plugin in WordPress Playground | 🔧 Manual (environment-dependent) | | Insert block in new post — editor UI loads | 🔧 Manual (environment-dependent) | | Open post with existing PayPal block — no "doesn't include support" error | 🔧 Manual (environment-dependent) | -| Block works in full Jetpack context (stub is no-op) | 🔧 Manual (environment-dependent) | +| Block works in full Jetpack context | 🔧 Manual (environment-dependent) | --- @@ -218,7 +218,7 @@ These cannot be automated and require a human tester with the specified environm - [ ] Standalone plugin installs cleanly in WordPress Playground - [ ] Create a new post → insert PayPal Payment Buttons block → editor UI loads (no "doesn't include support" error) - [ ] Open a post containing an existing PayPal block → block renders without error -- [ ] Same block works correctly in full Jetpack monorepo context (stub is a no-op when real `jetpack-script-data` handle is registered) +- [ ] Standalone and Playground load the real `jetpack-script-data` handle, so the editor gets `isWpcomPlatformSite` ### Live PayPal API (WOOPTP-163 + WOOPTP-164) diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/block.json b/projects/packages/paypal-payments/src/paypal-payment-buttons/block.json index c31196d9d19c..d02b72baf6dd 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/block.json +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/block.json @@ -61,7 +61,7 @@ "buttonType": { "type": "string", "enum": [ "stacked", "single" ], - "default": "single" + "default": "stacked" }, "scriptSrc": { "type": "string", @@ -136,8 +136,5 @@ "enum": [ "BUTTON", "LINK", "QR" ], "default": "BUTTON" } - }, - "editorScript": "file:../../dist/paypal-payment-buttons/editor.js", - "editorStyle": "file:../../dist/paypal-payment-buttons/editor.css", - "style": "file:../../dist/paypal-payment-buttons/style.css" + } } diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/class-paypal-admin-page.php b/projects/packages/paypal-payments/src/paypal-payment-buttons/class-paypal-admin-page.php index 2c56a93ada49..8f9d55591b2c 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/class-paypal-admin-page.php +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/class-paypal-admin-page.php @@ -116,6 +116,20 @@ public static function count_published_embeds() { return $counts; } + /** + * Initialize admin hooks when the API-managed buttons are enabled. + * + * @since $$next-version$$ + * @return void + */ + public static function maybe_init() { + if ( ! PayPal_Payment_Buttons::is_api_managed_enabled() ) { + return; + } + + self::init(); + } + /** * Initialize admin hooks. */ diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/class-paypal-email-sender.php b/projects/packages/paypal-payments/src/paypal-payment-buttons/class-paypal-email-sender.php index 622a55be4a08..467eb7474577 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/class-paypal-email-sender.php +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/class-paypal-email-sender.php @@ -41,6 +41,20 @@ class PayPal_Email_Sender { */ const AJAX_ACTION = 'paypal_send_payment_link'; + /** + * Initialize AJAX hooks when the API-managed buttons are enabled. + * + * @since $$next-version$$ + * @return void + */ + public static function maybe_init() { + if ( ! PayPal_Payment_Buttons::is_api_managed_enabled() ) { + return; + } + + self::init(); + } + /** * Initialize AJAX hooks. */ diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/class-paypal-payment-buttons.php b/projects/packages/paypal-payments/src/paypal-payment-buttons/class-paypal-payment-buttons.php index 513e2287495b..cb52c3986636 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/class-paypal-payment-buttons.php +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/class-paypal-payment-buttons.php @@ -9,6 +9,7 @@ use Automattic\Jetpack\Assets; use Automattic\Jetpack\Blocks; +use Automattic\Jetpack\Feature_Flags\Feature_Flags; /** * Class PayPal_Payment_Buttons @@ -30,6 +31,77 @@ class PayPal_Payment_Buttons { */ public const PAYPAL_PARTNER_ATTRIBUTION_ID = 'WooNCPS_Ecom_Wordpress'; + /** + * Feature flag gating the API-managed buttons: the connection wizard, the + * wpcom/v2/paypal REST routes, and the Payment Links admin page. + * + * @since $$next-version$$ + * @var string + */ + public const API_MANAGED_BUTTONS_FLAG = 'paypal-payments-api-managed-buttons'; + + /** + * Front-end style handle, registered by `register_block_style()`. + * + * @since $$next-version$$ + * @var string + */ + public const STYLE_HANDLE = 'jetpack-block-paypal-payment-buttons'; + + /** + * Register the feature flags this package owns. + * + * Call it from every bootstrap before `init`, so the flag exists on every + * request type that reads it (REST, admin, WP-CLI). + * + * @since $$next-version$$ + * @return void + */ + public static function register_feature_flags() { + Feature_Flags::register( + self::API_MANAGED_BUTTONS_FLAG, + array( + 'default' => false, + 'description' => 'Create and manage PayPal payment buttons from the editor through the PayPal API, instead of pasting button code.', + 'owner' => 'paypal-payments', + ) + ); + } + + /** + * Whether the API-managed buttons are enabled on this site. + * + * Rendering is deliberately not gated on this: a button created while the + * flag was on must keep rendering after it is turned off. + * + * @since $$next-version$$ + * @return bool + */ + public static function is_api_managed_enabled() { + return Feature_Flags::is_enabled( self::API_MANAGED_BUTTONS_FLAG ); + } + + /** + * Expose the flag to the block editor under the same name. + * + * Jetpack hooks this on `jetpack_block_editor_feature_flags`; the standalone + * plugin calls it while building its own editor state. + * + * @since $$next-version$$ + * + * @param array $flags Feature flags keyed by name. + * @return array + */ + public static function add_editor_feature_flags( $flags ) { + if ( ! is_array( $flags ) ) { + $flags = array(); + } + + $flags[ self::API_MANAGED_BUTTONS_FLAG ] = self::is_api_managed_enabled(); + + return $flags; + } + /** * Validates and sanitizes a script URL to ensure it's from an allowed PayPal domain. * @@ -100,17 +172,41 @@ public static function add_partner_attribution( $url ) { return add_query_arg( 'at_code', self::PAYPAL_PARTNER_ATTRIBUTION_ID, $sanitized ); } + /** + * Side-load the sibling style.css and register it under STYLE_HANDLE. + * + * A `file:` style in block.json would also make core register the editor bundle a + * second time, so the block takes this handle as its `style` arg. Both bootstraps + * call it. + * + * @since $$next-version$$ + * @return void + */ + public static function register_block_style() { + Assets::register_script( + self::STYLE_HANDLE, + '../../dist/paypal-payment-buttons/style.js', + __FILE__, + array( + 'css_path' => '../../dist/paypal-payment-buttons/style.css', + ) + ); + } + /** * Registers the block for use in Gutenberg * This is done via an action so that we can disable * registration if we need to. */ public static function register_block() { + self::register_block_style(); + Blocks::jetpack_register_block( __DIR__, array( 'render_callback' => array( __CLASS__, 'render_block' ), - 'plan_check' => false, + 'plan_check' => true, + 'style' => self::STYLE_HANDLE, ) ); } @@ -710,23 +806,37 @@ private static function enqueue_qr_script() { * @return void */ public static function init_api() { - add_action( 'init', array( __CLASS__, 'register_standalone_script_stubs' ), 1 ); self::init_rest_api(); - self::init_jetpack_sharing(); - PayPal_Email_Sender::init(); + add_action( 'init', array( __CLASS__, 'init_jetpack_sharing' ) ); + add_action( 'init', array( PayPal_Email_Sender::class, 'maybe_init' ) ); } /** - * Register just the PayPal REST routes. - * - * For hosts that already provide the Jetpack runtime -- the Jetpack plugin -- - * and therefore must not get the standalone script stubs. + * Register just the PayPal REST routes -- the subset the Jetpack loader uses, + * without init_api()'s sharing and email-sender hookups. * * @since $$next-version$$ * @return void */ public static function init_rest_api() { - add_action( 'rest_api_init', array( PayPal_REST_Controller::class, 'register_routes' ) ); + add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) ); + } + + /** + * Register the PayPal REST routes when the API-managed buttons are enabled. + * + * The flag is read here rather than in init_rest_api() so a filter added + * after the bootstrap ran still decides. + * + * @since $$next-version$$ + * @return void + */ + public static function register_rest_routes() { + if ( ! self::is_api_managed_enabled() ) { + return; + } + + PayPal_REST_Controller::register_routes(); } /** @@ -737,12 +847,14 @@ public static function init_rest_api() { * Sharedaddy module is not active. * * @since 0.9.0 + * @since $$next-version$$ Public, runs on `init`, and no-ops unless the API-managed buttons are enabled. * @return void */ - private static function init_jetpack_sharing() { + public static function init_jetpack_sharing() { // Only register if Jetpack + Sharedaddy are active. if ( - ! class_exists( 'Jetpack' ) + ! self::is_api_managed_enabled() + || ! class_exists( 'Jetpack' ) || ! method_exists( 'Jetpack', 'is_module_active' ) || ! \Jetpack::is_module_active( 'sharedaddy' ) ) { @@ -786,34 +898,20 @@ public static function enable_sharing_on_payment_pages( $show, $post = null ) { * all merchant payment links from wp-admin. * * @since 0.9.0 + * @since $$next-version$$ Defers to `init` and no-ops unless the API-managed buttons are enabled. */ public static function init_admin() { - PayPal_Admin_Page::init(); - PayPal_Email_Sender::init(); - } - - /** - * Register empty script stubs for Jetpack dependencies that may not be available - * when the plugin runs outside the full Jetpack monorepo (e.g., WordPress Playground). - * - * The wp_script_is() guard ensures this is a no-op inside the full Jetpack plugin - * where the real handle is already registered by the Assets package. - * - * @since 0.8.0 - * @return void - */ - public static function register_standalone_script_stubs() { - /* - * The Jetpack plugin registers the real handle from Script_Data on wp_loaded, - * which fires after init. Registering a stub first therefore wins, and the - * editor is left without window.JetpackScriptData. - */ - if ( class_exists( 'Jetpack' ) ) { - return; - } + add_action( + 'init', + static function () { + // Read the flag before naming the classes, so neither is autoloaded while it is off. + if ( ! self::is_api_managed_enabled() ) { + return; + } - if ( ! wp_script_is( 'jetpack-script-data', 'registered' ) ) { - wp_register_script( 'jetpack-script-data', false, array(), '1.0.0', false ); - } + PayPal_Admin_Page::maybe_init(); + PayPal_Email_Sender::maybe_init(); + } + ); } } diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/connection-wizard.jsx b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/connection-wizard.jsx index 8c3b1da89794..c93776e320cd 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/connection-wizard.jsx +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/connection-wizard.jsx @@ -322,7 +322,9 @@ export default function ConnectionWizard( { ? undefined : __( 'Found under your app name in the dashboard.', 'jetpack-paypal-payments' ) } - className={ clientIdWarning ? 'has-warning' : undefined } + className={ + clientIdWarning ? 'jetpack-paypal-payment-buttons__has-warning' : undefined + } autoComplete="off" /> { clientIdWarning && ( diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/edit-api-managed.jsx b/projects/packages/paypal-payments/src/paypal-payment-buttons/edit-api-managed.jsx new file mode 100644 index 000000000000..806b7830b201 --- /dev/null +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/edit-api-managed.jsx @@ -0,0 +1,565 @@ +/* eslint-disable react/jsx-no-bind */ +/** + * PayPal Payment Buttons — API-managed block editor (V2). + * + * When PayPal is connected, merchants fill in product details and create + * buttons directly in the editor. Shown while the API-managed buttons flag is on. + * + * @package + * @since 0.8.0 + */ + +import apiFetch from '@wordpress/api-fetch'; // eslint-disable-line import/no-unresolved +import { BlockControls, store as blockEditorStore, useBlockProps } from '@wordpress/block-editor'; +import { Notice, Spinner, ToolbarButton, ToolbarGroup } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; +import { useState, useCallback, useMemo } from '@wordpress/element'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import metadata from './block.json'; +import ConfirmDialogs from './components/confirm-dialogs'; +import ConnectionWizard from './components/connection-wizard'; +import { FORMAT_OPTIONS } from './components/format-switcher'; +import LegacyBlock from './components/legacy-block'; +import PayPalButtonPreview from './components/paypal-button-preview'; +import ProductForm from './components/product-form'; +import { hasVariantPricing, validateVariants } from './components/variant-builder'; +import PayPalInspectorControls from './controls'; +import { broadcastConnectionChange, usePayPalConnection } from './hooks/use-paypal-connection'; +import { usePayPalResource } from './hooks/use-paypal-resource'; +import { API_BASE } from './utils/api-base'; +import { VALID_CURRENCY_CODES } from './utils/currencies'; +import { validatePrice, validateProductName, validateDescription } from './utils/validation'; + +// Button type is always 'single' — the hosted payment page handles +// payment method selection (PayPal, cards, wallets, etc.). + +/** + * API-managed PayPal Payment Buttons edit component. + * + * @param {object} props - Block props. + * @param {object} props.attributes - Block attributes. + * @param {Function} props.setAttributes - Function to update block attributes. + * @param {string} props.clientId - The block's client ID (not the PayPal one). + * @return {Element} Block editor UI. + */ +export default function ApiManagedEdit( { attributes, setAttributes, clientId: blockClientId } ) { + const { + colorScheme, + isApiManaged, + scriptSrc, + hostedButtonId, + buttonText, + resourceId, + paymentLink, + productName, + price, + currencyCode, + productDescription, + imageUrl, + imageId, + returnUrl, + variantsEnabled, + variants, + adjustableQuantity, + maxQuantity, + customerNotes, + taxEnabled, + taxType, + taxName, + taxValue, + format, + } = attributes; + + // Normalize — old blocks without the attribute default to BUTTON. + const activeFormat = format || 'BUTTON'; + + const blockProps = useBlockProps(); + + // Pre-extract translated strings used in ternaries to avoid + // i18n-check-webpack-plugin errors when the minifier collapses branches. + const labelConnected = __( 'PayPal Connected', 'jetpack-paypal-payments' ); + const labelDisconnected = __( 'PayPal Disconnected', 'jetpack-paypal-payments' ); + + const { + isConnected, + setIsConnected, + environment, + setEnvironment, + connectionLoading, + partnerAttributionId, + showReconnect, + setShowReconnect, + signupUrl, + setOnboardingRequested, + isOverlayOpen, + isOpeningPayPal, + setFrameNode, + clientId, + clientSecret, + connectError, + setConnectError, + connectErrorDismissed, + setConnectErrorDismissed, + isConnecting, + isCompletingOnboarding, + wizardStep, + setWizardStep, + showSecretField, + setShowSecretField, + partnerReferralsAvailable, + handleClientIdChange, + handleClientSecretChange, + clientIdWarning, + handleConnect, + fetchSignupLink, + cancelOnboarding, + } = usePayPalConnection(); + + // Confirmation dialog state for destructive actions. + const [ showDeleteConfirm, setShowDeleteConfirm ] = useState( false ); + const [ showDisconnectConfirm, setShowDisconnectConfirm ] = useState( false ); + + // Edit/preview mode toggle. Start in preview if button already exists. + const [ isEditing, setIsEditing ] = useState( ! ( isApiManaged && resourceId && paymentLink ) ); + + // Inline validation state — track which fields have been touched. + const [ touchedFields, setTouchedFields ] = useState( {} ); + + /** + * Mark a field as touched (user has interacted with it). + * + * @param {string} field - Field name. + */ + const markTouched = useCallback( field => { + setTouchedFields( prev => ( { ...prev, [ field ]: true } ) ); + }, [] ); + + /** + * Whether the options group carries its own per-option prices. + * + * PayPal rejects a request with `unit_amount` at both the product and the + * variant level, so per-option prices replace the product-level price + * rather than sitting alongside it. + */ + const usesVariantPricing = useMemo( + () => hasVariantPricing( variantsEnabled, variants ), + [ variantsEnabled, variants ] + ); + + /** + * Compute validation errors for all form fields. + * Memoized to avoid re-computing on every render. + */ + const validationErrors = useMemo( + () => ( { + productName: validateProductName( productName ), + // The product price is only required when the options aren't priced + // individually. A stray value is still validated so it can't be sent + // half-formed if the merchant clears the per-option prices later. + price: usesVariantPricing && ! price ? null : validatePrice( price, currencyCode || 'USD' ), + productDescription: validateDescription( productDescription ), + currencyCode: + currencyCode && ! VALID_CURRENCY_CODES.has( currencyCode ) + ? __( 'Unsupported currency.', 'jetpack-paypal-payments' ) + : null, + } ), + [ productName, price, productDescription, currencyCode, usesVariantPricing ] + ); + + /** + * Variant validation errors (empty array if valid or disabled). + */ + const variantErrors = useMemo( + () => validateVariants( variantsEnabled, variants, currencyCode || 'USD' ), + [ variantsEnabled, variants, currencyCode ] + ); + + /** + * Whether the form is valid (no validation errors on required fields or variants). + */ + const isFormValid = + ! validationErrors.productName && + ! validationErrors.price && + ! validationErrors.productDescription && + ! validationErrors.currencyCode && + variantErrors.length === 0; + + const { + isCreating, + error, + setError, + successMessage, + setSuccessMessage, + handleCreateButton, + handleUpdateButton, + handleDeleteButton, + executeDeleteButton, + } = usePayPalResource( { + attributes, + setAttributes, + isConnected, + usesVariantPricing, + isFormValid, + setIsEditing, + setTouchedFields, + setShowDeleteConfirm, + } ); + + // Other blocks on this page pointing at the same PayPal payment. + const sharedResourceCount = useSelect( + select => { + if ( ! resourceId || ! blockClientId ) { + return 0; + } + const { getClientIdsWithDescendants, getBlockName, getBlockAttributes } = + select( blockEditorStore ); + return getClientIdsWithDescendants().filter( + id => + id !== blockClientId && + getBlockName( id ) === metadata.name && + getBlockAttributes( id )?.resourceId === resourceId + ).length; + }, + [ blockClientId, resourceId ] + ); + + /** + * Handle PayPal disconnect with confirmation. + * Triggers a ConfirmDialog — actual disconnect runs in executeDisconnect(). + */ + const handleDisconnect = useCallback( () => { + setShowDisconnectConfirm( true ); + }, [] ); + + /** + * Execute the PayPal disconnect after the user confirms. + */ + const executeDisconnect = useCallback( () => { + setShowDisconnectConfirm( false ); + + const doDisconnect = () => { + setIsConnected( false ); + setWizardStep( 'welcome' ); + setShowReconnect( false ); + broadcastConnectionChange( false ); + // Clear block attributes so the block shows the connect wizard. + setAttributes( { + isApiManaged: false, + resourceId: '', + paymentLink: '', + productName: '', + price: '', + productDescription: '', + imageUrl: undefined, + imageId: undefined, + returnUrl: '', + variantsEnabled: false, + variants: null, + currencyCode: 'USD', + } ); + setSuccessMessage( __( 'PayPal account disconnected.', 'jetpack-paypal-payments' ) ); + }; + + apiFetch( { + path: `${ API_BASE }/disconnect`, + method: 'POST', + } ) + .then( doDisconnect ) + .catch( doDisconnect ); // Still disconnect locally if API fails. + }, [ setAttributes, setIsConnected, setShowReconnect, setSuccessMessage, setWizardStep ] ); + + /** + * Whether the block has a created button to preview. + */ + const hasButton = isApiManaged && resourceId && paymentLink; + + // Loading state while checking connection. + if ( connectionLoading ) { + return ( +
+
+ +

{ __( 'Checking PayPal connection…', 'jetpack-paypal-payments' ) }

+
+
+ ); + } + + // Legacy paste-code block — render as-is without the new UI. + if ( ! isApiManaged && ( scriptSrc || hostedButtonId ) ) { + return ( + + ); + } + + // Not connected — show the guided connection wizard. A block that already + // holds a saved button keeps showing its preview instead (e.g. demo posts in + // Playground, or a button created before the site was disconnected), unless + // the merchant explicitly asked to reconnect. + if ( ! isConnected && ( ! hasButton || showReconnect ) ) { + return ( +
+ +
+ ); + } + + // Toolbar controls for edit/preview toggle (only when button exists). + const toolbarControls = hasButton ? ( + + + setIsEditing( false ) } + /> + setIsEditing( true ) } + /> + + + + + + ) : null; + + // Inspector sidebar — format switcher, Style preset, and connection info. + const inspectorControls = ( + + ); + + // Shared confirmation dialogs — extracted so they render regardless of which return branch is active. + const confirmDialogs = ( + + ); + + const formatLabel = FORMAT_OPTIONS.find( o => o.value === activeFormat )?.label || activeFormat; + + // The PayPal connection is site-wide, so a block can still hold a working + // button after the account was disconnected — from this post, another post, + // or the admin. The button keeps paying out; only editing it needs the + // connection back, so say so instead of failing on save. + const disconnectedNotice = ! isConnected ? ( + setShowReconnect( true ), + variant: 'primary', + }, + ] } + > + { __( + 'Your PayPal account is disconnected. This payment link still works for buyers, but you need to reconnect before you can edit or delete it.', + 'jetpack-paypal-payments' + ) } + + ) : null; + + const sharedResourceMessage = sprintf( + /* translators: %d: number of other blocks on this page using the same PayPal payment */ + _n( + '%d other block on this page uses this PayPal payment. Changing the product or price here changes it there too. To sell something different, add a new block and create a new payment.', + '%d other blocks on this page use this PayPal payment. Changing the product or price here changes it there too. To sell something different, add a new block and create a new payment.', + sharedResourceCount, + 'jetpack-paypal-payments' + ), + sharedResourceCount + ); + const sharedResourceNotice = + sharedResourceCount > 0 ? ( + + { sharedResourceMessage } + + ) : null; + + const connectionStatus = ( + + ); + + const connectionLabel = isConnected ? labelConnected : labelDisconnected; + + // Connected + has button + preview mode — show live button preview. + if ( hasButton && ! isEditing ) { + return ( +
+ { toolbarControls } + { inspectorControls } + +
+
+ { connectionStatus } + { connectionLabel } + + { sprintf( + /* translators: %s: format label (Button, Link, or QR Code) */ + __( 'Format: %s', 'jetpack-paypal-payments' ), + formatLabel + ) } + + { environment === 'sandbox' && ( + + { __( 'Sandbox', 'jetpack-paypal-payments' ) } + + ) } +
+ + { disconnectedNotice } + { sharedResourceNotice } + + { successMessage && ( + setSuccessMessage( null ) }> + { successMessage } + + ) } + + { error && ( + setError( null ) }> + { error } + + ) } + + +
+ + { confirmDialogs } +
+ ); + } + + // Connected — edit mode (either creating new or editing existing). + return ( +
+ { toolbarControls } + { inspectorControls } + + + + { confirmDialogs } +
+ ); +} diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/edit-paste-code.jsx b/projects/packages/paypal-payments/src/paypal-payment-buttons/edit-paste-code.jsx new file mode 100644 index 000000000000..3027ae035e71 --- /dev/null +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/edit-paste-code.jsx @@ -0,0 +1,443 @@ +/* eslint-disable react/jsx-no-bind */ +/** + * PayPal Payment Buttons — paste-code block editor (V1). + * + * @package + */ + +import { isWpcomPlatformSite } from '@automattic/jetpack-script-data'; +import { PlainText, useBlockProps } from '@wordpress/block-editor'; +import { + Notice, + Placeholder, + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalToggleGroupControl as ToggleGroupControl, + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalToggleGroupControlOption as ToggleGroupControlOption, + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalItemGroup as ItemGroup, + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalItem as Item, +} from '@wordpress/components'; +import { createInterpolateElement, useEffect, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Link } from '@wordpress/ui'; +import PayPalIcon from './icon'; + +const BUTTON_ID_PATTERN = '[A-Za-z0-9_-]+'; + +const extractScriptSrc = codeHead => { + const match = codeHead.match( + /src="(https:\/\/(www\.)?(sandbox\.)?paypal\.com\/sdk\/js\?[^"]+)"/ + ); + return match ? match[ 1 ] : ''; +}; + +const extractHostedButtonId = codeBody => { + let buttonId = ''; + + // Try to extract from hostedButtonId property first (stacked buttons) + const hostedButtonMatch = codeBody.match( + new RegExp( `hostedButtonId:\\s*["'](${ BUTTON_ID_PATTERN })["']` ) + ); + if ( hostedButtonMatch ) { + buttonId = hostedButtonMatch[ 1 ]; + } + + // Try to extract from form action URL (single buttons) + // Support international domains, protocol-relative URLs, and case-insensitive domain matching + // Extract ID before any query parameters or spaces + if ( ! buttonId ) { + const actionMatch = codeBody.match( + /action\s*=\s*["'](?:https?:)?\/\/(?:www\.)?(?:sandbox\.)?paypal\.[a-z.]+\/ncp\/payment\/([A-Za-z0-9_-]+)\s*(?:\?[^"']*)?["']/i + ); + if ( actionMatch ) { + buttonId = actionMatch[ 1 ]; + } + } + + return buttonId.trim(); +}; + +const extractButtonText = codeBody => { + // Extract button text from input value attribute (single buttons) + // Support spaces around equals sign and both self-closing and non-self-closing tags + const inputMatch = codeBody.match( /]*value\s*=\s*["']([^"']+)["'][^>]*\/?>/ ); + return inputMatch ? inputMatch[ 1 ].trim() : ''; +}; + +const generateHeadCode = scriptSrc => { + if ( ! scriptSrc ) { + return ''; + } + return ``; +}; + +const generateBodyCode = ( hostedButtonId, buttonType = 'stacked', buttonText = '' ) => { + if ( ! hostedButtonId ) { + return ''; + } + + if ( buttonType === 'single' ) { + return ` +
+ + cards +
Powered by paypal
+
`; + } + + return `
+`; +}; + +const validScriptSrc = scriptSrc => + /^https:\/\/(www\.)?(sandbox\.)?paypal\.com\/sdk\/js\?client-id=/.test( scriptSrc ); + +const validHostedButtonId = hostedButtonId => { + // Validate the button ID format + return new RegExp( `^${ BUTTON_ID_PATTERN }$` ).test( hostedButtonId ); +}; + +const validButtonText = buttonText => + buttonText && buttonText.trim().length > 0 && buttonText.length <= 50; + +/** + * Get PayPal signup URL with platform-specific tracking parameters + * + * @return {string} The PayPal signup URL + */ +const getPayPalSignupUrl = () => { + const isWpcom = isWpcomPlatformSite(); + const utmSource = isWpcom ? 'wp_com' : 'wp_org'; + const atCode = isWpcom ? 'wp_com' : 'wp_org'; + return `https://www.paypal.com/bizsignup/entry?product=payment_button&utm_source=${ utmSource }&at_code=${ atCode }`; +}; + +/** + * Get PayPal login URL with platform-specific tracking parameters + * + * @return {string} The PayPal login URL + */ +const getPayPalLoginUrl = () => { + const isWpcom = isWpcomPlatformSite(); + const utmSource = isWpcom ? 'wp_com' : 'wp_org'; + const atCode = isWpcom ? 'wp_com' : 'wp_org'; + return `https://www.paypal.com/ncp/buttons/create?utm_source=${ utmSource }&at_code=${ atCode }`; +}; + +/** + * PayPal Single Button Preview component (rendered directly) + * + * @param {object} root0 - The component props + * @param {string} root0.buttonText - The button text + * @return {Element} The PayPal single button preview component + */ +const PayPalSingleButtonPreview = ( { buttonText } ) => { + const paypalButtonStyles = { + textAlign: 'center', + border: 'none', + borderRadius: '0.25rem', + minWidth: '11.625rem', + padding: '0 2rem', + height: '2.625rem', + fontWeight: 'bold', + backgroundColor: '#FFD140', + color: '#000000', + fontFamily: '"Helvetica Neue", Arial, sans-serif', + fontSize: '1rem', + lineHeight: '1.25rem', + cursor: 'pointer', + pointerEvents: 'none', // Prevent clicking in editor + }; + + return ( +
+
+ + cards +
+ Powered by{ ' ' } + paypal +
+
+
+ ); +}; + +/** + * Check if we have the required data for a preview (only single buttons) + * + * @param {object} attributes - The block attributes + * @return {boolean} Whether preview can be shown + */ +const canShowPreview = attributes => { + const { buttonType, hostedButtonId, buttonText } = attributes; + + if ( ! hostedButtonId || ! validHostedButtonId( hostedButtonId ) ) { + return false; + } + + if ( buttonType === 'single' ) { + return buttonText && validButtonText( buttonText ); + } + + return false; +}; + +/** + * PayPal Preview component router + * + * @param {object} root0 - The component props + * @param {object} root0.attributes - The block attributes + * @return {Element|null} The PayPal preview component or null + */ +const PayPalPreview = ( { attributes } ) => { + const { buttonType, buttonText } = attributes; + + if ( ! canShowPreview( attributes ) ) { + return null; + } + + // Only render preview for single button type + if ( buttonType === 'single' ) { + return ; + } + + return null; +}; + +/** + * The paste-code editor: the merchant builds a button on PayPal.com and pastes + * the generated code here. Shown while the API-managed buttons flag is off. + * + * @param {object} props - Block props. + * @param {object} props.attributes - Block attributes. + * @param {Function} props.setAttributes - Function to update block attributes. + * @param {boolean} props.isSelected - Whether the block is selected. + * @return {Element} Block editor UI. + */ +export default function PasteCodeEdit( { attributes, setAttributes, isSelected } ) { + const { buttonType, scriptSrc, hostedButtonId, buttonText } = attributes; + const [ notice, setNotice ] = useState( null ); + const [ rawHeadCode, setRawHeadCode ] = useState( '' ); + const [ rawBodyCode, setRawBodyCode ] = useState( '' ); + + const stackedInstructions = __( + 'Stacked Buttons (Recommended): This option lets you present all of your product information and PayPal payment method upfront on your website.', + 'jetpack-paypal-payments' + ); + const singleInstructions = __( + 'Single Button: This option lets you quickly paste a single button on your site, with no product information.', + 'jetpack-paypal-payments' + ); + + // Initialize raw code when valid extracted values exist + useEffect( () => { + if ( ! rawHeadCode && scriptSrc && buttonType === 'stacked' ) { + setRawHeadCode( generateHeadCode( scriptSrc ) ); + } + }, [ scriptSrc, rawHeadCode, buttonType ] ); + + useEffect( () => { + if ( ! rawBodyCode && hostedButtonId ) { + setRawBodyCode( generateBodyCode( hostedButtonId, buttonType, buttonText ) ); + } + }, [ hostedButtonId, rawBodyCode, buttonType, buttonText ] ); + + useEffect( () => { + // Check if user has pasted invalid code that couldn't be extracted + if ( 'stacked' === buttonType && rawHeadCode && rawHeadCode.trim() && ! scriptSrc ) { + return setNotice( + + { __( + 'Invalid PayPal script URL. Please paste code from PayPal.com.', + 'jetpack-paypal-payments' + ) } + + ); + } + + if ( rawBodyCode && rawBodyCode.trim() && ! hostedButtonId ) { + return setNotice( + + { __( + 'Invalid PayPal button code. Please paste code from PayPal.com.', + 'jetpack-paypal-payments' + ) } + + ); + } + + // Validate extracted values + if ( 'stacked' === buttonType && scriptSrc && ! validScriptSrc( scriptSrc ) ) { + return setNotice( + + { __( 'Invalid PayPal script URL.', 'jetpack-paypal-payments' ) } + + ); + } + + if ( hostedButtonId && ! validHostedButtonId( hostedButtonId ) ) { + return setNotice( + + { __( 'Invalid PayPal button ID.', 'jetpack-paypal-payments' ) } + + ); + } + + if ( 'single' === buttonType && buttonText && ! validButtonText( buttonText ) ) { + return setNotice( + + { __( 'Button text must be between 1 and 50 characters.', 'jetpack-paypal-payments' ) } + + ); + } + + setNotice( null ); + }, [ buttonType, scriptSrc, hostedButtonId, buttonText, rawHeadCode, rawBodyCode ] ); + + const blockProps = useBlockProps(); + + // Early return for preview rendering + if ( ! isSelected && ! notice && canShowPreview( attributes ) ) { + return ( +
+ +
+ ); + } + + const stackedButtonCodeLabel = __( 'Part 2 code', 'jetpack-paypal-payments' ); + const stackedButtonCodePlaceholder = __( + 'Paste the part 2 code here…', + 'jetpack-paypal-payments' + ); + + const singleButtonCodeLabel = __( 'Single button code', 'jetpack-paypal-payments' ); + const singleButtonCodePlaceholder = __( + 'Paste the single button code here…', + 'jetpack-paypal-payments' + ); + + return ( +
+ + + + { createInterpolateElement( + __( + '1. Sign up or log in to PayPal to get your Payment Button code.', + 'jetpack-paypal-payments' + ), + { + SignupLink: , + LoginLink: , + strong: , + } + ) } + + + { 'stacked' === buttonType && + __( + '2. After login, choose Payment Buttons. Enter your product or service details, and build the buttons. Copy the button code for Stacked Buttons (copy html code).', + 'jetpack-paypal-payments' + ) } + { 'single' === buttonType && + __( + '2. After login, choose Payment Buttons. Enter your product or service details, and build the buttons. Copy the button code for Single Button.', + 'jetpack-paypal-payments' + ) } + + { __( '3. Paste the code below.', 'jetpack-paypal-payments' ) } + + { + const newAttributes = { buttonType: type }; + newAttributes.scriptSrc = ''; + newAttributes.buttonText = ''; + newAttributes.hostedButtonId = ''; + + setRawHeadCode( '' ); + setRawBodyCode( '' ); + + setAttributes( newAttributes ); + } } + isBlock + __nextHasNoMarginBottom={ true } + __next40pxDefaultSize={ true } + > + + + + { 'stacked' === buttonType && ( + { + setRawHeadCode( code ); + const extractedSrc = extractScriptSrc( code ); + setAttributes( { + scriptSrc: extractedSrc, + } ); + } } + placeholder={ __( 'Paste the part 1 code here…', 'jetpack-paypal-payments' ) } + aria-label={ __( 'Part 1 code', 'jetpack-paypal-payments' ) } + name="paypal-payment-buttons-code-head" + /> + ) } + <PlainText + value={ rawBodyCode } + onChange={ code => { + setRawBodyCode( code ); + const extractedButtonId = extractHostedButtonId( code ); + const extractedButtonText = extractButtonText( code ); + setAttributes( { + hostedButtonId: extractedButtonId, + buttonText: extractedButtonText, + } ); + } } + placeholder={ + 'stacked' === buttonType ? stackedButtonCodePlaceholder : singleButtonCodePlaceholder + } + aria-label={ 'stacked' === buttonType ? stackedButtonCodeLabel : singleButtonCodeLabel } + name="paypal-payment-buttons-code-body" + /> + </Placeholder> + </div> + ); +} diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/edit.jsx b/projects/packages/paypal-payments/src/paypal-payment-buttons/edit.jsx index 9dd0b92bbe3b..07f4b0ec0cd0 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/edit.jsx +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/edit.jsx @@ -1,574 +1,91 @@ -/* eslint-disable react/jsx-no-bind */ /** - * PayPal Payment Buttons — Block Editor Component. + * PayPal Payment Buttons — block editor entry point. * - * Replaces the legacy paste-code textarea with an API-driven form UI. - * When PayPal is connected, merchants fill in product details and create - * buttons directly in the editor. Falls back to the paste-code interface - * when PayPal is not connected. - * - * Updated for WOOPTP-151: Client-side validation with inline errors, - * user-friendly API error mapping, and graceful 404 handling. + * Picks the paste-code or the API-managed editor from the feature flag PHP + * puts on the editor state. The two editors are separate components so the + * API-managed hooks, which call REST routes that only exist while the flag is + * on, never run for the paste-code path. * * @package - * @since 0.8.0 */ -import apiFetch from '@wordpress/api-fetch'; // eslint-disable-line import/no-unresolved -import { BlockControls, store as blockEditorStore, useBlockProps } from '@wordpress/block-editor'; -import { Notice, Spinner, ToolbarButton, ToolbarGroup } from '@wordpress/components'; -import { useSelect } from '@wordpress/data'; -import { useState, useCallback, useMemo } from '@wordpress/element'; -import { __, _n, sprintf } from '@wordpress/i18n'; -import metadata from './block.json'; -import ConfirmDialogs from './components/confirm-dialogs'; -import ConnectionWizard from './components/connection-wizard'; -import { FORMAT_OPTIONS } from './components/format-switcher'; -import LegacyBlock from './components/legacy-block'; +import { hasFeatureFlag } from '@automattic/jetpack-shared-extension-utils'; +import { useBlockProps } from '@wordpress/block-editor'; +import { Notice } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; import PayPalButtonPreview from './components/paypal-button-preview'; -import ProductForm from './components/product-form'; -import { hasVariantPricing, validateVariants } from './components/variant-builder'; -import PayPalInspectorControls from './controls'; -import { broadcastConnectionChange, usePayPalConnection } from './hooks/use-paypal-connection'; -import { usePayPalResource } from './hooks/use-paypal-resource'; -import { API_BASE } from './utils/api-base'; -import { VALID_CURRENCY_CODES } from './utils/currencies'; -import { validatePrice, validateProductName, validateDescription } from './utils/validation'; +import ApiManagedEdit from './edit-api-managed'; +import PasteCodeEdit from './edit-paste-code'; -// Button type is always 'single' — the hosted payment page handles -// payment method selection (PayPal, cards, wallets, etc.). +/** + * Name of the flag registered by PayPal_Payment_Buttons::register_feature_flags(). + */ +export const API_MANAGED_BUTTONS_FLAG = 'paypal-payments-api-managed-buttons'; /** - * PayPal Payment Buttons edit component. + * Read-only view of a button created through the API while the flag is off. * - * @param {object} props - Block props. - * @param {object} props.attributes - Block attributes. - * @param {Function} props.setAttributes - Function to update block attributes. - * @param {string} props.clientId - The block's client ID (not the PayPal one). - * @return {Element} Block editor UI. + * The frontend keeps rendering the saved button; the editor just cannot + * change it until the flag is back on, so say so instead of showing an + * unrelated paste-code form. + * + * @param {object} props - Block props. + * @param {object} props.attributes - Block attributes. + * @return {Element} Read-only preview. */ -export default function PayPalPaymentButtonsEdit( { - attributes, - setAttributes, - clientId: blockClientId, -} ) { +function ApiManagedReadOnly( { attributes } ) { + const blockProps = useBlockProps(); const { colorScheme, - isApiManaged, - scriptSrc, - hostedButtonId, - buttonText, - resourceId, - paymentLink, productName, price, currencyCode, productDescription, - imageUrl, - imageId, - returnUrl, + paymentLink, variantsEnabled, variants, - adjustableQuantity, - maxQuantity, - customerNotes, - taxEnabled, - taxType, - taxName, - taxValue, - format, + imageUrl, } = attributes; - // Normalize — old blocks without the attribute default to BUTTON. - const activeFormat = format || 'BUTTON'; - - const blockProps = useBlockProps(); - - // Pre-extract translated strings used in ternaries to avoid - // i18n-check-webpack-plugin errors when the minifier collapses branches. - const labelConnected = __( 'PayPal Connected', 'jetpack-paypal-payments' ); - const labelDisconnected = __( 'PayPal Disconnected', 'jetpack-paypal-payments' ); - - const { - isConnected, - setIsConnected, - environment, - setEnvironment, - connectionLoading, - partnerAttributionId, - showReconnect, - setShowReconnect, - signupUrl, - setOnboardingRequested, - isOverlayOpen, - isOpeningPayPal, - setFrameNode, - clientId, - clientSecret, - connectError, - setConnectError, - connectErrorDismissed, - setConnectErrorDismissed, - isConnecting, - isCompletingOnboarding, - wizardStep, - setWizardStep, - showSecretField, - setShowSecretField, - partnerReferralsAvailable, - handleClientIdChange, - handleClientSecretChange, - clientIdWarning, - handleConnect, - fetchSignupLink, - cancelOnboarding, - } = usePayPalConnection(); - - // Confirmation dialog state for destructive actions. - const [ showDeleteConfirm, setShowDeleteConfirm ] = useState( false ); - const [ showDisconnectConfirm, setShowDisconnectConfirm ] = useState( false ); - - // Edit/preview mode toggle. Start in preview if button already exists. - const [ isEditing, setIsEditing ] = useState( ! ( isApiManaged && resourceId && paymentLink ) ); - - // Inline validation state — track which fields have been touched. - const [ touchedFields, setTouchedFields ] = useState( {} ); - - /** - * Mark a field as touched (user has interacted with it). - * - * @param {string} field - Field name. - */ - const markTouched = useCallback( field => { - setTouchedFields( prev => ( { ...prev, [ field ]: true } ) ); - }, [] ); - - /** - * Whether the options group carries its own per-option prices. - * - * PayPal rejects a request with `unit_amount` at both the product and the - * variant level, so per-option prices replace the product-level price - * rather than sitting alongside it. - */ - const usesVariantPricing = useMemo( - () => hasVariantPricing( variantsEnabled, variants ), - [ variantsEnabled, variants ] - ); - - /** - * Compute validation errors for all form fields. - * Memoized to avoid re-computing on every render. - */ - const validationErrors = useMemo( - () => ( { - productName: validateProductName( productName ), - // The product price is only required when the options aren't priced - // individually. A stray value is still validated so it can't be sent - // half-formed if the merchant clears the per-option prices later. - price: usesVariantPricing && ! price ? null : validatePrice( price, currencyCode || 'USD' ), - productDescription: validateDescription( productDescription ), - currencyCode: - currencyCode && ! VALID_CURRENCY_CODES.has( currencyCode ) - ? __( 'Unsupported currency.', 'jetpack-paypal-payments' ) - : null, - } ), - [ productName, price, productDescription, currencyCode, usesVariantPricing ] - ); - - /** - * Variant validation errors (empty array if valid or disabled). - */ - const variantErrors = useMemo( - () => validateVariants( variantsEnabled, variants, currencyCode || 'USD' ), - [ variantsEnabled, variants, currencyCode ] - ); - - /** - * Whether the form is valid (no validation errors on required fields or variants). - */ - const isFormValid = - ! validationErrors.productName && - ! validationErrors.price && - ! validationErrors.productDescription && - ! validationErrors.currencyCode && - variantErrors.length === 0; - - const { - isCreating, - error, - setError, - successMessage, - setSuccessMessage, - handleCreateButton, - handleUpdateButton, - handleDeleteButton, - executeDeleteButton, - } = usePayPalResource( { - attributes, - setAttributes, - isConnected, - usesVariantPricing, - isFormValid, - setIsEditing, - setTouchedFields, - setShowDeleteConfirm, - } ); - - // Other blocks on this page pointing at the same PayPal payment. - const sharedResourceCount = useSelect( - select => { - if ( ! resourceId || ! blockClientId ) { - return 0; - } - const { getClientIdsWithDescendants, getBlockName, getBlockAttributes } = - select( blockEditorStore ); - return getClientIdsWithDescendants().filter( - id => - id !== blockClientId && - getBlockName( id ) === metadata.name && - getBlockAttributes( id )?.resourceId === resourceId - ).length; - }, - [ blockClientId, resourceId ] - ); - - /** - * Handle PayPal disconnect with confirmation. - * Triggers a ConfirmDialog — actual disconnect runs in executeDisconnect(). - */ - const handleDisconnect = useCallback( () => { - setShowDisconnectConfirm( true ); - }, [] ); - - /** - * Execute the PayPal disconnect after the user confirms. - */ - const executeDisconnect = useCallback( () => { - setShowDisconnectConfirm( false ); - - const doDisconnect = () => { - setIsConnected( false ); - setWizardStep( 'welcome' ); - setShowReconnect( false ); - broadcastConnectionChange( false ); - // Clear block attributes so the block shows the connect wizard. - setAttributes( { - isApiManaged: false, - resourceId: '', - paymentLink: '', - productName: '', - price: '', - productDescription: '', - imageUrl: undefined, - imageId: undefined, - returnUrl: '', - variantsEnabled: false, - variants: null, - currencyCode: 'USD', - } ); - setSuccessMessage( __( 'PayPal account disconnected.', 'jetpack-paypal-payments' ) ); - }; - - apiFetch( { - path: `${ API_BASE }/disconnect`, - method: 'POST', - } ) - .then( doDisconnect ) - .catch( doDisconnect ); // Still disconnect locally if API fails. - }, [ setAttributes, setIsConnected, setShowReconnect, setSuccessMessage, setWizardStep ] ); - - /** - * Whether the block has a created button to preview. - */ - const hasButton = isApiManaged && resourceId && paymentLink; - - // Loading state while checking connection. - if ( connectionLoading ) { - return ( - <div { ...blockProps } data-color-scheme={ colorScheme || 'auto' }> - <div className="jetpack-paypal-payment-buttons__loading"> - <Spinner /> - <p>{ __( 'Checking PayPal connection…', 'jetpack-paypal-payments' ) }</p> - </div> - </div> - ); - } - - // Legacy paste-code block — render as-is without the new UI. - if ( ! isApiManaged && ( scriptSrc || hostedButtonId ) ) { - return ( - <LegacyBlock - setAttributes={ setAttributes } - colorScheme={ colorScheme } - buttonText={ buttonText } - blockProps={ blockProps } - /> - ); - } - - // Not connected — show the guided connection wizard. A block that already - // holds a saved button keeps showing its preview instead (e.g. demo posts in - // Playground, or a button created before the site was disconnected), unless - // the merchant explicitly asked to reconnect. - if ( ! isConnected && ( ! hasButton || showReconnect ) ) { - return ( - <div { ...blockProps } data-color-scheme={ colorScheme || 'auto' }> - <ConnectionWizard - setIsConnected={ setIsConnected } - environment={ environment } - setEnvironment={ setEnvironment } - showReconnect={ showReconnect } - setShowReconnect={ setShowReconnect } - signupUrl={ signupUrl } - setOnboardingRequested={ setOnboardingRequested } - isOverlayOpen={ isOverlayOpen } - isOpeningPayPal={ isOpeningPayPal } - setFrameNode={ setFrameNode } - clientId={ clientId } - clientSecret={ clientSecret } - connectError={ connectError } - setConnectError={ setConnectError } - connectErrorDismissed={ connectErrorDismissed } - setConnectErrorDismissed={ setConnectErrorDismissed } - isConnecting={ isConnecting } - isCompletingOnboarding={ isCompletingOnboarding } - wizardStep={ wizardStep } - setWizardStep={ setWizardStep } - showSecretField={ showSecretField } - setShowSecretField={ setShowSecretField } - partnerReferralsAvailable={ partnerReferralsAvailable } - handleClientIdChange={ handleClientIdChange } - handleClientSecretChange={ handleClientSecretChange } - clientIdWarning={ clientIdWarning } - handleConnect={ handleConnect } - fetchSignupLink={ fetchSignupLink } - cancelOnboarding={ cancelOnboarding } - /> - </div> - ); - } - - // Toolbar controls for edit/preview toggle (only when button exists). - const toolbarControls = hasButton ? ( - <BlockControls> - <ToolbarGroup> - <ToolbarButton - icon="visibility" - label={ __( 'Preview', 'jetpack-paypal-payments' ) } - isPressed={ ! isEditing } - onClick={ () => setIsEditing( false ) } - /> - <ToolbarButton - icon="edit" - label={ __( 'Edit', 'jetpack-paypal-payments' ) } - isPressed={ isEditing } - onClick={ () => setIsEditing( true ) } - /> - </ToolbarGroup> - <ToolbarGroup> - <ToolbarButton - icon="trash" - label={ __( 'Delete Payment Button', 'jetpack-paypal-payments' ) } - onClick={ handleDeleteButton } - disabled={ isCreating || ! isConnected } - isDestructive - /> - </ToolbarGroup> - </BlockControls> - ) : null; - - // Inspector sidebar — format switcher, Style preset, and connection info. - const inspectorControls = ( - <PayPalInspectorControls - setAttributes={ setAttributes } - colorScheme={ colorScheme } - resourceId={ resourceId } - activeFormat={ activeFormat } - isConnected={ isConnected } - environment={ environment } - setShowReconnect={ setShowReconnect } - isCreating={ isCreating } - handleDeleteButton={ handleDeleteButton } - handleDisconnect={ handleDisconnect } - hasButton={ hasButton } - /> - ); - - // Shared confirmation dialogs — extracted so they render regardless of which return branch is active. - const confirmDialogs = ( - <ConfirmDialogs - showDeleteConfirm={ showDeleteConfirm } - setShowDeleteConfirm={ setShowDeleteConfirm } - showDisconnectConfirm={ showDisconnectConfirm } - setShowDisconnectConfirm={ setShowDisconnectConfirm } - executeDeleteButton={ executeDeleteButton } - executeDisconnect={ executeDisconnect } - /> - ); - - const formatLabel = FORMAT_OPTIONS.find( o => o.value === activeFormat )?.label || activeFormat; - - // The PayPal connection is site-wide, so a block can still hold a working - // button after the account was disconnected — from this post, another post, - // or the admin. The button keeps paying out; only editing it needs the - // connection back, so say so instead of failing on save. - const disconnectedNotice = ! isConnected ? ( - <Notice - status="warning" - isDismissible={ false } - actions={ [ - { - label: __( 'Reconnect PayPal', 'jetpack-paypal-payments' ), - onClick: () => setShowReconnect( true ), - variant: 'primary', - }, - ] } - > - { __( - 'Your PayPal account is disconnected. This payment link still works for buyers, but you need to reconnect before you can edit or delete it.', - 'jetpack-paypal-payments' - ) } - </Notice> - ) : null; - - const sharedResourceMessage = sprintf( - /* translators: %d: number of other blocks on this page using the same PayPal payment */ - _n( - '%d other block on this page uses this PayPal payment. Changing the product or price here changes it there too. To sell something different, add a new block and create a new payment.', - '%d other blocks on this page use this PayPal payment. Changing the product or price here changes it there too. To sell something different, add a new block and create a new payment.', - sharedResourceCount, - 'jetpack-paypal-payments' - ), - sharedResourceCount - ); - const sharedResourceNotice = - sharedResourceCount > 0 ? ( + return ( + <div { ...blockProps } data-color-scheme={ colorScheme || 'auto' }> <Notice status="info" isDismissible={ false }> - { sharedResourceMessage } + { __( + 'This button is managed through your PayPal account and cannot be edited right now.', + 'jetpack-paypal-payments' + ) } </Notice> - ) : null; - - const connectionStatus = ( - <span - className={ `jetpack-paypal-payment-buttons__status-dot ${ - isConnected - ? 'jetpack-paypal-payment-buttons__status-dot--connected' - : 'jetpack-paypal-payment-buttons__status-dot--disconnected' - }` } - /> + <div className="jetpack-paypal-payment-buttons__preview"> + <PayPalButtonPreview + productName={ productName } + price={ price } + currencyCode={ currencyCode } + productDescription={ productDescription } + paymentLink={ paymentLink } + variantsEnabled={ variantsEnabled } + variants={ variants } + imageUrl={ imageUrl } + /> + </div> + </div> ); +} - const connectionLabel = isConnected ? labelConnected : labelDisconnected; - - // Connected + has button + preview mode — show live button preview. - if ( hasButton && ! isEditing ) { - return ( - <div { ...blockProps } data-color-scheme={ colorScheme || 'auto' }> - { toolbarControls } - { inspectorControls } - - <div className="jetpack-paypal-payment-buttons__preview"> - <div className="jetpack-paypal-payment-buttons__preview-status"> - { connectionStatus } - { connectionLabel } - <span className="jetpack-paypal-payment-buttons__format-badge"> - { sprintf( - /* translators: %s: format label (Button, Link, or QR Code) */ - __( 'Format: %s', 'jetpack-paypal-payments' ), - formatLabel - ) } - </span> - { environment === 'sandbox' && ( - <span className="jetpack-paypal-payment-buttons__sandbox-badge"> - { __( 'Sandbox', 'jetpack-paypal-payments' ) } - </span> - ) } - </div> - - { disconnectedNotice } - { sharedResourceNotice } - - { successMessage && ( - <Notice status="success" isDismissible onDismiss={ () => setSuccessMessage( null ) }> - { successMessage } - </Notice> - ) } - - { error && ( - <Notice status="error" isDismissible onDismiss={ () => setError( null ) }> - { error } - </Notice> - ) } - - <PayPalButtonPreview - productName={ productName } - price={ price } - currencyCode={ currencyCode } - productDescription={ productDescription } - paymentLink={ paymentLink } - variantsEnabled={ variantsEnabled } - variants={ variants } - imageUrl={ imageUrl } - partnerAttributionId={ partnerAttributionId } - /> - </div> - - { confirmDialogs } - </div> - ); +/** + * PayPal Payment Buttons edit component. + * + * @param {object} props - Block props. + * @param {object} props.attributes - Block attributes. + * @return {Element} Block editor UI. + */ +export default function Edit( props ) { + if ( hasFeatureFlag( API_MANAGED_BUTTONS_FLAG ) ) { + return <ApiManagedEdit { ...props } />; } - // Connected — edit mode (either creating new or editing existing). - return ( - <div { ...blockProps } data-color-scheme={ colorScheme || 'auto' }> - { toolbarControls } - { inspectorControls } - - <ProductForm - attributes={ attributes } - setAttributes={ setAttributes } - buttonText={ buttonText } - productName={ productName } - price={ price } - currencyCode={ currencyCode } - productDescription={ productDescription } - imageUrl={ imageUrl } - imageId={ imageId } - returnUrl={ returnUrl } - variantsEnabled={ variantsEnabled } - variants={ variants } - adjustableQuantity={ adjustableQuantity } - maxQuantity={ maxQuantity } - customerNotes={ customerNotes } - taxEnabled={ taxEnabled } - taxType={ taxType } - taxName={ taxName } - taxValue={ taxValue } - activeFormat={ activeFormat } - isConnected={ isConnected } - environment={ environment } - setIsEditing={ setIsEditing } - touchedFields={ touchedFields } - setTouchedFields={ setTouchedFields } - markTouched={ markTouched } - usesVariantPricing={ usesVariantPricing } - validationErrors={ validationErrors } - isFormValid={ isFormValid } - isCreating={ isCreating } - error={ error } - setError={ setError } - successMessage={ successMessage } - setSuccessMessage={ setSuccessMessage } - handleCreateButton={ handleCreateButton } - handleUpdateButton={ handleUpdateButton } - hasButton={ hasButton } - disconnectedNotice={ disconnectedNotice } - sharedResourceNotice={ sharedResourceNotice } - connectionStatus={ connectionStatus } - connectionLabel={ connectionLabel } - /> + if ( props.attributes.isApiManaged && props.attributes.resourceId ) { + return <ApiManagedReadOnly attributes={ props.attributes } />; + } - { confirmDialogs } - </div> - ); + return <PasteCodeEdit { ...props } />; } diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/editor.scss b/projects/packages/paypal-payments/src/paypal-payment-buttons/editor.scss index 9872f112e091..9f17bfb4b509 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/editor.scss +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/editor.scss @@ -8,6 +8,25 @@ * @since 0.8.0 */ +@use "@wordpress/base-styles/mixins" as *; + +// --------------------------------------------------------------- +// Paste-code editor (V1) +// --------------------------------------------------------------- + +.wp-block-jetpack-paypal-payment-buttons { + + .block-editor-plain-text { + width: auto; + + @include editor-input-reset(); + } + + .components-notice { + align-self: normal; + } +} + // --------------------------------------------------------------- // Shared / Utility // --------------------------------------------------------------- @@ -237,7 +256,7 @@ line-height: 1.4; } -.has-warning { +.jetpack-paypal-payment-buttons__has-warning { .components-text-control__input { border-color: #dba617; diff --git a/projects/packages/paypal-payments/tests/js/paypal-payment-buttons-block-tests/edit-flag.test.jsx b/projects/packages/paypal-payments/tests/js/paypal-payment-buttons-block-tests/edit-flag.test.jsx new file mode 100644 index 000000000000..017dc5d119dc --- /dev/null +++ b/projects/packages/paypal-payments/tests/js/paypal-payment-buttons-block-tests/edit-flag.test.jsx @@ -0,0 +1,107 @@ +import { render, screen } from '@testing-library/react'; +import Edit, { API_MANAGED_BUTTONS_FLAG } from '../../../src/paypal-payment-buttons/edit'; + +const mockHasFeatureFlag = jest.fn(); +jest.mock( '@automattic/jetpack-shared-extension-utils', () => ( { + hasFeatureFlag: flag => mockHasFeatureFlag( flag ), +} ) ); + +jest.mock( '../../../src/paypal-payment-buttons/edit-api-managed', () => () => ( + <div data-testid="api-managed-edit" /> +) ); +jest.mock( '../../../src/paypal-payment-buttons/edit-paste-code', () => () => ( + <div data-testid="paste-code-edit" /> +) ); +jest.mock( + '../../../src/paypal-payment-buttons/components/paypal-button-preview', + () => + ( { productName } ) => <div data-testid="button-preview">{ productName }</div> +); + +jest.mock( '@wordpress/block-editor', () => ( { + useBlockProps: () => ( { className: 'wp-block-jetpack-paypal-payment-buttons' } ), +} ) ); +jest.mock( '@wordpress/components', () => ( { + Notice: ( { children, status } ) => ( + <div data-testid="notice" data-status={ status }> + { children } + </div> + ), +} ) ); +jest.mock( '@wordpress/i18n', () => ( { __: text => text } ) ); + +const apiManagedAttributes = { + isApiManaged: true, + resourceId: 'PLB-123', + paymentLink: 'https://www.paypal.com/ncp/payment/PLB-123', + productName: 'Coffee', + price: '5.00', + currencyCode: 'USD', +}; + +describe( 'PayPal Payment Buttons edit switch', () => { + beforeEach( () => { + mockHasFeatureFlag.mockReset(); + } ); + + test( 'reads the API-managed buttons flag', () => { + mockHasFeatureFlag.mockReturnValue( false ); + + render( <Edit attributes={ {} } setAttributes={ jest.fn() } /> ); + + expect( mockHasFeatureFlag ).toHaveBeenCalledWith( API_MANAGED_BUTTONS_FLAG ); + expect( API_MANAGED_BUTTONS_FLAG ).toBe( 'paypal-payments-api-managed-buttons' ); + } ); + + test( 'renders the paste-code editor while the flag is off', () => { + mockHasFeatureFlag.mockReturnValue( false ); + + render( <Edit attributes={ {} } setAttributes={ jest.fn() } /> ); + + expect( screen.getByTestId( 'paste-code-edit' ) ).toBeInTheDocument(); + expect( screen.queryByTestId( 'api-managed-edit' ) ).not.toBeInTheDocument(); + } ); + + test( 'renders the API-managed editor while the flag is on', () => { + mockHasFeatureFlag.mockReturnValue( true ); + + render( <Edit attributes={ {} } setAttributes={ jest.fn() } /> ); + + expect( screen.getByTestId( 'api-managed-edit' ) ).toBeInTheDocument(); + expect( screen.queryByTestId( 'paste-code-edit' ) ).not.toBeInTheDocument(); + } ); + + test( 'renders a paste-code block in the API-managed editor while the flag is on', () => { + mockHasFeatureFlag.mockReturnValue( true ); + + render( + <Edit + attributes={ { hostedButtonId: 'ABC123', buttonType: 'single' } } + setAttributes={ jest.fn() } + /> + ); + + expect( screen.getByTestId( 'api-managed-edit' ) ).toBeInTheDocument(); + } ); + + test( 'shows a read-only preview of an API-managed button while the flag is off', () => { + mockHasFeatureFlag.mockReturnValue( false ); + + render( <Edit attributes={ apiManagedAttributes } setAttributes={ jest.fn() } /> ); + + expect( screen.getByTestId( 'button-preview' ) ).toHaveTextContent( 'Coffee' ); + expect( screen.getByTestId( 'notice' ) ).toHaveTextContent( 'cannot be edited right now' ); + expect( screen.queryByTestId( 'paste-code-edit' ) ).not.toBeInTheDocument(); + expect( screen.queryByTestId( 'api-managed-edit' ) ).not.toBeInTheDocument(); + } ); + + test( 'falls back to the paste-code editor for an API-managed block without a resource', () => { + mockHasFeatureFlag.mockReturnValue( false ); + + render( + <Edit attributes={ { isApiManaged: true, resourceId: '' } } setAttributes={ jest.fn() } /> + ); + + expect( screen.getByTestId( 'paste-code-edit' ) ).toBeInTheDocument(); + } ); +} ); diff --git a/projects/packages/paypal-payments/tests/js/paypal-payment-buttons-block-tests/edit-paste-code.test.jsx b/projects/packages/paypal-payments/tests/js/paypal-payment-buttons-block-tests/edit-paste-code.test.jsx new file mode 100644 index 000000000000..1824e716ce28 --- /dev/null +++ b/projects/packages/paypal-payments/tests/js/paypal-payment-buttons-block-tests/edit-paste-code.test.jsx @@ -0,0 +1,996 @@ +/* eslint-disable react/jsx-no-bind */ +import { fireEvent, render, screen } from '@testing-library/react'; +import PasteCodeEdit from '../../../src/paypal-payment-buttons/edit-paste-code'; + +// Mock Jetpack script data +jest.mock( '@automattic/jetpack-script-data', () => ( { + isWpcomPlatformSite: jest.fn( () => false ), // Default to WordPress.org for tests +} ) ); + +// Mock WordPress dependencies +jest.mock( '@wordpress/block-editor', () => ( { + useBlockProps: () => ( { className: 'wp-block-paypal-payment-buttons' } ), + PlainText: ( { value, onChange, placeholder, 'aria-label': ariaLabel } ) => ( + <input + data-testid="plain-text" + value={ value || '' } + onChange={ e => onChange( e.target.value ) } + placeholder={ placeholder } + aria-label={ ariaLabel } + /> + ), +} ) ); + +// Mock WordPress components +jest.mock( '@wordpress/components', () => ( { + Notice: ( { children, status, isDismissible } ) => ( + <span data-testid="notice" data-status={ status } data-dismissible={ isDismissible }> + { children } + </span> + ), + Placeholder: ( { icon, label, instructions, notices, children } ) => ( + <div data-testid="placeholder"> + { icon && <span data-testid="placeholder-icon"></span> } + <h2>{ label }</h2> + { instructions && <p>{ instructions }</p> } + { notices } + <div>{ children }</div> + </div> + ), + __experimentalToggleGroupControl: ( { value, onChange } ) => { + // Mock implementation that doesn't use React.Children methods + return ( + <div data-testid="toggle-group"> + <div> + { /* Simplified rendering for tests */ } + <button + data-testid={ `toggle-option-stacked` } + data-selected={ value === 'stacked' } + onClick={ () => onChange( 'stacked' ) } + > + Stacked Buttons + </button> + <button + data-testid={ `toggle-option-single` } + data-selected={ value === 'single' } + onClick={ () => onChange( 'single' ) } + > + Single Button + </button> + </div> + </div> + ); + }, + __experimentalToggleGroupControlOption: () => null, // We're not using the actual implementation + __experimentalItemGroup: ( { children } ) => <div data-testid="item-group">{ children }</div>, + __experimentalItem: ( { children } ) => <div data-testid="item">{ children }</div>, + SVG: props => <svg { ...props } />, + Path: props => <path { ...props } />, +} ) ); + +// Mock @wordpress/ui +jest.mock( '@wordpress/ui', () => ( { + Link: ( { href, children } ) => ( + <a href={ href } data-testid="link"> + { children } + </a> + ), +} ) ); + +// Mock i18n +jest.mock( '@wordpress/i18n', () => ( { + __: text => text, + _x: text => text, +} ) ); + +// Mock element +jest.mock( '@wordpress/element', () => { + const React = require( 'react' ); + return { + createElement: React.createElement, + useState: jest.fn().mockImplementation( initialValue => { + const [ state, setState ] = React.useState( initialValue ); + return [ state, setState ]; + } ), + useEffect: jest.fn().mockImplementation( ( callback, deps ) => { + React.useEffect( () => callback(), deps ); // eslint-disable-line react-hooks/exhaustive-deps + } ), + createInterpolateElement: ( text, elements ) => { + // Simple mock implementation for createInterpolateElement + // Replace the text with actual React elements + let result = text; + + // Replace SignupLink and LoginLink with actual Link components + if ( elements.SignupLink ) { + result = React.createElement( + React.Fragment, + null, + '1. ', + React.cloneElement( + elements.SignupLink, + { 'data-testid': 'link' }, + React.createElement( 'strong', null, 'Sign up' ) + ), + ' or ', + React.cloneElement( + elements.LoginLink, + { 'data-testid': 'link' }, + React.createElement( 'strong', null, 'log in' ) + ), + ' to PayPal to get your Payment Button code.' + ); + } + + return result; + }, + }; +} ); + +describe( 'Edit', () => { + const defaultProps = { + attributes: { + buttonType: 'stacked', + scriptSrc: '', + hostedButtonId: '', + buttonText: '', + }, + setAttributes: jest.fn(), + isSelected: true, + }; + + beforeEach( () => { + jest.clearAllMocks(); + } ); + + it( 'renders without crashing', () => { + render( <PasteCodeEdit { ...defaultProps } /> ); + expect( screen.getByTestId( 'placeholder' ) ).toBeInTheDocument(); + } ); + + it( 'displays the button type toggle control', () => { + render( <PasteCodeEdit { ...defaultProps } /> ); + expect( screen.getByTestId( 'toggle-group' ) ).toBeInTheDocument(); + expect( screen.getByTestId( 'toggle-option-stacked' ) ).toBeInTheDocument(); + expect( screen.getByTestId( 'toggle-option-single' ) ).toBeInTheDocument(); + } ); + + it( 'shows head code input only when stacked button type is selected', () => { + const { rerender } = render( <PasteCodeEdit { ...defaultProps } /> ); + + // With stacked button type, should have 2 PlainText inputs (head and body) + const inputs = screen.getAllByTestId( 'plain-text' ); + expect( inputs ).toHaveLength( 2 ); + + // Rerender with single button type + rerender( + <PasteCodeEdit + attributes={ { + ...defaultProps.attributes, + buttonType: 'single', + } } + setAttributes={ defaultProps.setAttributes } + isSelected={ true } + /> + ); + + // With single button type, should have only 1 PlainText input (body) + const singleInputs = screen.getAllByTestId( 'plain-text' ); + expect( singleInputs ).toHaveLength( 1 ); + } ); + + it( 'updates buttonType when toggle is clicked', () => { + const setAttributes = jest.fn(); + render( + <PasteCodeEdit + attributes={ defaultProps.attributes } + setAttributes={ setAttributes } + isSelected={ true } + /> + ); + + fireEvent.click( screen.getByTestId( 'toggle-option-single' ) ); // eslint-disable-line testing-library/prefer-user-event + expect( setAttributes ).toHaveBeenCalledWith( { + buttonType: 'single', + scriptSrc: '', + buttonText: '', + hostedButtonId: '', + } ); + } ); + + it( 'updates scriptSrc when head code is entered', () => { + const setAttributes = jest.fn(); + render( + <PasteCodeEdit + attributes={ { ...defaultProps.attributes } } + setAttributes={ setAttributes } + isSelected={ true } + /> + ); + + const inputs = screen.getAllByTestId( 'plain-text' ); + // First input should be the head code for stacked buttons + // eslint-disable-next-line testing-library/prefer-user-event + fireEvent.change( inputs[ 0 ], { + target: { value: '<script src="https://www.paypal.com/sdk/js?client-id=test"></script>' }, + } ); + + expect( setAttributes ).toHaveBeenCalledWith( { + scriptSrc: 'https://www.paypal.com/sdk/js?client-id=test', + } ); + } ); + + it( 'updates hostedButtonId when body code is entered', () => { + const setAttributes = jest.fn(); + render( + <PasteCodeEdit + attributes={ { ...defaultProps.attributes } } + setAttributes={ setAttributes } + isSelected={ true } + /> + ); + + const inputs = screen.getAllByTestId( 'plain-text' ); + // For stacked buttons, body code is the second input + // eslint-disable-next-line testing-library/prefer-user-event + fireEvent.change( inputs[ 1 ], { + target: { + value: + '(window.paypal_payment_buttons || window.paypal).HostedButtons({ hostedButtonId: "ABC123DEF", }).render("#paypal-container-ABC123DEF")', + }, + } ); + + expect( setAttributes ).toHaveBeenCalledWith( { + hostedButtonId: 'ABC123DEF', + buttonText: '', + } ); + } ); + + it( 'extracts payment ID and button text from single button code', () => { + const setAttributes = jest.fn(); + render( + <PasteCodeEdit + attributes={ { ...defaultProps.attributes, buttonType: 'single' } } + setAttributes={ setAttributes } + isSelected={ true } + /> + ); + + const inputs = screen.getAllByTestId( 'plain-text' ); + // For single buttons, there's only one input (no head code) + // eslint-disable-next-line testing-library/prefer-user-event + fireEvent.change( inputs[ 0 ], { + target: { + value: + '<form action="https://www.paypal.com/ncp/payment/9J2U2LUWM4SUY" method="post"><input type="submit" value="Pay Now" /></form>', + }, + } ); + + expect( setAttributes ).toHaveBeenCalledWith( { + hostedButtonId: '9J2U2LUWM4SUY', + buttonText: 'Pay Now', + } ); + } ); + + describe( 'PayPal Code Snippet Parsing', () => { + it( 'parses original PayPal single button snippet correctly', () => { + const setAttributes = jest.fn(); + render( + <PasteCodeEdit + attributes={ { ...defaultProps.attributes, buttonType: 'single' } } + setAttributes={ setAttributes } + isSelected={ true } + /> + ); + + const originalSnippet = `<style>.pp-HLDQA6NDL5TLG{text-align:center;border:none;border-radius:0.25rem;min-width:11.625rem;padding:0 2rem;height:2.625rem;font-weight:bold;background-color:#FFD140;color:#000000;font-family:"Helvetica Neue",Arial,sans-serif;font-size:1rem;line-height:1.25rem;cursor:pointer;}</style> <form action="https://www.paypal.com/ncp/payment/HLDQA6NDL5TLG" method="post" target="_blank" style="display:inline-grid;justify-items:center;align-content:start;gap:0.5rem;"> <input class="pp-HLDQA6NDL5TLG" type="submit" value="Buy Now" /> <img src=https://www.paypalobjects.com/images/Debit_Credit_APM.svg alt="cards" /> <section style="font-size: 0.75rem;"> Powered by <img src="https://www.paypalobjects.com/paypal-ui/logos/svg/paypal-wordmark-color.svg" alt="paypal" style="height:0.875rem;vertical-align:middle;"/></section> </form>`; + + const inputs = screen.getAllByTestId( 'plain-text' ); + // eslint-disable-next-line testing-library/prefer-user-event + fireEvent.change( inputs[ 0 ], { + target: { value: originalSnippet }, + } ); + + expect( setAttributes ).toHaveBeenCalledWith( { + hostedButtonId: 'HLDQA6NDL5TLG', + buttonText: 'Buy Now', + } ); + } ); + + it( 'parses PayPal snippet with query parameters and div wrapper', () => { + const setAttributes = jest.fn(); + render( + <PasteCodeEdit + attributes={ { ...defaultProps.attributes, buttonType: 'single' } } + setAttributes={ setAttributes } + isSelected={ true } + /> + ); + + const snippetWithQueryParams = `<div><style>.pp-HLDQA6NDL5TLG{text-align:center;border:none;border-radius:0.25rem;min-width:11.625rem;padding:0 2rem;height:2.625rem;font-weight:bold;background-color:#FFD140;color:#000000;font-family:"Helvetica Neue",Arial,sans-serif;font-size:1rem;line-height:1.25rem;cursor:pointer;}</style><form action="https://www.paypal.com/ncp/payment/HLDQA6NDL5TLG?at_code=WooNCPS_Ecom_Wordpress" method="post" target="_blank" style="display:inline-grid;justify-items:center;align-content:start;gap:0.5rem;"> + <input class="pp-HLDQA6NDL5TLG" type="submit" value="Buy Now"> + <img src="https://www.paypalobjects.com/images/Debit_Credit_APM.svg" alt="cards"> + <section style="font-size: 0.75rem;"> Powered by <img src="https://www.paypalobjects.com/paypal-ui/logos/svg/paypal-wordmark-color.svg" alt="paypal" style="height:0.875rem;vertical-align:middle;"></section> +</form></div>`; + + const inputs = screen.getAllByTestId( 'plain-text' ); + // eslint-disable-next-line testing-library/prefer-user-event + fireEvent.change( inputs[ 0 ], { + target: { value: snippetWithQueryParams }, + } ); + + expect( setAttributes ).toHaveBeenCalledWith( { + hostedButtonId: 'HLDQA6NDL5TLG', + buttonText: 'Buy Now', + } ); + } ); + + it( 'parses non-self-terminating input tags correctly', () => { + const setAttributes = jest.fn(); + render( + <PasteCodeEdit + attributes={ { ...defaultProps.attributes, buttonType: 'single' } } + setAttributes={ setAttributes } + isSelected={ true } + /> + ); + + const snippetNonSelfTerminating = `<form action="https://www.paypal.com/ncp/payment/ABC123DEF" method="post"> + <input class="pp-ABC123DEF" type="submit" value="Purchase Item"> + </form>`; + + const inputs = screen.getAllByTestId( 'plain-text' ); + // eslint-disable-next-line testing-library/prefer-user-event + fireEvent.change( inputs[ 0 ], { + target: { value: snippetNonSelfTerminating }, + } ); + + expect( setAttributes ).toHaveBeenCalledWith( { + hostedButtonId: 'ABC123DEF', + buttonText: 'Purchase Item', + } ); + } ); + + it( 'handles URL with multiple query parameters', () => { + const setAttributes = jest.fn(); + render( + <PasteCodeEdit + attributes={ { ...defaultProps.attributes, buttonType: 'single' } } + setAttributes={ setAttributes } + isSelected={ true } + /> + ); + + const snippetMultipleParams = `<form action="https://www.paypal.com/ncp/payment/XYZ789GHI?at_code=WooNCPS_Ecom_Wordpress&utm_source=wordpress&campaign=test" method="post"> + <input type="submit" value="Subscribe" /> + </form>`; + + const inputs = screen.getAllByTestId( 'plain-text' ); + // eslint-disable-next-line testing-library/prefer-user-event + fireEvent.change( inputs[ 0 ], { + target: { value: snippetMultipleParams }, + } ); + + expect( setAttributes ).toHaveBeenCalledWith( { + hostedButtonId: 'XYZ789GHI', + buttonText: 'Subscribe', + } ); + } ); + + it( 'handles multi-line input with various formatting', () => { + const setAttributes = jest.fn(); + render( + <PasteCodeEdit + attributes={ { ...defaultProps.attributes, buttonType: 'single' } } + setAttributes={ setAttributes } + isSelected={ true } + /> + ); + + const multiLineSnippet = `<style> + .pp-MULTILINE123 { + text-align: center; + } + </style> + <form + action="https://www.paypal.com/ncp/payment/MULTILINE123" + method="post" + target="_blank"> + <input + class="pp-MULTILINE123" + type="submit" + value="Multi Line Button" /> + </form>`; + + const inputs = screen.getAllByTestId( 'plain-text' ); + // eslint-disable-next-line testing-library/prefer-user-event + fireEvent.change( inputs[ 0 ], { + target: { value: multiLineSnippet }, + } ); + + expect( setAttributes ).toHaveBeenCalledWith( { + hostedButtonId: 'MULTILINE123', + buttonText: 'Multi Line Button', + } ); + } ); + } ); + + describe( 'Edge Cases Handling', () => { + it( 'handles lowercase button IDs', () => { + const setAttributes = jest.fn(); + render( + <PasteCodeEdit + attributes={ { ...defaultProps.attributes, buttonType: 'single' } } + setAttributes={ setAttributes } + isSelected={ true } + /> + ); + + const snippet = `<form action="https://www.paypal.com/ncp/payment/abc123def" method="post"> + <input type="submit" value="Buy" /> + </form>`; + + const inputs = screen.getAllByTestId( 'plain-text' ); + // eslint-disable-next-line testing-library/prefer-user-event + fireEvent.change( inputs[ 0 ], { + target: { value: snippet }, + } ); + + expect( setAttributes ).toHaveBeenCalledWith( { + hostedButtonId: 'abc123def', + buttonText: 'Buy', + } ); + } ); + + it( 'handles button IDs with hyphens and underscores', () => { + const setAttributes = jest.fn(); + render( + <PasteCodeEdit + attributes={ { ...defaultProps.attributes, buttonType: 'single' } } + setAttributes={ setAttributes } + isSelected={ true } + /> + ); + + const snippet = `<style>.pp-ABC-123_DEF{}</style> + <form action="https://www.paypal.com/ncp/payment/ABC-123_DEF" method="post"> + <input class="pp-ABC-123_DEF" type="submit" value="Purchase" /> + </form>`; + + const inputs = screen.getAllByTestId( 'plain-text' ); + // eslint-disable-next-line testing-library/prefer-user-event + fireEvent.change( inputs[ 0 ], { + target: { value: snippet }, + } ); + + expect( setAttributes ).toHaveBeenCalledWith( { + hostedButtonId: 'ABC-123_DEF', + buttonText: 'Purchase', + } ); + } ); + + it( 'handles international PayPal domains', () => { + const setAttributes = jest.fn(); + render( + <PasteCodeEdit + attributes={ { ...defaultProps.attributes, buttonType: 'single' } } + setAttributes={ setAttributes } + isSelected={ true } + /> + ); + + const snippetUK = `<form action="https://www.paypal.co.uk/ncp/payment/UK123ABC" method="post"> + <input type="submit" value="Buy from UK" /> + </form>`; + + const inputs = screen.getAllByTestId( 'plain-text' ); + // eslint-disable-next-line testing-library/prefer-user-event + fireEvent.change( inputs[ 0 ], { + target: { value: snippetUK }, + } ); + + expect( setAttributes ).toHaveBeenCalledWith( { + hostedButtonId: 'UK123ABC', + buttonText: 'Buy from UK', + } ); + } ); + + it( 'handles German PayPal domain', () => { + const setAttributes = jest.fn(); + render( + <PasteCodeEdit + attributes={ { ...defaultProps.attributes, buttonType: 'single' } } + setAttributes={ setAttributes } + isSelected={ true } + /> + ); + + const snippetDE = `<form action="https://www.paypal.de/ncp/payment/DE789XYZ" method="post"> + <input type="submit" value="Jetzt kaufen" /> + </form>`; + + const inputs = screen.getAllByTestId( 'plain-text' ); + // eslint-disable-next-line testing-library/prefer-user-event + fireEvent.change( inputs[ 0 ], { + target: { value: snippetDE }, + } ); + + expect( setAttributes ).toHaveBeenCalledWith( { + hostedButtonId: 'DE789XYZ', + buttonText: 'Jetzt kaufen', + } ); + } ); + + it( 'handles sandbox PayPal domain', () => { + const setAttributes = jest.fn(); + render( + <PasteCodeEdit + attributes={ { ...defaultProps.attributes, buttonType: 'single' } } + setAttributes={ setAttributes } + isSelected={ true } + /> + ); + + const snippetSandbox = `<style>.pp-FHK6SXBKZXM4A{text-align:center;border:none;border-radius:0.25rem;min-width:11.625rem;padding:0 2rem;height:2.625rem;font-weight:bold;background-color:#FFD140;color:#000000;font-family:"Helvetica Neue",Arial,sans-serif;font-size:1rem;line-height:1.25rem;cursor:pointer;}</style> +<form action="https://www.sandbox.paypal.com/ncp/payment/FHK6SXBKZXM4A" method="post" target="_blank" style="display:inline-grid;justify-items:center;align-content:start;gap:0.5rem;"> + <input class="pp-FHK6SXBKZXM4A" type="submit" value="Buy Now" /> + <img src=https://www.paypalobjects.com/images/Debit_Credit_APM.svg alt="cards" /> + <section style="font-size: 0.75rem;"> Powered by <img src="https://www.paypalobjects.com/paypal-ui/logos/svg/paypal-wordmark-color.svg" alt="paypal" style="height:0.875rem;vertical-align:middle;"/></section> +</form>`; + + const inputs = screen.getAllByTestId( 'plain-text' ); + // eslint-disable-next-line testing-library/prefer-user-event + fireEvent.change( inputs[ 0 ], { + target: { value: snippetSandbox }, + } ); + + expect( setAttributes ).toHaveBeenCalledWith( { + hostedButtonId: 'FHK6SXBKZXM4A', + buttonText: 'Buy Now', + } ); + } ); + + it( 'handles spaces around equals sign in attributes', () => { + const setAttributes = jest.fn(); + render( + <PasteCodeEdit + attributes={ { ...defaultProps.attributes, buttonType: 'single' } } + setAttributes={ setAttributes } + isSelected={ true } + /> + ); + + const snippet = `<form action = "https://www.paypal.com/ncp/payment/SPACES123" method="post"> + <input type="submit" value = "Buy Now" /> + </form>`; + + const inputs = screen.getAllByTestId( 'plain-text' ); + // eslint-disable-next-line testing-library/prefer-user-event + fireEvent.change( inputs[ 0 ], { + target: { value: snippet }, + } ); + + expect( setAttributes ).toHaveBeenCalledWith( { + hostedButtonId: 'SPACES123', + buttonText: 'Buy Now', + } ); + } ); + + it( 'trims whitespace from extracted values', () => { + const setAttributes = jest.fn(); + render( + <PasteCodeEdit + attributes={ { ...defaultProps.attributes, buttonType: 'single' } } + setAttributes={ setAttributes } + isSelected={ true } + /> + ); + + const snippet = `<form action="https://www.paypal.com/ncp/payment/TRIM123 " method="post"> + <input type="submit" value=" Buy Now " /> + </form>`; + + const inputs = screen.getAllByTestId( 'plain-text' ); + // eslint-disable-next-line testing-library/prefer-user-event + fireEvent.change( inputs[ 0 ], { + target: { value: snippet }, + } ); + + expect( setAttributes ).toHaveBeenCalledWith( { + hostedButtonId: 'TRIM123', + buttonText: 'Buy Now', + } ); + } ); + + it( 'handles multiple buttons - extracts only the first', () => { + const setAttributes = jest.fn(); + render( + <PasteCodeEdit + attributes={ { ...defaultProps.attributes, buttonType: 'single' } } + setAttributes={ setAttributes } + isSelected={ true } + /> + ); + + const snippet = `<form action="https://www.paypal.com/ncp/payment/FIRST123" method="post"> + <input type="submit" value="First Button" /> + </form> + <form action="https://www.paypal.com/ncp/payment/SECOND456" method="post"> + <input type="submit" value="Second Button" /> + </form>`; + + const inputs = screen.getAllByTestId( 'plain-text' ); + // eslint-disable-next-line testing-library/prefer-user-event + fireEvent.change( inputs[ 0 ], { + target: { value: snippet }, + } ); + + // Should extract only the first button + expect( setAttributes ).toHaveBeenCalledWith( { + hostedButtonId: 'FIRST123', + buttonText: 'First Button', + } ); + } ); + + it( 'handles protocol-relative URLs', () => { + const setAttributes = jest.fn(); + render( + <PasteCodeEdit + attributes={ { ...defaultProps.attributes, buttonType: 'single' } } + setAttributes={ setAttributes } + isSelected={ true } + /> + ); + + const snippet = `<form action="//www.paypal.com/ncp/payment/PROTOCOL123" method="post"> + <input type="submit" value="Buy" /> + </form>`; + + const inputs = screen.getAllByTestId( 'plain-text' ); + // eslint-disable-next-line testing-library/prefer-user-event + fireEvent.change( inputs[ 0 ], { + target: { value: snippet }, + } ); + + expect( setAttributes ).toHaveBeenCalledWith( { + hostedButtonId: 'PROTOCOL123', + buttonText: 'Buy', + } ); + } ); + + it( 'handles mixed case in domain names', () => { + const setAttributes = jest.fn(); + render( + <PasteCodeEdit + attributes={ { ...defaultProps.attributes, buttonType: 'single' } } + setAttributes={ setAttributes } + isSelected={ true } + /> + ); + + const snippet = `<form action="https://www.PayPal.COM/ncp/payment/MIXEDCASE123" method="post"> + <input type="submit" value="Buy" /> + </form>`; + + const inputs = screen.getAllByTestId( 'plain-text' ); + // eslint-disable-next-line testing-library/prefer-user-event + fireEvent.change( inputs[ 0 ], { + target: { value: snippet }, + } ); + + expect( setAttributes ).toHaveBeenCalledWith( { + hostedButtonId: 'MIXEDCASE123', + buttonText: 'Buy', + } ); + } ); + } ); + + describe( 'Validation Notices', () => { + it( 'shows error notice for invalid script URL', () => { + render( + <PasteCodeEdit + attributes={ { + buttonType: 'stacked', + scriptSrc: 'https://invalid-url.com/script.js', + hostedButtonId: 'ABC123', + } } + setAttributes={ jest.fn() } + isSelected={ false } + /> + ); + + expect( screen.getByTestId( 'notice' ) ).toBeInTheDocument(); + expect( screen.getByTestId( 'notice' ) ).toHaveAttribute( 'data-status', 'error' ); + expect( screen.getByText( 'Invalid PayPal script URL.' ) ).toBeInTheDocument(); + } ); + + it( 'shows no notice for valid stacked button data', () => { + render( + <PasteCodeEdit + attributes={ { + buttonType: 'stacked', + scriptSrc: 'https://www.paypal.com/sdk/js?client-id=test', + hostedButtonId: 'ABC123DEF', + } } + setAttributes={ jest.fn() } + isSelected={ false } + /> + ); + + expect( screen.queryByTestId( 'notice' ) ).not.toBeInTheDocument(); + } ); + + it( 'shows error notice for invalid hosted button ID', () => { + render( + <PasteCodeEdit + attributes={ { + buttonType: 'stacked', + scriptSrc: 'https://www.paypal.com/sdk/js?client-id=test', + hostedButtonId: 'invalid@button#id!123', // Contains invalid characters + } } + setAttributes={ jest.fn() } + isSelected={ false } + /> + ); + + expect( screen.getByTestId( 'notice' ) ).toBeInTheDocument(); + expect( screen.getByTestId( 'notice' ) ).toHaveAttribute( 'data-status', 'error' ); + expect( screen.getByText( 'Invalid PayPal button ID.' ) ).toBeInTheDocument(); + } ); + + it( 'shows error notice for invalid button text', () => { + render( + <PasteCodeEdit + attributes={ { + buttonType: 'single', + hostedButtonId: 'ABC123DEF', + buttonText: 'This is a really long button text that exceeds the maximum length allowed', + } } + setAttributes={ jest.fn() } + isSelected={ false } + /> + ); + + expect( screen.getByTestId( 'notice' ) ).toBeInTheDocument(); + expect( screen.getByTestId( 'notice' ) ).toHaveAttribute( 'data-status', 'error' ); + expect( + screen.getByText( 'Button text must be between 1 and 50 characters.' ) + ).toBeInTheDocument(); + } ); + + it( 'shows validation errors even when block is selected', () => { + render( + <PasteCodeEdit + attributes={ { + buttonType: 'stacked', + scriptSrc: 'https://invalid-url.com/script.js', // Invalid URL + hostedButtonId: 'ABC123', + } } + setAttributes={ jest.fn() } + isSelected={ true } // Block is selected + /> + ); + + expect( screen.getByTestId( 'notice' ) ).toBeInTheDocument(); + expect( screen.getByTestId( 'notice' ) ).toHaveAttribute( 'data-status', 'error' ); + } ); + } ); + + it( 'renders external links to PayPal signup and login pages for WordPress.org', () => { + render( <PasteCodeEdit { ...defaultProps } /> ); + const links = screen.getAllByTestId( 'link' ); + expect( links ).toHaveLength( 2 ); + + // Check signup link + expect( links[ 0 ] ).toHaveAttribute( + 'href', + 'https://www.paypal.com/bizsignup/entry?product=payment_button&utm_source=wp_org&at_code=wp_org' + ); + expect( links[ 0 ] ).toHaveTextContent( 'Sign up' ); + + // Check login link + expect( links[ 1 ] ).toHaveAttribute( + 'href', + 'https://www.paypal.com/ncp/buttons/create?utm_source=wp_org&at_code=wp_org' + ); + expect( links[ 1 ] ).toHaveTextContent( 'log in' ); + } ); + + it( 'renders external links to PayPal signup and login pages for WordPress.com', () => { + // Mock WordPress.com platform + const { isWpcomPlatformSite } = require( '@automattic/jetpack-script-data' ); + isWpcomPlatformSite.mockReturnValue( true ); + + render( <PasteCodeEdit { ...defaultProps } /> ); + const links = screen.getAllByTestId( 'link' ); + expect( links ).toHaveLength( 2 ); + + // Check signup link + expect( links[ 0 ] ).toHaveAttribute( + 'href', + 'https://www.paypal.com/bizsignup/entry?product=payment_button&utm_source=wp_com&at_code=wp_com' + ); + expect( links[ 0 ] ).toHaveTextContent( 'Sign up' ); + + // Check login link + expect( links[ 1 ] ).toHaveAttribute( + 'href', + 'https://www.paypal.com/ncp/buttons/create?utm_source=wp_com&at_code=wp_com' + ); + expect( links[ 1 ] ).toHaveTextContent( 'log in' ); + + // Reset mock + isWpcomPlatformSite.mockReturnValue( false ); + } ); + + describe( 'Parameter Clearing on Button Type Toggle', () => { + it( 'clears all parameters when switching from stacked to single', () => { + const setAttributes = jest.fn(); + render( + <PasteCodeEdit + attributes={ { + buttonType: 'stacked', + scriptSrc: 'https://www.paypal.com/sdk/js?client-id=test', + hostedButtonId: 'ABC123DEF', + buttonText: '', + } } + setAttributes={ setAttributes } + isSelected={ true } + /> + ); + + fireEvent.click( screen.getByTestId( 'toggle-option-single' ) ); // eslint-disable-line testing-library/prefer-user-event + + expect( setAttributes ).toHaveBeenCalledWith( { + buttonType: 'single', + scriptSrc: '', + buttonText: '', + hostedButtonId: '', + } ); + } ); + + it( 'clears all parameters when switching from single to stacked', () => { + const setAttributes = jest.fn(); + render( + <PasteCodeEdit + attributes={ { + buttonType: 'single', + scriptSrc: '', + hostedButtonId: 'ABC123DEF', + buttonText: 'Pay Now', + } } + setAttributes={ setAttributes } + isSelected={ true } + /> + ); + + fireEvent.click( screen.getByTestId( 'toggle-option-stacked' ) ); // eslint-disable-line testing-library/prefer-user-event + + expect( setAttributes ).toHaveBeenCalledWith( { + buttonType: 'stacked', + scriptSrc: '', + buttonText: '', + hostedButtonId: '', + } ); + } ); + + it( 'clears all parameters when switching to the same type', () => { + const setAttributes = jest.fn(); + render( + <PasteCodeEdit + attributes={ { + buttonType: 'stacked', + scriptSrc: 'https://www.paypal.com/sdk/js?client-id=test', + hostedButtonId: 'ABC123DEF', + buttonText: '', + } } + setAttributes={ setAttributes } + isSelected={ true } + /> + ); + + fireEvent.click( screen.getByTestId( 'toggle-option-stacked' ) ); // eslint-disable-line testing-library/prefer-user-event + + // Should clear all parameters even when switching to the same type + expect( setAttributes ).toHaveBeenCalledWith( { + buttonType: 'stacked', + scriptSrc: '', + buttonText: '', + hostedButtonId: '', + } ); + } ); + } ); + + describe( 'Preview Functionality', () => { + it( 'shows no preview for stacked buttons', () => { + render( + <PasteCodeEdit + attributes={ { + buttonType: 'stacked', + scriptSrc: 'https://www.paypal.com/sdk/js?client-id=test', + hostedButtonId: 'ABC123DEF', + } } + setAttributes={ jest.fn() } + isSelected={ false } + /> + ); + + // Should show the configuration form since stacked button previews are disabled + expect( screen.getByTestId( 'placeholder' ) ).toBeInTheDocument(); + expect( screen.queryByTitle( 'PayPal Button Preview' ) ).not.toBeInTheDocument(); + } ); + + it( 'shows direct button preview when block is not selected and has valid single button data', () => { + render( + <PasteCodeEdit + attributes={ { + buttonType: 'single', + hostedButtonId: 'ABC123DEF', + buttonText: 'Buy Now', + } } + setAttributes={ jest.fn() } + isSelected={ false } + /> + ); + + // Single button should render directly, not in iframe + const button = screen.getByDisplayValue( 'Buy Now' ); + expect( button ).toBeInTheDocument(); + expect( button ).toHaveAttribute( 'type', 'button' ); + expect( screen.queryByTitle( 'PayPal Button Preview' ) ).not.toBeInTheDocument(); + } ); + + it( 'shows placeholder when block is not selected but data is invalid', () => { + render( + <PasteCodeEdit + attributes={ { + buttonType: 'single', + hostedButtonId: '', + buttonText: '', + } } + setAttributes={ jest.fn() } + isSelected={ false } + /> + ); + + // Should show the configuration form, not the preview + expect( screen.getByTestId( 'placeholder' ) ).toBeInTheDocument(); + expect( screen.queryByTitle( 'PayPal Button Preview' ) ).not.toBeInTheDocument(); + } ); + + it( 'shows settings form when block is selected', () => { + render( + <PasteCodeEdit + attributes={ { + buttonType: 'single', + hostedButtonId: 'ABC123DEF', + buttonText: 'Buy Now', + } } + setAttributes={ jest.fn() } + isSelected={ true } + /> + ); + + expect( screen.getByTestId( 'placeholder' ) ).toBeInTheDocument(); + expect( screen.queryByTitle( 'PayPal Button Preview' ) ).not.toBeInTheDocument(); + } ); + + it( 'shows preview placeholder message when data is incomplete', () => { + // Mock the preview component to show up, but with incomplete data + render( + <PasteCodeEdit + attributes={ { + buttonType: 'single', + hostedButtonId: '', // Missing button ID + buttonText: '', + } } + setAttributes={ jest.fn() } + isSelected={ false } + /> + ); + + // Should show the configuration form since data is incomplete + expect( screen.getByTestId( 'placeholder' ) ).toBeInTheDocument(); + expect( screen.queryByTitle( 'PayPal Button Preview' ) ).not.toBeInTheDocument(); + } ); + } ); +} ); diff --git a/projects/packages/paypal-payments/tests/js/paypal-payment-buttons-block-tests/edit.test.jsx b/projects/packages/paypal-payments/tests/js/paypal-payment-buttons-block-tests/edit.test.jsx index 6691a56aa79e..49d76b04e831 100644 --- a/projects/packages/paypal-payments/tests/js/paypal-payment-buttons-block-tests/edit.test.jsx +++ b/projects/packages/paypal-payments/tests/js/paypal-payment-buttons-block-tests/edit.test.jsx @@ -14,6 +14,14 @@ import Edit from '../../../src/paypal-payment-buttons/edit'; // apiFetch mock — controls what the component receives from the REST API. const apiFetch = require( '@wordpress/api-fetch' ); +// The API-managed editor only renders while the feature flag is on. +jest.mock( '@automattic/jetpack-shared-extension-utils', () => ( { + hasFeatureFlag: () => true, +} ) ); + +// The paste-code editor has its own suite; keep its imports out of this one. +jest.mock( '../../../src/paypal-payment-buttons/edit-paste-code', () => () => null ); + // Mock WordPress element with real React hooks. jest.mock( '@wordpress/element', () => { const React = require( 'react' ); diff --git a/projects/packages/paypal-payments/tests/php/PayPal_Admin_Page_Test.php b/projects/packages/paypal-payments/tests/php/PayPal_Admin_Page_Test.php index aadd6e481614..de81dd9c161d 100644 --- a/projects/packages/paypal-payments/tests/php/PayPal_Admin_Page_Test.php +++ b/projects/packages/paypal-payments/tests/php/PayPal_Admin_Page_Test.php @@ -7,6 +7,7 @@ namespace Automattic\Jetpack\PaypalPayments; +use Automattic\Jetpack\Feature_Flags\Feature_Flags; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -18,12 +19,39 @@ #[CoversClass( PayPal_Admin_Page::class )] class PayPal_Admin_Page_Test extends TestCase { + /** + * Per-flag filter that forces the API-managed buttons on. + */ + private const FLAG_FILTER = 'jetpack_feature_flag_enabled_' . PayPal_Payment_Buttons::API_MANAGED_BUTTONS_FLAG; + + public function test_maybe_init_does_nothing_while_the_flag_is_off() { + remove_all_actions( 'admin_menu' ); + + PayPal_Admin_Page::maybe_init(); + + $this->assertFalse( has_action( 'admin_menu', array( PayPal_Admin_Page::class, 'register_menu' ) ) ); + } + + public function test_maybe_init_hooks_up_while_the_flag_is_on() { + remove_all_actions( 'admin_menu' ); + add_filter( self::FLAG_FILTER, '__return_true' ); + + PayPal_Admin_Page::maybe_init(); + + $this->assertNotFalse( has_action( 'admin_menu', array( PayPal_Admin_Page::class, 'register_menu' ) ) ); + + remove_all_actions( 'admin_menu' ); + } + /** * Clean up after each test. */ protected function tearDown(): void { parent::tearDown(); + remove_all_filters( self::FLAG_FILTER ); + Feature_Flags::reset(); + delete_option( PayPal_OAuth::CREDENTIALS_OPTION_KEY ); delete_option( PayPal_OAuth::ENVIRONMENT_OPTION_KEY ); delete_transient( PayPal_OAuth::TOKEN_TRANSIENT_KEY ); diff --git a/projects/packages/paypal-payments/tests/php/PayPal_Email_Sender_Test.php b/projects/packages/paypal-payments/tests/php/PayPal_Email_Sender_Test.php index 5ff70f1799bb..d4cee80f2712 100644 --- a/projects/packages/paypal-payments/tests/php/PayPal_Email_Sender_Test.php +++ b/projects/packages/paypal-payments/tests/php/PayPal_Email_Sender_Test.php @@ -7,6 +7,7 @@ namespace Automattic\Jetpack\PaypalPayments; +use Automattic\Jetpack\Feature_Flags\Feature_Flags; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -18,12 +19,39 @@ #[CoversClass( PayPal_Email_Sender::class )] class PayPal_Email_Sender_Test extends TestCase { + /** + * Per-flag filter that forces the API-managed buttons on. + */ + private const FLAG_FILTER = 'jetpack_feature_flag_enabled_' . PayPal_Payment_Buttons::API_MANAGED_BUTTONS_FLAG; + + public function test_maybe_init_does_nothing_while_the_flag_is_off() { + remove_all_actions( 'wp_ajax_' . PayPal_Email_Sender::AJAX_ACTION ); + + PayPal_Email_Sender::maybe_init(); + + $this->assertFalse( has_action( 'wp_ajax_' . PayPal_Email_Sender::AJAX_ACTION, array( PayPal_Email_Sender::class, 'handle_send' ) ) ); + } + + public function test_maybe_init_hooks_up_while_the_flag_is_on() { + remove_all_actions( 'wp_ajax_' . PayPal_Email_Sender::AJAX_ACTION ); + add_filter( self::FLAG_FILTER, '__return_true' ); + + PayPal_Email_Sender::maybe_init(); + + $this->assertNotFalse( has_action( 'wp_ajax_' . PayPal_Email_Sender::AJAX_ACTION, array( PayPal_Email_Sender::class, 'handle_send' ) ) ); + + remove_all_actions( 'wp_ajax_' . PayPal_Email_Sender::AJAX_ACTION ); + } + /** * Clean up after each test. */ protected function tearDown(): void { parent::tearDown(); + remove_all_filters( self::FLAG_FILTER ); + Feature_Flags::reset(); + delete_option( PayPal_Email_Sender::LOG_OPTION_KEY ); wp_set_current_user( 0 ); diff --git a/projects/packages/paypal-payments/tests/php/Paypal_Payment_Buttons_Test.php b/projects/packages/paypal-payments/tests/php/Paypal_Payment_Buttons_Test.php index c3a517d9350f..bff9d9cc7619 100644 --- a/projects/packages/paypal-payments/tests/php/Paypal_Payment_Buttons_Test.php +++ b/projects/packages/paypal-payments/tests/php/Paypal_Payment_Buttons_Test.php @@ -7,6 +7,7 @@ namespace Automattic\Jetpack\PaypalPayments; +use Automattic\Jetpack\Feature_Flags\Feature_Flags; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -30,6 +31,171 @@ protected function tearDown(): void { $wp_scripts = null; \WP_Block_Supports::$block_to_render = null; + + remove_all_filters( self::FLAG_FILTER ); + Feature_Flags::reset(); + } + + /** + * Per-flag filter that forces the API-managed buttons on. + */ + private const FLAG_FILTER = 'jetpack_feature_flag_enabled_' . PayPal_Payment_Buttons::API_MANAGED_BUTTONS_FLAG; + + /** + * Register the PayPal routes the way production does -- on rest_api_init -- + * and return the resulting route table. + * + * @return array The REST server's route table. + */ + private function build_rest_routes() { + global $wp_rest_server; + $wp_rest_server = null; + + remove_all_actions( 'rest_api_init' ); + PayPal_Payment_Buttons::init_rest_api(); + + $routes = rest_get_server()->get_routes(); + + remove_all_actions( 'rest_api_init' ); + + return $routes; + } + + public function test_feature_flag_registers_off_by_default() { + Feature_Flags::reset(); + + PayPal_Payment_Buttons::register_feature_flags(); + + $definition = Feature_Flags::get( PayPal_Payment_Buttons::API_MANAGED_BUTTONS_FLAG ); + $this->assertIsArray( $definition ); + $this->assertFalse( $definition['default'] ); + $this->assertFalse( PayPal_Payment_Buttons::is_api_managed_enabled() ); + } + + public function test_is_api_managed_enabled_honours_the_flag_filter() { + PayPal_Payment_Buttons::register_feature_flags(); + add_filter( self::FLAG_FILTER, '__return_true' ); + + $this->assertTrue( PayPal_Payment_Buttons::is_api_managed_enabled() ); + } + + public function test_add_editor_feature_flags_reports_the_flag_state() { + PayPal_Payment_Buttons::register_feature_flags(); + + $flags = PayPal_Payment_Buttons::add_editor_feature_flags( array( 'other-flag' => true ) ); + $this->assertSame( + array( + 'other-flag' => true, + PayPal_Payment_Buttons::API_MANAGED_BUTTONS_FLAG => false, + ), + $flags + ); + + add_filter( self::FLAG_FILTER, '__return_true' ); + + $flags = PayPal_Payment_Buttons::add_editor_feature_flags( array() ); + $this->assertTrue( $flags[ PayPal_Payment_Buttons::API_MANAGED_BUTTONS_FLAG ] ); + } + + public function test_register_rest_routes_registers_nothing_while_the_flag_is_off() { + PayPal_Payment_Buttons::register_feature_flags(); + + $routes = $this->build_rest_routes(); + + $this->assertArrayNotHasKey( '/wpcom/v2/paypal/connection', $routes ); + $this->assertArrayNotHasKey( '/wpcom/v2/paypal/buttons', $routes ); + } + + public function test_register_rest_routes_registers_the_routes_while_the_flag_is_on() { + PayPal_Payment_Buttons::register_feature_flags(); + add_filter( self::FLAG_FILTER, '__return_true' ); + + $routes = $this->build_rest_routes(); + + $this->assertArrayHasKey( '/wpcom/v2/paypal/connection', $routes ); + $this->assertArrayHasKey( '/wpcom/v2/paypal/buttons', $routes ); + } + + /** + * Gutenberg omits attributes matching the default, so buttons saved as stacked carry + * no buttonType -- the default must stay 'stacked' or they re-render as single. + */ + public function test_a_legacy_stacked_button_still_renders_the_sdk_widget() { + register_block_type_from_metadata( + dirname( __DIR__, 2 ) . '/src/paypal-payment-buttons', + array( 'render_callback' => array( PayPal_Payment_Buttons::class, 'render_block' ) ) + ); + + $html = do_blocks( '<!-- wp:jetpack/paypal-payment-buttons {"scriptSrc":"https://www.paypal.com/sdk/js?client-id=TEST&components=hosted-buttons","hostedButtonId":"ABC123XYZ"} /-->' ); + + unregister_block_type( 'jetpack/paypal-payment-buttons' ); + + $this->assertStringContainsString( 'paypal-container-ABC123XYZ', $html ); + $this->assertStringNotContainsString( '/ncp/payment/', $html ); + } + + /** + * A `file:` asset field here makes core register the editor bundle a second time, + * on top of the copy load_editor_scripts() already enqueues. + */ + public function test_block_json_declares_no_asset_fields() { + $metadata = json_decode( + file_get_contents( dirname( __DIR__, 2 ) . '/src/paypal-payment-buttons/block.json' ), + true + ); + + $this->assertArrayNotHasKey( 'editorScript', $metadata ); + $this->assertArrayNotHasKey( 'editorStyle', $metadata ); + $this->assertArrayNotHasKey( 'style', $metadata ); + } + + public function test_register_block_style_registers_the_front_end_handle() { + wp_deregister_script( PayPal_Payment_Buttons::STYLE_HANDLE ); + + PayPal_Payment_Buttons::register_block_style(); + + $this->assertTrue( wp_script_is( PayPal_Payment_Buttons::STYLE_HANDLE, 'registered' ) ); + + wp_deregister_script( PayPal_Payment_Buttons::STYLE_HANDLE ); + } + + public function test_init_admin_registers_nothing_while_the_flag_is_off() { + remove_all_actions( 'init' ); + remove_all_actions( 'admin_menu' ); + remove_all_actions( 'wp_ajax_' . PayPal_Email_Sender::AJAX_ACTION ); + + PayPal_Payment_Buttons::register_feature_flags(); + + PayPal_Payment_Buttons::init_admin(); + do_action( 'init' ); + + // Covers the outcome, not the guard: both maybe_init() methods gate on the flag + // themselves, so this still passes if init_admin()'s own check is removed. The + // guard's only other effect -- keeping both classes off the autoloader -- is + // process-global, so no in-process assertion can pin it. + $this->assertFalse( has_action( 'admin_menu', array( PayPal_Admin_Page::class, 'register_menu' ) ) ); + $this->assertFalse( has_action( 'wp_ajax_' . PayPal_Email_Sender::AJAX_ACTION, array( PayPal_Email_Sender::class, 'handle_send' ) ) ); + + remove_all_actions( 'init' ); + } + + public function test_init_admin_wires_the_admin_page_up_once_the_flag_is_on() { + remove_all_actions( 'init' ); + remove_all_actions( 'admin_menu' ); + remove_all_actions( 'wp_ajax_' . PayPal_Email_Sender::AJAX_ACTION ); + + PayPal_Payment_Buttons::register_feature_flags(); + add_filter( self::FLAG_FILTER, '__return_true' ); + + PayPal_Payment_Buttons::init_admin(); + do_action( 'init' ); + + $this->assertNotFalse( has_action( 'admin_menu', array( PayPal_Admin_Page::class, 'register_menu' ) ) ); + $this->assertNotFalse( has_action( 'wp_ajax_' . PayPal_Email_Sender::AJAX_ACTION, array( PayPal_Email_Sender::class, 'handle_send' ) ) ); + + remove_all_actions( 'init' ); + remove_all_actions( 'admin_menu' ); + remove_all_actions( 'wp_ajax_' . PayPal_Email_Sender::AJAX_ACTION ); } /** @@ -279,53 +445,33 @@ public function test_init_rest_api_registers_the_routes() { PayPal_Payment_Buttons::init_rest_api(); $this->assertNotFalse( - has_action( 'rest_api_init', array( PayPal_REST_Controller::class, 'register_routes' ) ) + has_action( 'rest_api_init', array( PayPal_Payment_Buttons::class, 'register_rest_routes' ) ) ); remove_all_actions( 'rest_api_init' ); } /** - * Test that init_rest_api() does not register the standalone script stubs. - * - * Script_Data registers the real jetpack-script-data handle on wp_loaded, after - * init. A stub registered first wins, and the block editor is then left without - * window.JetpackScriptData -- which breaks the editor for every block, not just - * this one. + * The editor bundle imports isWpcomPlatformSite from @automattic/jetpack-script-data, + * which only exists in the real jetpack-script-data.js. Script_Data registers that file + * on wp_loaded; anything that claims the handle on init wins, and the editor is left with + * a module missing the export. */ - public function test_init_rest_api_does_not_register_script_stubs() { + public function test_init_api_leaves_the_script_data_handle_alone() { remove_all_actions( 'init' ); remove_all_actions( 'rest_api_init' ); + wp_deregister_script( 'jetpack-script-data' ); - PayPal_Payment_Buttons::init_rest_api(); + PayPal_Payment_Buttons::init_api(); + do_action( 'init' ); - $this->assertFalse( - has_action( 'init', array( PayPal_Payment_Buttons::class, 'register_standalone_script_stubs' ) ) - ); + $this->assertFalse( wp_script_is( 'jetpack-script-data', 'registered' ) ); + $this->assertNotFalse( has_action( 'rest_api_init', array( PayPal_Payment_Buttons::class, 'register_rest_routes' ) ) ); remove_all_actions( 'init' ); remove_all_actions( 'rest_api_init' ); } - /** - * Test that the script stub is registered when the Jetpack runtime is absent. - * - * This is the standalone and Playground case the stub exists for. The Jetpack - * case is covered by init_rest_api() not hooking the stub at all, since - * defining a stand-in Jetpack class here would leak into every other test. - */ - public function test_script_stub_registers_without_jetpack() { - $this->assertFalse( class_exists( 'Jetpack' ), 'Precondition: no Jetpack runtime in this suite.' ); - - wp_deregister_script( 'jetpack-script-data' ); - - PayPal_Payment_Buttons::register_standalone_script_stubs(); - - $this->assertTrue( wp_script_is( 'jetpack-script-data', 'registered' ) ); - - wp_deregister_script( 'jetpack-script-data' ); - } - /** * Test that render_block includes product image when imageUrl is set. */ diff --git a/projects/plugins/jetpack/changelog/add-paypal-api-managed-buttons-flag b/projects/plugins/jetpack/changelog/add-paypal-api-managed-buttons-flag new file mode 100644 index 000000000000..a8f8f3a53b3a --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-paypal-api-managed-buttons-flag @@ -0,0 +1,5 @@ +Significance: patch +Type: other +Comment: Add a feature flag for the API-managed PayPal payment buttons, off by default. + + diff --git a/projects/plugins/jetpack/changelog/add-paypal-payment-buttons-v2 b/projects/plugins/jetpack/changelog/add-paypal-payment-buttons-v2 index 5f7c9e590031..01c22e8a5656 100644 --- a/projects/plugins/jetpack/changelog/add-paypal-payment-buttons-v2 +++ b/projects/plugins/jetpack/changelog/add-paypal-payment-buttons-v2 @@ -1,4 +1,4 @@ Significance: minor Type: enhancement -PayPal Payment Buttons: connect a PayPal account from WordPress, create and manage payment links without leaving the editor, choose a Button, Link, or QR format, and pick a Light, Auto, or Dark style preset. +PayPal Payment Buttons: add API-managed buttons behind a feature flag that is not yet enabled. Once it is on, you can connect a PayPal account from WordPress, create and manage payment links without leaving the editor, choose a Button, Link, or QR format, and pick a Light, Auto, or Dark style preset. diff --git a/projects/plugins/jetpack/class.jetpack-gutenberg.php b/projects/plugins/jetpack/class.jetpack-gutenberg.php index 676954ae7d1e..3dfda5654dd4 100644 --- a/projects/plugins/jetpack/class.jetpack-gutenberg.php +++ b/projects/plugins/jetpack/class.jetpack-gutenberg.php @@ -172,8 +172,9 @@ class Jetpack_Gutenberg { * @var array Feature slug => minimum WordPress.com plan slug. */ private static $wpcom_minimum_plan_fallbacks = array( - 'donations' => 'value_bundle', - 'payment-buttons' => 'value_bundle', + 'donations' => 'value_bundle', + 'payment-buttons' => 'value_bundle', + 'paypal-payment-buttons' => 'value_bundle', ); /** diff --git a/projects/plugins/jetpack/composer.lock b/projects/plugins/jetpack/composer.lock index fd2c278929d1..d0d54a2f41e8 100644 --- a/projects/plugins/jetpack/composer.lock +++ b/projects/plugins/jetpack/composer.lock @@ -2662,12 +2662,13 @@ "dist": { "type": "path", "url": "../../packages/paypal-payments", - "reference": "7a8b44c4c3054eb4cf2c846b5aa1b6ffcc074be0" + "reference": "b69608477af0d1dec58aaff82dd8d0744836b500" }, "require": { "automattic/jetpack-assets": "@dev", "automattic/jetpack-blocks": "@dev", "automattic/jetpack-connection": "@dev", + "automattic/jetpack-feature-flags": "@dev", "automattic/jetpack-plans": "@dev", "automattic/jetpack-status": "@dev", "php": ">=7.4" diff --git a/projects/plugins/jetpack/extensions/blocks/paypal-payment-buttons/paypal-payment-buttons.php b/projects/plugins/jetpack/extensions/blocks/paypal-payment-buttons/paypal-payment-buttons.php index ec3fd071be11..6e00947d5126 100644 --- a/projects/plugins/jetpack/extensions/blocks/paypal-payment-buttons/paypal-payment-buttons.php +++ b/projects/plugins/jetpack/extensions/blocks/paypal-payment-buttons/paypal-payment-buttons.php @@ -13,6 +13,10 @@ exit( 0 ); } +// The API-managed buttons ship behind a flag; register it before anything reads it. +PayPal_Payment_Buttons::register_feature_flags(); +add_filter( 'jetpack_block_editor_feature_flags', array( PayPal_Payment_Buttons::class, 'add_editor_feature_flags' ) ); + // Register the block. add_action( 'init', array( PayPal_Payment_Buttons::class, 'register_block' ), 9 ); @@ -21,9 +25,9 @@ * to connect an account and manage payment links. Without them the block renders * but every request the editor makes -- onboarding, connect, button CRUD -- 404s. * - * Only the routes: init_api() would also register the standalone script stubs, which - * exist for hosts without the Jetpack runtime and would shadow Jetpack's own - * jetpack-script-data handle. + * Only the routes: init_api() also hooks sharing and the email sender, neither of + * which is wired up on Jetpack today. Both this and init_admin() no-op while the + * flag is off. */ PayPal_Payment_Buttons::init_rest_api(); diff --git a/projects/plugins/paypal-payment-buttons/changelog/add-paypal-api-managed-buttons-flag b/projects/plugins/paypal-payment-buttons/changelog/add-paypal-api-managed-buttons-flag new file mode 100644 index 000000000000..5c6f5d8bbf9d --- /dev/null +++ b/projects/plugins/paypal-payment-buttons/changelog/add-paypal-api-managed-buttons-flag @@ -0,0 +1,5 @@ +Significance: patch +Type: changed +Comment: Add a feature flag for the API-managed payment buttons, off by default. + + diff --git a/projects/plugins/paypal-payment-buttons/changelog/add-paypal-payment-buttons-v2 b/projects/plugins/paypal-payment-buttons/changelog/add-paypal-payment-buttons-v2 index 1232e0e08dd3..0f00330f4047 100644 --- a/projects/plugins/paypal-payment-buttons/changelog/add-paypal-payment-buttons-v2 +++ b/projects/plugins/paypal-payment-buttons/changelog/add-paypal-payment-buttons-v2 @@ -1,4 +1,4 @@ Significance: minor Type: added -Add API-driven payment buttons: connect a PayPal account from WordPress, create and manage payment links without leaving the editor, choose a Button, Link, or QR format, and pick a Light, Auto, or Dark style preset. +Add API-managed payment buttons behind a feature flag that is not yet enabled. Once it is on, you can connect a PayPal account from WordPress, create and manage payment links without leaving the editor, choose a Button, Link, or QR format, and pick a Light, Auto, or Dark style preset. diff --git a/projects/plugins/paypal-payment-buttons/changelog/fix-jetpack-script-data-stub b/projects/plugins/paypal-payment-buttons/changelog/fix-jetpack-script-data-stub deleted file mode 100644 index 4abba0bfe45e..000000000000 --- a/projects/plugins/paypal-payment-buttons/changelog/fix-jetpack-script-data-stub +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: fixed - -Stop the standalone plugin registering a jetpack-script-data stub when the Jetpack plugin is active, which left the block editor blank. diff --git a/projects/plugins/paypal-payment-buttons/composer.lock b/projects/plugins/paypal-payment-buttons/composer.lock index 6f43504ad057..03ff7235d912 100644 --- a/projects/plugins/paypal-payment-buttons/composer.lock +++ b/projects/plugins/paypal-payment-buttons/composer.lock @@ -535,6 +535,64 @@ "relative": true } }, + { + "name": "automattic/jetpack-feature-flags", + "version": "dev-trunk", + "dist": { + "type": "path", + "url": "../../packages/feature-flags", + "reference": "d5def8cd2f0e9da4f405329c2d0304d37d0ed08c" + }, + "require": { + "php": ">=7.4" + }, + "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-feature-flags", + "changelogger": { + "link-template": "https://github.com/Automattic/jetpack-feature-flags/compare/v${old}...v${new}" + }, + "branch-alias": { + "dev-trunk": "0.2.x-dev" + }, + "textdomain": "jetpack-feature-flags", + "version-constants": { + "::PACKAGE_VERSION": "src/class-feature-flags.php" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "scripts": { + "phpunit": [ + "phpunit-select-config phpunit.#.xml.dist --colors=always" + ], + "test-php": [ + "@composer phpunit" + ], + "test-php-coverage": [ + "php -dpcov.directory=. ./vendor/bin/phpunit-select-config phpunit.#.xml.dist --coverage-php \"$COVERAGE_DIR/php.cov\"" + ] + }, + "license": [ + "GPL-2.0-or-later" + ], + "description": "Shared utilities for registering and checking lightweight Jetpack feature flags.", + "transport-options": { + "relative": true + } + }, { "name": "automattic/jetpack-ip", "version": "dev-trunk", @@ -599,12 +657,13 @@ "dist": { "type": "path", "url": "../../packages/paypal-payments", - "reference": "7a8b44c4c3054eb4cf2c846b5aa1b6ffcc074be0" + "reference": "b69608477af0d1dec58aaff82dd8d0744836b500" }, "require": { "automattic/jetpack-assets": "@dev", "automattic/jetpack-blocks": "@dev", "automattic/jetpack-connection": "@dev", + "automattic/jetpack-feature-flags": "@dev", "automattic/jetpack-plans": "@dev", "automattic/jetpack-status": "@dev", "php": ">=7.4" diff --git a/projects/plugins/paypal-payment-buttons/src/class-paypal-payment-buttons.php b/projects/plugins/paypal-payment-buttons/src/class-paypal-payment-buttons.php index 9133ee0aee88..e4ce0cef1863 100644 --- a/projects/plugins/paypal-payment-buttons/src/class-paypal-payment-buttons.php +++ b/projects/plugins/paypal-payment-buttons/src/class-paypal-payment-buttons.php @@ -60,8 +60,8 @@ private function __construct() { * @return void */ public function init_hooks() { - // Register standalone script stubs for Jetpack dependencies not available outside the monorepo. - add_action( 'init', array( $this, 'register_standalone_script_stubs' ), 1 ); + // The API-managed buttons ship behind a flag; register it before anything reads it. + Jetpack_PayPal_Payment_Buttons::register_feature_flags(); // Initialize PayPal Payment Buttons block with correct dist path add_action( 'init', array( $this, 'register_paypal_block' ), 9 ); @@ -86,68 +86,6 @@ public function init_hooks() { } } - /** - * Register script stubs for Jetpack dependencies that are not available in standalone mode. - * - * The editor.js bundle declares `jetpack-script-data` as a dependency (from - * - * @automattic/jetpack-script-data). In the Jetpack plugin this is registered by the - * Assets package, but in standalone mode it does not exist. WordPress silently - * refuses to enqueue scripts with unregistered dependencies, so we register an - * empty stub to satisfy the dependency chain. - */ - public function register_standalone_script_stubs() { - /* - * The Jetpack plugin registers the real handle from Script_Data on wp_loaded, - * which fires after init. Registering a stub first therefore wins, and the - * editor is left without window.JetpackScriptData. - */ - if ( class_exists( 'Jetpack' ) ) { - return; - } - - if ( ! wp_script_is( 'jetpack-script-data', 'registered' ) ) { - wp_register_script( 'jetpack-script-data', false, array(), '1.0.0', false ); - - // The webpack build externalizes @automattic/jetpack-script-data to - // window.JetpackScriptDataModule (UMD global). The module's getScriptData() - // returns window.JetpackScriptData. Without these globals the editor.js - // bundle crashes at module init time in connection/state/store.jsx. - $current_user = wp_get_current_user(); - $script_data = wp_json_encode( - array( - 'site' => array( - 'icon' => get_site_icon_url(), - 'title' => get_bloginfo( 'name' ), - 'admin_url' => admin_url(), - 'rest_root' => esc_url_raw( rest_url() ), - 'rest_nonce' => wp_create_nonce( 'wp_rest' ), - 'wp_version' => get_bloginfo( 'version' ), - ), - 'user' => array( - 'current_user' => array( - 'id' => $current_user->ID, - 'display_name' => $current_user->display_name, - 'capabilities' => array( - 'manage_options' => current_user_can( 'manage_options' ), - 'manage_modules' => current_user_can( 'manage_options' ), - ), - ), - ), - ), - JSON_HEX_TAG | JSON_HEX_AMP - ); - - $inline_js = sprintf( - 'window.JetpackScriptData = %s;' - . 'window.JetpackScriptDataModule = { getScriptData: function() { return window.JetpackScriptData; } };', - $script_data - ); - - wp_add_inline_script( 'jetpack-script-data', $inline_js, 'before' ); - } - } - /** * Register the PayPal Payment Buttons block with the correct dist path. */ @@ -160,11 +98,14 @@ public function register_paypal_block() { return false; } + Jetpack_PayPal_Payment_Buttons::register_block_style(); + // Register the block using the Blocks package with the correct dist path Blocks::jetpack_register_block( $dist_dir, array( 'render_callback' => array( Jetpack_PayPal_Payment_Buttons::class, 'render_block' ), + 'style' => Jetpack_PayPal_Payment_Buttons::STYLE_HANDLE, ) ); } @@ -190,6 +131,7 @@ public function enqueue_block_availability_data() { 'available' => true, ), ), + 'feature_flags' => Jetpack_PayPal_Payment_Buttons::add_editor_feature_flags( array() ), ); wp_localize_script( diff --git a/projects/plugins/paypal-payment-buttons/tests/e2e/package.json b/projects/plugins/paypal-payment-buttons/tests/e2e/package.json index 861101e3684b..3a83f814501a 100644 --- a/projects/plugins/paypal-payment-buttons/tests/e2e/package.json +++ b/projects/plugins/paypal-payment-buttons/tests/e2e/package.json @@ -7,8 +7,8 @@ "config:decrypt": "openssl enc -md sha1 -aes-256-cbc -pbkdf2 -iter 100000 -d -pass env:CONFIG_KEY -in ./node_modules/@automattic/_jetpack-e2e-commons/config/encrypted.enc -out ./config/local.cjs", "distclean": "rm -rf node_modules", "env:down": "e2e-env stop", - "env:reset": "e2e-env reset --activate-plugins paypal-payment-buttons", - "env:up": "e2e-env start --activate-plugins paypal-payment-buttons", + "env:reset": "e2e-env reset --activate-plugins paypal-payment-buttons e2e-paypal-feature-flag", + "env:up": "e2e-env start --activate-plugins paypal-payment-buttons e2e-paypal-feature-flag", "pretest:run": "pnpm run clean", "test:run": "playwright install chromium && NODE_CONFIG_DIR='./config' ALLURE_RESULTS_DIR=./output/allure-results NODE_PATH=\"$PWD/node_modules\" playwright test", "tunnel:down": "tunnel down", diff --git a/projects/plugins/paypal-payment-buttons/tests/e2e/plugins/e2e-paypal-feature-flag.php b/projects/plugins/paypal-payment-buttons/tests/e2e/plugins/e2e-paypal-feature-flag.php new file mode 100644 index 000000000000..1326135eed5f --- /dev/null +++ b/projects/plugins/paypal-payment-buttons/tests/e2e/plugins/e2e-paypal-feature-flag.php @@ -0,0 +1,15 @@ +<?php +/** + * Plugin Name: PayPal Payment Buttons E2E Feature Flag + * Plugin URI: https://github.com/automattic/jetpack + * Author: Jetpack Team + * Version: 1.0.0 + * Text Domain: jetpack + * + * Turns on the API-managed buttons flag for the E2E site. The suite drives the + * V2 editor, which the block only shows while this flag is on. + * + * @package automattic/jetpack + */ + +add_filter( 'jetpack_feature_flag_enabled_paypal-payments-api-managed-buttons', '__return_true' ); diff --git a/projects/plugins/paypal-payment-buttons/tests/e2e/specs/paypal-payment-buttons.spec.js b/projects/plugins/paypal-payment-buttons/tests/e2e/specs/paypal-payment-buttons.spec.js index 680a59a1d10e..e3ee64234c1a 100644 --- a/projects/plugins/paypal-payment-buttons/tests/e2e/specs/paypal-payment-buttons.spec.js +++ b/projects/plugins/paypal-payment-buttons/tests/e2e/specs/paypal-payment-buttons.spec.js @@ -1,4 +1,6 @@ /* eslint-disable playwright/no-wait-for-selector, playwright/no-conditional-in-test, playwright/no-conditional-expect, playwright/no-wait-for-timeout, playwright/no-force-option, no-undef */ +// Needs the `paypal-payments-api-managed-buttons` flag on, which the +// e2e-paypal-feature-flag helper plugin (activated by `pnpm env:up`) provides. /** * PayPal Payment Buttons — E2E Tests (Playwright). * diff --git a/tools/docker/jetpack-docker-config-default.yml b/tools/docker/jetpack-docker-config-default.yml index a1a6001a55e6..5ee5f441aed4 100644 --- a/tools/docker/jetpack-docker-config-default.yml +++ b/tools/docker/jetpack-docker-config-default.yml @@ -71,6 +71,7 @@ e2e: projects/plugins/boost/tests/e2e/plugins/e2e-external-css-enqueue/: /var/www/html/wp-content/plugins/e2e-external-css-enqueue/ projects/plugins/boost/tests/e2e/plugins/e2e-mock-premium-features.php: /var/www/html/wp-content/plugins/e2e-mock-premium-features.php projects/plugins/boost/tests/e2e/plugins/e2e-critical-css-force-errors.php: /var/www/html/wp-content/plugins/e2e-critical-css-force-errors.php + projects/plugins/paypal-payment-buttons/tests/e2e/plugins/e2e-paypal-feature-flag.php: /var/www/html/wp-content/plugins/e2e-paypal-feature-flag.php tools/e2e-commons/plugins/e2e-search-test-helper.php: /var/www/html/wp-content/plugins/e2e-search-test-helper.php tools/e2e-commons/plugins/e2e-wpcom-request-interceptor.php: /var/www/html/wp-content/plugins/e2e-wpcom-request-interceptor.php tools/e2e-commons/plugins/e2e-plan-helper.php: /var/www/html/wp-content/plugins/e2e-plan-helper.php