From b5a65bbde939ce3136751f616a98aacc9b0d2190 Mon Sep 17 00:00:00 2001 From: Julian Strahan Date: Fri, 4 Sep 2026 14:59:18 -0700 Subject: [PATCH 01/34] PayPal Payment Buttons: move the product name field into the block inspector --- .../components/product-form.jsx | 24 +--------- .../edit-api-managed.jsx | 48 +++++++++++++++++-- .../src/paypal-payment-buttons/editor.scss | 19 ++++---- 3 files changed, 56 insertions(+), 35 deletions(-) diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx index c7f7d91a5bc3..5b956aa4bc4a 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx @@ -18,7 +18,7 @@ import { __, sprintf } from '@wordpress/i18n'; import metadata from '../block.json'; import { SUPPORTED_CURRENCIES } from '../utils/currencies'; import { getPriceStep } from '../utils/currency-symbols'; -import { MAX_NAME_LENGTH, MAX_DESCRIPTION_LENGTH } from '../utils/validation'; +import { MAX_DESCRIPTION_LENGTH } from '../utils/validation'; import FormatSwitcher from './format-switcher'; import VariantBuilder from './variant-builder'; @@ -166,28 +166,6 @@ export default function ProductForm( { ) } - setAttributes( { productName: value } ) } - onBlur={ () => markTouched( 'productName' ) } - disabled={ isCreating } - placeholder={ __( 'e.g., Premium Widget', 'jetpack-paypal-payments' ) } - help={ - touchedFields.productName && validationErrors.productName - ? validationErrors.productName - : sprintf( - /* translators: 1: current character count, 2: maximum allowed */ - __( '%1$d / %2$d characters', 'jetpack-paypal-payments' ), - ( productName || '' ).length, - MAX_NAME_LENGTH - ) - } - className={ - touchedFields.productName && validationErrors.productName ? 'has-error' : undefined - } - /> -
{ toolbarControls } + + + setAttributes( { productName: value } ) } + onBlur={ () => markTouched( 'productName' ) } + disabled={ isCreating } + placeholder={ __( 'e.g., Premium Widget', 'jetpack-paypal-payments' ) } + help={ + touchedFields.productName && validationErrors.productName + ? validationErrors.productName + : sprintf( + /* translators: 1: current character count, 2: maximum allowed */ + __( '%1$d / %2$d characters', 'jetpack-paypal-payments' ), + ( productName || '' ).length, + MAX_NAME_LENGTH + ) + } + className={ + touchedFields.productName && validationErrors.productName ? 'has-error' : undefined + } + /> + + { inspectorControls } Date: Fri, 4 Sep 2026 16:05:47 -0700 Subject: [PATCH 02/34] PayPal Payment Buttons: move the price and currency fields into the block inspector --- .../components/product-form.jsx | 44 ----------------- .../edit-api-managed.jsx | 47 +++++++++++++++++-- .../src/paypal-payment-buttons/editor.scss | 1 + 3 files changed, 45 insertions(+), 47 deletions(-) diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx index 5b956aa4bc4a..a4f04cfc68fb 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx @@ -16,8 +16,6 @@ import { } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; import metadata from '../block.json'; -import { SUPPORTED_CURRENCIES } from '../utils/currencies'; -import { getPriceStep } from '../utils/currency-symbols'; import { MAX_DESCRIPTION_LENGTH } from '../utils/validation'; import FormatSwitcher from './format-switcher'; import VariantBuilder from './variant-builder'; @@ -28,12 +26,6 @@ const helpQtyOn = __( 'Customers can buy multiple units at checkout.', 'jetpack- const helpQtyOff = __( 'Fixed at 1 unit per purchase.', 'jetpack-paypal-payments' ); const helpTaxOn = __( 'Tax will be added at PayPal checkout.', 'jetpack-paypal-payments' ); const helpTaxOff = __( 'No tax collected.', 'jetpack-paypal-payments' ); -const labelPrice = __( 'Price', 'jetpack-paypal-payments' ); -const labelPriceOptional = __( 'Price (not used)', 'jetpack-paypal-payments' ); -const helpPriceFromOptions = __( - 'Each product option sets its own price, so this value is ignored.', - 'jetpack-paypal-payments' -); /** * The product creation form, shown when PayPal is connected and the block is in edit mode. @@ -43,7 +35,6 @@ const helpPriceFromOptions = __( * @param {Function} props.setAttributes - Function to update block attributes. * @param {string} props.buttonText - The button label attribute. * @param {string} props.productName - The product name attribute. - * @param {string} props.price - The product price attribute. * @param {string} props.currencyCode - The currency code attribute. * @param {string} props.productDescription - The product description attribute. * @param {string} props.imageUrl - The product image URL attribute. @@ -65,7 +56,6 @@ const helpPriceFromOptions = __( * @param {object} props.touchedFields - Which fields the merchant has interacted with. * @param {Function} props.setTouchedFields - Setter for the touched fields. * @param {Function} props.markTouched - Mark a field as touched. - * @param {boolean} props.usesVariantPricing - Whether the options carry their own prices. * @param {object} props.validationErrors - Validation errors keyed by field name. * @param {boolean} props.isFormValid - Whether the form has no validation errors. * @param {boolean} props.isCreating - Whether a create or update request is in flight. @@ -87,7 +77,6 @@ export default function ProductForm( { setAttributes, buttonText, productName, - price, currencyCode, productDescription, imageUrl, @@ -109,7 +98,6 @@ export default function ProductForm( { touchedFields, setTouchedFields, markTouched, - usesVariantPricing, validationErrors, isFormValid, isCreating, @@ -125,10 +113,6 @@ export default function ProductForm( { connectionStatus, connectionLabel, } ) { - // PayPal rejects any decimal in JPY, HUF and TWD, so the input must not offer one. - const priceStep = getPriceStep( currencyCode || 'USD' ); - const pricePlaceholder = priceStep === '1' ? '1500' : '29.99'; - return (
@@ -166,34 +150,6 @@ export default function ProductForm( { ) } -
-
- setAttributes( { price: value } ) } - onBlur={ () => markTouched( 'price' ) } - disabled={ isCreating } - type="number" - min={ priceStep } - step={ priceStep } - placeholder={ pricePlaceholder } - help={ - touchedFields.price && validationErrors.price - ? validationErrors.price - : ( usesVariantPricing && helpPriceFromOptions ) || undefined - } - className={ touchedFields.price && validationErrors.price ? 'has-error' : undefined } - /> -
- setAttributes( { currencyCode: value } ) } - /> -
- + +
+
+ setAttributes( { price: value } ) } + onBlur={ () => markTouched( 'price' ) } + disabled={ isCreating } + type="number" + min={ priceStep } + step={ priceStep } + placeholder={ pricePlaceholder } + help={ + touchedFields.price && validationErrors.price + ? validationErrors.price + : ( usesVariantPricing && helpPriceFromOptions ) || undefined + } + className={ + touchedFields.price && validationErrors.price ? 'has-error' : undefined + } + /> +
+ setAttributes( { currencyCode: value } ) } + /> +
{ inspectorControls } @@ -562,7 +605,6 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b setAttributes={ setAttributes } buttonText={ buttonText } productName={ productName } - price={ price } currencyCode={ currencyCode } productDescription={ productDescription } imageUrl={ imageUrl } @@ -584,7 +626,6 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b touchedFields={ touchedFields } setTouchedFields={ setTouchedFields } markTouched={ markTouched } - usesVariantPricing={ usesVariantPricing } validationErrors={ validationErrors } isFormValid={ isFormValid } isCreating={ isCreating } 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 2598f78bc7d1..0deeb9542ea8 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/editor.scss +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/editor.scss @@ -308,6 +308,7 @@ > * { flex: 1; + min-width: 0; } } From ef7924651f2ba4ccf5c26316f044a92bbcdd275b Mon Sep 17 00:00:00 2001 From: Julian Strahan Date: Fri, 4 Sep 2026 16:33:10 -0700 Subject: [PATCH 03/34] PayPal Payment Buttons: add changelog entry for the inspector move --- .../changelog/update-paypal-buttons-form-to-inspector | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 projects/packages/paypal-payments/changelog/update-paypal-buttons-form-to-inspector diff --git a/projects/packages/paypal-payments/changelog/update-paypal-buttons-form-to-inspector b/projects/packages/paypal-payments/changelog/update-paypal-buttons-form-to-inspector new file mode 100644 index 000000000000..b7409e49e486 --- /dev/null +++ b/projects/packages/paypal-payments/changelog/update-paypal-buttons-form-to-inspector @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Move the product form fields into the block inspector. From 1ac51869e1c8b79e5ca55a2419e85a07d827e20b Mon Sep 17 00:00:00 2001 From: Julian Strahan Date: Fri, 4 Sep 2026 16:49:20 -0700 Subject: [PATCH 04/34] PayPal Payment Buttons: move the description field into the block inspector --- .../components/product-form.jsx | 39 +------------------ .../edit-api-managed.jsx | 29 +++++++++++++- 2 files changed, 28 insertions(+), 40 deletions(-) diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx index a4f04cfc68fb..4805828cd7bf 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx @@ -6,17 +6,9 @@ */ import { MediaUpload, MediaUploadCheck } from '@wordpress/block-editor'; -import { - Button, - Notice, - SelectControl, - TextControl, - TextareaControl, - ToggleControl, -} from '@wordpress/components'; +import { Button, Notice, SelectControl, TextControl, ToggleControl } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; import metadata from '../block.json'; -import { MAX_DESCRIPTION_LENGTH } from '../utils/validation'; import FormatSwitcher from './format-switcher'; import VariantBuilder from './variant-builder'; @@ -36,7 +28,6 @@ const helpTaxOff = __( 'No tax collected.', 'jetpack-paypal-payments' ); * @param {string} props.buttonText - The button label attribute. * @param {string} props.productName - The product name attribute. * @param {string} props.currencyCode - The currency code attribute. - * @param {string} props.productDescription - The product description attribute. * @param {string} props.imageUrl - The product image URL attribute. * @param {number} props.imageId - The product image media ID attribute. * @param {string} props.returnUrl - The post-payment return URL attribute. @@ -56,7 +47,6 @@ const helpTaxOff = __( 'No tax collected.', 'jetpack-paypal-payments' ); * @param {object} props.touchedFields - Which fields the merchant has interacted with. * @param {Function} props.setTouchedFields - Setter for the touched fields. * @param {Function} props.markTouched - Mark a field as touched. - * @param {object} props.validationErrors - Validation errors keyed by field name. * @param {boolean} props.isFormValid - Whether the form has no validation errors. * @param {boolean} props.isCreating - Whether a create or update request is in flight. * @param {string} props.error - Error message, or null. @@ -78,7 +68,6 @@ export default function ProductForm( { buttonText, productName, currencyCode, - productDescription, imageUrl, imageId, returnUrl, @@ -98,7 +87,6 @@ export default function ProductForm( { touchedFields, setTouchedFields, markTouched, - validationErrors, isFormValid, isCreating, error, @@ -150,31 +138,6 @@ export default function ProductForm( { ) } - setAttributes( { productDescription: value } ) } - onBlur={ () => markTouched( 'productDescription' ) } - help={ - touchedFields.productDescription && validationErrors.productDescription - ? validationErrors.productDescription - : sprintf( - /* translators: 1: current character count, 2: maximum allowed */ - __( - 'Shown to customers at checkout. %1$d / %2$d characters', - 'jetpack-paypal-payments' - ), - ( productDescription || '' ).length, - MAX_DESCRIPTION_LENGTH - ) - } - className={ - touchedFields.productDescription && validationErrors.productDescription - ? 'has-error' - : undefined - } - /> -

{ __( 'Product Image (optional)', 'jetpack-paypal-payments' ) } 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 index 6b8795bd6db6..939bef7e0c47 100644 --- 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 @@ -22,6 +22,7 @@ import { SelectControl, Spinner, TextControl, + TextareaControl, ToolbarButton, ToolbarGroup, } from '@wordpress/components'; @@ -43,6 +44,7 @@ import { API_BASE } from './utils/api-base'; import { SUPPORTED_CURRENCIES, VALID_CURRENCY_CODES } from './utils/currencies'; import { getPriceStep } from './utils/currency-symbols'; import { + MAX_DESCRIPTION_LENGTH, MAX_NAME_LENGTH, validatePrice, validateProductName, @@ -596,6 +598,31 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b onChange={ value => setAttributes( { currencyCode: value } ) } />

+ + setAttributes( { productDescription: value } ) } + onBlur={ () => markTouched( 'productDescription' ) } + help={ + touchedFields.productDescription && validationErrors.productDescription + ? validationErrors.productDescription + : sprintf( + /* translators: 1: current character count, 2: maximum allowed */ + __( + 'Shown to customers at checkout. %1$d / %2$d characters', + 'jetpack-paypal-payments' + ), + ( productDescription || '' ).length, + MAX_DESCRIPTION_LENGTH + ) + } + className={ + touchedFields.productDescription && validationErrors.productDescription + ? 'has-error' + : undefined + } + /> { inspectorControls } @@ -606,7 +633,6 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b buttonText={ buttonText } productName={ productName } currencyCode={ currencyCode } - productDescription={ productDescription } imageUrl={ imageUrl } imageId={ imageId } returnUrl={ returnUrl } @@ -626,7 +652,6 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b touchedFields={ touchedFields } setTouchedFields={ setTouchedFields } markTouched={ markTouched } - validationErrors={ validationErrors } isFormValid={ isFormValid } isCreating={ isCreating } error={ error } From 9092629715c9d9b0ba95745f45a80aa76887001a Mon Sep 17 00:00:00 2001 From: Julian Strahan Date: Fri, 4 Sep 2026 17:10:10 -0700 Subject: [PATCH 05/34] PayPal Payment Buttons: move the product image field into the block inspector --- .../components/product-form.jsx | 57 ------------------ .../edit-api-managed.jsx | 58 ++++++++++++++++++- 2 files changed, 55 insertions(+), 60 deletions(-) diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx index 4805828cd7bf..b22226cc28b3 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx @@ -5,7 +5,6 @@ * @package */ -import { MediaUpload, MediaUploadCheck } from '@wordpress/block-editor'; import { Button, Notice, SelectControl, TextControl, ToggleControl } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; import metadata from '../block.json'; @@ -26,10 +25,7 @@ const helpTaxOff = __( 'No tax collected.', 'jetpack-paypal-payments' ); * @param {object} props.attributes - The full block attributes bag. * @param {Function} props.setAttributes - Function to update block attributes. * @param {string} props.buttonText - The button label attribute. - * @param {string} props.productName - The product name attribute. * @param {string} props.currencyCode - The currency code attribute. - * @param {string} props.imageUrl - The product image URL attribute. - * @param {number} props.imageId - The product image media ID attribute. * @param {string} props.returnUrl - The post-payment return URL attribute. * @param {boolean} props.variantsEnabled - Whether product options are enabled. * @param {object} props.variants - The product options attribute. @@ -66,10 +62,7 @@ export default function ProductForm( { attributes, setAttributes, buttonText, - productName, currencyCode, - imageUrl, - imageId, returnUrl, variantsEnabled, variants, @@ -138,56 +131,6 @@ export default function ProductForm( { ) } -
-

- { __( 'Product Image (optional)', 'jetpack-paypal-payments' ) } -

- { imageUrl ? ( -
- { -
- - setAttributes( { imageUrl: media.url, imageId: media.id } ) } - allowedTypes={ [ 'image' ] } - value={ imageId } - render={ ( { open } ) => ( - - ) } - /> - - -
-
- ) : ( - - setAttributes( { imageUrl: media.url, imageId: media.id } ) } - allowedTypes={ [ 'image' ] } - value={ imageId } - render={ ( { open } ) => ( - - ) } - /> - - ) } -
-
+ +
+

+ { __( 'Product Image (optional)', 'jetpack-paypal-payments' ) } +

+ { imageUrl ? ( +
+ { +
+ + + setAttributes( { imageUrl: media.url, imageId: media.id } ) + } + allowedTypes={ [ 'image' ] } + value={ imageId } + render={ ( { open } ) => ( + + ) } + /> + + +
+
+ ) : ( + + setAttributes( { imageUrl: media.url, imageId: media.id } ) } + allowedTypes={ [ 'image' ] } + value={ imageId } + render={ ( { open } ) => ( + + ) } + /> + + ) } +
{ inspectorControls } @@ -631,10 +686,7 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b attributes={ attributes } setAttributes={ setAttributes } buttonText={ buttonText } - productName={ productName } currencyCode={ currencyCode } - imageUrl={ imageUrl } - imageId={ imageId } returnUrl={ returnUrl } variantsEnabled={ variantsEnabled } variants={ variants } From 7a531107ed242f2b4ac6d1b47e3f52cbb6efa78f Mon Sep 17 00:00:00 2001 From: Julian Strahan Date: Fri, 4 Sep 2026 17:23:50 -0700 Subject: [PATCH 06/34] PayPal Payment Buttons: move the return URL field into the block inspector --- .../components/product-form.jsx | 28 ------------------- .../edit-api-managed.jsx | 26 +++++++++++++++-- 2 files changed, 23 insertions(+), 31 deletions(-) diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx index b22226cc28b3..e52e51a8d6a7 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx @@ -26,7 +26,6 @@ const helpTaxOff = __( 'No tax collected.', 'jetpack-paypal-payments' ); * @param {Function} props.setAttributes - Function to update block attributes. * @param {string} props.buttonText - The button label attribute. * @param {string} props.currencyCode - The currency code attribute. - * @param {string} props.returnUrl - The post-payment return URL attribute. * @param {boolean} props.variantsEnabled - Whether product options are enabled. * @param {object} props.variants - The product options attribute. * @param {boolean} props.adjustableQuantity - Whether customers can adjust quantity. @@ -40,9 +39,7 @@ const helpTaxOff = __( 'No tax collected.', 'jetpack-paypal-payments' ); * @param {boolean} props.isConnected - Whether the site is connected to PayPal. * @param {string} props.environment - 'production' or 'sandbox'. * @param {Function} props.setIsEditing - Setter for the edit/preview toggle. - * @param {object} props.touchedFields - Which fields the merchant has interacted with. * @param {Function} props.setTouchedFields - Setter for the touched fields. - * @param {Function} props.markTouched - Mark a field as touched. * @param {boolean} props.isFormValid - Whether the form has no validation errors. * @param {boolean} props.isCreating - Whether a create or update request is in flight. * @param {string} props.error - Error message, or null. @@ -63,7 +60,6 @@ export default function ProductForm( { setAttributes, buttonText, currencyCode, - returnUrl, variantsEnabled, variants, adjustableQuantity, @@ -77,9 +73,7 @@ export default function ProductForm( { isConnected, environment, setIsEditing, - touchedFields, setTouchedFields, - markTouched, isFormValid, isCreating, error, @@ -141,28 +135,6 @@ export default function ProductForm( { />
- setAttributes( { returnUrl: value } ) } - onBlur={ () => markTouched( 'returnUrl' ) } - type="url" - disabled={ isCreating } - help={ - touchedFields.returnUrl && returnUrl && ! /^https:\/\/.+/.test( returnUrl ) - ? __( - 'Return URL must use HTTPS (e.g., https://example.com/thank-you).', - 'jetpack-paypal-payments' - ) - : __( 'Redirect customers here after payment.', 'jetpack-paypal-payments' ) - } - className={ - touchedFields.returnUrl && returnUrl && ! /^https:\/\/.+/.test( returnUrl ) - ? 'has-error' - : undefined - } - /> -

{ __( 'Checkout Options', 'jetpack-paypal-payments' ) } 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 index a085f1ff937e..69dc4ccca47e 100644 --- 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 @@ -679,6 +679,29 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b ) }

+ + setAttributes( { returnUrl: value } ) } + onBlur={ () => markTouched( 'returnUrl' ) } + type="url" + disabled={ isCreating } + help={ + touchedFields.returnUrl && returnUrl && ! /^https:\/\/.+/.test( returnUrl ) + ? __( + 'Return URL must use HTTPS (e.g., https://example.com/thank-you).', + 'jetpack-paypal-payments' + ) + : __( 'Redirect customers here after payment.', 'jetpack-paypal-payments' ) + } + className={ + touchedFields.returnUrl && returnUrl && ! /^https:\/\/.+/.test( returnUrl ) + ? 'has-error' + : undefined + } + /> + { inspectorControls } @@ -687,7 +710,6 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b setAttributes={ setAttributes } buttonText={ buttonText } currencyCode={ currencyCode } - returnUrl={ returnUrl } variantsEnabled={ variantsEnabled } variants={ variants } adjustableQuantity={ adjustableQuantity } @@ -701,9 +723,7 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b isConnected={ isConnected } environment={ environment } setIsEditing={ setIsEditing } - touchedFields={ touchedFields } setTouchedFields={ setTouchedFields } - markTouched={ markTouched } isFormValid={ isFormValid } isCreating={ isCreating } error={ error } From 53f78618ee9d3ffba1d2901c9dff1d6beadf6180 Mon Sep 17 00:00:00 2001 From: Julian Strahan Date: Fri, 4 Sep 2026 17:37:37 -0700 Subject: [PATCH 07/34] PayPal Payment Buttons: move the checkout options into the block inspector --- .../components/product-form.jsx | 201 +----------------- .../edit-api-managed.jsx | 192 ++++++++++++++++- 2 files changed, 187 insertions(+), 206 deletions(-) diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx index e52e51a8d6a7..e5b658a91f48 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx @@ -5,18 +5,14 @@ * @package */ -import { Button, Notice, SelectControl, TextControl, ToggleControl } from '@wordpress/components'; -import { __, sprintf } from '@wordpress/i18n'; +import { Button, Notice, TextControl, ToggleControl } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; import metadata from '../block.json'; import FormatSwitcher from './format-switcher'; import VariantBuilder from './variant-builder'; const labelEditHeading = __( 'Edit PayPal Payment Button', 'jetpack-paypal-payments' ); const labelCreateHeading = __( 'Create PayPal Payment Button', 'jetpack-paypal-payments' ); -const helpQtyOn = __( 'Customers can buy multiple units at checkout.', 'jetpack-paypal-payments' ); -const helpQtyOff = __( 'Fixed at 1 unit per purchase.', 'jetpack-paypal-payments' ); -const helpTaxOn = __( 'Tax will be added at PayPal checkout.', 'jetpack-paypal-payments' ); -const helpTaxOff = __( 'No tax collected.', 'jetpack-paypal-payments' ); /** * The product creation form, shown when PayPal is connected and the block is in edit mode. @@ -28,13 +24,6 @@ const helpTaxOff = __( 'No tax collected.', 'jetpack-paypal-payments' ); * @param {string} props.currencyCode - The currency code attribute. * @param {boolean} props.variantsEnabled - Whether product options are enabled. * @param {object} props.variants - The product options attribute. - * @param {boolean} props.adjustableQuantity - Whether customers can adjust quantity. - * @param {number} props.maxQuantity - The maximum quantity attribute. - * @param {Array} props.customerNotes - The custom checkout fields attribute. - * @param {boolean} props.taxEnabled - Whether tax is collected. - * @param {string} props.taxType - The tax type attribute. - * @param {string} props.taxName - The tax name attribute. - * @param {string} props.taxValue - The tax rate attribute. * @param {string} props.activeFormat - The display format, normalized. * @param {boolean} props.isConnected - Whether the site is connected to PayPal. * @param {string} props.environment - 'production' or 'sandbox'. @@ -62,13 +51,6 @@ export default function ProductForm( { currencyCode, variantsEnabled, variants, - adjustableQuantity, - maxQuantity, - customerNotes, - taxEnabled, - taxType, - taxName, - taxValue, activeFormat, isConnected, environment, @@ -135,185 +117,6 @@ export default function ProductForm( { />
-
-

- { __( 'Checkout Options', 'jetpack-paypal-payments' ) } -

- - { /* WOOPTP-170: Adjustable Quantity */ } - setAttributes( { adjustableQuantity: value } ) } - disabled={ isCreating } - /> - { adjustableQuantity && ( - setAttributes( { maxQuantity: parseInt( value, 10 ) || 10 } ) } - type="number" - min={ 2 } - max={ 999 } - disabled={ isCreating } - help={ __( 'Customers can select from 1 to this number.', 'jetpack-paypal-payments' ) } - /> - ) } - - { /* WOOPTP-172: Tax Configuration */ } - setAttributes( { taxEnabled: value } ) } - disabled={ isCreating } - /> - { taxEnabled && ( -
- setAttributes( { taxType: value } ) } - disabled={ isCreating } - /> - setAttributes( { taxName: value } ) } - placeholder={ __( 'Sales Tax', 'jetpack-paypal-payments' ) } - disabled={ isCreating } - /> - { taxType === 'PERCENTAGE' && ( - setAttributes( { taxValue: value } ) } - type="number" - min="0.01" - max="99.99" - step="0.01" - placeholder="8.25" - disabled={ isCreating } - help={ __( 'Percentage added to the product price.', 'jetpack-paypal-payments' ) } - /> - ) } -
- ) } - - { /* WOOPTP-171: Customer Notes */ } - 0 - ? sprintf( - /* translators: %d: number of custom fields */ - __( '%d custom field(s) configured.', 'jetpack-paypal-payments' ), - customerNotes.length - ) - : __( - 'Add fields for gift messages, personalization, etc.', - 'jetpack-paypal-payments' - ) - } - checked={ customerNotes?.length > 0 } - onChange={ value => { - if ( value ) { - setAttributes( { - customerNotes: [ { label: '', required: false } ], - } ); - } else { - setAttributes( { customerNotes: [] } ); - } - } } - disabled={ isCreating } - /> - { customerNotes?.length > 0 && ( -
- { customerNotes.map( ( note, noteIndex ) => ( -
- { - const updated = [ ...customerNotes ]; - updated[ noteIndex ] = { - ...updated[ noteIndex ], - label: value, - }; - setAttributes( { customerNotes: updated } ); - } } - placeholder={ __( 'e.g., Gift Message', 'jetpack-paypal-payments' ) } - disabled={ isCreating } - /> -
- { - const updated = [ ...customerNotes ]; - updated[ noteIndex ] = { - ...updated[ noteIndex ], - required: value, - }; - setAttributes( { customerNotes: updated } ); - } } - disabled={ isCreating } - /> - { customerNotes.length > 1 && ( - - ) } -
-
- ) ) } - { customerNotes.length < 5 && ( - - ) } -
- ) } -
-

