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/changelog/fix-paypal-invisible-variant-errors b/projects/packages/paypal-payments/changelog/fix-paypal-invisible-variant-errors new file mode 100644 index 000000000000..cb1f56ec2b0d --- /dev/null +++ b/projects/packages/paypal-payments/changelog/fix-paypal-invisible-variant-errors @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Show product option validation errors under the field that caused them, instead of only disabling the save button. diff --git a/projects/packages/paypal-payments/changelog/fix-paypal-price-field-silent-when-options-cleared b/projects/packages/paypal-payments/changelog/fix-paypal-price-field-silent-when-options-cleared new file mode 100644 index 000000000000..47e079cd8702 --- /dev/null +++ b/projects/packages/paypal-payments/changelog/fix-paypal-price-field-silent-when-options-cleared @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Say why the product price field is invalid when clearing the option prices brings it back, instead of disabling save with nothing on screen. diff --git a/projects/packages/paypal-payments/changelog/fix-paypal-tax-rate-required b/projects/packages/paypal-payments/changelog/fix-paypal-tax-rate-required new file mode 100644 index 000000000000..920f1779fac0 --- /dev/null +++ b/projects/packages/paypal-payments/changelog/fix-paypal-tax-rate-required @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Require a tax rate when tax collection is on, instead of saving an empty rate as 0%. diff --git a/projects/packages/paypal-payments/changelog/fix-paypal-variant-errors-hidden-panel b/projects/packages/paypal-payments/changelog/fix-paypal-variant-errors-hidden-panel new file mode 100644 index 000000000000..a7ff24a65387 --- /dev/null +++ b/projects/packages/paypal-payments/changelog/fix-paypal-variant-errors-hidden-panel @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Open the Product Options panel on a saved button when an option needs fixing, so the error is on screen. 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/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/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..fae7b2ccab93 --- /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 and fit them to the sidebar column. The primary button is now Create New / Save instead of naming the display format. diff --git a/projects/packages/paypal-payments/changelog/update-paypal-return-url-picker b/projects/packages/paypal-payments/changelog/update-paypal-return-url-picker new file mode 100644 index 000000000000..0414f9f2220e --- /dev/null +++ b/projects/packages/paypal-payments/changelog/update-paypal-return-url-picker @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Pick a page for the return URL, or paste one, instead of typing the address by hand. diff --git a/projects/packages/paypal-payments/changelog/update-paypal-shared-link-warning b/projects/packages/paypal-payments/changelog/update-paypal-shared-link-warning new file mode 100644 index 000000000000..2c207f1011eb --- /dev/null +++ b/projects/packages/paypal-payments/changelog/update-paypal-shared-link-warning @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Warn on every payment button that changes apply to every button sharing the same payment link, wherever it is used. 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/changelog/update-paypal-variant-pricing-first-group b/projects/packages/paypal-payments/changelog/update-paypal-variant-pricing-first-group new file mode 100644 index 000000000000..c7a583dc41bd --- /dev/null +++ b/projects/packages/paypal-payments/changelog/update-paypal-variant-pricing-first-group @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +One checkbox turns on per-variant pricing, and the price belongs to the first option group. diff --git a/projects/packages/paypal-payments/docs/readme.txt b/projects/packages/paypal-payments/docs/readme.txt index be3f31057708..26d1511e5550 100644 --- a/projects/packages/paypal-payments/docs/readme.txt +++ b/projects/packages/paypal-payments/docs/readme.txt @@ -28,7 +28,7 @@ PayPal Payment Buttons lets you accept payments on your WordPress site using Pay 1. Connect your PayPal account using API credentials from the PayPal Developer Dashboard 2. Add the PayPal Payment Buttons block to any post or page 3. Enter your product details (name, price, currency, and optional description) -4. Click "Create Button" — the plugin creates a payment link via PayPal's API +4. Click "Create New" — the plugin creates a payment link via PayPal's API 5. Publish your post — visitors see a theme-native "Buy Now" button with "Powered by PayPal" attribution that links to checkout. The payment link URL is also available to share directly or embed in text links. == Installation == @@ -134,6 +134,6 @@ Major update: API-driven PayPal button creation replaces the paste-code workflow == Screenshots == 1. Connect PayPal — Enter API credentials from the PayPal Developer Dashboard. -2. Create Button — Fill in product name, price, and currency in the block editor. +2. Create New — Fill in product name, price, and currency in the block editor. 3. Live Preview — See the theme-native "Buy Now" button preview before publishing. 4. Frontend — Published "Buy Now" button with product info, "Powered by PayPal" attribution, and payment link. diff --git a/projects/packages/paypal-payments/docs/support-pay-with-paypal.md b/projects/packages/paypal-payments/docs/support-pay-with-paypal.md index 1d44b5eb6b89..9ae768b1377d 100644 --- a/projects/packages/paypal-payments/docs/support-pay-with-paypal.md +++ b/projects/packages/paypal-payments/docs/support-pay-with-paypal.md @@ -19,7 +19,7 @@ The Pay with PayPal block lets you accept payments directly on your WordPress si 1. Connect your PayPal account using API credentials 2. Add the Pay with PayPal block to any post or page 3. Enter product details (name, price, currency) -4. Click "Create Button" — the plugin creates a payment link via PayPal's API +4. Click "Create New" — the plugin creates a payment link via PayPal's API 5. Publish — visitors see a styled PayPal button that links to checkout Each button also generates a shareable payment link URL you can use in emails, social media, or text links. @@ -65,7 +65,7 @@ Once connected: 2. Enter a **Price** (positive number, up to 2 decimal places) 3. Select a **Currency** from the dropdown (26 supported) 4. Optionally add a **Description** (max 256 characters) -5. Click **Create Button** +5. Click **Create New** A live preview appears showing exactly how your button will look on the published page. @@ -78,7 +78,7 @@ A live preview appears showing exactly how your button will look on the publishe ### Editing and Deleting Buttons -- **Edit:** Click "Edit Button" in the block sidebar to update product details, then "Update Button" to save +- **Edit:** Click "Edit" in the block toolbar to update product details, then "Save" - **Delete:** Click "Delete Button" to remove the PayPal resource and start over ### Legacy Buttons diff --git a/projects/packages/paypal-payments/docs/test_plan.md b/projects/packages/paypal-payments/docs/test_plan.md index 8e2242bb190c..7356073b20c9 100644 --- a/projects/packages/paypal-payments/docs/test_plan.md +++ b/projects/packages/paypal-payments/docs/test_plan.md @@ -113,7 +113,7 @@ pnpm test:run | Production Default | ✅ | Connected badge, environment defaults to production | WOOPTP-163 | | Token Pre-validation | ✅ | 403 shows Payment Links guidance + stays on Credentials, 403 clears partial state, 5xx does not block connection | WOOPTP-164 | | SVG Block Icon | ✅ | SVG in block inserter, SVG in block toolbar | WOOPTP-166 | -| Format Switcher | ✅ | Button/Link/QR selection, CTA label changes, format badge in preview, switching format without recreating the product, front-end anchor and standalone QR canvas | WOOPTP-390 | +| Format Switcher | ✅ | Button/Link/QR selection, format badge in preview, switching format without recreating the product, front-end anchor and standalone QR canvas | WOOPTP-390 | | Style Preset | ✅ | Light/Auto/Dark selection and theme custom-property inheritance | WOOPTP-390 | **WP 6.9 compatibility notes:** 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 => (
- { /* 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 c7f7d91a5bc3..000000000000 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx +++ /dev/null @@ -1,613 +0,0 @@ -/* eslint-disable react/jsx-no-bind */ -/** - * PayPal Payment Buttons — The product creation form. - * - * @package - */ - -import { MediaUpload, MediaUploadCheck } from '@wordpress/block-editor'; -import { - Button, - Notice, - SelectControl, - TextControl, - TextareaControl, - ToggleControl, -} 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_NAME_LENGTH, MAX_DESCRIPTION_LENGTH } from '../utils/validation'; -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' ); -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. - * - * @param {object} props - Component props. - * @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.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. - * @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. - * @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'. - * @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.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. - * @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( { - attributes, - setAttributes, - buttonText, - productName, - price, - currencyCode, - productDescription, - imageUrl, - imageId, - returnUrl, - variantsEnabled, - variants, - adjustableQuantity, - maxQuantity, - customerNotes, - taxEnabled, - taxType, - taxName, - taxValue, - activeFormat, - isConnected, - environment, - setIsEditing, - touchedFields, - setTouchedFields, - markTouched, - usesVariantPricing, - validationErrors, - isFormValid, - isCreating, - error, - setError, - successMessage, - setSuccessMessage, - handleCreateButton, - handleUpdateButton, - hasButton, - disconnectedNotice, - sharedResourceNotice, - 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 ( -
-
- { 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 } - - ) } - - 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 - } - /> - -
-
- 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( { 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' ) } -

- { 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 } ) => ( - - ) } - /> - - ) } -
- -
- setAttributes( updates ) } - disabled={ isCreating } - /> -
- - 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' ) } -

