From f80440d9fd8af9ef0ac9106429c5ce28ea332cef Mon Sep 17 00:00:00 2001 From: MILLER/F Date: Fri, 4 Sep 2026 12:21:05 +0200 Subject: [PATCH 1/6] PayPal Payment Buttons: put the API-managed buttons behind a feature flag Register `paypal-payments-api-managed-buttons` (off by default) in the package and read it from both bootstraps. While it is off the REST routes, the Payment Links admin page, the email sender, and the sharing hook stay unregistered, and the block editor shows the paste-code editor restored from trunk. A button created through the API keeps rendering on the frontend and shows as a read-only preview in the editor. Also restores trunk's WordPress.com plan gate on the block (`plan_check` and the `value_bundle` upsell fallback), which the V2 port had dropped. Co-Authored-By: Claude Fable 5.1 --- .../packages/paypal-payments/DEVELOPMENT.md | 19 + .../add-paypal-api-managed-buttons-flag | 4 + .../packages/paypal-payments/composer.json | 1 + .../class-paypal-admin-page.php | 14 + .../class-paypal-email-sender.php | 14 + .../class-paypal-payment-buttons.php | 100 +- .../edit-api-managed.jsx | 565 ++++++++++ .../edit-paste-code.jsx | 443 ++++++++ .../src/paypal-payment-buttons/edit.jsx | 605 ++--------- .../src/paypal-payment-buttons/editor.scss | 19 + .../edit-flag.test.jsx | 107 ++ .../edit-paste-code.test.jsx | 996 ++++++++++++++++++ .../edit.test.jsx | 8 + .../tests/php/PayPal_Admin_Page_Test.php | 28 + .../tests/php/PayPal_Email_Sender_Test.php | 28 + .../tests/php/Paypal_Payment_Buttons_Test.php | 98 +- .../add-paypal-api-managed-buttons-flag | 5 + .../jetpack/class.jetpack-gutenberg.php | 5 +- projects/plugins/jetpack/composer.lock | 3 +- .../paypal-payment-buttons.php | 6 +- .../add-paypal-api-managed-buttons-flag | 5 + .../paypal-payment-buttons/composer.lock | 61 +- .../src/class-paypal-payment-buttons.php | 4 + 23 files changed, 2580 insertions(+), 558 deletions(-) create mode 100644 projects/packages/paypal-payments/changelog/add-paypal-api-managed-buttons-flag create mode 100644 projects/packages/paypal-payments/src/paypal-payment-buttons/edit-api-managed.jsx create mode 100644 projects/packages/paypal-payments/src/paypal-payment-buttons/edit-paste-code.jsx create mode 100644 projects/packages/paypal-payments/tests/js/paypal-payment-buttons-block-tests/edit-flag.test.jsx create mode 100644 projects/packages/paypal-payments/tests/js/paypal-payment-buttons-block-tests/edit-paste-code.test.jsx create mode 100644 projects/plugins/jetpack/changelog/add-paypal-api-managed-buttons-flag create mode 100644 projects/plugins/paypal-payment-buttons/changelog/add-paypal-api-managed-buttons-flag 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/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/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..c2e0db3ecb34 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,69 @@ 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'; + + /** + * 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. * @@ -110,7 +174,7 @@ public static function register_block() { __DIR__, array( 'render_callback' => array( __CLASS__, 'render_block' ), - 'plan_check' => false, + 'plan_check' => true, ) ); } @@ -712,8 +776,8 @@ private static function enqueue_qr_script() { 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' ) ); } /** @@ -726,7 +790,24 @@ public static function init_api() { * @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 +818,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,10 +869,11 @@ 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(); + add_action( 'init', array( PayPal_Admin_Page::class, 'maybe_init' ) ); + add_action( 'init', array( PayPal_Email_Sender::class, 'maybe_init' ) ); } /** 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..36464a5a9a65 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 // --------------------------------------------------------------- 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..f4f7880abea3 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,101 @@ 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' ); + $wp_rest_server = null; + + 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 ); + } + + public function test_init_admin_defers_the_gated_initializers_to_init() { + remove_all_actions( 'init' ); + + PayPal_Payment_Buttons::init_admin(); + + $this->assertNotFalse( has_action( 'init', array( PayPal_Admin_Page::class, 'maybe_init' ) ) ); + $this->assertNotFalse( has_action( 'init', array( PayPal_Email_Sender::class, 'maybe_init' ) ) ); + + remove_all_actions( 'init' ); } /** @@ -279,7 +375,7 @@ 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' ); 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/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..6ab5b911e581 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 ); @@ -23,7 +27,7 @@ * * 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. + * jetpack-script-data handle. 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/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..d199bb62a795 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,6 +60,9 @@ private function __construct() { * @return void */ public function init_hooks() { + // The API-managed buttons ship behind a flag; register it before anything reads it. + Jetpack_PayPal_Payment_Buttons::register_feature_flags(); + // Register standalone script stubs for Jetpack dependencies not available outside the monorepo. add_action( 'init', array( $this, 'register_standalone_script_stubs' ), 1 ); @@ -190,6 +193,7 @@ public function enqueue_block_availability_data() { 'available' => true, ), ), + 'feature_flags' => Jetpack_PayPal_Payment_Buttons::add_editor_feature_flags( array() ), ); wp_localize_script( From 552b33abe5ad2e81c235890d11c17e8c7317c9e5 Mon Sep 17 00:00:00 2001 From: MILLER/F <millerf@automattic.com> Date: Fri, 4 Sep 2026 12:23:03 +0200 Subject: [PATCH 2/6] PayPal Payment Buttons: note the flag the e2e suite needs on Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .../tests/e2e/specs/paypal-payment-buttons.spec.js | 2 ++ 1 file changed, 2 insertions(+) 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..f1587e3fe31e 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 */ +// The site must have the `paypal-payments-api-managed-buttons` flag on, or the +// block shows the paste-code editor instead. See the package DEVELOPMENT.md. /** * PayPal Payment Buttons — E2E Tests (Playwright). * From 6d744f44fdd3d59a6354bf242eafc99c1c322c93 Mon Sep 17 00:00:00 2001 From: MILLER/F <millerf@automattic.com> Date: Fri, 4 Sep 2026 13:21:54 +0200 Subject: [PATCH 3/6] PayPal Payment Buttons: turn the flag on for the e2e suite and say it is off in the changelog The standalone plugin's e2e suite drives the V2 editor, so it gets an e2e helper plugin that forces the flag on, mounted like Boost's helpers and activated by the suite's env scripts. The V2 changelog entries now say the feature sits behind a flag that is not yet enabled. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .../changelog/add-paypal-payment-buttons-v2 | 2 +- .../changelog/add-paypal-payment-buttons-v2 | 2 +- .../changelog/add-paypal-payment-buttons-v2 | 2 +- .../paypal-payment-buttons/tests/e2e/package.json | 4 ++-- .../tests/e2e/plugins/e2e-paypal-feature-flag.php | 15 +++++++++++++++ .../e2e/specs/paypal-payment-buttons.spec.js | 4 ++-- tools/docker/jetpack-docker-config-default.yml | 1 + 7 files changed, 23 insertions(+), 7 deletions(-) create mode 100644 projects/plugins/paypal-payment-buttons/tests/e2e/plugins/e2e-paypal-feature-flag.php 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/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/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/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 f1587e3fe31e..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,6 +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 */ -// The site must have the `paypal-payments-api-managed-buttons` flag on, or the -// block shows the paste-code editor instead. See the package DEVELOPMENT.md. +// 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 From f9eaa512197aca1c6aca10eaa006edfc95b7bb0d Mon Sep 17 00:00:00 2001 From: MILLER/F <millerf@automattic.com> Date: Fri, 4 Sep 2026 13:25:08 +0200 Subject: [PATCH 4/6] PayPal Payment Buttons: drop a REST server reset Phan reports as redundant Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .../paypal-payments/tests/php/Paypal_Payment_Buttons_Test.php | 1 - 1 file changed, 1 deletion(-) 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 f4f7880abea3..7ead89408d6a 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 @@ -57,7 +57,6 @@ private function build_rest_routes() { $routes = rest_get_server()->get_routes(); remove_all_actions( 'rest_api_init' ); - $wp_rest_server = null; return $routes; } From e80fc72a93bc03002db50b3da564ab2b9dad2e93 Mon Sep 17 00:00:00 2001 From: Julian Strahan <julian.strahan@automattic.com> Date: Fri, 4 Sep 2026 14:14:22 -0700 Subject: [PATCH 5/6] PayPal Payment Buttons: fix duplicate editor bundle, ungated endpoint, flipped button default * Fixed the editor loading the PayPal block bundle twice on every editor screen * Fixed the onboarding endpoint registering on every site while the feature is off * Reverted a buttonType default flip that re-rendered already-published buttons * Fixed the block stylesheet loading on every classic-theme page * Fixed a warning style leaking onto every warned block in the editor * Changed the Payment Links admin page to load nothing while the feature is off * Changed the package to stop shipping 49 KB of internal docs --- .../fix-paypal-180-flag-review-fixes | 4 ++ ...rest-api-v2-endpoint-paypal-onboarding.php | 7 +++ .../packages/paypal-payments/.gitattributes | 2 + .../src/paypal-payment-buttons/block.json | 7 +-- .../class-paypal-payment-buttons.php | 52 ++++++++++++++++++- .../components/connection-wizard.jsx | 4 +- .../src/paypal-payment-buttons/editor.scss | 2 +- .../tests/php/Paypal_Payment_Buttons_Test.php | 48 +++++++++++++++-- .../src/class-paypal-payment-buttons.php | 3 ++ 9 files changed, 117 insertions(+), 12 deletions(-) create mode 100644 projects/packages/jetpack-mu-wpcom/changelog/fix-paypal-180-flag-review-fixes 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..b6898a1166b4 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() { + // Same flag as the plugin-side controller. Spelled out because mu-wpcom + // cannot see the paypal-payments constant. + 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/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/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-payment-buttons.php b/projects/packages/paypal-payments/src/paypal-payment-buttons/class-paypal-payment-buttons.php index c2e0db3ecb34..f05eb1c73860 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 @@ -40,6 +40,15 @@ class PayPal_Payment_Buttons { */ public const API_MANAGED_BUTTONS_FLAG = 'paypal-payments-api-managed-buttons'; + /** + * Front-end style handle, side-loaded from the sibling style.css by + * `Assets::register_script` and handed to the block as its `style` arg. + * + * @since $$next-version$$ + * @var string + */ + public const STYLE_HANDLE = 'jetpack-block-paypal-payment-buttons'; + /** * Register the feature flags this package owns. * @@ -164,17 +173,46 @@ public static function add_partner_attribution( $url ) { return add_query_arg( 'at_code', self::PAYPAL_PARTNER_ATTRIBUTION_ID, $sanitized ); } + /** + * Side-load the front-end stylesheet and register it under STYLE_HANDLE. + * + * The block.json `style` field can't do this. Its metadata is read from src/ in the + * Jetpack plugin, and a `file:` path there also makes core register the editor + * bundle a second time under its own generated handle, so the whole bundle is + * parsed twice on every editor screen. Pass the handle to jetpack_register_block() + * as `style` instead -- the podcast-episode block does the same. + * + * Called by both bootstraps: the standalone plugin registers the block itself and + * never goes through register_block(). + * + * @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' => true, + 'style' => self::STYLE_HANDLE, ) ); } @@ -872,8 +910,18 @@ public static function enable_sharing_on_payment_pages( $show, $post = null ) { * @since $$next-version$$ Defers to `init` and no-ops unless the API-managed buttons are enabled. */ public static function init_admin() { - add_action( 'init', array( PayPal_Admin_Page::class, 'maybe_init' ) ); - add_action( 'init', array( PayPal_Email_Sender::class, 'maybe_init' ) ); + 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; + } + + 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/editor.scss b/projects/packages/paypal-payments/src/paypal-payment-buttons/editor.scss index 36464a5a9a65..9f17bfb4b509 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/editor.scss +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/editor.scss @@ -256,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/php/Paypal_Payment_Buttons_Test.php b/projects/packages/paypal-payments/tests/php/Paypal_Payment_Buttons_Test.php index 7ead89408d6a..7d7097abaa08 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 @@ -116,15 +116,57 @@ public function test_register_rest_routes_registers_the_routes_while_the_flag_is $this->assertArrayHasKey( '/wpcom/v2/paypal/buttons', $routes ); } - public function test_init_admin_defers_the_gated_initializers_to_init() { + /** + * A stacked button saved under the old schema carries no buttonType: Gutenberg + * drops attributes equal to the default, and the default was 'stacked' then. + */ + 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 ); + } + + 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::init_admin(); + $this->assertTrue( has_action( 'init' ) ); + + do_action( 'init' ); + + $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( 'init', array( PayPal_Admin_Page::class, 'maybe_init' ) ) ); - $this->assertNotFalse( has_action( 'init', array( PayPal_Email_Sender::class, 'maybe_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 ); } /** 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 d199bb62a795..7d890764e0ac 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 @@ -163,11 +163,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, ) ); } From 80528508e570db6a59c6418a77231ce199ea9188 Mon Sep 17 00:00:00 2001 From: Julian Strahan <julian.strahan@automattic.com> Date: Tue, 8 Sep 2026 17:26:42 -0700 Subject: [PATCH 6/6] PayPal Payment Buttons: let the real jetpack-script-data script load Two stubs claimed the handle on init, ahead of Script_Data registering the real file on wp_loaded. The editor bundle imports isWpcomPlatformSite, which only the real module exports, so the block failed to render with the flag off. --- ...rest-api-v2-endpoint-paypal-onboarding.php | 4 +- ...API_V2_Endpoint_PayPal_Onboarding_Test.php | 32 ++++++++ .../paypal-payments/docs/test_plan.md | 4 +- .../class-paypal-payment-buttons.php | 48 ++---------- .../tests/php/Paypal_Payment_Buttons_Test.php | 77 +++++++++++-------- .../paypal-payment-buttons.php | 6 +- .../changelog/fix-jetpack-script-data-stub | 4 - .../src/class-paypal-payment-buttons.php | 65 ---------------- 8 files changed, 89 insertions(+), 151 deletions(-) delete mode 100644 projects/plugins/paypal-payment-buttons/changelog/fix-jetpack-script-data-stub 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 b6898a1166b4..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 @@ -127,8 +127,8 @@ public function __construct() { * Register REST API routes. */ public function register_routes() { - // Same flag as the plugin-side controller. Spelled out because mu-wpcom - // cannot see the paypal-payments constant. + // 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; } 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/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/class-paypal-payment-buttons.php b/projects/packages/paypal-payments/src/paypal-payment-buttons/class-paypal-payment-buttons.php index f05eb1c73860..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 @@ -41,8 +41,7 @@ class PayPal_Payment_Buttons { public const API_MANAGED_BUTTONS_FLAG = 'paypal-payments-api-managed-buttons'; /** - * Front-end style handle, side-loaded from the sibling style.css by - * `Assets::register_script` and handed to the block as its `style` arg. + * Front-end style handle, registered by `register_block_style()`. * * @since $$next-version$$ * @var string @@ -174,16 +173,11 @@ public static function add_partner_attribution( $url ) { } /** - * Side-load the front-end stylesheet and register it under STYLE_HANDLE. + * Side-load the sibling style.css and register it under STYLE_HANDLE. * - * The block.json `style` field can't do this. Its metadata is read from src/ in the - * Jetpack plugin, and a `file:` path there also makes core register the editor - * bundle a second time under its own generated handle, so the whole bundle is - * parsed twice on every editor screen. Pass the handle to jetpack_register_block() - * as `style` instead -- the podcast-episode block does the same. - * - * Called by both bootstraps: the standalone plugin registers the block itself and - * never goes through register_block(). + * 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 @@ -812,17 +806,14 @@ 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(); 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 @@ -923,29 +914,4 @@ static function () { } ); } - - /** - * 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; - } - - if ( ! wp_script_is( 'jetpack-script-data', 'registered' ) ) { - wp_register_script( 'jetpack-script-data', false, array(), '1.0.0', false ); - } - } } 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 7d7097abaa08..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 @@ -117,8 +117,8 @@ public function test_register_rest_routes_registers_the_routes_while_the_flag_is } /** - * A stacked button saved under the old schema carries no buttonType: Gutenberg - * drops attributes equal to the default, and the default was 'stacked' then. + * 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( @@ -134,16 +134,45 @@ public function test_a_legacy_stacked_button_still_renders_the_sdk_widget() { $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::init_admin(); - $this->assertTrue( has_action( 'init' ) ); + 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' ) ) ); @@ -423,46 +452,26 @@ public function test_init_rest_api_registers_the_routes() { } /** - * 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/extensions/blocks/paypal-payment-buttons/paypal-payment-buttons.php b/projects/plugins/jetpack/extensions/blocks/paypal-payment-buttons/paypal-payment-buttons.php index 6ab5b911e581..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 @@ -25,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. Both this and init_admin() no-op while the flag is off. + * 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/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/src/class-paypal-payment-buttons.php b/projects/plugins/paypal-payment-buttons/src/class-paypal-payment-buttons.php index 7d890764e0ac..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 @@ -63,9 +63,6 @@ public function init_hooks() { // The API-managed buttons ship behind a flag; register it before anything reads it. Jetpack_PayPal_Payment_Buttons::register_feature_flags(); - // Register standalone script stubs for Jetpack dependencies not available outside the monorepo. - add_action( 'init', array( $this, 'register_standalone_script_stubs' ), 1 ); - // Initialize PayPal Payment Buttons block with correct dist path add_action( 'init', array( $this, 'register_paypal_block' ), 9 ); @@ -89,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. */