{ __( 'Button Appearance', 'jetpack-paypal-payments' ) } 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 index 69dc4ccca47e..ff9821630de6 100644 --- 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 @@ -26,6 +26,7 @@ import { Spinner, TextControl, TextareaControl, + ToggleControl, ToolbarButton, ToolbarGroup, } from '@wordpress/components'; @@ -63,6 +64,10 @@ const helpPriceFromOptions = __( 'Each product option sets its own price, so this value is ignored.', 'jetpack-paypal-payments' ); +const helpQtyOn = __( 'Customers can buy multiple units at checkout.', 'jetpack-paypal-payments' ); +const helpQtyOff = __( 'Fixed at 1 unit per purchase.', 'jetpack-paypal-payments' ); +const helpTaxOn = __( 'Tax will be added at PayPal checkout.', 'jetpack-paypal-payments' ); +const helpTaxOff = __( 'No tax collected.', 'jetpack-paypal-payments' ); /** * API-managed PayPal Payment Buttons edit component. @@ -702,6 +707,186 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b } /> + + { /* WOOPTP-170: Adjustable Quantity */ } + setAttributes( { adjustableQuantity: value } ) } + disabled={ isCreating } + /> + { adjustableQuantity && ( + setAttributes( { maxQuantity: parseInt( value, 10 ) || 10 } ) } + type="number" + min={ 2 } + max={ 999 } + disabled={ isCreating } + help={ __( + 'Customers can select from 1 to this number.', + 'jetpack-paypal-payments' + ) } + /> + ) } + + { /* WOOPTP-172: Tax Configuration */ } + setAttributes( { taxEnabled: value } ) } + disabled={ isCreating } + /> + { taxEnabled && ( +
+ setAttributes( { taxType: value } ) } + disabled={ isCreating } + /> + setAttributes( { taxName: value } ) } + placeholder={ __( 'Sales Tax', 'jetpack-paypal-payments' ) } + disabled={ isCreating } + /> + { taxType === 'PERCENTAGE' && ( + setAttributes( { taxValue: value } ) } + type="number" + min="0.01" + max="99.99" + step="0.01" + placeholder="8.25" + disabled={ isCreating } + help={ __( 'Percentage added to the product price.', 'jetpack-paypal-payments' ) } + /> + ) } +
+ ) } + + { /* WOOPTP-171: Customer Notes */ } + 0 + ? sprintf( + /* translators: %d: number of custom fields */ + __( '%d custom field(s) configured.', 'jetpack-paypal-payments' ), + customerNotes.length + ) + : __( + 'Add fields for gift messages, personalization, etc.', + 'jetpack-paypal-payments' + ) + } + checked={ customerNotes?.length > 0 } + onChange={ value => { + if ( value ) { + setAttributes( { + customerNotes: [ { label: '', required: false } ], + } ); + } else { + setAttributes( { customerNotes: [] } ); + } + } } + disabled={ isCreating } + /> + { customerNotes?.length > 0 && ( +
+ { customerNotes.map( ( note, noteIndex ) => ( +
+ { + const updated = [ ...customerNotes ]; + updated[ noteIndex ] = { + ...updated[ noteIndex ], + label: value, + }; + setAttributes( { customerNotes: updated } ); + } } + placeholder={ __( 'e.g., Gift Message', 'jetpack-paypal-payments' ) } + disabled={ isCreating } + /> +
+ { + const updated = [ ...customerNotes ]; + updated[ noteIndex ] = { + ...updated[ noteIndex ], + required: value, + }; + setAttributes( { customerNotes: updated } ); + } } + disabled={ isCreating } + /> + { customerNotes.length > 1 && ( + + ) } +
+
+ ) ) } + { customerNotes.length < 5 && ( + + ) } +
+ ) } +
{ inspectorControls } @@ -712,13 +897,6 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b currencyCode={ currencyCode } variantsEnabled={ variantsEnabled } variants={ variants } - adjustableQuantity={ adjustableQuantity } - maxQuantity={ maxQuantity } - customerNotes={ customerNotes } - taxEnabled={ taxEnabled } - taxType={ taxType } - taxName={ taxName } - taxValue={ taxValue } activeFormat={ activeFormat } isConnected={ isConnected } environment={ environment } From fa3fd95ad8f8f5270e70e799dfcc364f27a4c7ee Mon Sep 17 00:00:00 2001 From: Julian Strahan Date: Fri, 4 Sep 2026 17:45:54 -0700 Subject: [PATCH 08/34] PayPal Payment Buttons: move the product options into the block inspector --- .../components/product-form.jsx | 17 ----------------- .../components/variant-builder.jsx | 4 ---- .../paypal-payment-buttons/edit-api-managed.jsx | 17 +++++++++++++---- .../src/paypal-payment-buttons/editor.scss | 9 +++------ 4 files changed, 16 insertions(+), 31 deletions(-) diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx index e5b658a91f48..8c9eeecf2bb5 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx @@ -9,7 +9,6 @@ import { Button, Notice, TextControl, ToggleControl } from '@wordpress/component import { __ } from '@wordpress/i18n'; import metadata from '../block.json'; import FormatSwitcher from './format-switcher'; -import VariantBuilder from './variant-builder'; const labelEditHeading = __( 'Edit PayPal Payment Button', 'jetpack-paypal-payments' ); const labelCreateHeading = __( 'Create PayPal Payment Button', 'jetpack-paypal-payments' ); @@ -21,9 +20,6 @@ const labelCreateHeading = __( 'Create PayPal Payment Button', 'jetpack-paypal-p * @param {object} props.attributes - The full block attributes bag. * @param {Function} props.setAttributes - Function to update block attributes. * @param {string} props.buttonText - The button label attribute. - * @param {string} props.currencyCode - The currency code attribute. - * @param {boolean} props.variantsEnabled - Whether product options are enabled. - * @param {object} props.variants - The product options attribute. * @param {string} props.activeFormat - The display format, normalized. * @param {boolean} props.isConnected - Whether the site is connected to PayPal. * @param {string} props.environment - 'production' or 'sandbox'. @@ -48,9 +44,6 @@ export default function ProductForm( { attributes, setAttributes, buttonText, - currencyCode, - variantsEnabled, - variants, activeFormat, isConnected, environment, @@ -107,16 +100,6 @@ export default function ProductForm( { ) } -
- setAttributes( updates ) } - disabled={ isCreating } - /> -
-

{ __( 'Button Appearance', 'jetpack-paypal-payments' ) } diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/variant-builder.jsx b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/variant-builder.jsx index 65f77314d268..b63f48182720 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/variant-builder.jsx +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/variant-builder.jsx @@ -475,10 +475,6 @@ export default function VariantBuilder( { return (
-

- { __( 'Product Options', 'jetpack-paypal-payments' ) } -

- ) } + + setAttributes( updates ) } + disabled={ isCreating } + /> + { inspectorControls } @@ -894,9 +906,6 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b attributes={ attributes } setAttributes={ setAttributes } buttonText={ buttonText } - currencyCode={ currencyCode } - variantsEnabled={ variantsEnabled } - variants={ variants } activeFormat={ activeFormat } isConnected={ isConnected } environment={ environment } 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 0deeb9542ea8..5e23ef93a5d6 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/editor.scss +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/editor.scss @@ -584,24 +584,21 @@ .jetpack-paypal-variants__option { display: flex; + flex-direction: column; gap: 8px; - align-items: flex-end; + align-items: stretch; margin-bottom: 8px; .components-text-control__input { min-width: 0; } - - @media ( max-width: 480px ) { - flex-direction: column; - align-items: stretch; - } } .jetpack-paypal-variants__remove-option { min-width: 44px; min-height: 44px; display: flex; + align-self: flex-start; align-items: center; justify-content: center; } From eeda7c6a22ea4c120633e78192124fab351c211c Mon Sep 17 00:00:00 2001 From: Julian Strahan Date: Fri, 4 Sep 2026 18:00:41 -0700 Subject: [PATCH 09/34] PayPal Payment Buttons: move the display format control into the block inspector --- .../components/product-form.jsx | 12 ------------ .../src/paypal-payment-buttons/controls.jsx | 16 +++++++--------- 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx index 8c9eeecf2bb5..d031fa89f736 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx @@ -8,7 +8,6 @@ import { Button, Notice, TextControl, ToggleControl } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import metadata from '../block.json'; -import FormatSwitcher from './format-switcher'; const labelEditHeading = __( 'Edit PayPal Payment Button', 'jetpack-paypal-payments' ); const labelCreateHeading = __( 'Create PayPal Payment Button', 'jetpack-paypal-payments' ); @@ -122,17 +121,6 @@ export default function ProductForm( { />
-
-

- { __( 'Display Format', 'jetpack-paypal-payments' ) } -

- setAttributes( { format: value } ) } - disabled={ isCreating } - /> -
-

- { /* Payment link with copy button */ } - + { /* Payment link with copy button — only once PayPal has issued one. */ } + { paymentLink && ( + + ) }

); } diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx deleted file mode 100644 index daa80f2e07f8..000000000000 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx +++ /dev/null @@ -1,173 +0,0 @@ -/* eslint-disable react/jsx-no-bind */ -/** - * PayPal Payment Buttons — The product creation form. - * - * @package - */ - -import { Button, Notice } from '@wordpress/components'; -import { __ } from '@wordpress/i18n'; -import metadata from '../block.json'; - -const labelEditHeading = __( 'Edit PayPal Payment Button', 'jetpack-paypal-payments' ); -const labelCreateHeading = __( 'Create PayPal Payment Button', 'jetpack-paypal-payments' ); - -/** - * The product creation form, shown when PayPal is connected and the block is in edit mode. - * - * @param {object} props - Component props. - * @param {Function} props.setAttributes - Function to update block attributes. - * @param {string} props.activeFormat - The display format, normalized. - * @param {boolean} props.isConnected - Whether the site is connected to PayPal. - * @param {string} props.environment - 'production' or 'sandbox'. - * @param {Function} props.setIsEditing - Setter for the edit/preview toggle. - * @param {Function} props.setTouchedFields - Setter for the touched fields. - * @param {boolean} props.isFormValid - Whether the form has no validation errors. - * @param {boolean} props.isCreating - Whether a create or update request is in flight. - * @param {string} props.error - Error message, or null. - * @param {Function} props.setError - Setter for the error message. - * @param {string} props.successMessage - Success message, or null. - * @param {Function} props.setSuccessMessage - Setter for the success message. - * @param {Function} props.handleCreateButton - Create the PayPal payment. - * @param {Function} props.handleUpdateButton - Update the PayPal payment. - * @param {boolean} props.hasButton - Whether the block has a created button. - * @param {Element} props.disconnectedNotice - The disconnected warning notice, or null. - * @param {Element} props.sharedResourceNotice - The shared-payment info notice, or null. - * @param {Element} props.connectionStatus - The connection status dot. - * @param {string} props.connectionLabel - The connection status label. - * @return {Element} The product creation form. - */ -export default function ProductForm( { - setAttributes, - activeFormat, - isConnected, - environment, - setIsEditing, - setTouchedFields, - isFormValid, - isCreating, - error, - setError, - successMessage, - setSuccessMessage, - handleCreateButton, - handleUpdateButton, - hasButton, - disconnectedNotice, - sharedResourceNotice, - connectionStatus, - connectionLabel, -} ) { - return ( -
-
- { connectionStatus } - { connectionLabel } - { environment === 'sandbox' && ( - - { __( 'Sandbox', 'jetpack-paypal-payments' ) } - - ) } -
- - { disconnectedNotice } - { sharedResourceNotice } - -

{ hasButton ? labelEditHeading : labelCreateHeading }

- { ! hasButton && ( -

- { __( - 'Use the button on this page, or share the link anywhere.', - 'jetpack-paypal-payments' - ) } -

- ) } - - { error && ( - setError( null ) }> - { error } - - ) } - - { successMessage && ( - setSuccessMessage( null ) }> - { successMessage } - - ) } - -
- - - -
-
- ); -} 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 index 13100f7de234..8b722b54ec1c 100644 --- 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 @@ -39,7 +39,6 @@ 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 VariantBuilder, { hasVariantPricing, validateVariants } from './components/variant-builder'; import PayPalInspectorControls from './controls'; import { broadcastConnectionChange, usePayPalConnection } from './hooks/use-paypal-connection'; @@ -922,28 +921,120 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b { inspectorControls } + +
+ - + +
+
+ +
+
+ { connectionStatus } + { connectionLabel } + { environment === 'sandbox' && ( + + { __( 'Sandbox', 'jetpack-paypal-payments' ) } + + ) } +
+ + { disconnectedNotice } + { sharedResourceNotice } + + { error && ( + setError( null ) }> + { error } + + ) } + + { successMessage && ( + setSuccessMessage( null ) }> + { successMessage } + + ) } + + +
{ confirmDialogs }
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 5e23ef93a5d6..5a0ee8249e99 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/editor.scss +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/editor.scss @@ -312,11 +312,15 @@ } } +// Renders as a bare child of the inspector fill rather than inside a PanelBody, +// so it has to supply the separator and the 16px inset a panel would have given +// it. Without them it reads as the footer of whichever panel sits above. .jetpack-paypal-payment-buttons__form-actions { display: flex; align-items: center; gap: 12px; - margin-top: 4px; + padding: 16px; + border-top: 1px solid #e0e0e0; } // Image upload field in the create/edit form. 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 49d76b04e831..c394e9fdb029 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 @@ -804,9 +804,7 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => { // The frame going away means little on its own: clearing the referral // and flipping to connected each remove it. Check for the connected // view itself. - await expect( - screen.findByText( /Create PayPal Payment Button/ ) - ).resolves.toBeInTheDocument(); + await expect( screen.findByText( /Create Button/ ) ).resolves.toBeInTheDocument(); expect( screen.queryByTitle( 'PayPal onboarding' ) ).not.toBeInTheDocument(); } ); @@ -825,9 +823,7 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => { window.jetpackPayPalOnboardComplete( 'AUTH_CODE_1', 'SHARED_ID_1' ); } ); - await expect( - screen.findByText( /Create PayPal Payment Button/ ) - ).resolves.toBeInTheDocument(); + await expect( screen.findByText( /Create Button/ ) ).resolves.toBeInTheDocument(); await user.click( screen.getByRole( 'button', { name: /Disconnect PayPal/i } ) ); await user.click( screen.getByTestId( 'confirm-dialog-confirm' ) ); @@ -970,9 +966,7 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => { ); // Then the wizard gives way to the connected view. - await expect( - screen.findByText( /Create PayPal Payment Button/ ) - ).resolves.toBeInTheDocument(); + await expect( screen.findByText( /Create Button/ ) ).resolves.toBeInTheDocument(); expect( screen.queryByTitle( 'PayPal onboarding' ) ).not.toBeInTheDocument(); } ); @@ -1273,27 +1267,13 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => { it( 'shows the create form when connected but no button exists', async () => { render( ); - await expect( - screen.findByText( /Create PayPal Payment Button/ ) - ).resolves.toBeInTheDocument(); + await expect( screen.findByText( /Create Button/ ) ).resolves.toBeInTheDocument(); expect( screen.getByLabelText( 'Product Name' ) ).toBeInTheDocument(); expect( screen.getByLabelText( 'Price' ) ).toBeInTheDocument(); expect( screen.getByLabelText( 'Currency' ) ).toBeInTheDocument(); expect( screen.getByLabelText( /Description/ ) ).toBeInTheDocument(); } ); - it( 'shows PayPal Connected status', async () => { - render( ); - - await expect( screen.findByText( 'PayPal Connected' ) ).resolves.toBeInTheDocument(); - } ); - - it( 'shows sandbox badge when in sandbox mode', async () => { - render( ); - - await expect( screen.findByText( 'Sandbox' ) ).resolves.toBeInTheDocument(); - } ); - it( 'calls setAttributes when product name changes', async () => { const user = userEvent.setup(); @@ -1504,6 +1484,36 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => { apiFetch.mockResolvedValue( { connected: true, environment: 'sandbox' } ); } ); + it( 'shows PayPal Connected status', async () => { + render( + + ); + + await expect( screen.findByText( 'PayPal Connected' ) ).resolves.toBeInTheDocument(); + } ); + + it( 'shows sandbox badge when in sandbox mode', async () => { + render( + + ); + + await expect( screen.findByText( 'Sandbox' ) ).resolves.toBeInTheDocument(); + } ); + it( 'shows button preview when API-managed button exists', async () => { render( { const editButton = screen.getByTestId( 'toolbar-Edit' ); await user.click( editButton ); - // Should now show the edit form with "Edit PayPal Button" heading. - expect( screen.getByText( /Edit PayPal Payment Button/ ) ).toBeInTheDocument(); + // Should now show the edit form. + expect( screen.getByLabelText( 'Product Name' ) ).toBeInTheDocument(); expect( screen.getByText( /Update Button/ ) ).toBeInTheDocument(); } ); } ); From 2f366ae1fbe4c8c1f8e9040ffeed52cc6ac4dafd Mon Sep 17 00:00:00 2001 From: Julian Strahan Date: Fri, 4 Sep 2026 18:57:39 -0700 Subject: [PATCH 12/34] PayPal Payment Buttons: remove the CSS rules orphaned by the inspector move --- .../src/paypal-payment-buttons/editor.scss | 89 ++----------------- 1 file changed, 6 insertions(+), 83 deletions(-) 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 5a0ee8249e99..c06ebc3936a7 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/editor.scss +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/editor.scss @@ -272,23 +272,8 @@ // Create / Edit Form // --------------------------------------------------------------- -.jetpack-paypal-payment-buttons__create-form { - padding: 24px; - border: 1px solid #c3c4c7; - border-radius: 4px; - - h3 { - margin-top: 0; - margin-bottom: 16px; - } - - .components-base-control { - margin-bottom: 12px; - } -} - -// Error state for form fields. Top-level: fields live on the canvas or in the -// inspector depending on how far the migration has got, and this matches both. +// Error state for form fields. Top-level because has-error is our own +// class, so it can't reach a control we don't own. .has-error { .components-text-control__input, @@ -357,18 +342,6 @@ margin-bottom: 4px; } -// --------------------------------------------------------------- -// Inline Field Validation Errors -// --------------------------------------------------------------- - -.jetpack-paypal-payment-buttons__field-error { - margin: -8px 0 12px; - padding: 0; - font-size: 12px; - color: #cc1818; - line-height: 1.4; -} - // --------------------------------------------------------------- // Button Preview (editor wrapper) // --------------------------------------------------------------- @@ -519,25 +492,7 @@ } } -// Variant builder inline section. -.jetpack-paypal-payment-buttons__variants-section { - margin: 16px 0; - padding: 16px; - border: 1px solid #e0e0e0; - border-radius: 4px; - background: #fafafa; -} - -// Variant builder heading. -.jetpack-paypal-variants__heading { - margin: 0 0 8px; - font-size: 13px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - color: #1e1e1e; -} - +// Variant builder. .jetpack-paypal-variants__empty-help { font-size: 13px; color: #757575; @@ -558,8 +513,7 @@ } // Variant builder option groups. -.jetpack-paypal-variants__group, -.jetpack-paypal-variants__dimension { +.jetpack-paypal-variants__group { border: 1px solid #ddd; border-radius: 4px; padding: 12px; @@ -567,19 +521,6 @@ background: #f9f9f9; } -.jetpack-paypal-variants__dimension-header { - display: flex; - flex-direction: column; - gap: 8px; - margin-bottom: 8px; -} - -.jetpack-paypal-variants__dimension-controls { - display: flex; - justify-content: space-between; - align-items: center; -} - .jetpack-paypal-variants__options { margin-left: 12px; padding-left: 12px; @@ -611,8 +552,7 @@ margin-top: 4px; } -.jetpack-paypal-variants__add-group, -.jetpack-paypal-variants__add-dimension { +.jetpack-paypal-variants__add-group { margin-top: 8px; display: flex; align-items: center; @@ -700,24 +640,7 @@ } } -// Checkout options section (WOOPTP-170/171/172). -.jetpack-paypal-payment-buttons__checkout-options { - margin: 16px 0; - padding: 16px; - border: 1px solid #e0e0e0; - border-radius: 4px; - background: #fafafa; -} - -.jetpack-paypal-payment-buttons__section-heading { - margin: 0 0 12px; - font-size: 13px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - color: #1e1e1e; -} - +// Checkout options (WOOPTP-170/171/172). .jetpack-paypal-payment-buttons__tax-config { margin-left: 12px; padding-left: 12px; From 3c16b7da52d381e8d310e95fdb5f2b3066f474c6 Mon Sep 17 00:00:00 2001 From: Julian Strahan Date: Tue, 8 Sep 2026 11:23:13 -0700 Subject: [PATCH 13/34] PayPal Payment Buttons: tidy the display format panel --- .../components/format-switcher.jsx | 5 +---- .../src/paypal-payment-buttons/editor.scss | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/format-switcher.jsx b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/format-switcher.jsx index c1f4895e9392..f6fd84bdf62c 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/format-switcher.jsx +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/format-switcher.jsx @@ -39,10 +39,7 @@ export default function FormatSwitcher( { value, onChange, disabled } ) { const activeValue = value || 'BUTTON'; return (
-