- - { /* 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' ) } -

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

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

- setAttributes( { format: value } ) } - disabled={ isCreating } - /> -
- -
- - - -
-
- ); -} 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..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,32 +9,20 @@ * @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 { getPriceStep } from '../utils/currency-symbols'; -import { getPriceFormatError } from '../utils/validation'; +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 helpPrimaryOn = __( 'Each option can have its own price.', 'jetpack-paypal-payments' ); -const helpPrimaryOff = __( - 'Enable to charge different prices per option.', +const helpVariants = __( + 'Options for your item, up to 5 variations in color, size, type, etc.', '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.', - 'jetpack-paypal-payments' -); -// PayPal accepts a price at the product level or the option level, never both, -// so per-option pricing is all-or-nothing across the group. -const helpOptionPrice = __( - 'Price every option in this group, or leave them all empty to charge the product price.', +const helpPricingFirstGroup = __( + 'You can only set the price for your first variant.', 'jetpack-paypal-payments' ); @@ -55,14 +43,15 @@ function uid() { /** * Create a new empty option group (dimension). * - * @param {boolean} isPrimary - Whether this is the primary group. + * Never primary - pricing is off until the merchant turns it on. + * * @return {object} New group object with a stable _key. */ -function createGroup( isPrimary = false ) { +function createGroup() { return { _key: uid(), name: '', - primary: isPrimary, + primary: false, options: [ { _key: uid(), label: '' } ], }; } @@ -82,6 +71,18 @@ function createOption( withPricing = false, currency = 'USD' ) { return opt; } +/** + * Drop an option's price, keeping everything else it carries. + * + * @param {object} option - The option to strip. + * @return {object} The option without its amount. + */ +function withoutAmount( option ) { + const rest = { ...option }; + delete rest.unit_amount; + return rest; +} + /** * Find the primary option group — the one that carries per-option pricing. * @@ -93,12 +94,21 @@ export function getPrimaryDimension( variants ) { } /** - * Whether per-option pricing is in use. + * Which option group carries the prices. + * + * @param {Array} dimensions - The option groups. + * @return {number} Its index, or -1 when pricing is off. + */ +function getPrimaryIndex( dimensions ) { + return dimensions.findIndex( dim => dim.primary ); +} + +/** + * Whether the primary group carries prices. * - * PayPal rejects a request that carries `unit_amount` at both the product - * level and the variant level, so the two are mutually exclusive: as soon as - * one option in the primary group has a price, the product-level price is - * dropped and every option must carry its own. + * PayPal rejects a request that carries `unit_amount` at both the product level + * and the variant level, so the request builder drops the product-level price + * once this is true. The server's sanitiser reads the same thing. * * @param {boolean} enabled - Whether variants are enabled. * @param {object} variants - The variants data. @@ -115,12 +125,30 @@ export function hasVariantPricing( enabled, variants ) { } /** - * Validate variant data and return error messages. + * Whether per-variant pricing is turned on. + * + * The form reads this for which fields to show and whether an empty option price + * is an error. `hasVariantPricing` above reads the prices actually entered, which + * is what the request builder and the server sanitiser use. + * + * @param {boolean} enabled - Whether variants are enabled. + * @param {object} variants - The variants data. + * @return {boolean} True when a group is marked primary. + */ +export function isVariantPricingOn( enabled, variants ) { + return !! enabled && !! getPrimaryDimension( variants ); +} + +/** + * Validate variant data. + * + * Errors carry the group and option they belong to so the builder can render each + * one under the control that caused it. * * @param {boolean} enabled - Whether variants are enabled. * @param {object} variants - The variants data. * @param {string} currencyCode - Product currency the option prices are in. - * @return {Array} Array of error strings. Empty if valid. + * @return {Array} Errors as { group, option, field, message }. Empty if valid. */ export function validateVariants( enabled, variants, currencyCode = 'USD' ) { if ( ! enabled || ! variants?.dimensions?.length ) { @@ -128,83 +156,46 @@ export function validateVariants( enabled, variants, currencyCode = 'USD' ) { } const errors = []; - const perOptionPricing = hasVariantPricing( enabled, variants ); variants.dimensions.forEach( ( dim, i ) => { if ( ! dim.name?.trim() ) { - errors.push( - sprintf( - /* translators: %d: option group number */ - __( 'Option group %d needs a name.', 'jetpack-paypal-payments' ), - i + 1 - ) - ); + errors.push( { + group: i, + option: null, + field: 'name', + message: __( 'Variant name is required.', 'jetpack-paypal-payments' ), + } ); + } + + // A priced group with nothing in it hides the product price field and prices + // nothing in its place, which reaches PayPal as a 0.00 product. + if ( dim.primary && ! dim.options?.length ) { + errors.push( { + group: i, + option: null, + field: 'options', + message: __( 'Add at least one option to price.', 'jetpack-paypal-payments' ), + } ); } dim.options?.forEach( ( opt, j ) => { if ( ! opt.label?.trim() ) { - errors.push( - sprintf( - /* translators: 1: option number, 2: group name or number */ - __( 'Option %1$d in "%2$s" needs a label.', 'jetpack-paypal-payments' ), - j + 1, - dim.name || `#${ i + 1 }` - ) - ); + errors.push( { + group: i, + option: j, + field: 'label', + message: __( 'Option name is required.', 'jetpack-paypal-payments' ), + } ); } + // Primary-group prices replace the product price, so every option needs one. if ( ! dim.primary ) { return; } - const rawValue = `${ opt.unit_amount?.value ?? '' }`.trim(); - - // Per-option pricing replaces the product-level price, so it is - // all-or-nothing: a half-filled group would leave options unpriced. - if ( perOptionPricing && rawValue === '' ) { - errors.push( - sprintf( - /* translators: 1: option label or number, 2: group name */ - __( - 'Price for "%1$s" in "%2$s" is required once any option in the group has its own price.', - 'jetpack-paypal-payments' - ), - opt.label || `Option ${ j + 1 }`, - dim.name || `#${ i + 1 }` - ) - ); - return; - } - - if ( rawValue !== '' ) { - const val = parseFloat( rawValue ); - if ( isNaN( val ) || val <= 0 ) { - errors.push( - sprintf( - /* translators: 1: option label or number, 2: group name */ - __( - 'Price for "%1$s" in "%2$s" must be a positive number.', - 'jetpack-paypal-payments' - ), - opt.label || `Option ${ j + 1 }`, - dim.name || `#${ i + 1 }` - ) - ); - return; - } - - const formatError = getPriceFormatError( rawValue, currencyCode ); - if ( formatError ) { - errors.push( - sprintf( - /* translators: 1: option label or number, 2: group name, 3: what is wrong with the price */ - __( 'Price for "%1$s" in "%2$s": %3$s', 'jetpack-paypal-payments' ), - opt.label || `Option ${ j + 1 }`, - dim.name || `#${ i + 1 }`, - formatError - ) - ); - } + const message = validatePrice( `${ opt.unit_amount?.value ?? '' }`.trim(), currencyCode ); + if ( message ) { + errors.push( { group: i, option: j, field: 'price', message } ); } } ); } ); @@ -219,13 +210,27 @@ export function validateVariants( enabled, variants, currencyCode = 'USD' ) { * @param {object} props.group - The group data. * @param {number} props.index - Group index. * @param {string} props.currencyCode - Product currency for pricing. + * @param {Array} props.errors - This group's validation errors. + * @param {object} props.touched - Fields the merchant has left, keyed by field key. + * @param {boolean} props.showAll - Show every error, whether or not its field was left. + * @param {Function} props.onTouch - Callback with a field key once it is left. * @param {Function} props.onChange - Callback when group changes. * @param {Function} props.onRemove - Callback to remove this group. - * @param {Function} props.onSetPrimary - Callback to set this as primary. * @param {boolean} props.disabled - Whether inputs are disabled. * @return {Element} Group editor. */ -function GroupEditor( { group, index, currencyCode, onChange, onRemove, onSetPrimary, disabled } ) { +function GroupEditor( { + group, + index, + currencyCode, + errors, + touched, + showAll, + onTouch, + onChange, + onRemove, + disabled, +} ) { const lastOptionRef = useRef( null ); const [ focusNewOption, setFocusNewOption ] = useState( false ); @@ -237,6 +242,25 @@ function GroupEditor( { group, index, currencyCode, onChange, onRemove, onSetPri } }, [ focusNewOption ] ); + // A field only shows its error once the merchant has left it - enabling the panel + // seeds an empty group, which is invalid from the first render. Keys ride on the + // stable _key, so removing a group or an option does not hand its marks to a neighbour. + // A variants structure read back from PayPal carries no _key, so fall back to the index. + const groupKey = group._key || `#${ index }`; + const fieldKey = ( optIndex, field ) => + optIndex === null + ? `variant:${ groupKey }:${ field }` + : `variant:${ groupKey }:${ group.options[ optIndex ]?._key || `#${ optIndex }` }:${ field }`; + + // Turning pricing on is the interaction, so price errors show right away - waiting + // for a blur would disable Save with no visible error. The tax rate field does the same. + const errorFor = ( optIndex, field ) => { + const revealed = showAll || field === 'price' || touched[ fieldKey( optIndex, field ) ]; + return revealed + ? errors.find( e => e.option === optIndex && e.field === field )?.message + : undefined; + }; + const updateName = name => { onChange( { ...group, name } ); }; @@ -266,49 +290,41 @@ function GroupEditor( { group, index, currencyCode, onChange, onRemove, onSetPri 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 (
-
- -
- onSetPrimary( index ) } - disabled={ disabled } - /> - -
-
+ onTouch( fieldKey( null, 'name' ) ) } + placeholder={ __( 'Enter variant name', 'jetpack-paypal-payments' ) } + disabled={ disabled } + help={ errorFor( null, 'name' ) } + className={ errorFor( null, 'name' ) ? 'has-error' : undefined } + /> + + /> ) }
) ) }
{ group.options.length < MAX_OPTIONS ? ( - ) : (

@@ -398,6 +418,10 @@ function GroupEditor( { group, index, currencyCode, onChange, onRemove, onSetPri * @param {string} props.currencyCode - Product currency code. * @param {Function} props.onChange - Callback with { variantsEnabled, variants }. * @param {boolean} props.disabled - Whether inputs are disabled. + * @param {Array} props.errors - Validation errors from validateVariants(). + * @param {object} props.touched - The form's touched fields, keyed by field key. + * @param {boolean} props.showAll - Show every error, whether or not its field was left. + * @param {Function} props.onTouch - Callback with a field key once it is left. * @return {Element} Variants builder. */ export default function VariantBuilder( { @@ -406,8 +430,14 @@ export default function VariantBuilder( { currencyCode = 'USD', onChange, disabled, + errors = [], + touched = {}, + showAll, + onTouch, } ) { const dimensions = variants?.dimensions || []; + const primaryIndex = getPrimaryIndex( dimensions ); + const pricingOn = primaryIndex !== -1; const lastGroupRef = useRef( null ); const [ focusNewGroup, setFocusNewGroup ] = useState( false ); @@ -423,7 +453,7 @@ export default function VariantBuilder( { if ( newEnabled && dimensions.length === 0 ) { onChange( { variantsEnabled: true, - variants: { dimensions: [ createGroup( true ) ] }, + variants: { dimensions: [ createGroup() ] }, } ); } else { onChange( { variantsEnabled: newEnabled } ); @@ -446,28 +476,46 @@ 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( + /* translators: %d: variant number */ + __( 'Prices are set on variant %d.', 'jetpack-paypal-payments' ), + primaryIndex + 1 + ) + : helpPricingFirstGroup; + const addGroup = () => { if ( dimensions.length >= MAX_GROUPS ) { return; } onChange( { variants: { - dimensions: [ ...dimensions, createGroup( false ) ], + dimensions: [ ...dimensions, createGroup() ], }, } ); setFocusNewGroup( true ); @@ -475,13 +523,9 @@ export default function VariantBuilder( { return (

-

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

- { __( - '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.map( ( dimension, dimIndex ) => (
e.group === dimIndex ) } + touched={ touched } + showAll={ showAll } + onTouch={ onTouch } onChange={ newDim => updateDimension( dimIndex, newDim ) } onRemove={ () => removeDimension( dimIndex ) } - onSetPrimary={ setPrimary } disabled={ disabled } />
@@ -519,24 +576,14 @@ export default function VariantBuilder( { { dimensions.length < MAX_GROUPS ? ( <> - { 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/controls.jsx b/projects/packages/paypal-payments/src/paypal-payment-buttons/controls.jsx index 6ac390aca2ad..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,15 +86,13 @@ export default function PayPalInspectorControls( {

- { hasButton && ( - - setAttributes( { format: value } ) } - disabled={ isCreating } - /> - - ) } + + setAttributes( { format: value } ) } + disabled={ isCreating } + /> + { hasButton && ( 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. + const taxIsPercentage = ( taxType || 'PERCENTAGE' ) === 'PERCENTAGE'; + /** * Compute validation errors for all form fields. * Memoized to avoid re-computing on every render. @@ -153,17 +208,28 @@ 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 per-variant pricing is on, so don't validate + // it - an error on an invisible field would disable Save with nothing to fix. + price: variantPricingOn ? null : validatePrice( price, currencyCode || 'USD' ), productDescription: validateDescription( productDescription ), + returnUrl: validateReturnUrl( returnUrl ), + taxValue: taxEnabled && taxIsPercentage ? validateTaxRate( taxValue ) : null, currencyCode: currencyCode && ! VALID_CURRENCY_CODES.has( currencyCode ) ? __( 'Unsupported currency.', 'jetpack-paypal-payments' ) : null, } ), - [ productName, price, productDescription, currencyCode, usesVariantPricing ] + [ + productName, + price, + productDescription, + returnUrl, + currencyCode, + variantPricingOn, + taxEnabled, + taxIsPercentage, + taxValue, + ] ); /** @@ -177,11 +243,24 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b /** * Whether the form is valid (no validation errors on required fields or variants). */ + // 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 = + ( touchedFields.price || variantsEnabled ) && validationErrors.price + ? validationErrors.price + : null; + + const returnUrlError = touchedFields.returnUrl ? validationErrors.returnUrl : null; + + // returnUrl is deliberately absent below - a bad one warns and still saves, as it + // always has. const isFormValid = ! validationErrors.productName && ! validationErrors.price && ! validationErrors.productDescription && ! validationErrors.currencyCode && + ! validationErrors.taxValue && variantErrors.length === 0; const { @@ -205,24 +284,6 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b setShowDeleteConfirm, } ); - // Other blocks on this page pointing at the same PayPal payment. - const sharedResourceCount = useSelect( - select => { - if ( ! resourceId || ! blockClientId ) { - return 0; - } - const { getClientIdsWithDescendants, getBlockName, getBlockAttributes } = - select( blockEditorStore ); - return getClientIdsWithDescendants().filter( - id => - id !== blockClientId && - getBlockName( id ) === metadata.name && - getBlockAttributes( id )?.resourceId === resourceId - ).length; - }, - [ blockClientId, resourceId ] - ); - /** * Handle PayPal disconnect with confirmation. * Triggers a ConfirmDialog — actual disconnect runs in executeDisconnect(). @@ -273,6 +334,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 ( @@ -382,6 +448,7 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b handleDeleteButton={ handleDeleteButton } handleDisconnect={ handleDisconnect } hasButton={ hasButton } + isPreviewingButton={ isPreviewingButton } /> ); @@ -422,22 +489,15 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b ) : null; - const sharedResourceMessage = sprintf( - /* translators: %d: number of other blocks on this page using the same PayPal payment */ - _n( - '%d other block on this page uses this PayPal payment. Changing the product or price here changes it there too. To sell something different, add a new block and create a new payment.', - '%d other blocks on this page use this PayPal payment. Changing the product or price here changes it there too. To sell something different, add a new block and create a new payment.', - sharedResourceCount, - 'jetpack-paypal-payments' - ), - sharedResourceCount - ); - const sharedResourceNotice = - sharedResourceCount > 0 ? ( - - { sharedResourceMessage } - - ) : null; + // A payment link can be used by blocks on any post, so warn whenever there is one. + const sharedResourceNotice = hasButton ? ( + + { __( + 'Changes made will apply to all payment buttons with this link.', + 'jetpack-paypal-payments' + ) } + + ) : null; const connectionStatus = ( { toolbarControls } @@ -513,51 +573,473 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b return (
{ 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 + } + /> + +
+ { ! variantPricingOn && ( +
+ setAttributes( { price: value } ) } + onBlur={ () => markTouched( 'price' ) } + disabled={ isCreating } + type="number" + min={ priceStep } + step={ priceStep } + placeholder={ pricePlaceholder } + help={ priceError || undefined } + className={ priceError ? 'has-error' : undefined } + /> +
+ ) } + setAttributes( { currencyCode: value } ) } + /> +
+ + setAttributes( { productDescription: value } ) } + onBlur={ () => markTouched( 'productDescription' ) } + help={ + touchedFields.productDescription && validationErrors.productDescription ? ( + validationErrors.productDescription + ) : ( + <> + { __( 'Shown to customers at checkout.', 'jetpack-paypal-payments' ) } +
+ { sprintf( + /* translators: 1: current character count, 2: maximum allowed */ + __( '%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' ) } + + { 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 } ) => ( + + ) } + /> + + ) } +
+
+ { /* A closed panel renders no children, so open it when an option needs + fixing - otherwise the error is invisible on a saved button. */ } + 0 } + > + setAttributes( updates ) } + disabled={ isCreating } + errors={ variantErrors } + touched={ touchedFields } + // A saved button's groups came out of storage already invalid, so + // there is no blur coming - the same reason the panel opens below. + showAll={ hasButton } + onTouch={ markTouched } + /> + + { /* Same again for the tax rate. initialOpen, not a controlled `opened`: the + panel opens when there is an error, and the merchant can still close it. */ } + + { /* 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 } + /> + { taxIsPercentage && ( + setAttributes( { taxValue: value } ) } + type="number" + min="0.01" + max="99.99" + step="0.01" + placeholder="8.25" + disabled={ isCreating } + help={ + validationErrors.taxValue || + __( 'Percentage added to the product price.', 'jetpack-paypal-payments' ) + } + className={ validationErrors.taxValue ? 'has-error' : undefined } + /> + ) } + + ) } + + { /* 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 < MAX_CUSTOMER_NOTES && ( + + ) } +
+ ) } +
+ + { /* URLInput takes no onBlur, so the wrapper catches it as it bubbles, and + carries has-error too. URLInput gets exactly one class - it appends + `__suggestions` to whatever it is given, and a second one in there + would break the suggestion list's width. */ } +
markTouched( 'returnUrl' ) } + > + setAttributes( { returnUrl: value } ) } + required={ false } + disabled={ isCreating } + help={ + returnUrlError || + __( 'Redirect customers here after payment.', 'jetpack-paypal-payments' ) + } + /> +
+
+ + setAttributes( { buttonText: value } ) } + disabled={ isCreating } + /> + setAttributes( { showQrCode: value } ) } + disabled={ isCreating } + /> + +
{ 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 9f17bfb4b509..6941075a9cf6 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/editor.scss +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/editor.scss @@ -272,59 +272,73 @@ // Create / Edit Form // --------------------------------------------------------------- -.jetpack-paypal-payment-buttons__create-form { - padding: 24px; - border: 1px solid #c3c4c7; - border-radius: 4px; +// 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 { - h3 { - margin-top: 0; - margin-bottom: 16px; - } + .components-text-control__input, + .components-textarea-control__input { + border-color: #cc1818; - .components-base-control { - margin-bottom: 12px; + &:focus { + border-color: #cc1818; + box-shadow: 0 0 0 1px #cc1818; + } } - // Error state for form fields. - .has-error { + // URLInput builds on InputControl, which draws its border on a backdrop + // behind the input. Core's emotion class repeats itself three times, so + // naming the backdrop alone loses - walk its structure to out-specify it. + .components-input-control .components-input-control__container { - .components-text-control__input, - .components-textarea-control__input { + .components-input-control__backdrop { border-color: #cc1818; - - &:focus { - border-color: #cc1818; - box-shadow: 0 0 0 1px #cc1818; - } } } } +// Core sizes URLInput and its suggestion list wider than the 280px sidebar. +// Same three overrides the contact form block uses for this control. +.jetpack-paypal-payment-buttons__return-url { + min-width: auto; + + input[type="text"] { + width: 100%; + } + + &__suggestions { + width: 230px; + } +} + .jetpack-paypal-payment-buttons__price-row { display: flex; gap: 12px; + // The controls in here end flush with the row box - unlike the ones stacked + // above and below, they carry no bottom margin of their own - so the row has + // to supply the gap. + margin-bottom: 16px; > * { flex: 1; + min-width: 0; } } +// 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. .jetpack-paypal-payment-buttons__image-field { margin-bottom: 16px; - - .components-base-control__label { - display: block; - margin-bottom: 8px; - } } .jetpack-paypal-payment-buttons__image-preview { @@ -351,18 +365,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) // --------------------------------------------------------------- @@ -513,25 +515,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; @@ -545,61 +529,33 @@ margin: 4px 0; } -.jetpack-paypal-variants__counter { - font-size: 12px; - color: #757575; - margin-left: 8px; -} - // Variant builder option groups. -.jetpack-paypal-variants__group, -.jetpack-paypal-variants__dimension { - border: 1px solid #ddd; - border-radius: 4px; - padding: 12px; +// 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 { + padding: 12px 0; margin-bottom: 12px; - 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; - border-left: 2px solid #ddd; } .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; } @@ -608,11 +564,15 @@ 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; + + .components-button { + justify-content: center; + width: 100%; + } } // Variant summary badges in preview. @@ -697,30 +657,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; -} - -.jetpack-paypal-payment-buttons__tax-config { - margin-left: 12px; - padding-left: 12px; - border-left: 2px solid #ddd; -} - +// Checkout options (WOOPTP-170/171/172). .jetpack-paypal-payment-buttons__customer-notes { margin-top: 8px; } @@ -766,3 +703,17 @@ margin: 0; font-style: italic; } + +// --------------------------------------------------------------- +// Display Format panel +// --------------------------------------------------------------- + +.jetpack-paypal-payment-buttons__format-toggle { + display: flex; + margin-bottom: 12px; + + .components-button { + flex: 1; + justify-content: center; + } +} diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/hooks/use-paypal-resource.js b/projects/packages/paypal-payments/src/paypal-payment-buttons/hooks/use-paypal-resource.js index 572f47a9cefa..36e0888a430a 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/hooks/use-paypal-resource.js +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/hooks/use-paypal-resource.js @@ -10,7 +10,7 @@ import { useDispatch } from '@wordpress/data'; import { useState, useEffect, useCallback, useRef } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { API_BASE } from '../utils/api-base'; -import { getResourceAttributeUpdates } from '../utils/resource-sync'; +import { getResourceAttributeUpdates, withCurrency } from '../utils/resource-sync'; import { getUserFriendlyError } from '../utils/validation'; /** @@ -132,7 +132,9 @@ export function usePayPalResource( { }, } ), ...( productDescription ? { description: productDescription } : {} ), - ...( variantsEnabled && variants ? { variants } : {} ), + ...( variantsEnabled && variants + ? { variants: withCurrency( variants, currencyCode || 'USD' ) } + : {} ), ...( adjustableQuantity && maxQuantity > 1 ? { adjustable_quantity: { maximum: parseInt( maxQuantity, 10 ) } } : {} ), diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/currencies.js b/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/currencies.js index a4b35c18b404..cfdbf8db9924 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/currencies.js +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/currencies.js @@ -4,38 +4,50 @@ * @package */ +import { CURRENCY_SYMBOLS } from './currency-symbols'; + /** - * Supported currencies for the currency selector. + * Supported currency codes. * Matches PayPal_Attribute_Mapper::SUPPORTED_CURRENCIES on the server. */ -export const SUPPORTED_CURRENCIES = [ - { label: 'USD — US Dollar', value: 'USD' }, - { label: 'EUR — Euro', value: 'EUR' }, - { label: 'GBP — British Pound', value: 'GBP' }, - { label: 'CAD — Canadian Dollar', value: 'CAD' }, - { label: 'AUD — Australian Dollar', value: 'AUD' }, - { label: 'JPY — Japanese Yen', value: 'JPY' }, - { label: 'CHF — Swiss Franc', value: 'CHF' }, - { label: 'SEK — Swedish Krona', value: 'SEK' }, - { label: 'NOK — Norwegian Krone', value: 'NOK' }, - { label: 'DKK — Danish Krone', value: 'DKK' }, - { label: 'NZD — New Zealand Dollar', value: 'NZD' }, - { label: 'SGD — Singapore Dollar', value: 'SGD' }, - { label: 'HKD — Hong Kong Dollar', value: 'HKD' }, - { label: 'MXN — Mexican Peso', value: 'MXN' }, - { label: 'BRL — Brazilian Real', value: 'BRL' }, - { label: 'PLN — Polish Zloty', value: 'PLN' }, - { label: 'CZK — Czech Koruna', value: 'CZK' }, - { label: 'HUF — Hungarian Forint', value: 'HUF' }, - { label: 'ILS — Israeli Shekel', value: 'ILS' }, - { label: 'MYR — Malaysian Ringgit', value: 'MYR' }, - { label: 'PHP — Philippine Peso', value: 'PHP' }, - { label: 'TWD — Taiwan Dollar', value: 'TWD' }, - { label: 'THB — Thai Baht', value: 'THB' }, - { label: 'CNY — Chinese Yuan', value: 'CNY' }, +const SUPPORTED_CURRENCY_CODES = [ + 'USD', + 'EUR', + 'GBP', + 'CAD', + 'AUD', + 'JPY', + 'CHF', + 'SEK', + 'NOK', + 'DKK', + 'NZD', + 'SGD', + 'HKD', + 'MXN', + 'BRL', + 'PLN', + 'CZK', + 'HUF', + 'ILS', + 'MYR', + 'PHP', + 'TWD', + 'THB', + 'CNY', ]; +/** + * Options for the currency selector — the code and its symbol, the way Simple + * Payments and the Donations block label theirs. + */ +export const SUPPORTED_CURRENCIES = SUPPORTED_CURRENCY_CODES.map( value => { + const symbol = CURRENCY_SYMBOLS[ value ] || value; + // Don't print CHF twice when the symbol is the code. + return { label: symbol === value ? value : `${ value } ${ symbol }`, value }; +} ); + /** * Currency code set for fast lookup. */ -export const VALID_CURRENCY_CODES = new Set( SUPPORTED_CURRENCIES.map( c => c.value ) ); +export const VALID_CURRENCY_CODES = new Set( SUPPORTED_CURRENCY_CODES ); 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 c96e3527fb40..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 @@ -1,7 +1,6 @@ /** * Currency symbol map for common currencies. * - * Shared across paypal-button-preview.js and save.js to avoid duplication. * Must match class-paypal-payment-buttons.php for consistent WYSIWYG rendering. * * @package @@ -52,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/src/paypal-payment-buttons/utils/validation.js b/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/validation.js index 0eef0eefa64d..270bcb569b98 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/validation.js +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/validation.js @@ -17,36 +17,8 @@ import { ZERO_DECIMAL_CURRENCIES } from './currency-symbols'; */ export const MAX_NAME_LENGTH = 127; export const MAX_DESCRIPTION_LENGTH = 256; - -/** - * Set of valid ISO currency codes supported by PayPal. - */ -export const VALID_CURRENCY_CODES = new Set( [ - 'USD', - 'EUR', - 'GBP', - 'CAD', - 'AUD', - 'JPY', - 'CHF', - 'SEK', - 'NOK', - 'DKK', - 'NZD', - 'SGD', - 'HKD', - 'MXN', - 'BRL', - 'PLN', - 'CZK', - 'HUF', - 'ILS', - 'MYR', - 'PHP', - 'TWD', - 'THB', - 'CNY', -] ); +// PayPal rejects a third custom checkout field with a 400. +export const MAX_CUSTOMER_NOTES = 2; /** * Check the decimals a price carries against what PayPal accepts for the currency. @@ -135,6 +107,48 @@ export function validateDescription( value ) { return null; } +/** + * Validate a percentage tax rate. + * + * Required and above zero. The upper bound is the control's own max attribute - + * PayPal's real limit has never been measured. + * + * @param {string} value - The tax rate. + * @return {string|null} Error message or null if valid. + */ +export function validateTaxRate( value ) { + const num = parseFloat( value ); + + if ( isNaN( num ) || num <= 0 ) { + return __( + 'To continue, add the requested info or turn off this feature.', + 'jetpack-paypal-payments' + ); + } + + return null; +} + +/** + * Validate a return URL (optional field). + * + * HTTPS only - a rule this block has always enforced on its own. Empty means the + * buyer is not redirected anywhere. + * + * @param {string} value - The return URL. + * @return {string|null} Error message or null if valid. + */ +export function validateReturnUrl( value ) { + if ( ! value || /^https:\/\/.+/.test( value ) ) { + return null; + } + + return __( + 'Return URL must use HTTPS (e.g., https://example.com/thank-you).', + 'jetpack-paypal-payments' + ); +} + /** * Map an API error response to a user-friendly message. * diff --git a/projects/packages/paypal-payments/tests/js/currencies.test.js b/projects/packages/paypal-payments/tests/js/currencies.test.js new file mode 100644 index 000000000000..3eb653b5a177 --- /dev/null +++ b/projects/packages/paypal-payments/tests/js/currencies.test.js @@ -0,0 +1,56 @@ +/** + * Tests for the PayPal Payment Buttons currency lists. + * + * Covers the selector options and the code set the form validates against. + * + * @package + */ + +import { + SUPPORTED_CURRENCIES, + VALID_CURRENCY_CODES, +} from '../../src/paypal-payment-buttons/utils/currencies'; +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 => { + expect( VALID_CURRENCY_CODES.has( code ) ).toBe( true ); + } ); + + it( 'does not contain an invalid currency code', () => { + expect( VALID_CURRENCY_CODES.has( 'XYZ' ) ).toBe( false ); + } ); +} ); + +describe( 'SUPPORTED_CURRENCIES', () => { + it( 'labels every currency with its symbol', () => { + expect( SUPPORTED_CURRENCIES.find( c => c.value === 'USD' ).label ).toBe( 'USD $' ); + } ); + + it( 'does not repeat the code when the symbol is the code', () => { + expect( SUPPORTED_CURRENCIES.find( c => c.value === 'CHF' ).label ).toBe( 'CHF' ); + } ); + + it( 'has a symbol for every supported code', () => { + const missing = SUPPORTED_CURRENCIES.map( c => c.value ).filter( v => ! CURRENCY_SYMBOLS[ v ] ); + expect( missing ).toEqual( [] ); + } ); + + // PayPal_Attribute_Mapper_Test asserts the same count on the PHP list. + it( 'carries all 24 currencies PayPal supports', () => { + 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 49d76b04e831..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', () => ( { @@ -41,37 +43,58 @@ jest.mock( '@wordpress/element', () => { jest.mock( '@wordpress/i18n', () => ( { __: text => text, _x: text => text, - _n: ( single, plural, count ) => ( count === 1 ? single : plural ), sprintf: ( format, ...args ) => { let i = 0; return format.replace( /%[ds]/g, () => args[ i++ ] ); }, } ) ); -// Block-editor store: the sibling count reads other blocks through useSelect. -const mockBlockEditorSelect = { - getClientIdsWithDescendants: jest.fn( () => [] ), - getBlockName: jest.fn(), - getBlockAttributes: jest.fn(), -}; const mockMarkNotPersistent = jest.fn(); jest.mock( '@wordpress/data', () => ( { - useSelect: selector => selector( () => mockBlockEditorSelect ), 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 + // own. The testid deliberately differs from `control-` so a test can tell this + // apart from the TextControl it replaced. + URLInput: ( { label, value, onChange, help, className, ...rest } ) => ( +
+ + onChange( e.target.value ) } + type="text" + { ...rest } + /> + { help && { help } } +
+ ), } ) ); // Mock WordPress components with simple HTML equivalents. jest.mock( '@wordpress/components', () => ( { + BaseControl: { + VisualLabel: ( { children } ) => ( + { children } + ), + }, Button: ( { children, onClick, disabled, variant, isBusy, ...rest } ) => (
), - Notice: ( { children, status, isDismissible, onDismiss } ) => ( + Notice: ( { children, status, isDismissible, onDismiss, actions } ) => (
{ children } + { actions?.map( action => ( + + ) ) } { isDismissible && onDismiss && (
), - PanelBody: ( { children, title } ) => ( -
+ // The real PanelBody renders nothing when closed, so initialOpen decides whether an + // error inside it is on screen at all. The mock always renders, and exposes the prop. + PanelBody: ( { children, title, initialOpen } ) => ( +
{ children }
), @@ -121,8 +151,27 @@ jest.mock( '@wordpress/components', () => ( { ), Spinner: () =>
Loading...
, + CheckboxControl: ( { label, checked, onChange, help, disabled } ) => { + const id = `checkbox-${ label }`; + return ( +
+ onChange( ! checked ) } + disabled={ disabled } + /> + + { help && { help } } +
+ ); + }, + // 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 (
( {
); }, + // Real TextControl puts className and help on the BaseControl wrapper, not the input, + // which is what editor.scss's `.has-error .components-text-control__input` expects. TextControl: ( { label, value, onChange, onBlur, type, help, className, ...rest } ) => ( -
+
( { onChange={ e => onChange( e.target.value ) } onBlur={ onBlur } type={ type || 'text' } - className={ className } { ...rest } /> { help && { help } }
), TextareaControl: ( { label, value, onChange, onBlur, help, className } ) => ( -
+