- { __( 'Display Format', 'jetpack-paypal-payments' ) } -

- + { FORMAT_OPTIONS.map( option => (
- - setAttributes( { returnUrl: value } ) } - onBlur={ () => markTouched( 'returnUrl' ) } - type="url" + + setAttributes( updates ) } disabled={ isCreating } - help={ - touchedFields.returnUrl && returnUrl && ! /^https:\/\/.+/.test( returnUrl ) - ? __( - 'Return URL must use HTTPS (e.g., https://example.com/thank-you).', - 'jetpack-paypal-payments' - ) - : __( 'Redirect customers here after payment.', 'jetpack-paypal-payments' ) - } - className={ - touchedFields.returnUrl && returnUrl && ! /^https:\/\/.+/.test( returnUrl ) - ? 'has-error' - : undefined - } /> ) } - - setAttributes( updates ) } + + setAttributes( { returnUrl: value } ) } + onBlur={ () => markTouched( 'returnUrl' ) } + type="url" disabled={ isCreating } + help={ + touchedFields.returnUrl && returnUrl && ! /^https:\/\/.+/.test( returnUrl ) + ? __( + 'Return URL must use HTTPS (e.g., https://example.com/thank-you).', + 'jetpack-paypal-payments' + ) + : __( 'Redirect customers here after payment.', 'jetpack-paypal-payments' ) + } + className={ + touchedFields.returnUrl && returnUrl && ! /^https:\/\/.+/.test( returnUrl ) + ? 'has-error' + : undefined + } /> Date: Tue, 8 Sep 2026 15:21:09 -0700 Subject: [PATCH 19/34] PayPal Payment Buttons: give the tax fields the full column --- .../src/paypal-payment-buttons/edit-api-managed.jsx | 4 ++-- .../paypal-payments/src/paypal-payment-buttons/editor.scss | 6 ------ 2 files changed, 2 insertions(+), 8 deletions(-) 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 index e0e9932e0644..1f9942828c1e 100644 --- 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 @@ -736,7 +736,7 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b disabled={ isCreating } /> { taxEnabled && ( -
+ <> ) } -
+ ) } { /* WOOPTP-171: Customer Notes */ } 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 6626f87b0aca..93905eb5a91f 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/editor.scss +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/editor.scss @@ -642,12 +642,6 @@ } // Checkout options (WOOPTP-170/171/172). -.jetpack-paypal-payment-buttons__tax-config { - margin-left: 12px; - padding-left: 12px; - border-left: 2px solid #ddd; -} - .jetpack-paypal-payment-buttons__customer-notes { margin-top: 8px; } From 21c5eba41870cb791d931dcc301c3c4e2b298b29 Mon Sep 17 00:00:00 2001 From: Julian Strahan Date: Tue, 8 Sep 2026 15:22:25 -0700 Subject: [PATCH 20/34] PayPal Payment Buttons: open the panels the design opens on load --- .../src/paypal-payment-buttons/controls.jsx | 11 ++++++++--- .../src/paypal-payment-buttons/edit-api-managed.jsx | 10 ++++++++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/controls.jsx b/projects/packages/paypal-payments/src/paypal-payment-buttons/controls.jsx index a88c3681c82a..592bc9766acd 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/controls.jsx +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/controls.jsx @@ -24,7 +24,8 @@ import FormatSwitcher from './components/format-switcher'; * @param {boolean} props.isCreating - Whether a create or update request is in flight. * @param {Function} props.handleDeleteButton - Delete the PayPal payment. * @param {Function} props.handleDisconnect - Disconnect the PayPal account. - * @param {boolean} props.hasButton - Whether the block has a created button. + * @param {string} props.hasButton - The block's payment link, empty when it has no button. + * @param {boolean} props.isPreviewingButton - Whether the block shows that button rather than the form. * @return {Element} The inspector sidebar. */ export default function PayPalInspectorControls( { @@ -39,11 +40,15 @@ export default function PayPalInspectorControls( { handleDeleteButton, handleDisconnect, hasButton, + isPreviewingButton, } ) { return ( { /* Style preset: Light / Auto / Dark — overrides the OS/theme auto-detect */ } - +

{ __( 'Choose how the button adapts to your site theme. "Auto" follows the visitor\'s OS preference.', @@ -81,7 +86,7 @@ export default function PayPalInspectorControls( {

- + setAttributes( { format: value } ) } 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 index 1f9942828c1e..04441fe77002 100644 --- 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 @@ -313,6 +313,11 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b */ const hasButton = isApiManaged && resourceId && paymentLink; + /** + * Whether the block is showing that button rather than the form. + */ + const isPreviewingButton = !! hasButton && ! isEditing; + // Loading state while checking connection. if ( connectionLoading ) { return ( @@ -422,6 +427,7 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b handleDeleteButton={ handleDeleteButton } handleDisconnect={ handleDisconnect } hasButton={ hasButton } + isPreviewingButton={ isPreviewingButton } /> ); @@ -492,7 +498,7 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b const connectionLabel = isConnected ? labelConnected : labelDisconnected; // Connected + has button + preview mode — show live button preview. - if ( hasButton && ! isEditing ) { + if ( isPreviewingButton ) { return (
{ toolbarControls } @@ -689,7 +695,7 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b Date: Tue, 8 Sep 2026 17:21:14 -0700 Subject: [PATCH 21/34] PayPal Payment Buttons: remove the tax name field --- .../changelog/remove-paypal-tax-name-field | 4 ++ .../edit-api-managed.jsx | 8 ---- .../edit.test.jsx | 42 +++++++++++++++++++ 3 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 projects/packages/paypal-payments/changelog/remove-paypal-tax-name-field diff --git a/projects/packages/paypal-payments/changelog/remove-paypal-tax-name-field b/projects/packages/paypal-payments/changelog/remove-paypal-tax-name-field new file mode 100644 index 000000000000..5c2addcda33e --- /dev/null +++ b/projects/packages/paypal-payments/changelog/remove-paypal-tax-name-field @@ -0,0 +1,4 @@ +Significance: patch +Type: removed + +Remove the Tax name field. PayPal labels the tax itself, so the buyer sees Tax whatever the merchant types. 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 index 04441fe77002..7106e25ba6c6 100644 --- 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 @@ -101,7 +101,6 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b customerNotes, taxEnabled, taxType, - taxName, taxValue, format, } = attributes; @@ -759,13 +758,6 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b onChange={ value => setAttributes( { taxType: value } ) } disabled={ isCreating } /> - setAttributes( { taxName: value } ) } - placeholder={ __( 'Sales Tax', 'jetpack-paypal-payments' ) } - disabled={ isCreating } - /> { taxType === 'PERCENTAGE' && ( { } ); } ); + describe( 'Tax', () => { + const resourcePath = '/wpcom/v2/paypal/buttons/PLB-TAX1'; + const attributes = { + isApiManaged: true, + resourceId: 'PLB-TAX1', + paymentLink: 'https://www.paypal.com/ncp/payment/PLB-TAX1', + productName: 'Test Widget', + price: '29.99', + currencyCode: 'USD', + taxEnabled: true, + taxType: 'PERCENTAGE', + taxValue: '8.25', + }; + + /** + * Mock the connection check and the pre-update read. + */ + function mockConnected() { + apiFetch.mockImplementation( ( { path, method } ) => { + if ( path.endsWith( '/connection' ) ) { + return Promise.resolve( { connected: true, environment: 'sandbox' } ); + } + if ( path === resourcePath && method === undefined ) { + return Promise.resolve( { id: 'PLB-TAX1', line_items: [ {} ] } ); + } + return Promise.resolve( {} ); + } ); + } + + it( 'offers no tax name to fill in', async () => { + const user = userEvent.setup(); + mockConnected(); + + render( ); + await expect( screen.findByTestId( 'toolbar-Edit' ) ).resolves.toBeInTheDocument(); + await user.click( screen.getByTestId( 'toolbar-Edit' ) ); + + expect( screen.queryByLabelText( 'Tax name' ) ).not.toBeInTheDocument(); + expect( screen.getByLabelText( 'Tax rate (%)' ) ).toBeInTheDocument(); + } ); + } ); + describe( 'Preview Mode (connected, has button)', () => { beforeEach( () => { apiFetch.mockResolvedValue( { connected: true, environment: 'sandbox' } ); From 174a532d43d6e91443859e6cf52aee62aa260471 Mon Sep 17 00:00:00 2001 From: Julian Strahan Date: Tue, 8 Sep 2026 17:52:09 -0700 Subject: [PATCH 22/34] PayPal Payment Buttons: drop the price field once the options carry prices --- ...move-paypal-price-field-with-option-prices | 4 + .../edit-api-managed.jsx | 57 +++---- .../edit.test.jsx | 152 ++++++++++++++++++ 3 files changed, 182 insertions(+), 31 deletions(-) create mode 100644 projects/packages/paypal-payments/changelog/remove-paypal-price-field-with-option-prices diff --git a/projects/packages/paypal-payments/changelog/remove-paypal-price-field-with-option-prices b/projects/packages/paypal-payments/changelog/remove-paypal-price-field-with-option-prices new file mode 100644 index 000000000000..84ee39610fbe --- /dev/null +++ b/projects/packages/paypal-payments/changelog/remove-paypal-price-field-with-option-prices @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Hide the product price field when the product options carry their own prices, and stop a leftover value there from blocking Update. 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 index 7106e25ba6c6..cbd19317df59 100644 --- 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 @@ -58,12 +58,6 @@ import { // Button type is always 'single' — the hosted payment page handles // payment method selection (PayPal, cards, wallets, etc.). -const labelPrice = __( 'Price', 'jetpack-paypal-payments' ); -const labelPriceOptional = __( 'Price (not used)', 'jetpack-paypal-payments' ); -const helpPriceFromOptions = __( - 'Each product option sets its own price, so this value is ignored.', - 'jetpack-paypal-payments' -); const helpQtyOn = __( 'Customers can buy multiple units at checkout.', 'jetpack-paypal-payments' ); const helpQtyOff = __( 'Fixed at 1 unit per purchase.', 'jetpack-paypal-payments' ); const helpTaxOn = __( 'Tax will be added at PayPal checkout.', 'jetpack-paypal-payments' ); @@ -192,10 +186,9 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b 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' ), + // The price field is hidden once the options are priced, so don't validate + // it - an error on an invisible field would disable Save with nothing to fix. + price: usesVariantPricing ? null : validatePrice( price, currencyCode || 'USD' ), productDescription: validateDescription( productDescription ), currencyCode: currencyCode && ! VALID_CURRENCY_CODES.has( currencyCode ) @@ -583,27 +576,29 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b />
-
- setAttributes( { price: value } ) } - onBlur={ () => markTouched( 'price' ) } - disabled={ isCreating } - type="number" - min={ priceStep } - step={ priceStep } - placeholder={ pricePlaceholder } - help={ - touchedFields.price && validationErrors.price - ? validationErrors.price - : ( usesVariantPricing && helpPriceFromOptions ) || undefined - } - className={ - touchedFields.price && validationErrors.price ? 'has-error' : undefined - } - /> -
+ { ! usesVariantPricing && ( +
+ setAttributes( { price: value } ) } + onBlur={ () => markTouched( 'price' ) } + disabled={ isCreating } + type="number" + min={ priceStep } + step={ priceStep } + placeholder={ pricePlaceholder } + help={ + touchedFields.price && validationErrors.price + ? validationErrors.price + : undefined + } + className={ + touchedFields.price && validationErrors.price ? 'has-error' : undefined + } + /> +
+ ) } { } ); } ); + describe( 'Per-variant pricing', () => { + beforeEach( () => { + apiFetch.mockResolvedValue( { connected: true, environment: 'sandbox' } ); + } ); + + /** + * Build a variants structure with one primary option group. + * + * A price on any option means PayPal takes the amounts from the options, + * not from the product. + * + * @param {Array} prices - One price per option; '' means unpriced. + * @return {object} Variants structure. + */ + const variantsWithPrices = prices => ( { + dimensions: [ + { + _key: 'grp-1', + name: 'Size', + primary: true, + options: prices.map( ( value, i ) => ( { + _key: `opt-${ i }`, + label: `Option ${ i + 1 }`, + unit_amount: { currency_code: 'USD', value }, + } ) ), + }, + ], + } ); + + // The variant builder gives every option in the primary group its own + // 'Price' control, so the product price is looked up inside Details. + const details = () => + within( + screen + .getAllByTestId( 'panel-body' ) + .find( p => p.getAttribute( 'data-title' ) === 'Details' ) + ); + + it( 'drops the price field and keeps the currency select', async () => { + render( + + ); + + await expect( screen.findByLabelText( 'Currency' ) ).resolves.toBeInTheDocument(); + expect( details().queryByLabelText( 'Price' ) ).not.toBeInTheDocument(); + } ); + + it( 'keeps the price field while the options carry no prices', async () => { + render( + + ); + + await expect( screen.findByLabelText( 'Currency' ) ).resolves.toBeInTheDocument(); + expect( details().getByLabelText( 'Price' ) ).toBeInTheDocument(); + } ); + + it( 'ignores a price left behind from before the options were priced', async () => { + render( + + ); + + await expect( screen.findByText( 'Create New' ) ).resolves.toBeInTheDocument(); + expect( screen.getByText( 'Create New' ) ).toBeEnabled(); + } ); + + it( 'gates the save button again once the option prices are cleared', async () => { + const attributes = { + productName: 'Test Widget', + price: '0', + currencyCode: 'USD', + variantsEnabled: true, + variants: variantsWithPrices( [ '10.00', '20.00' ] ), + }; + + const { rerender } = render( + + ); + + await expect( screen.findByText( 'Create New' ) ).resolves.toBeInTheDocument(); + + rerender( + + ); + + expect( details().getByLabelText( 'Price' ) ).toHaveValue( 0 ); + expect( screen.getByText( 'Create New' ) ).toBeDisabled(); + } ); + + it( 'leaves the product amount out of the create request', async () => { + const user = userEvent.setup(); + + apiFetch + .mockResolvedValueOnce( { connected: true, environment: 'sandbox' } ) + .mockResolvedValueOnce( { + id: 'PLB-TEST123', + payment_link: 'https://www.paypal.com/paymentpage/PLB-TEST123', + } ); + + render( + + ); + + await expect( screen.findByText( 'Create New' ) ).resolves.toBeInTheDocument(); + await user.click( screen.getByText( 'Create New' ) ); + + // PayPal rejects a request carrying unit_amount at both levels. + const [ , create ] = apiFetch.mock.calls; + expect( create[ 0 ].method ).toBe( 'POST' ); + expect( create[ 0 ].data.line_items[ 0 ].name ).toBe( 'Test Widget' ); + expect( create[ 0 ].data.line_items[ 0 ] ).not.toHaveProperty( 'unit_amount' ); + } ); + } ); + describe( 'Shared payment resource', () => { const attributes = { isApiManaged: true, From b320255a0e723762a777205b466c9945efa5f9aa Mon Sep 17 00:00:00 2001 From: Julian Strahan Date: Wed, 9 Sep 2026 12:24:44 -0700 Subject: [PATCH 23/34] PayPal Payment Buttons: cap the custom checkout fields at two --- .../fix-paypal-custom-checkout-fields-cap | 4 + .../edit-api-managed.jsx | 3 +- .../utils/validation.js | 2 + .../edit.test.jsx | 98 +++++++++++++++++++ 4 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 projects/packages/paypal-payments/changelog/fix-paypal-custom-checkout-fields-cap diff --git a/projects/packages/paypal-payments/changelog/fix-paypal-custom-checkout-fields-cap b/projects/packages/paypal-payments/changelog/fix-paypal-custom-checkout-fields-cap new file mode 100644 index 000000000000..a8b5d9ac6541 --- /dev/null +++ b/projects/packages/paypal-payments/changelog/fix-paypal-custom-checkout-fields-cap @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Stop offering a third custom checkout field, which PayPal rejects. 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 index cbd19317df59..7983b06dd9bd 100644 --- 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 @@ -48,6 +48,7 @@ import { API_BASE } from './utils/api-base'; import { SUPPORTED_CURRENCIES, VALID_CURRENCY_CODES } from './utils/currencies'; import { getPriceStep } from './utils/currency-symbols'; import { + MAX_CUSTOMER_NOTES, MAX_DESCRIPTION_LENGTH, MAX_NAME_LENGTH, validatePrice, @@ -855,7 +856,7 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b
) ) } - { customerNotes.length < 5 && ( + { customerNotes.length < MAX_CUSTOMER_NOTES && ( -
-
+ onTouch( fieldKey( null, 'name' ) ) } + placeholder={ index === 0 ? placeholderColor : placeholderSize } + disabled={ disabled } + help={ errorFor( null, 'name' ) } + className={ errorFor( null, 'name' ) ? 'has-error' : undefined } + /> + + /> ) } ) ) } @@ -426,6 +442,8 @@ export default function VariantBuilder( { onTouch, } ) { const dimensions = variants?.dimensions || []; + const primaryIndex = getPrimaryIndex( dimensions ); + const pricingOn = primaryIndex !== -1; const lastGroupRef = useRef( null ); const [ focusNewGroup, setFocusNewGroup ] = useState( false ); @@ -441,7 +459,7 @@ export default function VariantBuilder( { if ( newEnabled && dimensions.length === 0 ) { onChange( { variantsEnabled: true, - variants: { dimensions: [ createGroup( true ) ] }, + variants: { dimensions: [ createGroup() ] }, } ); } else { onChange( { variantsEnabled: newEnabled } ); @@ -464,28 +482,40 @@ export default function VariantBuilder( { } ); }; - const setPrimary = dimIndex => { + // PayPal prices the primary group only, so turning pricing on marks a group primary. + // New buttons use the first group; one that already prices a later group keeps it, + // so its prices survive the save. + const setPricingEnabled = on => { + const target = primaryIndex === -1 ? 0 : primaryIndex; + const newDimensions = dimensions.map( ( dim, i ) => ( { ...dim, - primary: i === dimIndex, - options: - i === dimIndex - ? dim.options.map( opt => ( { + primary: on && i === target, + options: ( dim.options || [] ).map( opt => + on && i === target + ? { ...opt, unit_amount: opt.unit_amount || { currency_code: currencyCode, value: '' }, - } ) ) - : dim.options.map( ( { _key, label } ) => ( { _key, label } ) ), + } + : withoutAmount( opt ) + ), } ) ); + onChange( { variants: { dimensions: newDimensions } } ); }; + // New buttons always price the first group, but a payment created elsewhere can + // price a later one - name that group instead of claiming it is the first. + const pricingHelp = + primaryIndex > 0 ? sprintf( helpPricingOtherGroup, primaryIndex + 1 ) : helpPricingFirstGroup; + const addGroup = () => { if ( dimensions.length >= MAX_GROUPS ) { return; } onChange( { variants: { - dimensions: [ ...dimensions, createGroup( false ) ], + dimensions: [ ...dimensions, createGroup() ], }, } ); setFocusNewGroup( true ); @@ -512,6 +542,16 @@ export default function VariantBuilder( {

) } + { dimensions.length > 0 && ( + + ) } + { dimensions.map( ( dimension, dimIndex ) => (
updateDimension( dimIndex, newDim ) } onRemove={ () => removeDimension( dimIndex ) } - onSetPrimary={ setPrimary } disabled={ disabled } />
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 index b4f30289a23b..59cf446fce31 100644 --- 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 @@ -39,13 +39,17 @@ 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 VariantBuilder, { hasVariantPricing, validateVariants } from './components/variant-builder'; +import VariantBuilder, { + hasVariantPricing, + isVariantPricingOn, + 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 { SUPPORTED_CURRENCIES, VALID_CURRENCY_CODES } from './utils/currencies'; -import { getPriceStep } from './utils/currency-symbols'; +import { getPricePlaceholder, getPriceStep } from './utils/currency-symbols'; import { MAX_CUSTOMER_NOTES, MAX_DESCRIPTION_LENGTH, @@ -105,7 +109,7 @@ export default function ApiManagedEdit( { attributes, setAttributes } ) { // PayPal rejects any decimal in JPY, HUF and TWD, so the input must not offer one. const priceStep = getPriceStep( currencyCode || 'USD' ); - const pricePlaceholder = priceStep === '1' ? '1500' : '29.99'; + const pricePlaceholder = getPricePlaceholder( currencyCode || 'USD' ); const blockProps = useBlockProps(); @@ -180,6 +184,18 @@ export default function ApiManagedEdit( { attributes, setAttributes } ) { [ variantsEnabled, variants ] ); + /** + * Whether per-variant pricing is turned on. + * + * The product price field goes as soon as the toggle is on, before any option price + * is typed - unlike `usesVariantPricing` above, which keys on the prices themselves + * because the server sanitiser reads the same thing. + */ + const variantPricingOn = useMemo( + () => isVariantPricingOn( variantsEnabled, variants ), + [ variantsEnabled, variants ] + ); + // PERCENTAGE is the only type that carries a rate, and a missing type counts as one - // the request builder sends PERCENTAGE for it. PayPal also accepts FLAT, which this // form cannot produce but can be handed by a link created elsewhere. @@ -192,9 +208,9 @@ export default function ApiManagedEdit( { attributes, setAttributes } ) { const validationErrors = useMemo( () => ( { productName: validateProductName( productName ), - // The price field is hidden once the options are priced, so don't validate + // The price field is hidden once per-variant pricing is on, so don't validate // it - an error on an invisible field would disable Save with nothing to fix. - price: usesVariantPricing ? null : validatePrice( price, currencyCode || 'USD' ), + price: variantPricingOn ? null : validatePrice( price, currencyCode || 'USD' ), productDescription: validateDescription( productDescription ), returnUrl: validateReturnUrl( returnUrl ), taxValue: taxEnabled && taxIsPercentage ? validateTaxRate( taxValue ) : null, @@ -209,7 +225,7 @@ export default function ApiManagedEdit( { attributes, setAttributes } ) { productDescription, returnUrl, currencyCode, - usesVariantPricing, + variantPricingOn, taxEnabled, taxIsPercentage, taxValue, @@ -227,7 +243,7 @@ export default function ApiManagedEdit( { attributes, setAttributes } ) { /** * Whether the form is valid (no validation errors on required fields or variants). */ - // The price field only hides while the options carry prices, so seeing it with product + // The price field only hides while per-variant pricing is on, so seeing it with product // options on means the merchant has been in the pricing UI - say what is wrong rather // than wait for a blur on a field they never asked for. const priceError = @@ -582,7 +598,7 @@ export default function ApiManagedEdit( { attributes, setAttributes } ) { />
- { ! usesVariantPricing && ( + { ! variantPricingOn && (
1 ? { adjustable_quantity: { maximum: parseInt( maxQuantity, 10 ) } } : {} ), diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/currency-symbols.js b/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/currency-symbols.js index 0c48f487fad4..38bcc93034a0 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/currency-symbols.js +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/currency-symbols.js @@ -51,3 +51,13 @@ export const ZERO_DECIMAL_CURRENCIES = new Set( [ 'HUF', 'JPY', 'TWD' ] ); export function getPriceStep( currencyCode ) { return ZERO_DECIMAL_CURRENCIES.has( currencyCode ) ? '1' : '0.01'; } + +/** + * An example price to show in an empty price field. + * + * @param {string} currencyCode - The ISO currency code. + * @return {string} A whole amount for a zero-decimal currency, 29.99 otherwise. + */ +export function getPricePlaceholder( currencyCode ) { + return ZERO_DECIMAL_CURRENCIES.has( currencyCode ) ? '1500' : '29.99'; +} diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/resource-sync.js b/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/resource-sync.js index dec4feda00e9..a6c3cd7c29ab 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/resource-sync.js +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/resource-sync.js @@ -62,7 +62,9 @@ function comparableVariants( variants ) { * Put a payment's variants into the shape the variant builder edits. * * Adds the `_key`s the builder uses for React keys, and infers `primary` from - * where the prices are when the payment does not carry the flag. + * where the prices are when the payment does not carry the flag. `primary` is + * what the form reads for "per-variant pricing is on", so a payment with no + * prices at all leaves every group unflagged rather than guessing at the first. * * @param {object} variants - Variants data from the payment. * @return {object|null} Editable variants, or null when there are none. @@ -77,13 +79,12 @@ export function normalizeResourceVariants( variants ) { const pricedIndex = dimensions.findIndex( dim => ( dim.options || [] ).some( opt => `${ opt.unit_amount?.value ?? '' }`.trim() !== '' ) ); - const primaryIndex = pricedIndex === -1 ? 0 : pricedIndex; return { dimensions: dimensions.map( ( dim, i ) => ( { ...dim, _key: dim._key || `rs-${ nextKey++ }`, - primary: hasPrimaryFlag ? !! dim.primary : i === primaryIndex, + primary: hasPrimaryFlag ? !! dim.primary : pricedIndex === i, options: ( dim.options || [] ).map( opt => ( { ...opt, _key: opt._key || `rs-${ nextKey++ }`, @@ -92,6 +93,35 @@ export function normalizeResourceVariants( variants ) { }; } +/** + * Retag every option price with the product's currency. + * + * An option carries its own `currency_code`, written when the price was typed. + * Changing the product currency afterwards does not rewrite them, so a group + * priced in USD and then switched to EUR would ship as USD. + * + * @param {object} variants - The variants data. + * @param {string} currencyCode - Product currency. + * @return {object} Variants with every priced option in that currency. + */ +export function withCurrency( variants, currencyCode ) { + if ( ! variants?.dimensions?.length ) { + return variants; + } + + return { + ...variants, + dimensions: variants.dimensions.map( dim => ( { + ...dim, + options: ( dim.options || [] ).map( opt => + opt.unit_amount + ? { ...opt, unit_amount: { ...opt.unit_amount, currency_code: currencyCode } } + : opt + ), + } ) ), + }; +} + /** * Whether two attribute values mean the same thing. * diff --git a/projects/packages/paypal-payments/tests/js/currencies.test.js b/projects/packages/paypal-payments/tests/js/currencies.test.js index 445c42b4f95e..3eb653b5a177 100644 --- a/projects/packages/paypal-payments/tests/js/currencies.test.js +++ b/projects/packages/paypal-payments/tests/js/currencies.test.js @@ -10,7 +10,10 @@ import { SUPPORTED_CURRENCIES, VALID_CURRENCY_CODES, } from '../../src/paypal-payment-buttons/utils/currencies'; -import { CURRENCY_SYMBOLS } from '../../src/paypal-payment-buttons/utils/currency-symbols'; +import { + CURRENCY_SYMBOLS, + getPricePlaceholder, +} from '../../src/paypal-payment-buttons/utils/currency-symbols'; describe( 'VALID_CURRENCY_CODES', () => { it.each( [ 'USD', 'EUR', 'GBP', 'JPY' ] )( 'contains %s', code => { @@ -41,3 +44,13 @@ describe( 'SUPPORTED_CURRENCIES', () => { expect( SUPPORTED_CURRENCIES ).toHaveLength( 24 ); } ); } ); + +describe( 'getPricePlaceholder', () => { + it( 'shows a decimal amount for a currency PayPal prices in cents', () => { + expect( getPricePlaceholder( 'USD' ) ).toBe( '29.99' ); + } ); + + it( 'shows a whole amount for a currency PayPal prices whole', () => { + expect( getPricePlaceholder( 'JPY' ) ).toBe( '1500' ); + } ); +} ); 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 ab2fed2c6b1c..9ca16475cdd5 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 @@ -1402,8 +1402,8 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => { /** * Build a variants structure with one primary option group. * - * A price on any option means PayPal takes the amounts from the options, - * not from the product. + * The group is primary, so PayPal takes the amounts from the options rather + * than from the product. * * @param {Array} prices - One price per option; '' means unpriced. * @return {object} Variants structure. @@ -1449,7 +1449,7 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => { expect( details().queryByLabelText( 'Price' ) ).not.toBeInTheDocument(); } ); - it( 'keeps the price field while the options carry no prices', async () => { + it( 'drops the price field before a single option price is typed', async () => { render( { /> ); + await expect( screen.findByLabelText( 'Currency' ) ).resolves.toBeInTheDocument(); + expect( details().queryByLabelText( 'Price' ) ).not.toBeInTheDocument(); + } ); + + it( 'keeps the price field while no group is primary', async () => { + const variants = variantsWithPrices( [ '', '' ] ); + variants.dimensions[ 0 ].primary = false; + + render( + + ); + await expect( screen.findByLabelText( 'Currency' ) ).resolves.toBeInTheDocument(); expect( details().getByLabelText( 'Price' ) ).toBeInTheDocument(); } ); @@ -1486,7 +1507,7 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => { expect( screen.getByText( 'Create New' ) ).toBeEnabled(); } ); - it( 'gates the save button again once the option prices are cleared', async () => { + it( 'gates the save button on the options once their prices are cleared', async () => { const attributes = { productName: 'Test Widget', price: '0', @@ -1508,12 +1529,114 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => { /> ); - expect( details().getByLabelText( 'Price' ) ).toHaveValue( 0 ); + // Pricing stays on, so the product price field stays away and the options + // themselves carry the error rather than handing it back to a hidden field. + expect( details().queryByLabelText( 'Price' ) ).not.toBeInTheDocument(); expect( screen.getByText( 'Create New' ) ).toBeDisabled(); - // The field only reappeared because the option prices were cleared, so it says - // what is wrong instead of waiting for a blur that may never come. - expect( details().getByText( 'Price must be a positive number.' ) ).toBeInTheDocument(); - expect( details().getByTestId( 'control-Price' ) ).toHaveClass( 'has-error' ); + expect( screen.getAllByText( 'Price is required.' ) ).toHaveLength( 2 ); + } ); + + it( 'prices the first group and only the first group', async () => { + const user = userEvent.setup(); + + render( + + ); + + await user.click( await screen.findByLabelText( 'Add price per variant' ) ); + + const { dimensions } = setAttributes.mock.lastCall[ 0 ].variants; + expect( dimensions.map( dim => dim.primary ) ).toEqual( [ true, false ] ); + expect( dimensions[ 0 ].options[ 0 ].unit_amount.currency_code ).toBe( 'EUR' ); + expect( dimensions[ 1 ].options[ 0 ] ).not.toHaveProperty( 'unit_amount' ); + } ); + + it( 'takes the option prices away when pricing is turned off', async () => { + const user = userEvent.setup(); + + render( + + ); + + await user.click( await screen.findByLabelText( 'Add price per variant' ) ); + + const [ dimension ] = setAttributes.mock.lastCall[ 0 ].variants.dimensions; + expect( dimension.primary ).toBe( false ); + expect( dimension.options.every( opt => ! opt.unit_amount ) ).toBe( true ); + // Turning pricing off drops the prices only - labels and keys stay. + expect( dimension.options.map( opt => opt.label ) ).toEqual( [ 'Option 1', 'Option 2' ] ); + expect( dimension.options.map( opt => opt._key ) ).toEqual( [ 'opt-0', 'opt-1' ] ); + } ); + + // A payment created outside the block can price a later group; moving it to the + // first would drop those prices on the next save. + it( 'leaves a payment that prices a later group alone', async () => { + const user = userEvent.setup(); + const priced = variantsWithPrices( [ '10.00', '20.00' ] ).dimensions[ 0 ]; + + render( + + ); + + await expect( + screen.findByText( 'Prices are set on option group 2.' ) + ).resolves.toBeInTheDocument(); + expect( screen.getByLabelText( 'Add price per variant' ) ).toBeChecked(); + + // Turning pricing off drops the prices from the group that has them, not from + // the one a new button would have used. + await user.click( screen.getByLabelText( 'Add price per variant' ) ); + + const { dimensions } = setAttributes.mock.lastCall[ 0 ].variants; + expect( dimensions.some( dim => dim.primary ) ).toBe( false ); + expect( dimensions[ 1 ].options.every( opt => ! opt.unit_amount ) ).toBe( true ); } ); it( 'leaves the product amount out of the create request', async () => { @@ -1548,6 +1671,40 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => { expect( create[ 0 ].data.line_items[ 0 ].name ).toBe( 'Test Widget' ); expect( create[ 0 ].data.line_items[ 0 ] ).not.toHaveProperty( 'unit_amount' ); } ); + + it( 'sends the option prices in the currency the product is in', async () => { + const user = userEvent.setup(); + + apiFetch + .mockResolvedValueOnce( { connected: true, environment: 'sandbox' } ) + .mockResolvedValueOnce( { + id: 'PLB-TEST124', + payment_link: 'https://www.paypal.com/paymentpage/PLB-TEST124', + } ); + + render( + + ); + + await expect( screen.findByText( 'Create New' ) ).resolves.toBeInTheDocument(); + await user.click( screen.getByText( 'Create New' ) ); + + const [ , create ] = apiFetch.mock.calls; + const [ dimension ] = create[ 0 ].data.line_items[ 0 ].variants.dimensions; + expect( dimension.options.map( opt => opt.unit_amount.currency_code ) ).toEqual( [ + 'EUR', + 'EUR', + ] ); + } ); } ); describe( 'Product option errors', () => { @@ -1565,7 +1722,7 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => { const group = ( key, overrides = {} ) => ( { _key: key, name: '', - primary: true, + primary: false, options: [ { _key: `${ key }-o1`, label: '' } ], ...overrides, } ); @@ -1729,7 +1886,7 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => { it( 'keeps one group quiet while the merchant works in another', async () => { const user = userEvent.setup(); - renderWith( [ group( 'g1' ), group( 'g2', { primary: false } ) ] ); + renderWith( [ group( 'g1' ), group( 'g2' ) ] ); await visit( user, await screen.findByRole( 'textbox', { name: 'Option group 2' } ) ); @@ -1741,11 +1898,11 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => { ).not.toBeInTheDocument(); } ); - it( 'reports an unpriced option the merchant never visited', async () => { - const user = userEvent.setup(); + it( 'reports an unpriced option without waiting for a visit', async () => { renderWith( [ group( 'g1', { name: 'Size', + primary: true, options: [ { _key: 'o1', label: 'Small', unit_amount: { currency_code: 'USD', value: '10.00' } }, { _key: 'o2', label: 'Large', unit_amount: { currency_code: 'USD', value: '' } }, @@ -1753,14 +1910,16 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => { } ), ] ); - // Pricing is all-or-nothing across the group, so pricing one option is what - // makes the other one wrong. Touching either price reveals both. - const prices = await screen.findAllByLabelText( 'Price' ); - await visit( user, prices[ 0 ] ); + // Turning pricing on is the interaction, so an option with no price says so + // rather than waiting for a blur on a field nobody has reached. + await expect( screen.findAllByLabelText( 'Price' ) ).resolves.toHaveLength( 2 ); - expect( screen.getByText( 'Price is required.' ) ).toBeInTheDocument(); - // The priced option keeps its help text. - expect( screen.getAllByText( /Price every option in this group/ ) ).toHaveLength( 1 ); + // Every option renders its own control labelled 'Price', so the message has to + // be checked against the one that is missing a price rather than the panel. + const [ priced, unpriced ] = screen.getAllByTestId( 'control-Price' ); + expect( within( unpriced ).getByText( 'Price is required.' ) ).toBeInTheDocument(); + expect( unpriced ).toHaveClass( 'has-error' ); + expect( within( priced ).queryByText( 'Price is required.' ) ).not.toBeInTheDocument(); } ); } ); diff --git a/projects/packages/paypal-payments/tests/js/resource-sync.test.js b/projects/packages/paypal-payments/tests/js/resource-sync.test.js index 00b87db8b7de..55e83dd6617a 100644 --- a/projects/packages/paypal-payments/tests/js/resource-sync.test.js +++ b/projects/packages/paypal-payments/tests/js/resource-sync.test.js @@ -7,6 +7,7 @@ import { getResourceAttributeUpdates, normalizeResourceVariants, + withCurrency, } from '../../src/paypal-payment-buttons/utils/resource-sync'; const blockAttributes = { @@ -99,6 +100,31 @@ describe( 'getResourceAttributeUpdates', () => { expect( updates.variants.dimensions[ 0 ].options[ 0 ].unit_amount.value ).toBe( '12.50' ); } ); + // The mount GET runs through here, so inventing a primary group would turn the + // pricing toggle on behind the merchant and brick the form on a saved button. + it( 'leaves the pricing toggle off for a saved button with no per-option prices', () => { + const groups = dimensions => ( { dimensions } ); + const current = { + ...blockAttributes, + variantsEnabled: true, + variants: groups( [ + { + _key: 'vb-1', + name: 'Color', + primary: false, + options: [ { _key: 'vb-2', label: 'Red' } ], + }, + ] ), + }; + const fromResource = { + ...resourceAttributes, + variantsEnabled: true, + variants: groups( [ { name: 'Color', options: [ { label: 'Red' } ] } ] ), + }; + + expect( getResourceAttributeUpdates( current, fromResource ) ).toEqual( {} ); + } ); + it( 'treats editor keys and empty per-option amounts as no difference', () => { const current = { ...blockAttributes, @@ -160,7 +186,10 @@ describe( 'normalizeResourceVariants', () => { expect( normalized.dimensions.map( dim => dim.primary ) ).toEqual( [ false, true ] ); } ); - it( 'falls back to the first group when nothing is priced or flagged', () => { + // `primary` is what the form reads for "per-variant pricing is on", so guessing at + // the first group here would reopen a saved button with the product price field gone + // and a price error on every option. + it( 'leaves every group unflagged when nothing is priced or flagged', () => { const normalized = normalizeResourceVariants( { dimensions: [ { name: 'Color', options: [ { label: 'Red' } ] }, @@ -168,7 +197,7 @@ describe( 'normalizeResourceVariants', () => { ], } ); - expect( normalized.dimensions.map( dim => dim.primary ) ).toEqual( [ true, false ] ); + expect( normalized.dimensions.map( dim => dim.primary ) ).toEqual( [ false, false ] ); } ); it( 'keeps the flag when the payment carries one', () => { @@ -182,3 +211,46 @@ describe( 'normalizeResourceVariants', () => { expect( normalized.dimensions.map( dim => dim.primary ) ).toEqual( [ false, true ] ); } ); } ); + +describe( 'withCurrency', () => { + it( 'retags every priced option, in every group', () => { + const variants = withCurrency( + { + dimensions: [ + { + name: 'Color', + primary: true, + options: [ + { label: 'Black', unit_amount: { currency_code: 'USD', value: '10.00' } }, + { label: 'White', unit_amount: { currency_code: 'USD', value: '20.00' } }, + ], + }, + { + name: 'Size', + primary: false, + options: [ { label: 'Small', unit_amount: { currency_code: 'GBP', value: '5.00' } } ], + }, + ], + }, + 'EUR' + ); + + expect( + variants.dimensions.flatMap( dim => dim.options.map( opt => opt.unit_amount.currency_code ) ) + ).toEqual( [ 'EUR', 'EUR', 'EUR' ] ); + } ); + + it( 'leaves an unpriced option without an amount', () => { + const variants = withCurrency( + { dimensions: [ { name: 'Size', primary: false, options: [ { label: 'S' } ] } ] }, + 'EUR' + ); + + expect( variants.dimensions[ 0 ].options[ 0 ] ).toEqual( { label: 'S' } ); + } ); + + it( 'passes through variants it cannot price', () => { + expect( withCurrency( null, 'EUR' ) ).toBeNull(); + expect( withCurrency( { dimensions: [] }, 'EUR' ) ).toEqual( { dimensions: [] } ); + } ); +} ); diff --git a/projects/packages/paypal-payments/tests/js/variant-pricing.test.js b/projects/packages/paypal-payments/tests/js/variant-pricing.test.js index f52820f67510..9e069174cd23 100644 --- a/projects/packages/paypal-payments/tests/js/variant-pricing.test.js +++ b/projects/packages/paypal-payments/tests/js/variant-pricing.test.js @@ -1,9 +1,9 @@ /** * Tests for variant pricing helpers. * - * PayPal rejects a line item that carries `unit_amount` at both the product - * level and the variant level, so per-option prices replace the product-level - * price and are all-or-nothing across the group. + * PayPal rejects a line item that carries `unit_amount` at both the product level + * and the variant level, so per-option prices replace the product price. Only the + * primary group is priced, and one toggle marks it. * * @package */ @@ -11,6 +11,7 @@ import { getPrimaryDimension, hasVariantPricing, + isVariantPricingOn, validateVariants, } from '../../src/paypal-payment-buttons/components/variant-builder'; @@ -74,13 +75,61 @@ describe( 'hasVariantPricing', () => { } ); } ); +describe( 'isVariantPricingOn', () => { + it( 'is true as soon as a group is primary, before any price is typed', () => { + expect( isVariantPricingOn( true, variantsWithPrices( [ '', '' ] ) ) ).toBe( true ); + } ); + + it( 'is false when no group is primary', () => { + const variants = variantsWithPrices( [ '10.00' ] ); + variants.dimensions[ 0 ].primary = false; + + expect( isVariantPricingOn( true, variants ) ).toBe( false ); + } ); + + it( 'is false when variants are disabled', () => { + expect( isVariantPricingOn( false, variantsWithPrices( [ '10.00' ] ) ) ).toBe( false ); + } ); +} ); + describe( 'validateVariants', () => { it( 'accepts a group where every option is priced', () => { expect( validateVariants( true, variantsWithPrices( [ '10.00', '20.00' ] ) ) ).toEqual( [] ); } ); - it( 'accepts a group where no option is priced', () => { - expect( validateVariants( true, variantsWithPrices( [ '', '' ] ) ) ).toEqual( [] ); + it( 'requires a price on every option once the group is primary', () => { + const errors = validateVariants( true, variantsWithPrices( [ '', '' ] ) ); + + expect( errors ).toEqual( [ + { group: 0, option: 0, field: 'price', message: 'Price is required.' }, + { group: 0, option: 1, field: 'price', message: 'Price is required.' }, + ] ); + } ); + + it( 'asks for no price at all when no group is primary', () => { + const variants = variantsWithPrices( [ '', '' ] ); + variants.dimensions[ 0 ].primary = false; + + expect( validateVariants( true, variants ) ).toEqual( [] ); + } ); + + it( 'ignores an amount left on a group that is not primary', () => { + const variants = { + dimensions: [ + { + name: 'Color', + primary: true, + options: [ { label: 'Black', unit_amount: { currency_code: 'USD', value: '10.00' } } ], + }, + { + name: 'Size', + primary: false, + options: [ { label: 'S', unit_amount: { currency_code: 'USD', value: 'nonsense' } } ], + }, + ], + }; + + expect( validateVariants( true, variants ) ).toEqual( [] ); } ); it( 'rejects a partially priced group, against the option that is missing a price', () => { @@ -118,15 +167,32 @@ describe( 'validateVariants', () => { ); } ); + it( 'wants something to price in a group that carries the prices', () => { + const errors = validateVariants( true, { + dimensions: [ { name: 'Size', primary: true, options: [] } ], + } ); + + expect( errors ).toEqual( [ + { + group: 0, + option: null, + field: 'options', + message: 'Add at least one option to price.', + }, + ] ); + } ); + it( 'still requires group names and option labels', () => { const errors = validateVariants( true, { dimensions: [ { name: '', primary: true, options: [ { label: '' } ] } ], } ); // The group name error belongs to no single option, so it carries option: null. + // The group is primary, so its option needs a price too. expect( errors ).toEqual( [ { group: 0, option: null, field: 'name', message: 'Option group name is required.' }, { group: 0, option: 0, field: 'label', message: 'Option name is required.' }, + { group: 0, option: 0, field: 'price', message: 'Price is required.' }, ] ); } ); From 944ee07d0e0de0e3b107f004db0a0d683ea88d77 Mon Sep 17 00:00:00 2001 From: Julian Strahan Date: Wed, 9 Sep 2026 16:40:11 -0700 Subject: [PATCH 31/34] PayPal Payment Buttons: call an option group a variant, and give it the whole column --- .../changelog/update-paypal-variant-labels | 4 ++ .../components/variant-builder.jsx | 72 ++++++++----------- .../src/paypal-payment-buttons/editor.scss | 28 +++----- .../edit.test.jsx | 60 ++++++++++++---- .../tests/js/variant-pricing.test.js | 2 +- 5 files changed, 93 insertions(+), 73 deletions(-) create mode 100644 projects/packages/paypal-payments/changelog/update-paypal-variant-labels diff --git a/projects/packages/paypal-payments/changelog/update-paypal-variant-labels b/projects/packages/paypal-payments/changelog/update-paypal-variant-labels new file mode 100644 index 000000000000..443936eb811e --- /dev/null +++ b/projects/packages/paypal-payments/changelog/update-paypal-variant-labels @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Use the design's wording in Product Options: an option group is a variant. diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/variant-builder.jsx b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/variant-builder.jsx index 11f5c54afc6e..9b011cb3f323 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/variant-builder.jsx +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/variant-builder.jsx @@ -9,30 +9,22 @@ * @since 0.9.0 */ -import { Button, TextControl, ToggleControl } from '@wordpress/components'; +import { Button, CheckboxControl, TextControl, ToggleControl } from '@wordpress/components'; import { useRef, useEffect, useState } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import GridiconTrash from 'gridicons/dist/trash'; -import { getPricePlaceholder, getPriceStep } from '../utils/currency-symbols'; +import { getPriceStep } from '../utils/currency-symbols'; import { validatePrice } from '../utils/validation'; // Pre-extract translated strings used in ternaries to avoid i18n build errors. -const placeholderColor = __( 'e.g., Color', 'jetpack-paypal-payments' ); -const placeholderSize = __( 'e.g., Size', 'jetpack-paypal-payments' ); -const helpVariantsOn = __( - 'Customers choose options (e.g., size, color) at checkout.', - 'jetpack-paypal-payments' -); -const helpVariantsOff = __( - 'Add size, color, or other product options.', +const helpVariants = __( + 'Options for your item, up to 5 variations in color, size, type, etc.', 'jetpack-paypal-payments' ); const helpPricingFirstGroup = __( 'You can only set the price for your first variant.', 'jetpack-paypal-payments' ); -/* translators: %d: option group number */ -const helpPricingOtherGroup = __( 'Prices are set on option group %d.', 'jetpack-paypal-payments' ); const MAX_GROUPS = 5; const MAX_OPTIONS = 10; @@ -171,7 +163,7 @@ export function validateVariants( enabled, variants, currencyCode = 'USD' ) { group: i, option: null, field: 'name', - message: __( 'Option group name is required.', 'jetpack-paypal-payments' ), + message: __( 'Variant name is required.', 'jetpack-paypal-payments' ), } ); } @@ -298,23 +290,24 @@ function GroupEditor( { const groupLabel = group.name || sprintf( - /* translators: %d: group number */ - __( 'Option group %d', 'jetpack-paypal-payments' ), + /* translators: %d: variant number */ + __( 'Variant %d', 'jetpack-paypal-payments' ), index + 1 ); return (
onTouch( fieldKey( null, 'name' ) ) } - placeholder={ index === 0 ? placeholderColor : placeholderSize } + placeholder={ __( 'Enter variant name', 'jetpack-paypal-payments' ) } disabled={ disabled } help={ errorFor( null, 'name' ) } className={ errorFor( null, 'name' ) ? 'has-error' : undefined } @@ -328,7 +321,7 @@ function GroupEditor( { disabled={ disabled } label={ sprintf( /* translators: %s: group name */ - __( 'Remove option group "%s"', 'jetpack-paypal-payments' ), + __( 'Remove variant "%s"', 'jetpack-paypal-payments' ), groupLabel ) } /> @@ -349,7 +342,7 @@ function GroupEditor( { value={ option.label } onChange={ label => updateOption( optIndex, { label } ) } onBlur={ () => onTouch( fieldKey( optIndex, 'label' ) ) } - placeholder={ __( 'e.g., Black', 'jetpack-paypal-payments' ) } + placeholder={ __( 'Enter option name', 'jetpack-paypal-payments' ) } disabled={ disabled } help={ errorFor( optIndex, 'label' ) } className={ errorFor( optIndex, 'label' ) ? 'has-error' : undefined } @@ -357,6 +350,7 @@ function GroupEditor( { { group.primary && ( updateOption( optIndex, { @@ -369,7 +363,7 @@ function GroupEditor( { type="number" min={ getPriceStep( currencyCode ) } step={ getPriceStep( currencyCode ) } - placeholder={ getPricePlaceholder( currencyCode ) } + placeholder={ __( 'Price', 'jetpack-paypal-payments' ) } disabled={ disabled } help={ errorFor( optIndex, 'price' ) } className={ errorFor( optIndex, 'price' ) ? 'has-error' : undefined } @@ -397,8 +391,8 @@ function GroupEditor( {
{ group.options.length < MAX_OPTIONS ? ( - ) : (

@@ -507,7 +501,13 @@ export default function VariantBuilder( { // New buttons always price the first group, but a payment created elsewhere can // price a later one - name that group instead of claiming it is the first. const pricingHelp = - primaryIndex > 0 ? sprintf( helpPricingOtherGroup, primaryIndex + 1 ) : helpPricingFirstGroup; + primaryIndex > 0 + ? sprintf( + /* translators: %d: variant number */ + __( 'Prices are set on variant %d.', 'jetpack-paypal-payments' ), + primaryIndex + 1 + ) + : helpPricingFirstGroup; const addGroup = () => { if ( dimensions.length >= MAX_GROUPS ) { @@ -524,8 +524,8 @@ export default function VariantBuilder( { return (

{ __( - 'No option groups yet. Click "Add option group" to create one. An option group is a product attribute like Color or Size — add choices within each group.', + 'No variants yet. Click "Add Variant" to create one. A variant is a product attribute like Color or Size — add options within each one.', 'jetpack-paypal-payments' ) }

) } { dimensions.length > 0 && ( - - { dimensions.length > 0 && ( - - { sprintf( - /* translators: 1: current count, 2: maximum */ - __( '%1$d / %2$d groups', 'jetpack-paypal-payments' ), - dimensions.length, - MAX_GROUPS - ) } - - ) } ) : (

{ sprintf( /* translators: %d: maximum number of groups */ - __( 'Maximum of %d option groups reached.', 'jetpack-paypal-payments' ), + __( 'Maximum of %d variants reached.', 'jetpack-paypal-payments' ), MAX_GROUPS ) }

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 c58af9555c70..6941075a9cf6 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/editor.scss +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/editor.scss @@ -529,27 +529,14 @@ margin: 4px 0; } -.jetpack-paypal-variants__counter { - font-size: 12px; - color: #757575; - margin-left: 8px; -} - // Variant builder option groups. +// No card and no indent to the left or right. A 280px sidebar leaves 228px here +// once core takes its scrollbar and panel padding, and the design spends all of +// it on the fields - its name and price inputs are 159 + 89, edge to edge. +// The vertical rhythm is unchanged. .jetpack-paypal-variants__group { - border: 1px solid #ddd; - border-radius: 4px; - padding: 12px; + padding: 12px 0; margin-bottom: 12px; - background: #f9f9f9; -} - -.jetpack-paypal-variants__options { - // The border carries the nesting cue on its own, and this already - // sits inside __group's card padding, so keep it tight - the option - // fields start from about 230px. - padding-left: 8px; - border-left: 2px solid #ddd; } .jetpack-paypal-variants__option { @@ -581,6 +568,11 @@ margin-top: 8px; display: flex; align-items: center; + + .components-button { + justify-content: center; + width: 100%; + } } // Variant summary badges in preview. 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 9ca16475cdd5..39fd8a1089f8 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 @@ -138,6 +138,22 @@ jest.mock( '@wordpress/components', () => ( { ), Spinner: () =>
Loading...
, + CheckboxControl: ( { label, checked, onChange, help, disabled } ) => { + const id = `checkbox-${ label }`; + return ( +
+ onChange( ! checked ) } + disabled={ disabled } + /> + + { help && { help } } +
+ ); + }, ToggleControl: ( { label, checked, onChange, help, disabled } ) => { const id = `toggle-${ label }`; return ( @@ -1626,7 +1642,7 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => { ); await expect( - screen.findByText( 'Prices are set on option group 2.' ) + screen.findByText( 'Prices are set on variant 2.' ) ).resolves.toBeInTheDocument(); expect( screen.getByLabelText( 'Add price per variant' ) ).toBeChecked(); @@ -1755,6 +1771,14 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => { */ const control = label => within( screen.getByTestId( `control-${ label }` ) ); + /** + * One variant's name control. They all share a label, so they go by position. + * + * @param {number} index - Zero-based variant index. + * @return {Element} That variant's name control. + */ + const variantControl = index => screen.getAllByTestId( 'control-Variant name' )[ index ]; + /** * Focus a control and leave it, which is what marks the field touched. * @@ -1817,7 +1841,7 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => { expect( optionsPanel() ).toHaveAttribute( 'data-initial-open', 'true' ); expect( - control( 'Option group 1' ).getByText( 'Option group name is required.' ) + within( variantControl( 0 ) ).getByText( 'Variant name is required.' ) ).toBeVisible(); } ); @@ -1830,13 +1854,23 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => { expect( optionsPanel() ).toHaveAttribute( 'data-initial-open', 'false' ); } ); + // The design labels every variant the same, so the number only exists for screen + // readers - without it two name fields on one panel are indistinguishable. + it( 'labels every variant the same on screen and numbers them for screen readers', async () => { + renderWith( [ group( 'g1' ), group( 'g2' ) ] ); + + await expect( screen.findAllByText( 'Variant name' ) ).resolves.toHaveLength( 2 ); + expect( screen.getByRole( 'textbox', { name: 'Variant name 1' } ) ).toBeInTheDocument(); + expect( screen.getByRole( 'textbox', { name: 'Variant name 2' } ) ).toBeInTheDocument(); + } ); + it( 'holds its tongue until the merchant leaves the field', async () => { renderWith(); await expect( - screen.findByRole( 'textbox', { name: 'Option group 1' } ) + screen.findByRole( 'textbox', { name: 'Variant name 1' } ) ).resolves.toBeInTheDocument(); - expect( screen.queryByText( 'Option group name is required.' ) ).not.toBeInTheDocument(); + expect( screen.queryByText( 'Variant name is required.' ) ).not.toBeInTheDocument(); expect( screen.queryByText( 'Option name is required.' ) ).not.toBeInTheDocument(); // The save button is dead from the first render, with nothing on screen saying why. expect( screen.getByText( 'Create New' ) ).toBeDisabled(); @@ -1846,12 +1880,12 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => { const user = userEvent.setup(); renderWith(); - await visit( user, await screen.findByRole( 'textbox', { name: 'Option group 1' } ) ); + await visit( user, await screen.findByRole( 'textbox', { name: 'Variant name 1' } ) ); expect( - control( 'Option group 1' ).getByText( 'Option group name is required.' ) + within( variantControl( 0 ) ).getByText( 'Variant name is required.' ) ).toBeInTheDocument(); - expect( screen.getByTestId( 'control-Option group 1' ) ).toHaveClass( 'has-error' ); + expect( variantControl( 0 ) ).toHaveClass( 'has-error' ); // Leaving the group name says nothing about the option below it. expect( screen.queryByText( 'Option name is required.' ) ).not.toBeInTheDocument(); expect( screen.getByTestId( 'control-Option 1' ) ).not.toHaveClass( 'has-error' ); @@ -1865,8 +1899,8 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => { const user = userEvent.setup(); const { rerender } = renderWith(); - await visit( user, await screen.findByRole( 'textbox', { name: 'Option group 1' } ) ); - expect( screen.getByText( 'Option group name is required.' ) ).toBeInTheDocument(); + await visit( user, await screen.findByRole( 'textbox', { name: 'Variant name 1' } ) ); + expect( screen.getByText( 'Variant name is required.' ) ).toBeInTheDocument(); rerender( { /> ); - expect( screen.queryByText( 'Option group name is required.' ) ).not.toBeInTheDocument(); + expect( screen.queryByText( 'Variant name is required.' ) ).not.toBeInTheDocument(); } ); it( 'keeps one group quiet while the merchant works in another', async () => { const user = userEvent.setup(); renderWith( [ group( 'g1' ), group( 'g2' ) ] ); - await visit( user, await screen.findByRole( 'textbox', { name: 'Option group 2' } ) ); + await visit( user, await screen.findByRole( 'textbox', { name: 'Variant name 2' } ) ); expect( - control( 'Option group 2' ).getByText( 'Option group name is required.' ) + within( variantControl( 1 ) ).getByText( 'Variant name is required.' ) ).toBeInTheDocument(); expect( - control( 'Option group 1' ).queryByText( 'Option group name is required.' ) + within( variantControl( 0 ) ).queryByText( 'Variant name is required.' ) ).not.toBeInTheDocument(); } ); diff --git a/projects/packages/paypal-payments/tests/js/variant-pricing.test.js b/projects/packages/paypal-payments/tests/js/variant-pricing.test.js index 9e069174cd23..cb2e5b1991b1 100644 --- a/projects/packages/paypal-payments/tests/js/variant-pricing.test.js +++ b/projects/packages/paypal-payments/tests/js/variant-pricing.test.js @@ -190,7 +190,7 @@ describe( 'validateVariants', () => { // The group name error belongs to no single option, so it carries option: null. // The group is primary, so its option needs a price too. expect( errors ).toEqual( [ - { group: 0, option: null, field: 'name', message: 'Option group name is required.' }, + { group: 0, option: null, field: 'name', message: 'Variant name is required.' }, { group: 0, option: 0, field: 'label', message: 'Option name is required.' }, { group: 0, option: 0, field: 'price', message: 'Price is required.' }, ] ); From 37cffe17e472635caa57312cc57de95892336d36 Mon Sep 17 00:00:00 2001 From: Julian Strahan Date: Wed, 9 Sep 2026 19:57:47 -0700 Subject: [PATCH 32/34] PayPal Payment Buttons: add tests for the form handlers moved into the inspector --- .../edit.test.jsx | 695 ++++++++++++++++-- 1 file changed, 634 insertions(+), 61 deletions(-) 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 39fd8a1089f8..f625c9c40b39 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 @@ -13,6 +13,8 @@ import userEvent from '@testing-library/user-event'; 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' ); +// Used by the ToggleControl mock below to id each toggle. +const mockReact = require( 'react' ); // The API-managed editor only renders while the feature flag is on. jest.mock( '@automattic/jetpack-shared-extension-utils', () => ( { @@ -52,13 +54,19 @@ jest.mock( '@wordpress/data', () => ( { useDispatch: () => ( { __unstableMarkNextChangeAsNotPersistent: mockMarkNotPersistent } ), } ) ); +// What the media library hands back. jsdom has none, so the MediaUpload mock +// passes this to onSelect. +const mockSelectedMedia = { url: 'https://example.com/chosen.png', id: 42 }; + // Mock WordPress block-editor. jest.mock( '@wordpress/block-editor', () => ( { store: { name: 'core/block-editor' }, useBlockProps: () => ( { className: 'wp-block-paypal-payment-buttons' } ), BlockControls: ( { children } ) =>
{ children }
, InspectorControls: ( { children } ) =>
{ children }
, - MediaUpload: ( { render: renderProp } ) => renderProp( { open: jest.fn() } ), + // open() calls onSelect straight away so the block's handler runs. + MediaUpload: ( { onSelect, render: renderProp } ) => + renderProp( { open: () => onSelect( mockSelectedMedia ) } ), MediaUploadCheck: ( { children } ) => <>{ children }, // Like TextControl, className and help sit on the BaseControl wrapper rather than // the input. URLInput has no onBlur - the form catches that on a wrapper of its @@ -110,9 +118,14 @@ jest.mock( '@wordpress/components', () => ( {
), - Notice: ( { children, status, isDismissible, onDismiss } ) => ( + Notice: ( { children, status, isDismissible, onDismiss, actions } ) => (
{ children } + { actions?.map( action => ( + + ) ) } { isDismissible && onDismiss && (
); }, + // One id per instance, the way the real control ids itself. Custom checkout + // fields repeat the same 'Required' label, and a shared id would point every + // one of those labels at the first field's input. ToggleControl: ( { label, checked, onChange, help, disabled } ) => { - const id = `toggle-${ label }`; + const id = `toggle-${ mockReact.useId() }`; return (
( {
), TextareaControl: ( { label, value, onChange, onBlur, help, className } ) => ( -
+