From 7fb73cceb20137f1ed9d1add5fe67f77dc9a4102 Mon Sep 17 00:00:00 2001 From: Saad Tarhi Date: Tue, 7 Feb 2023 08:06:05 +0100 Subject: [PATCH 01/17] Update the incompatible gateways notice design (#8365) * Fix the alert icon's name * Add "status" to the Alert's API * Update the incompatible gateways notice's design * Set the list's bullet points from CSS --- .../editor.scss | 8 +++++ .../index.tsx | 4 +-- assets/js/icons/index.js | 2 +- assets/js/icons/library/alert.tsx | 30 +++++++++++++++---- 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/assets/js/editor-components/incompatible-payment-gateways-notice/editor.scss b/assets/js/editor-components/incompatible-payment-gateways-notice/editor.scss index db6f6c78b95..5a10598ea7f 100644 --- a/assets/js/editor-components/incompatible-payment-gateways-notice/editor.scss +++ b/assets/js/editor-components/incompatible-payment-gateways-notice/editor.scss @@ -24,6 +24,14 @@ .wc-blocks-incompatible-extensions-notice__element { display: flex; align-items: center; + position: relative; + + &::before { + content: "•"; + position: absolute; + left: -13px; + font-size: 1.2rem; + } } } diff --git a/assets/js/editor-components/incompatible-payment-gateways-notice/index.tsx b/assets/js/editor-components/incompatible-payment-gateways-notice/index.tsx index b4f8f6f9ec1..1c33b987a35 100644 --- a/assets/js/editor-components/incompatible-payment-gateways-notice/index.tsx +++ b/assets/js/editor-components/incompatible-payment-gateways-notice/index.tsx @@ -9,7 +9,7 @@ import { createInterpolateElement, useEffect, } from '@wordpress/element'; -import { alert } from '@woocommerce/icons'; +import { Alert } from '@woocommerce/icons'; import { Icon } from '@wordpress/icons'; /** * Internal dependencies @@ -74,7 +74,7 @@ export function IncompatiblePaymentGatewaysNotice( {
} />

{ noticeContent }

diff --git a/assets/js/icons/index.js b/assets/js/icons/index.js index cf8687b350f..70acf6bdd56 100644 --- a/assets/js/icons/index.js +++ b/assets/js/icons/index.js @@ -13,7 +13,7 @@ export { default as woo } from './library/woo'; export { default as miniCart } from './library/mini-cart'; export { default as miniCartAlt } from './library/mini-cart-alt'; export { default as stacks } from './library/stacks'; -export { default as alert } from './library/alert'; +export { default as Alert } from './library/alert'; export { default as customerAccount } from './library/customer-account'; export { default as customerAccountStyle } from './library/customer-account-style'; export { default as customerAccountStyleAlt } from './library/customer-account-style-alt'; diff --git a/assets/js/icons/library/alert.tsx b/assets/js/icons/library/alert.tsx index 096b1329c18..4cec8a9384a 100644 --- a/assets/js/icons/library/alert.tsx +++ b/assets/js/icons/library/alert.tsx @@ -1,18 +1,36 @@ /** * External dependencies */ +import { IconProps } from '@wordpress/icons/build-types/icon'; import { SVG } from '@wordpress/primitives'; -const cart = ( - +interface AlertProps { + status?: 'warning' | 'error' | 'success' | 'info'; + props?: IconProps; +} + +const statusToColorMap = { + warning: '#F0B849', + error: '#CC1818', + success: '#46B450', + info: '#0073AA', +}; + +const Alert = ( { status = 'warning', ...props }: AlertProps ) => ( + - - + + ); -export default cart; +export default Alert; From f62f60d2eaee1800caf0e0b241a81a3334783ce1 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Tue, 7 Feb 2023 15:47:13 +0000 Subject: [PATCH 02/17] Update Cart/Checkout Default Template Styling (#8380) * Switch default cart headings for cross sells to h2/h3 Fixes #8193 * Style empty cart icon #8360 * Ensure editable content fields are transparent in the editor #8359 #7007 --- .../cart-cross-sells-product.tsx | 2 +- .../cart-cross-sells-block/edit.tsx | 4 ++-- .../cart-order-summary-heading/edit.tsx | 1 + .../inner-blocks/empty-cart-block/edit.tsx | 13 ++----------- .../empty-cart-block/icon-data-uri.js | 1 - .../inner-blocks/empty-cart-block/style.scss | 18 ++++++++++++++++++ .../checkout/form-step/form-step-block.tsx | 2 ++ assets/js/blocks/product-search/edit.js | 1 + .../js/editor-components/block-title/index.js | 1 + .../backend/__fixtures__/cart.fixture.json | 2 +- 10 files changed, 29 insertions(+), 16 deletions(-) delete mode 100644 assets/js/blocks/cart/inner-blocks/empty-cart-block/icon-data-uri.js diff --git a/assets/js/blocks/cart/cart-cross-sells-product-list/cart-cross-sells-product.tsx b/assets/js/blocks/cart/cart-cross-sells-product-list/cart-cross-sells-product.tsx index 2ac0d9a33c6..ba67ed5d74e 100644 --- a/assets/js/blocks/cart/cart-cross-sells-product-list/cart-cross-sells-product.tsx +++ b/assets/js/blocks/cart/cart-cross-sells-product-list/cart-cross-sells-product.tsx @@ -49,7 +49,7 @@ const CartCrossSellsProduct = ( { /> diff --git a/assets/js/blocks/cart/inner-blocks/cart-cross-sells-block/edit.tsx b/assets/js/blocks/cart/inner-blocks/cart-cross-sells-block/edit.tsx index 25d8a8a9b20..67c15660241 100644 --- a/assets/js/blocks/cart/inner-blocks/cart-cross-sells-block/edit.tsx +++ b/assets/js/blocks/cart/inner-blocks/cart-cross-sells-block/edit.tsx @@ -17,9 +17,9 @@ export const Edit = (): JSX.Element => { 'You may be interested in…', 'woo-gutenberg-products-block' ), - level: 3, + level: 2, + fontSize: 'large', }, - , [], ], [ 'woocommerce/cart-cross-sells-products-block', {}, [] ], diff --git a/assets/js/blocks/cart/inner-blocks/cart-order-summary-heading/edit.tsx b/assets/js/blocks/cart/inner-blocks/cart-order-summary-heading/edit.tsx index 2b9c7e08b6a..ee105b65b6d 100644 --- a/assets/js/blocks/cart/inner-blocks/cart-order-summary-heading/edit.tsx +++ b/assets/js/blocks/cart/inner-blocks/cart-order-summary-heading/edit.tsx @@ -37,6 +37,7 @@ export const Edit = ( { onChange={ ( value ) => setAttributes( { content: value } ) } + style={ { backgroundColor: 'transparent' } } />
diff --git a/assets/js/blocks/cart/inner-blocks/empty-cart-block/edit.tsx b/assets/js/blocks/cart/inner-blocks/empty-cart-block/edit.tsx index 7b7a909b23c..d55c95784ea 100644 --- a/assets/js/blocks/cart/inner-blocks/empty-cart-block/edit.tsx +++ b/assets/js/blocks/cart/inner-blocks/empty-cart-block/edit.tsx @@ -15,7 +15,6 @@ import { useForcedLayout, getAllowedBlocks, } from '../../../cart-checkout-shared'; -import iconDataUri from './icon-data-uri.js'; import './style.scss'; const browseStoreTemplate = SHOP_URL @@ -26,7 +25,7 @@ const browseStoreTemplate = SHOP_URL content: sprintf( /* translators: %s is the link to the store product directory. */ __( - 'Browse store.', + 'Browse store', 'woo-gutenberg-products-block' ), SHOP_URL @@ -37,14 +36,6 @@ const browseStoreTemplate = SHOP_URL : null; const defaultTemplate = [ - [ - 'core/image', - { - align: 'center', - url: iconDataUri, - sizeSlug: 'small', - }, - ], [ 'core/heading', { @@ -54,7 +45,7 @@ const defaultTemplate = [ 'woo-gutenberg-products-block' ), level: 2, - className: 'wc-block-cart__empty-cart__title', + className: 'with-empty-cart-icon wc-block-cart__empty-cart__title', }, ], browseStoreTemplate, diff --git a/assets/js/blocks/cart/inner-blocks/empty-cart-block/icon-data-uri.js b/assets/js/blocks/cart/inner-blocks/empty-cart-block/icon-data-uri.js deleted file mode 100644 index d5082ec4a4e..00000000000 --- a/assets/js/blocks/cart/inner-blocks/empty-cart-block/icon-data-uri.js +++ /dev/null @@ -1 +0,0 @@ -export default 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzgiIGhlaWdodD0iMzgiIHZpZXdCb3g9IjAgMCAzOCAzOCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE5IDBDOC41MDQwMyAwIDAgOC41MDQwMyAwIDE5QzAgMjkuNDk2IDguNTA0MDMgMzggMTkgMzhDMjkuNDk2IDM4IDM4IDI5LjQ5NiAzOCAxOUMzOCA4LjUwNDAzIDI5LjQ5NiAwIDE5IDBaTTI1LjEyOSAxMi44NzFDMjYuNDg1MSAxMi44NzEgMjcuNTgwNiAxMy45NjY1IDI3LjU4MDYgMTUuMzIyNkMyNy41ODA2IDE2LjY3ODYgMjYuNDg1MSAxNy43NzQyIDI1LjEyOSAxNy43NzQyQzIzLjc3MyAxNy43NzQyIDIyLjY3NzQgMTYuNjc4NiAyMi42Nzc0IDE1LjMyMjZDMjIuNjc3NCAxMy45NjY1IDIzLjc3MyAxMi44NzEgMjUuMTI5IDEyLjg3MVpNMTEuNjQ1MiAzMS4yNTgxQzkuNjE0OTIgMzEuMjU4MSA3Ljk2Nzc0IDI5LjY0OTIgNy45Njc3NCAyNy42NTczQzcuOTY3NzQgMjYuMTI1IDEwLjE1MTIgMjMuMDI5OCAxMS4xNTQ4IDIxLjY5NjhDMTEuNCAyMS4zNjczIDExLjg5MDMgMjEuMzY3MyAxMi4xMzU1IDIxLjY5NjhDMTMuMTM5MSAyMy4wMjk4IDE1LjMyMjYgMjYuMTI1IDE1LjMyMjYgMjcuNjU3M0MxNS4zMjI2IDI5LjY0OTIgMTMuNjc1NCAzMS4yNTgxIDExLjY0NTIgMzEuMjU4MVpNMTIuODcxIDE3Ljc3NDJDMTEuNTE0OSAxNy43NzQyIDEwLjQxOTQgMTYuNjc4NiAxMC40MTk0IDE1LjMyMjZDMTAuNDE5NCAxMy45NjY1IDExLjUxNDkgMTIuODcxIDEyLjg3MSAxMi44NzFDMTQuMjI3IDEyLjg3MSAxNS4zMjI2IDEzLjk2NjUgMTUuMzIyNiAxNS4zMjI2QzE1LjMyMjYgMTYuNjc4NiAxNC4yMjcgMTcuNzc0MiAxMi44NzEgMTcuNzc0MlpNMjUuOTEwNSAyOS41ODc5QzI0LjE5NDQgMjcuNTM0NyAyMS42NzM4IDI2LjM1NDggMTkgMjYuMzU0OEMxNy4zNzU4IDI2LjM1NDggMTcuMzc1OCAyMy45MDMyIDE5IDIzLjkwMzJDMjIuNDAxNiAyMy45MDMyIDI1LjYxMTcgMjUuNDA0OCAyNy43ODc1IDI4LjAyNUMyOC44NDQ4IDI5LjI4MTUgMjYuOTI5NCAzMC44MjE0IDI1LjkxMDUgMjkuNTg3OVoiIGZpbGw9ImJsYWNrIi8+Cjwvc3ZnPgo='; diff --git a/assets/js/blocks/cart/inner-blocks/empty-cart-block/style.scss b/assets/js/blocks/cart/inner-blocks/empty-cart-block/style.scss index e0c402f6ee1..1c96b67ef01 100644 --- a/assets/js/blocks/cart/inner-blocks/empty-cart-block/style.scss +++ b/assets/js/blocks/cart/inner-blocks/empty-cart-block/style.scss @@ -2,3 +2,21 @@ .editor-styles-wrapper .wc-block-cart__empty-cart__title { font-size: inherit; } +.wc-block-cart__empty-cart__title.with-empty-cart-icon { + &::before { + content: ""; + background-color: currentColor; + display: block; + margin: 0 auto 2em; + mask-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzOCIgaGVpZ2h0PSIzOCIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iY3VycmVudENvbG9yIiBkPSJNMTkgMEM4LjUwNCAwIDAgOC41MDQgMCAxOXM4LjUwNCAxOSAxOSAxOSAxOS04LjUwNCAxOS0xOVMyOS40OTYgMCAxOSAwWm02LjEyOSAxMi44NzFhMi40NDkgMi40NDkgMCAwIDEgMi40NTIgMi40NTIgMi40NDkgMi40NDkgMCAwIDEtMi40NTIgMi40NTEgMi40NDkgMi40NDkgMCAwIDEtMi40NTItMi40NTEgMi40NDkgMi40NDkgMCAwIDEgMi40NTItMi40NTJaTTExLjY0NSAzMS4yNThjLTIuMDMgMC0zLjY3Ny0xLjYwOS0zLjY3Ny0zLjYgMC0xLjUzMyAyLjE4My00LjYyOCAzLjE4Ny01Ljk2MWEuNjEuNjEgMCAwIDEgLjk4IDBjMS4wMDQgMS4zMzMgMy4xODggNC40MjggMy4xODggNS45NiAwIDEuOTkyLTEuNjQ4IDMuNjAxLTMuNjc4IDMuNjAxWm0xLjIyNi0xMy40ODRhMi40NDkgMi40NDkgMCAwIDEtMi40NTItMi40NTEgMi40NDkgMi40NDkgMCAwIDEgMi40NTItMi40NTIgMi40NDkgMi40NDkgMCAwIDEgMi40NTIgMi40NTIgMi40NDkgMi40NDkgMCAwIDEtMi40NTIgMi40NTFabTEzLjA0IDExLjgxNEE4Ljk4OSA4Ljk4OSAwIDAgMCAxOSAyNi4zNTVjLTEuNjI0IDAtMS42MjQtMi40NTIgMC0yLjQ1MiAzLjQwMiAwIDYuNjEyIDEuNTAyIDguNzg4IDQuMTIyIDEuMDU3IDEuMjU3LS44NTkgMi43OTYtMS44NzggMS41NjNaIi8+PC9zdmc+); + mask-position: center; + mask-repeat: no-repeat; + mask-size: 5em; + width: 5em; + height: 5em; + } +} +.wp-block-woocommerce-empty-cart-block > .aligncenter { + margin-left: auto !important; + margin-right: auto !important; +} diff --git a/assets/js/blocks/checkout/form-step/form-step-block.tsx b/assets/js/blocks/checkout/form-step/form-step-block.tsx index d4eaee87de3..d20d806df96 100644 --- a/assets/js/blocks/checkout/form-step/form-step-block.tsx +++ b/assets/js/blocks/checkout/form-step/form-step-block.tsx @@ -66,6 +66,7 @@ export const FormStepBlock = ( { className={ '' } value={ title } onChange={ ( value ) => setAttributes( { title: value } ) } + style={ { backgroundColor: 'transparent' } } />
@@ -86,6 +87,7 @@ export const FormStepBlock = ( { description: value, } ) } + style={ { backgroundColor: 'transparent' } } />

diff --git a/assets/js/blocks/product-search/edit.js b/assets/js/blocks/product-search/edit.js index c881d261b50..74d0530e02a 100644 --- a/assets/js/blocks/product-search/edit.js +++ b/assets/js/blocks/product-search/edit.js @@ -86,6 +86,7 @@ const Edit = ( { onChange={ ( value ) => setAttributes( { label: value } ) } + style={ { backgroundColor: 'transparent' } } /> ) } diff --git a/assets/js/editor-components/block-title/index.js b/assets/js/editor-components/block-title/index.js index 18ccd0f605d..0827dac567a 100644 --- a/assets/js/editor-components/block-title/index.js +++ b/assets/js/editor-components/block-title/index.js @@ -32,6 +32,7 @@ const BlockTitle = ( { className="wc-block-editor-components-title" value={ heading } onChange={ onChange } + style={ { backgroundColor: 'transparent' } } /> ); diff --git a/tests/e2e/specs/backend/__fixtures__/cart.fixture.json b/tests/e2e/specs/backend/__fixtures__/cart.fixture.json index d7414bce4e5..0e4253c9ac1 100644 --- a/tests/e2e/specs/backend/__fixtures__/cart.fixture.json +++ b/tests/e2e/specs/backend/__fixtures__/cart.fixture.json @@ -1 +1 @@ -{"title":"Cart Block","pageContent":"

You may be interested in…

\"\"/

Your cart is currently empty!

Browse store.


New in store

"} +{"title":"Cart Block","pageContent":"

You may be interested in…

\"\"/

Your cart is currently empty!

Browse store.


New in store

"} From d5d401cc67ee3392d356d909f99c1111c73be8ad Mon Sep 17 00:00:00 2001 From: Alex Florisca Date: Thu, 9 Feb 2023 11:53:25 +0000 Subject: [PATCH 03/17] Push address changes for email, name and address fields (#8400) --- assets/js/data/cart/push-changes.ts | 30 +++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/assets/js/data/cart/push-changes.ts b/assets/js/data/cart/push-changes.ts index 4484b978e29..06228265a8e 100644 --- a/assets/js/data/cart/push-changes.ts +++ b/assets/js/data/cart/push-changes.ts @@ -3,11 +3,7 @@ */ import { debounce, pick } from 'lodash'; import { select, dispatch } from '@wordpress/data'; -import { - pluckAddress, - pluckEmail, - removeAllNotices, -} from '@woocommerce/base-utils'; +import { pluckEmail, removeAllNotices } from '@woocommerce/base-utils'; import { CartBillingAddress, CartShippingAddress, @@ -27,15 +23,33 @@ type CustomerData = { shippingAddress: CartShippingAddress; }; +type BillingOrShippingAddress = CartBillingAddress | CartShippingAddress; + /** * Checks if a cart response contains an email property. */ const isBillingAddress = ( - address: CartBillingAddress | CartShippingAddress + address: BillingOrShippingAddress ): address is CartBillingAddress => { return 'email' in address; }; +export const trimAddress = ( address: BillingOrShippingAddress ) => { + const trimmedAddress = { + ...address, + }; + Object.keys( address ).forEach( ( key ) => { + trimmedAddress[ key as keyof BillingOrShippingAddress ] = + address[ key as keyof BillingOrShippingAddress ].trim(); + } ); + + trimmedAddress.postcode = trimmedAddress.postcode + ? trimmedAddress.postcode.replace( ' ', '' ).toUpperCase() + : ''; + + return trimmedAddress; +}; + /** * Does a shallow compare of important address data to determine if the cart needs updating on the server. This takes * the current and previous address into account, as well as the billing email field. @@ -57,8 +71,8 @@ const isAddressDirty = < T extends CartBillingAddress | CartShippingAddress >( return ( !! address.country && ! isShallowEqual( - pluckAddress( previousAddress ), - pluckAddress( address ) + trimAddress( previousAddress ), + trimAddress( address ) ) ); }; From 95702eddb5fc75b2737ba0bf5cba53dec0a0ea45 Mon Sep 17 00:00:00 2001 From: Seghir Nadir Date: Thu, 9 Feb 2023 14:41:18 +0100 Subject: [PATCH 04/17] Capture notices from hidden block into siblings block (#8390) * Capture notices from hidden block into siblings block * switch to using a single context * make change bwc * add tests * support context as array in StoreNotice * move filter logic to Notice component --- .../shipping-calculator/index.tsx | 3 +- assets/js/blocks/checkout/block.tsx | 5 +- .../checkout-billing-address-block/block.tsx | 5 +- .../checkout-shipping-address-block/block.tsx | 7 +- .../block-client-apis/notices.md | 36 +++++---- .../store-notices-container/index.tsx | 18 +++-- .../store-notices-container/store-notices.tsx | 23 ++++-- .../store-notices-container/test/index.tsx | 75 +++++++++++++++++++ .../store-notices-container/types.ts | 2 +- 9 files changed, 136 insertions(+), 38 deletions(-) diff --git a/assets/js/base/components/cart-checkout/shipping-calculator/index.tsx b/assets/js/base/components/cart-checkout/shipping-calculator/index.tsx index 108fb33f25a..204596bffd0 100644 --- a/assets/js/base/components/cart-checkout/shipping-calculator/index.tsx +++ b/assets/js/base/components/cart-checkout/shipping-calculator/index.tsx @@ -30,10 +30,9 @@ const ShippingCalculator = ( { addressFields = [ 'country', 'state', 'city', 'postcode' ], }: ShippingCalculatorProps ): JSX.Element => { const { shippingAddress } = useCustomerData(); - const noticeContext = 'wc/cart/shipping-calculator'; return (
- + - - + { /* SlotFillProvider need to be defined before CheckoutProvider so fills have the SlotFill context ready when they mount. */ } diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-billing-address-block/block.tsx b/assets/js/blocks/checkout/inner-blocks/checkout-billing-address-block/block.tsx index 3d42b8ebc6a..1d372046da0 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-billing-address-block/block.tsx +++ b/assets/js/blocks/checkout/inner-blocks/checkout-billing-address-block/block.tsx @@ -88,10 +88,13 @@ const Block = ( { ] ) as Record< keyof AddressFields, Partial< AddressField > >; const AddressFormWrapperComponent = isEditor ? Noninteractive : Fragment; + const noticeContext = useBillingAsShipping + ? [ noticeContexts.BILLING_ADDRESS, noticeContexts.SHIPPING_ADDRESS ] + : [ noticeContexts.BILLING_ADDRESS ]; return ( - + >; const AddressFormWrapperComponent = isEditor ? Noninteractive : Fragment; + const noticeContext = useShippingAsBilling + ? [ noticeContexts.SHIPPING_ADDRESS, noticeContexts.BILLING_ADDRESS ] + : [ noticeContexts.SHIPPING_ADDRESS ]; return ( <> - + { }; ``` +#### Multiple contexts + +```jsx +import { StoreNoticesContainer } from '@woocommerce/blocks-checkout'; + +const AddressForm = () => { + return ( + + ); +}; +``` + ## Snackbar notices in WooCommerce Blocks WooCommerce Blocks also shows snackbar notices, to add a snackbar notice you need to create a notice with `type:snackbar` in the options object. @@ -53,17 +73,3 @@ dispatch( 'core/notices' ).createNotice( 'snackbar-notice-id' ); ``` - -### `SnackbarNoticesContainer` - -To display snackbar notices, use the `SnackbarNoticesContainer` component. This component is rendered with the Cart and Checkout blocks, so there is no need to add another. The context it displays notices for is `default`. If, for some reason you do need to show snackbar messages for a different context, you can render this component again and pass the context as a prop to the component. - -```jsx -import { SnackbarNoticesContainer } from '@woocommerce/base-components/snackbar-notices-container'; - -const AlternativeSnackbarNotices = () => { - return ( - - ); -}; -``` diff --git a/packages/checkout/components/store-notices-container/index.tsx b/packages/checkout/components/store-notices-container/index.tsx index 08178df360d..65ce1caa7d4 100644 --- a/packages/checkout/components/store-notices-container/index.tsx +++ b/packages/checkout/components/store-notices-container/index.tsx @@ -38,13 +38,14 @@ const StoreNoticesContainer = ( { ).getRegisteredContainers(), } ) ); - + const contexts = Array.isArray( context ) ? context : [ context ]; // Find sub-contexts that have not been registered. We will show notices from those contexts here too. const allContexts = getNoticeContexts(); const unregisteredSubContexts = allContexts.filter( ( subContext: string ) => - subContext.includes( context + '/' ) && - ! registeredContainers.includes( subContext ) + contexts.some( ( _context: string ) => + subContext.includes( _context + '/' ) + ) && ! registeredContainers.includes( subContext ) ); // Get notices from the current context and any sub-contexts and append the name of the context to the notice @@ -56,13 +57,14 @@ const StoreNoticesContainer = ( { ...unregisteredSubContexts.flatMap( ( subContext: string ) => formatNotices( getNotices( subContext ), subContext ) ), - ...formatNotices( - getNotices( context ).concat( additionalNotices ), - context + ...contexts.flatMap( ( subContext: string ) => + formatNotices( + getNotices( subContext ).concat( additionalNotices ), + subContext + ) ), ].filter( Boolean ) as StoreNotice[]; } ); - if ( suppressNotices || ! notices.length ) { return null; } @@ -71,7 +73,7 @@ const StoreNoticesContainer = ( { <> notice.type === 'default' ) } diff --git a/packages/checkout/components/store-notices-container/store-notices.tsx b/packages/checkout/components/store-notices-container/store-notices.tsx index 8262ff18764..7957e1d3822 100644 --- a/packages/checkout/components/store-notices-container/store-notices.tsx +++ b/packages/checkout/components/store-notices-container/store-notices.tsx @@ -21,7 +21,7 @@ const StoreNotices = ( { className, notices, }: { - context: string; + context: string | string[]; className: string; notices: StoreNotice[]; } ): JSX.Element => { @@ -64,12 +64,12 @@ const StoreNotices = ( { } ); } }, [ noticeIds, previousNoticeIds, ref ] ); - // Register the container context with the parent. useEffect( () => { - registerContainer( context ); + const contexts = Array.isArray( context ) ? context : [ context ]; + contexts.map( ( _context ) => registerContainer( _context ) ); return () => { - unregisterContainer( context ); + contexts.map( ( _context ) => unregisterContainer( _context ) ); }; }, [ context, registerContainer, unregisterContainer ] ); @@ -117,6 +117,17 @@ const StoreNotices = ( { if ( ! noticeGroup.length ) { return null; } + const uniqueNotices = noticeGroup.filter( + ( + notice: Notice, + noticeIndex: number, + noticesArray: Notice[] + ) => + noticesArray.findIndex( + ( _notice: Notice ) => + _notice.content === notice.content + ) === noticeIndex + ); return ( - { noticeGroup.length === 1 ? ( + { uniqueNotices.length === 1 ? ( <> { sanitizeHTML( decodeEntities( @@ -140,7 +151,7 @@ const StoreNotices = ( { ) : (
    - { noticeGroup.map( ( notice ) => ( + { uniqueNotices.map( ( notice ) => (
  • { ) ); } ); + + it( 'Shows notices from several contexts', async () => { + dispatch( noticesStore ).createErrorNotice( 'Custom shipping error', { + id: 'custom-subcontext-test-error', + context: 'wc/checkout/shipping-address', + } ); + dispatch( noticesStore ).createErrorNotice( 'Custom billing error', { + id: 'custom-subcontext-test-error', + context: 'wc/checkout/billing-address', + } ); + render( + + ); + // This should match against 4 elements; A written and spoken message for each error. + expect( screen.getAllByText( /Custom shipping error/i ) ).toHaveLength( + 2 + ); + expect( screen.getAllByText( /Custom billing error/i ) ).toHaveLength( + 2 + ); + // Clean up notices. + await act( () => + dispatch( noticesStore ).removeNotice( + 'custom-subcontext-test-error', + 'wc/checkout/shipping-address' + ) + ); + await act( () => + dispatch( noticesStore ).removeNotice( + 'custom-subcontext-test-error', + 'wc/checkout/billing-address' + ) + ); + } ); + + it( 'Combine same notices from several contexts', async () => { + dispatch( noticesStore ).createErrorNotice( 'Custom generic error', { + id: 'custom-subcontext-test-error', + context: 'wc/checkout/shipping-address', + } ); + dispatch( noticesStore ).createErrorNotice( 'Custom generic error', { + id: 'custom-subcontext-test-error', + context: 'wc/checkout/billing-address', + } ); + render( + + ); + // This should match against 2 elements; A written and spoken message. + expect( screen.getAllByText( /Custom generic error/i ) ).toHaveLength( + 2 + ); + // Clean up notices. + await act( () => + dispatch( noticesStore ).removeNotice( + 'custom-subcontext-test-error', + 'wc/checkout/shipping-address' + ) + ); + await act( () => + dispatch( noticesStore ).removeNotice( + 'custom-subcontext-test-error', + 'wc/checkout/billing-address' + ) + ); + } ); } ); diff --git a/packages/checkout/components/store-notices-container/types.ts b/packages/checkout/components/store-notices-container/types.ts index 332ea079f5d..63e1db419b7 100644 --- a/packages/checkout/components/store-notices-container/types.ts +++ b/packages/checkout/components/store-notices-container/types.ts @@ -8,7 +8,7 @@ import type { export interface StoreNoticesContainerProps { className?: string | undefined; - context: string; + context: string | string[]; // List of additional notices that were added inline and not stored in the `core/notices` store. additionalNotices?: ( NoticeType & NoticeOptions )[]; } From 37830e305af662f92dadc976a38d962c5a9b9d53 Mon Sep 17 00:00:00 2001 From: Seghir Nadir Date: Thu, 9 Feb 2023 16:04:02 +0100 Subject: [PATCH 05/17] Handle single notice issues (#8404) * Capture notices from hidden block into siblings block * switch to using a single context * make change bwc * support context as array in StoreNotice * Handle single context use cases --- .../store-notices-container/index.tsx | 23 +++++++++++++++---- .../store-notices-container/store-notices.tsx | 14 ----------- .../store-notices-container/test/index.tsx | 13 +++++++---- .../store-notices-container/types.ts | 2 +- 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/packages/checkout/components/store-notices-container/index.tsx b/packages/checkout/components/store-notices-container/index.tsx index 65ce1caa7d4..d1271174931 100644 --- a/packages/checkout/components/store-notices-container/index.tsx +++ b/packages/checkout/components/store-notices-container/index.tsx @@ -1,13 +1,14 @@ /** * External dependencies */ -import { useSelect } from '@wordpress/data'; +import { useSelect, useDispatch } from '@wordpress/data'; import { PAYMENT_STORE_KEY, STORE_NOTICES_STORE_KEY, } from '@woocommerce/block-data'; import { getNoticeContexts } from '@woocommerce/base-utils'; import type { Notice } from '@wordpress/notices'; +import { useMemo, useEffect } from '@wordpress/element'; /** * Internal dependencies @@ -26,9 +27,12 @@ const formatNotices = ( notices: Notice[], context: string ): StoreNotice[] => { const StoreNoticesContainer = ( { className = '', - context, + context = '', additionalNotices = [], }: StoreNoticesContainerProps ): JSX.Element | null => { + const { registerContainer, unregisterContainer } = useDispatch( + STORE_NOTICES_STORE_KEY + ); const { suppressNotices, registeredContainers } = useSelect( ( select ) => ( { suppressNotices: @@ -38,7 +42,10 @@ const StoreNoticesContainer = ( { ).getRegisteredContainers(), } ) ); - const contexts = Array.isArray( context ) ? context : [ context ]; + const contexts = useMemo< string[] >( + () => ( Array.isArray( context ) ? context : [ context ] ), + [ context ] + ); // Find sub-contexts that have not been registered. We will show notices from those contexts here too. const allContexts = getNoticeContexts(); const unregisteredSubContexts = allContexts.filter( @@ -65,6 +72,15 @@ const StoreNoticesContainer = ( { ), ].filter( Boolean ) as StoreNotice[]; } ); + + // Register the container context with the parent. + useEffect( () => { + contexts.map( ( _context ) => registerContainer( _context ) ); + return () => { + contexts.map( ( _context ) => unregisterContainer( _context ) ); + }; + }, [ contexts, registerContainer, unregisterContainer ] ); + if ( suppressNotices || ! notices.length ) { return null; } @@ -73,7 +89,6 @@ const StoreNoticesContainer = ( { <> notice.type === 'default' ) } diff --git a/packages/checkout/components/store-notices-container/store-notices.tsx b/packages/checkout/components/store-notices-container/store-notices.tsx index 7957e1d3822..73a6e29ad8a 100644 --- a/packages/checkout/components/store-notices-container/store-notices.tsx +++ b/packages/checkout/components/store-notices-container/store-notices.tsx @@ -8,7 +8,6 @@ import { sanitizeHTML } from '@woocommerce/utils'; import { useDispatch } from '@wordpress/data'; import { usePrevious } from '@woocommerce/base-hooks'; import { decodeEntities } from '@wordpress/html-entities'; -import { STORE_NOTICES_STORE_KEY } from '@woocommerce/block-data'; /** * Internal dependencies @@ -17,19 +16,14 @@ import { getClassNameFromStatus } from './utils'; import type { StoreNotice } from './types'; const StoreNotices = ( { - context, className, notices, }: { - context: string | string[]; className: string; notices: StoreNotice[]; } ): JSX.Element => { const ref = useRef< HTMLDivElement >( null ); const { removeNotice } = useDispatch( 'core/notices' ); - const { registerContainer, unregisterContainer } = useDispatch( - STORE_NOTICES_STORE_KEY - ); const noticeIds = notices.map( ( notice ) => notice.id ); const previousNoticeIds = usePrevious( noticeIds ); @@ -64,14 +58,6 @@ const StoreNotices = ( { } ); } }, [ noticeIds, previousNoticeIds, ref ] ); - // Register the container context with the parent. - useEffect( () => { - const contexts = Array.isArray( context ) ? context : [ context ]; - contexts.map( ( _context ) => registerContainer( _context ) ); - return () => { - contexts.map( ( _context ) => unregisterContainer( _context ) ); - }; - }, [ context, registerContainer, unregisterContainer ] ); // Group notices by whether or not they are dismissible. Dismissible notices can be grouped. const dismissibleNotices = notices.filter( diff --git a/packages/checkout/components/store-notices-container/test/index.tsx b/packages/checkout/components/store-notices-container/test/index.tsx index 0617dc51b1b..71e83998dc1 100644 --- a/packages/checkout/components/store-notices-container/test/index.tsx +++ b/packages/checkout/components/store-notices-container/test/index.tsx @@ -105,24 +105,27 @@ describe( 'StoreNoticesContainer', () => { it( 'Shows notices from unregistered sub-contexts', async () => { dispatch( noticesStore ).createErrorNotice( - 'Custom sub-context error', + 'Custom first sub-context error', { id: 'custom-subcontext-test-error', context: 'wc/checkout/shipping-address', } ); dispatch( noticesStore ).createErrorNotice( - 'Custom sub-context error', + 'Custom second sub-context error', { id: 'custom-subcontext-test-error', context: 'wc/checkout/billing-address', } ); render( ); - // This should match against 3 elements; 2 error messages, and the spoken message where they are combined into one element. + // This should match against 2 messages, one for each sub-context. expect( - screen.getAllByText( /Custom sub-context error/i ) - ).toHaveLength( 3 ); + screen.getAllByText( /Custom first sub-context error/i ) + ).toHaveLength( 2 ); + expect( + screen.getAllByText( /Custom second sub-context error/i ) + ).toHaveLength( 2 ); // Clean up notices. await act( () => dispatch( noticesStore ).removeNotice( diff --git a/packages/checkout/components/store-notices-container/types.ts b/packages/checkout/components/store-notices-container/types.ts index 63e1db419b7..999c1ac291d 100644 --- a/packages/checkout/components/store-notices-container/types.ts +++ b/packages/checkout/components/store-notices-container/types.ts @@ -8,7 +8,7 @@ import type { export interface StoreNoticesContainerProps { className?: string | undefined; - context: string | string[]; + context?: string | string[]; // List of additional notices that were added inline and not stored in the `core/notices` store. additionalNotices?: ( NoticeType & NoticeOptions )[]; } From 4eda72955108774835946da7974b92aebf77c59b Mon Sep 17 00:00:00 2001 From: Tarun Vijwani Date: Fri, 10 Feb 2023 00:56:30 +0100 Subject: [PATCH 06/17] Change coupon component label to "Add a coupon" (#8385) - Revert the label change to "Add a coupon" so that it will be merged to WC Core and translation for the new string will be available. Co-authored-by: Saad Tarhi --- .../base/components/cart-checkout/totals/coupon/index.tsx | 7 ++----- .../checkout-order-summary-block/test/block.js | 2 +- tests/e2e/specs/shopper/cart-checkout/translations.test.js | 6 ++++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/assets/js/base/components/cart-checkout/totals/coupon/index.tsx b/assets/js/base/components/cart-checkout/totals/coupon/index.tsx index 93384977e65..945b1f0ab9a 100644 --- a/assets/js/base/components/cart-checkout/totals/coupon/index.tsx +++ b/assets/js/base/components/cart-checkout/totals/coupon/index.tsx @@ -92,15 +92,12 @@ export const TotalsCoupon = ( { href="#wc-block-components-totals-coupon__form" className="wc-block-components-totals-coupon-link" aria-label={ __( - 'Apply a coupon code', + 'Add a coupon', 'woo-gutenberg-products-block' ) } onClick={ handleCouponAnchorClick } > - { __( - 'Apply a coupon code', - 'woo-gutenberg-products-block' - ) } + { __( 'Add a coupon', 'woo-gutenberg-products-block' ) } ) } { setUseStoreCartReturnValue(); const { container } = render( ); expect( - await findByText( container, 'Apply a coupon code' ) + await findByText( container, 'Add a coupon' ) ).toBeInTheDocument(); } ); diff --git a/tests/e2e/specs/shopper/cart-checkout/translations.test.js b/tests/e2e/specs/shopper/cart-checkout/translations.test.js index 3a6c26308ca..c97f4552a8a 100644 --- a/tests/e2e/specs/shopper/cart-checkout/translations.test.js +++ b/tests/e2e/specs/shopper/cart-checkout/translations.test.js @@ -48,7 +48,8 @@ describe( 'Shopper → Cart & Checkout → Translations', () => { ); await expect( orderSummary ).toMatch( 'Subtotaal' ); - await expect( orderSummary ).toMatch( 'Een waardebon code toepassen' ); + // Skipping translation for now, as it's not available in WooCommerce Core. + // await expect( orderSummary ).toMatch( 'Een waardebon toevoegen' ); await expect( orderSummary ).toMatch( 'Totaal' ); } ); @@ -90,7 +91,8 @@ describe( 'Shopper → Cart & Checkout → Translations', () => { ); await expect( orderSummary ).toMatch( 'Besteloverzicht' ); await expect( orderSummary ).toMatch( 'Subtotaal' ); - await expect( orderSummary ).toMatch( 'Een waardebon code toepassen' ); + // Skipping translation for now, as it's not available in WooCommerce Core. + // await expect( orderSummary ).toMatch( 'Een waardebon toevoegen' ); await expect( orderSummary ).toMatch( 'Verzending' ); await expect( orderSummary ).toMatch( 'Totaal' ); } ); From a9e04646e918d75131a104d36510c7eab8ed38f0 Mon Sep 17 00:00:00 2001 From: Thomas Roberts <5656702+opr@users.noreply.github.com> Date: Mon, 13 Feb 2023 11:11:00 +0000 Subject: [PATCH 07/17] Prevent coupon error being removed when blurring the input and fix tests (#8349) * Prevent coupon error disappearing on blur if value unchanged * Check for inline selector on coupon error * Prevent coupon error disappearing on blur if value unchanged * Check for inline selector on coupon error * Update selector for coupon E2E test --- .../checkout/components/text-input/validated-text-input.tsx | 5 +++++ tests/e2e/specs/shopper/cart-checkout/checkout.test.js | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/checkout/components/text-input/validated-text-input.tsx b/packages/checkout/components/text-input/validated-text-input.tsx index fb79925bd70..fc8f836a7b1 100644 --- a/packages/checkout/components/text-input/validated-text-input.tsx +++ b/packages/checkout/components/text-input/validated-text-input.tsx @@ -101,6 +101,10 @@ const ValidatedTextInput = ( { inputObject.value = inputObject.value.trim(); inputObject.setCustomValidity( '' ); + if ( previousValue === inputObject.value ) { + return; + } + const inputIsValid = customValidation ? inputObject.checkValidity() && customValidation( inputObject ) : inputObject.checkValidity(); @@ -120,6 +124,7 @@ const ValidatedTextInput = ( { } ); }, [ + previousValue, clearValidationError, customValidation, errorIdString, diff --git a/tests/e2e/specs/shopper/cart-checkout/checkout.test.js b/tests/e2e/specs/shopper/cart-checkout/checkout.test.js index 4aa7a161e85..11e3d50f9bb 100644 --- a/tests/e2e/specs/shopper/cart-checkout/checkout.test.js +++ b/tests/e2e/specs/shopper/cart-checkout/checkout.test.js @@ -304,7 +304,7 @@ describe( 'Shopper → Checkout', () => { await shopper.block.goToCheckout(); await shopper.block.applyCouponFromCheckout( coupon.code ); await page.waitForSelector( - '.wc-block-components-notices__notice' + '.wc-block-components-totals-coupon__content .wc-block-components-validation-error' ); await expect( page ).toMatch( 'Coupon usage limit has been reached.' From 8ed46043ae7c8bb9d94e16f28f3928d2f4278b81 Mon Sep 17 00:00:00 2001 From: Lucio Giannotta Date: Mon, 13 Feb 2023 12:26:39 +0100 Subject: [PATCH 08/17] Change location of Feedback prompt in Products block. (#8382) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With WordPress 6.2, the inspector controls are going to be tabbed into a “Styles” and “Setting” section (where applicable). Previously, in order to display our Feedback Prompt at the bottom of the controls, we had hooked it into the “Colors” section as a temporary/hacky solution to the problem. Now that the styles sections are all moved, the problem doesn't really exist anymore, and we can just place it at the bottom of the controls. --- assets/js/blocks/product-query/inspector-controls.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/assets/js/blocks/product-query/inspector-controls.tsx b/assets/js/blocks/product-query/inspector-controls.tsx index 692bd60fb64..5df7dba8336 100644 --- a/assets/js/blocks/product-query/inspector-controls.tsx +++ b/assets/js/blocks/product-query/inspector-controls.tsx @@ -208,13 +208,6 @@ const ProductQueryControls = ( props: ProductQueryBlock ) => { ) } - { - // Hacky temporary solution to display the feedback prompt - // at the bottom of the inspector controls - } - - - ); }; @@ -226,6 +219,9 @@ export const withProductQueryControls = <> + + + ) : ( From c90990920a30d94327c861c73d6b84d03418d577 Mon Sep 17 00:00:00 2001 From: Thomas Roberts <5656702+opr@users.noreply.github.com> Date: Mon, 13 Feb 2023 11:30:09 +0000 Subject: [PATCH 09/17] Graduate `__experimentalApplyCheckoutFilter` and `__experimentalRegisterCheckoutFilters` to stable. (#8346) * Add wrapper for __experimentalRegisterCheckoutFilters and deprecate it * Update internal references from __experimentalRegisterCheckoutFilters * Add wrapper for deprecated __experimentalApplyCheckoutFilter * Update references from `__experimentalApplyCheckoutFilter` --- .../order-summary/order-summary-item.tsx | 15 ++--- .../cart-checkout/totals/discount/index.tsx | 7 +-- .../totals/footer-item/index.tsx | 9 +-- .../hooks/cart/use-store-cart-coupons.ts | 6 +- .../cart-line-item-row.tsx | 19 +++--- assets/js/blocks/cart/test/block.js | 4 +- .../checkout-actions-block/block.tsx | 4 +- ...ature-flags-and-experimental-interfaces.md | 1 - .../checkout-block/available-filters.md | 30 ++++----- packages/checkout/filter-registry/README.md | 22 +++---- packages/checkout/filter-registry/index.ts | 61 ++++++++++++++++++- .../checkout/filter-registry/test/admin.js | 13 ++-- .../checkout/filter-registry/test/index.js | 19 +++--- 13 files changed, 124 insertions(+), 86 deletions(-) diff --git a/assets/js/base/components/cart-checkout/order-summary/order-summary-item.tsx b/assets/js/base/components/cart-checkout/order-summary/order-summary-item.tsx index c11a16eb2a4..968192ed6b6 100644 --- a/assets/js/base/components/cart-checkout/order-summary/order-summary-item.tsx +++ b/assets/js/base/components/cart-checkout/order-summary/order-summary-item.tsx @@ -10,10 +10,7 @@ import { getCurrencyFromPriceResponse, formatPrice, } from '@woocommerce/price-format'; -import { - __experimentalApplyCheckoutFilter, - mustContain, -} from '@woocommerce/blocks-checkout'; +import { applyCheckoutFilter, mustContain } from '@woocommerce/blocks-checkout'; import Dinero from 'dinero.js'; import { getSetting } from '@woocommerce/settings'; import { useMemo } from '@wordpress/element'; @@ -52,7 +49,7 @@ const OrderSummaryItem = ( { cartItem }: OrderSummaryProps ): JSX.Element => { extensions, } = cartItem; - // Prepare props to pass to the __experimentalApplyCheckoutFilter filter. + // Prepare props to pass to the applyCheckoutFilter filter. // We need to pluck out receiveCart. // eslint-disable-next-line no-unused-vars const { receiveCart, ...cart } = useStoreCart(); @@ -68,7 +65,7 @@ const OrderSummaryItem = ( { cartItem }: OrderSummaryProps ): JSX.Element => { const priceCurrency = getCurrencyFromPriceResponse( prices ); - const name = __experimentalApplyCheckoutFilter( { + const name = applyCheckoutFilter( { filterName: 'itemName', defaultValue: initialName, extensions, @@ -101,7 +98,7 @@ const OrderSummaryItem = ( { cartItem }: OrderSummaryProps ): JSX.Element => { amount: lineSubtotal, precision: totalsCurrency.minorUnit, } ).getAmount(); - const subtotalPriceFormat = __experimentalApplyCheckoutFilter( { + const subtotalPriceFormat = applyCheckoutFilter( { filterName: 'subtotalPriceFormat', defaultValue: '', extensions, @@ -110,7 +107,7 @@ const OrderSummaryItem = ( { cartItem }: OrderSummaryProps ): JSX.Element => { } ); // Allow extensions to filter how the price is displayed. Ie: prepending or appending some values. - const productPriceFormat = __experimentalApplyCheckoutFilter( { + const productPriceFormat = applyCheckoutFilter( { filterName: 'cartItemPrice', defaultValue: '', extensions, @@ -118,7 +115,7 @@ const OrderSummaryItem = ( { cartItem }: OrderSummaryProps ): JSX.Element => { validation: productPriceValidation, } ); - const cartItemClassNameFilter = __experimentalApplyCheckoutFilter( { + const cartItemClassNameFilter = applyCheckoutFilter( { filterName: 'cartItemClass', defaultValue: '', extensions, diff --git a/assets/js/base/components/cart-checkout/totals/discount/index.tsx b/assets/js/base/components/cart-checkout/totals/discount/index.tsx index d50332d87a0..4909c5ecd59 100644 --- a/assets/js/base/components/cart-checkout/totals/discount/index.tsx +++ b/assets/js/base/components/cart-checkout/totals/discount/index.tsx @@ -4,10 +4,7 @@ import { __, sprintf } from '@wordpress/i18n'; import LoadingMask from '@woocommerce/base-components/loading-mask'; import { RemovableChip } from '@woocommerce/base-components/chip'; -import { - __experimentalApplyCheckoutFilter, - TotalsItem, -} from '@woocommerce/blocks-checkout'; +import { applyCheckoutFilter, TotalsItem } from '@woocommerce/blocks-checkout'; import { getSetting } from '@woocommerce/settings'; import { CartResponseCouponItemWithLabel, @@ -64,7 +61,7 @@ const TotalsDiscount = ( { ? discountValue + discountTaxValue : discountValue; - const filteredCartCoupons = __experimentalApplyCheckoutFilter( { + const filteredCartCoupons = applyCheckoutFilter( { arg: filteredCartCouponsFilterArg, filterName: 'coupons', defaultValue: cartCoupons, diff --git a/assets/js/base/components/cart-checkout/totals/footer-item/index.tsx b/assets/js/base/components/cart-checkout/totals/footer-item/index.tsx index c32bc0c13ce..60a7a8f6fc8 100644 --- a/assets/js/base/components/cart-checkout/totals/footer-item/index.tsx +++ b/assets/js/base/components/cart-checkout/totals/footer-item/index.tsx @@ -5,10 +5,7 @@ import { __, sprintf } from '@wordpress/i18n'; import classNames from 'classnames'; import { createInterpolateElement } from '@wordpress/element'; import FormattedMonetaryAmount from '@woocommerce/base-components/formatted-monetary-amount'; -import { - __experimentalApplyCheckoutFilter, - TotalsItem, -} from '@woocommerce/blocks-checkout'; +import { applyCheckoutFilter, TotalsItem } from '@woocommerce/blocks-checkout'; import { useStoreCart } from '@woocommerce/base-context/hooks'; import { getSetting } from '@woocommerce/settings'; import { @@ -60,11 +57,11 @@ const TotalsFooterItem = ( { tax_lines: taxLines, } = values; - // Prepare props to pass to the __experimentalApplyCheckoutFilter filter. + // Prepare props to pass to the applyCheckoutFilter filter. // We need to pluck out receiveCart. // eslint-disable-next-line no-unused-vars const { receiveCart, ...cart } = useStoreCart(); - const label = __experimentalApplyCheckoutFilter( { + const label = applyCheckoutFilter( { filterName: 'totalLabel', defaultValue: __( 'Total', 'woo-gutenberg-products-block' ), extensions: cart.extensions, diff --git a/assets/js/base/context/hooks/cart/use-store-cart-coupons.ts b/assets/js/base/context/hooks/cart/use-store-cart-coupons.ts index 2923fb7e031..81a25490dc4 100644 --- a/assets/js/base/context/hooks/cart/use-store-cart-coupons.ts +++ b/assets/js/base/context/hooks/cart/use-store-cart-coupons.ts @@ -6,7 +6,7 @@ import { useDispatch, useSelect } from '@wordpress/data'; import { CART_STORE_KEY, VALIDATION_STORE_KEY } from '@woocommerce/block-data'; import { decodeEntities } from '@wordpress/html-entities'; import type { StoreCartCoupon } from '@woocommerce/types'; -import { __experimentalApplyCheckoutFilter } from '@woocommerce/blocks-checkout'; +import { applyCheckoutFilter } from '@woocommerce/blocks-checkout'; /** * Internal dependencies @@ -46,7 +46,7 @@ export const useStoreCartCoupons = ( context = '' ): StoreCartCoupon => { return applyCoupon( couponCode ) .then( () => { if ( - __experimentalApplyCheckoutFilter( { + applyCheckoutFilter( { filterName: 'showApplyCouponNotice', defaultValue: true, arg: { couponCode, context }, @@ -86,7 +86,7 @@ export const useStoreCartCoupons = ( context = '' ): StoreCartCoupon => { return removeCoupon( couponCode ) .then( () => { if ( - __experimentalApplyCheckoutFilter( { + applyCheckoutFilter( { filterName: 'showRemoveCouponNotice', defaultValue: true, arg: { couponCode, context }, diff --git a/assets/js/blocks/cart/cart-line-items-table/cart-line-item-row.tsx b/assets/js/blocks/cart/cart-line-items-table/cart-line-item-row.tsx index 5ecfb80d399..fdf3976bdcf 100644 --- a/assets/js/blocks/cart/cart-line-items-table/cart-line-item-row.tsx +++ b/assets/js/blocks/cart/cart-line-items-table/cart-line-item-row.tsx @@ -20,10 +20,7 @@ import { ProductSaleBadge, } from '@woocommerce/base-components/cart-checkout'; import { getCurrencyFromPriceResponse } from '@woocommerce/price-format'; -import { - __experimentalApplyCheckoutFilter, - mustContain, -} from '@woocommerce/blocks-checkout'; +import { applyCheckoutFilter, mustContain } from '@woocommerce/blocks-checkout'; import Dinero from 'dinero.js'; import { forwardRef, useMemo } from '@wordpress/element'; import type { CartItem } from '@woocommerce/types'; @@ -118,7 +115,7 @@ const CartLineItemRow: React.ForwardRefExoticComponent< useStoreCartItemQuantity( lineItem ); const { dispatchStoreEvent } = useStoreEvents(); - // Prepare props to pass to the __experimentalApplyCheckoutFilter filter. + // Prepare props to pass to the applyCheckoutFilter filter. // We need to pluck out receiveCart. // eslint-disable-next-line no-unused-vars const { receiveCart, ...cart } = useStoreCart(); @@ -131,7 +128,7 @@ const CartLineItemRow: React.ForwardRefExoticComponent< [ lineItem, cart ] ); const priceCurrency = getCurrencyFromPriceResponse( prices ); - const name = __experimentalApplyCheckoutFilter( { + const name = applyCheckoutFilter( { filterName: 'itemName', defaultValue: initialName, extensions, @@ -163,7 +160,7 @@ const CartLineItemRow: React.ForwardRefExoticComponent< const isProductHiddenFromCatalog = catalogVisibility === 'hidden' || catalogVisibility === 'search'; - const cartItemClassNameFilter = __experimentalApplyCheckoutFilter( { + const cartItemClassNameFilter = applyCheckoutFilter( { filterName: 'cartItemClass', defaultValue: '', extensions, @@ -171,7 +168,7 @@ const CartLineItemRow: React.ForwardRefExoticComponent< } ); // Allow extensions to filter how the price is displayed. Ie: prepending or appending some values. - const productPriceFormat = __experimentalApplyCheckoutFilter( { + const productPriceFormat = applyCheckoutFilter( { filterName: 'cartItemPrice', defaultValue: '', extensions, @@ -179,7 +176,7 @@ const CartLineItemRow: React.ForwardRefExoticComponent< validation: productPriceValidation, } ); - const subtotalPriceFormat = __experimentalApplyCheckoutFilter( { + const subtotalPriceFormat = applyCheckoutFilter( { filterName: 'subtotalPriceFormat', defaultValue: '', extensions, @@ -187,7 +184,7 @@ const CartLineItemRow: React.ForwardRefExoticComponent< validation: productPriceValidation, } ); - const saleBadgePriceFormat = __experimentalApplyCheckoutFilter( { + const saleBadgePriceFormat = applyCheckoutFilter( { filterName: 'saleBadgePriceFormat', defaultValue: '', extensions, @@ -195,7 +192,7 @@ const CartLineItemRow: React.ForwardRefExoticComponent< validation: productPriceValidation, } ); - const showRemoveItemLink = __experimentalApplyCheckoutFilter( { + const showRemoveItemLink = applyCheckoutFilter( { filterName: 'showRemoveItemLink', defaultValue: true, extensions, diff --git a/assets/js/blocks/cart/test/block.js b/assets/js/blocks/cart/test/block.js index 14ba47dc156..3eea7abf2fa 100644 --- a/assets/js/blocks/cart/test/block.js +++ b/assets/js/blocks/cart/test/block.js @@ -6,7 +6,7 @@ import { previewCart } from '@woocommerce/resource-previews'; import { dispatch } from '@wordpress/data'; import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data'; import { default as fetchMock } from 'jest-fetch-mock'; -import { __experimentalRegisterCheckoutFilters } from '@woocommerce/blocks-checkout'; +import { registerCheckoutFilters } from '@woocommerce/blocks-checkout'; /** * Internal dependencies @@ -240,7 +240,7 @@ describe( 'Testing cart', () => { items: [ previewCart.items[ 0 ] ], }; - __experimentalRegisterCheckoutFilters( 'woo-blocks-test-extension', { + registerCheckoutFilters( 'woo-blocks-test-extension', { showRemoveItemLink: ( value, extensions, { cartItem } ) => { return cartItem.id !== cart.items[ 0 ].id; }, diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-actions-block/block.tsx b/assets/js/blocks/checkout/inner-blocks/checkout-actions-block/block.tsx index 4ad6356032a..b4c36a071fb 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-actions-block/block.tsx +++ b/assets/js/blocks/checkout/inner-blocks/checkout-actions-block/block.tsx @@ -11,7 +11,7 @@ import { useCheckoutSubmit } from '@woocommerce/base-context/hooks'; import { noticeContexts } from '@woocommerce/base-context'; import { StoreNoticesContainer, - __experimentalApplyCheckoutFilter, + applyCheckoutFilter, } from '@woocommerce/blocks-checkout'; /** @@ -32,7 +32,7 @@ const Block = ( { placeOrderButtonLabel: string; } ): JSX.Element => { const { paymentMethodButtonLabel } = useCheckoutSubmit(); - const label = __experimentalApplyCheckoutFilter( { + const label = applyCheckoutFilter( { filterName: 'placeOrderButtonLabel', defaultValue: paymentMethodButtonLabel || diff --git a/docs/internal-developers/blocks/feature-flags-and-experimental-interfaces.md b/docs/internal-developers/blocks/feature-flags-and-experimental-interfaces.md index c03549c947d..44e2e521cc0 100644 --- a/docs/internal-developers/blocks/feature-flags-and-experimental-interfaces.md +++ b/docs/internal-developers/blocks/feature-flags-and-experimental-interfaces.md @@ -86,7 +86,6 @@ We also have individual features or code blocks behind a feature flag, this is a - `__experimentalDeRegisterPaymentMethod` function used to deregister a payment method, only used in tests ([experimental function](https://github.com/woocommerce/woocommerce-blocks/blob/f27456dd00fa0b21b29a935943defb18351edf48/assets/js/blocks-registry/payment-methods/registry.ts#L110-L114)). - `__experimentalDeRegisterExpressPaymentMethod` function used to deregister an express payment method, only used in tests ([experimental function](https://github.com/woocommerce/woocommerce-blocks/blob/f27456dd00fa0b21b29a935943defb18351edf48/assets/js/blocks-registry/payment-methods/registry.ts#L116-L120)). -- `__experimentalRegisterCheckoutFilters` and `__experimentalApplyCheckoutFilter` methods included with `@woocommerce/blocks-checkout` package. They allow registering and applying a filter to certain parts of the Cart and Checkout blocks ([experimental method 1](https://github.com/woocommerce/woocommerce-gutenberg-products-block/blob/3e59ec9842464f783f6e087947e717fa0b0a7b1b/packages/checkout/registry/index.js#L2) | [experimental method 2](https://github.com/woocommerce/woocommerce-gutenberg-products-block/blob/3e59ec9842464f783f6e087947e717fa0b0a7b1b/packages/checkout/registry/index.js#L17)). ### Slots diff --git a/docs/third-party-developers/extensibility/checkout-block/available-filters.md b/docs/third-party-developers/extensibility/checkout-block/available-filters.md index 275ec0ae1ad..0e5093a80c2 100644 --- a/docs/third-party-developers/extensibility/checkout-block/available-filters.md +++ b/docs/third-party-developers/extensibility/checkout-block/available-filters.md @@ -110,11 +110,11 @@ For this example, let's suppose we are building an extension that lets customers const replaceTotalWithDeposit = () => 'Deposit due today'; ``` -2. Now we need to register this filter function, and have it executed when the `totalLabel` filter is applied. We can access the `__experimentalRegisterCheckoutFilters` function on the `window.wc.blocksCheckout` object. As long as your extension's script is enqueued _after_ WooCommerce Blocks' scripts (i.e. by registering `wc-blocks-checkout` as a dependency), then this will be available. +2. Now we need to register this filter function, and have it executed when the `totalLabel` filter is applied. We can access the `registerCheckoutFilters` function on the `window.wc.blocksCheckout` object. As long as your extension's script is enqueued _after_ WooCommerce Blocks' scripts (i.e. by registering `wc-blocks-checkout` as a dependency), then this will be available. ```ts -const { __experimentalRegisterCheckoutFilters } = window.wc.blocksCheckout; -__experimentalRegisterCheckoutFilters( 'my-hypothetical-deposit-plugin', { +const { registerCheckoutFilters } = window.wc.blocksCheckout; +registerCheckoutFilters( 'my-hypothetical-deposit-plugin', { totalLabel: replaceTotalWithDeposit, } ); ``` @@ -140,11 +140,11 @@ const appendTextToPriceInCart = ( value, extensions, args ) => { }; ``` -2. Now we must register it. Refer to the first example for information about `__experimentalRegisterCheckoutFilters`. +2. Now we must register it. Refer to the first example for information about `registerCheckoutFilters`. ```ts -const { __experimentalRegisterCheckoutFilters } = window.wc.blocksCheckout; -__experimentalRegisterCheckoutFilters( 'my-hypothetical-price-plugin', { +const { registerCheckoutFilters } = window.wc.blocksCheckout; +registerCheckoutFilters( 'my-hypothetical-price-plugin', { subtotalPriceFormat: appendTextToPriceInCart, } ); ``` @@ -175,9 +175,9 @@ const filterCoupons = ( coupons ) => { We'd register our filter like this: ```ts -import { __experimentalRegisterCheckoutFilters } from '@woocommerce/blocks-checkout'; +import { registerCheckoutFilters } from '@woocommerce/blocks-checkout'; -__experimentalRegisterCheckoutFilters( 'automatic-coupon-extension', { +registerCheckoutFilters( 'automatic-coupon-extension', { coupons: filterCoupons, } ); ``` @@ -193,9 +193,9 @@ If you want to prevent a coupon apply notice from appearing, you can use the `sh The same can be done with the `showRemoveCouponNotice` filter to prevent a notice when a coupon is removed from the cart. ```ts -import { __experimentalRegisterCheckoutFilters } from '@woocommerce/blocks-checkout'; +import { registerCheckoutFilters } from '@woocommerce/blocks-checkout'; -__experimentalRegisterCheckoutFilters( 'example-extension', { +registerCheckoutFilters( 'example-extension', { showApplyCouponNotice: ( value, extensions, { couponCode } ) => { // Prevent a couponCode called '10off' from creating a notice. return couponCode === '10off' ? false : value; @@ -215,9 +215,9 @@ An important caveat to note is this does _not_ prevent the item from being remov removing it in the Mini Cart, or traditional shortcode cart. ```ts -import { __experimentalRegisterCheckoutFilters } from '@woocommerce/blocks-checkout'; +import { registerCheckoutFilters } from '@woocommerce/blocks-checkout'; -__experimentalRegisterCheckoutFilters( 'example-extension', { +registerCheckoutFilters( 'example-extension', { showRemoveItemLink: ( value, extensions, { cartItem } ) => { // Prevent items with ID 1 being removed from the cart. return cartItem.id !== 1; @@ -235,11 +235,11 @@ Let's assume a merchant want to change the label of the Place Order button _Plac const label = () => `Pay now`; ``` -2. Now we have to register this filter function, and have it executed when the `placeOrderButtonLabel` filter is applied. We can access the `__experimentalRegisterCheckoutFilters` function on the `window.wc.blocksCheckout` object. As long as your extension's script is enqueued _after_ WooCommerce Blocks' scripts (i.e. by registering `wc-blocks-checkout` as a dependency), then this will be available. +2. Now we have to register this filter function, and have it executed when the `placeOrderButtonLabel` filter is applied. We can access the `registerCheckoutFilters` function on the `window.wc.blocksCheckout` object. As long as your extension's script is enqueued _after_ WooCommerce Blocks' scripts (i.e. by registering `wc-blocks-checkout` as a dependency), then this will be available. ```ts -const { __experimentalRegisterCheckoutFilters } = window.wc.blocksCheckout; -__experimentalRegisterCheckoutFilters( 'custom-place-order-button-label', { +const { registerCheckoutFilters } = window.wc.blocksCheckout; +registerCheckoutFilters( 'custom-place-order-button-label', { placeOrderButtonLabel: label, } ); ``` diff --git a/packages/checkout/filter-registry/README.md b/packages/checkout/filter-registry/README.md index c90ff9f7d1b..31b04780f27 100644 --- a/packages/checkout/filter-registry/README.md +++ b/packages/checkout/filter-registry/README.md @@ -2,12 +2,12 @@ ## Table of Contents -- [\_\_experimentalRegisterCheckoutFilters](#__experimentalregistercheckoutfilters) +- [registerCheckoutFilters](#registercheckoutfilters) - [Usage](#usage) - [Options](#options) - [`namespace (string)`](#namespace-string) - [`filters (object)`](#filters-object) -- [\_\_experimentalApplyCheckoutFilter](#__experimentalapplycheckoutfilter) +- [applyCheckoutFilter](#applycheckoutfilter) - [Usage](#usage-1) - [Options](#options-1) - [`filterName (string, required)`](#filtername-string-required) @@ -19,7 +19,7 @@ The filter registry allows callbacks to be registered to manipulate certain values. This is similar to the traditional filter system in WordPress (where you register a callback with a specific filter and return a modified value). -## \_\_experimentalRegisterCheckoutFilters +## registerCheckoutFilters Registers a callback function with an available filter. This function has the following signature: @@ -44,16 +44,16 @@ type CheckoutFilterFunction = < T >( ```js // Aliased import -import { __experimentalRegisterCheckoutFilters } from '@woocommerce/blocks-checkout'; +import { registerCheckoutFilters } from '@woocommerce/blocks-checkout'; // Global import -// const { __experimentalRegisterCheckoutFilters } = wc.blocksCheckout; + const { registerCheckoutFilters } = wc.blocksCheckout; const callback = ( value ) => { return value; }; -__experimentalRegisterCheckoutFilters( 'my-extension-namespace', { +registerCheckoutFilters( 'my-extension-namespace', { filterName: callback, } ); ``` @@ -74,7 +74,7 @@ A list of filter names and functions (`CheckoutFilterFunction`) to execute when - `extensions` A n object containing extension data. If your extension has extended any of the store's API routes, one of the keys of this object will be your extension's namespace. The value will contain any data you add to the endpoint. Each key in the `extensions` object is an extension namespace, so a third party extension cannot interfere with _your_ extension's schema modifications, unless there is a naming collision, so please ensure your extension has a unique namespace that is unlikely to conflict with other extensions. - `args` - An object containing any additional data passed to the filter function. This usually (but not always) contains at least a key called `context`. The value of `context` will be (at the moment) either `cart` or `checkout`. This is provided to inform extensions about the exact location that the filter is being applied. The same filter can be applied in multiple places. -## \_\_experimentalApplyCheckoutFilter +## applyCheckoutFilter This function applies a filter, and all registered callbacks, to a given value. @@ -82,17 +82,17 @@ This function applies a filter, and all registered callbacks, to a given value. ```js // Aliased import -import { __experimentalApplyCheckoutFilter } from '@woocommerce/blocks-checkout'; +import { applyCheckoutFilter } from '@woocommerce/blocks-checkout'; // Global import -// const { __experimentalApplyCheckoutFilter } = wc.blocksCheckout; + const { applyCheckoutFilter } = wc.blocksCheckout; const options = { filterName: 'my-filter', defaultValue: 'Default Value', }; -const filteredValue = __experimentalApplyCheckoutFilter( options ); +const filteredValue = applyCheckoutFilter( options ); ``` ### Options @@ -121,7 +121,7 @@ A function that needs to return true when the filtered value is passed in order ## Available Filters -Filters are implemented throughout the Mini Cart, Cart and Checkout Blocks, as well as some components. For a list of filters, [see this document](../../../docs/third-party-developers/extensibility/checkout-block/available-filters.md). You can also search for [usage of `__experimentalApplyCheckoutFilter` within the source code](https://github.com/woocommerce/woocommerce-gutenberg-products-block/search?q=__experimentalApplyCheckoutFilter). +Filters are implemented throughout the Mini Cart, Cart and Checkout Blocks, as well as some components. For a list of filters, [see this document](../../../docs/third-party-developers/extensibility/checkout-block/available-filters.md). You can also search for [usage of `applyCheckoutFilter` within the source code](https://github.com/woocommerce/woocommerce-gutenberg-products-block/search?q=applyCheckoutFilter). diff --git a/packages/checkout/filter-registry/index.ts b/packages/checkout/filter-registry/index.ts index 91efcbb66a8..7522006f943 100644 --- a/packages/checkout/filter-registry/index.ts +++ b/packages/checkout/filter-registry/index.ts @@ -33,10 +33,11 @@ let checkoutFilters: Record< > = {}; const cachedValues: Record< string, T > = {}; + /** * Register filters for a specific extension. */ -export const __experimentalRegisterCheckoutFilters = ( +export const registerCheckoutFilters = ( namespace: string, filters: Record< string, CheckoutFilterFunction > ): void => { @@ -59,6 +60,24 @@ export const __experimentalRegisterCheckoutFilters = ( }; }; +/** + * Backward compatibility for __experimentalRegisterCheckoutFilters, this has been graduated to stable now. + * Remove after July 2023. + */ +export const __experimentalRegisterCheckoutFilters = ( + namespace: string, + filters: Record< string, CheckoutFilterFunction > +) => { + deprecated( '__experimentalRegisterCheckoutFilters', { + alternative: 'registerCheckoutFilters', + plugin: 'WooCommerce Blocks', + link: '', + since: '6.0.0', + hint: '__experimentalRegisterCheckoutFilters has graduated to stable and this experimental function will be removed.', + } ); + registerCheckoutFilters( namespace, filters ); +}; + /** * Get all filters with a specific name. * @@ -179,7 +198,7 @@ const shouldReRunFilters = < T >( /** * Apply a filter. */ -export const __experimentalApplyCheckoutFilter = < T >( { +export const applyCheckoutFilter = < T >( { filterName, defaultValue, extensions = null, @@ -234,3 +253,41 @@ export const __experimentalApplyCheckoutFilter = < T >( { cachedValues[ filterName ] = value; return value; }; + +/** + * Backward compatibility for __experimentalApplyCheckoutFilter, this has been graduated to stable now. + * Remove after July 2023. + */ +export const __experimentalApplyCheckoutFilter = < T >( { + filterName, + defaultValue, + extensions = null, + arg = null, + validation = returnTrue, +}: { + /** Name of the filter to apply. */ + filterName: string; + /** Default value to filter. */ + defaultValue: T; + /** Values extend to REST API response. */ + extensions?: Record< string, unknown > | null; + /** Object containing arguments for the filter function. */ + arg?: CheckoutFilterArguments; + /** Function that needs to return true when the filtered value is passed in order for the filter to be applied. */ + validation?: ( value: T ) => true | Error; +} ): T => { + deprecated( '__experimentalApplyCheckoutFilter', { + alternative: 'applyCheckoutFilter', + plugin: 'WooCommerce Blocks', + link: '', + since: '6.0.0', + hint: '__experimentalApplyCheckoutFilter has graduated to stable and this experimental function will be removed.', + } ); + return applyCheckoutFilter( { + filterName, + defaultValue, + extensions, + arg, + validation, + } ); +}; diff --git a/packages/checkout/filter-registry/test/admin.js b/packages/checkout/filter-registry/test/admin.js index 50d17783e56..aed5284b817 100644 --- a/packages/checkout/filter-registry/test/admin.js +++ b/packages/checkout/filter-registry/test/admin.js @@ -5,10 +5,7 @@ import { renderHook } from '@testing-library/react-hooks'; /** * Internal dependencies */ -import { - __experimentalRegisterCheckoutFilters, - __experimentalApplyCheckoutFilter, -} from '../'; +import { registerCheckoutFilters, applyCheckoutFilter } from '../'; jest.mock( '@woocommerce/settings', () => { const originalModule = jest.requireActual( '@woocommerce/settings' ); @@ -23,14 +20,14 @@ describe( 'Checkout registry (as admin user)', () => { test( 'should throw if the filter throws and user is an admin', () => { const filterName = 'ErrorTestFilter'; const value = 'Hello World'; - __experimentalRegisterCheckoutFilters( filterName, { + registerCheckoutFilters( filterName, { [ filterName ]: () => { throw new Error( 'test error' ); }, } ); const { result } = renderHook( () => - __experimentalApplyCheckoutFilter( { + applyCheckoutFilter( { filterName, defaultValue: value, } ) @@ -41,11 +38,11 @@ describe( 'Checkout registry (as admin user)', () => { test( 'should throw if validation throws and user is an admin', () => { const filterName = 'ValidationTestFilter'; const value = 'Hello World'; - __experimentalRegisterCheckoutFilters( filterName, { + registerCheckoutFilters( filterName, { [ filterName ]: ( val ) => val, } ); const { result } = renderHook( () => - __experimentalApplyCheckoutFilter( { + applyCheckoutFilter( { filterName, defaultValue: value, validation: () => { diff --git a/packages/checkout/filter-registry/test/index.js b/packages/checkout/filter-registry/test/index.js index 0bff625320c..b9a1d1057fb 100644 --- a/packages/checkout/filter-registry/test/index.js +++ b/packages/checkout/filter-registry/test/index.js @@ -5,10 +5,7 @@ import { renderHook } from '@testing-library/react-hooks'; /** * Internal dependencies */ -import { - __experimentalRegisterCheckoutFilters, - __experimentalApplyCheckoutFilter, -} from '../'; +import { registerCheckoutFilters, applyCheckoutFilter } from '../'; describe( 'Checkout registry', () => { const filterName = 'loremIpsum'; @@ -16,7 +13,7 @@ describe( 'Checkout registry', () => { test( 'should return default value if there are no filters', () => { const value = 'Hello World'; const { result: newValue } = renderHook( () => - __experimentalApplyCheckoutFilter( { + applyCheckoutFilter( { filterName, defaultValue: value, } ) @@ -26,12 +23,12 @@ describe( 'Checkout registry', () => { test( 'should return filtered value when a filter is registered', () => { const value = 'Hello World'; - __experimentalRegisterCheckoutFilters( filterName, { + registerCheckoutFilters( filterName, { [ filterName ]: ( val, extensions, args ) => val.toUpperCase() + args.punctuationSign, } ); const { result: newValue } = renderHook( () => - __experimentalApplyCheckoutFilter( { + applyCheckoutFilter( { filterName, defaultValue: value, arg: { @@ -45,11 +42,11 @@ describe( 'Checkout registry', () => { test( 'should not return filtered value if validation failed', () => { const value = 'Hello World'; - __experimentalRegisterCheckoutFilters( filterName, { + registerCheckoutFilters( filterName, { [ filterName ]: ( val ) => val.toUpperCase(), } ); const { result: newValue } = renderHook( () => - __experimentalApplyCheckoutFilter( { + applyCheckoutFilter( { filterName, defaultValue: value, validation: ( val ) => ! val.includes( 'HELLO' ), @@ -69,13 +66,13 @@ describe( 'Checkout registry', () => { // We use this new filter name here to avoid return the cached value for the filter const filterNameThatThrows = 'throw'; const value = 'Hello World'; - __experimentalRegisterCheckoutFilters( filterNameThatThrows, { + registerCheckoutFilters( filterNameThatThrows, { [ filterNameThatThrows ]: () => { throw error; }, } ); const { result: newValue } = renderHook( () => - __experimentalApplyCheckoutFilter( { + applyCheckoutFilter( { filterName: filterNameThatThrows, defaultValue: value, } ) From 7b148ddffbe3b3b670e09e9d7d94c1feecc7596a Mon Sep 17 00:00:00 2001 From: Thomas Roberts <5656702+opr@users.noreply.github.com> Date: Mon, 13 Feb 2023 11:43:57 +0000 Subject: [PATCH 10/17] Add stricter observer type checks in payments thunk to improve resilience to bad observer responses (#8319) * Allow observers to set billingAddress by returning billingData This is required since we didn't correctly deprecate billingData when we changed the name to billingAddress * Add tests for shippingAddress and paymentMethodData * Add mocked __internalSetPaymentMethodData to correct object It was in registry, but should be in dispatch as the action is on the same store as the thunk. Registry is used for actions on other stores. * Re-add FieldValidationStatus type * Add FieldValidationStatus back * Remove empty file * Import FieldValidationStatus from correct place * Remove import of deleted types file * Add isObserverResponse type guard * Use error constant instead of magic string in event emitter * Remove composite project tsconfig * Add ObserverResponse type * Add types to emitEventWithAbort * Check if paymentmethod data is an object before dispatching * Set types on observer responses * Add validationErrors type guards * Add tests for validation typeguards * Add validation errors as option on observer response * Add more granular observer response types * Check observer response has correct types before dispatching actions * Force type on deprecated billingData and shippingData * Remove unnecessary comment --- .../address-form/address-form.tsx | 6 +- assets/js/base/context/event-emit/emitters.ts | 11 ++- assets/js/base/context/event-emit/utils.ts | 28 ++++-- assets/js/base/context/tsconfig.json | 19 ----- assets/js/data/checkout/types.ts | 2 +- assets/js/data/index.ts | 1 - assets/js/data/payment/thunks.ts | 85 ++++++++++++++----- assets/js/data/payment/types.ts | 7 +- assets/js/data/types.ts | 0 assets/js/data/validation/actions.ts | 2 +- assets/js/data/validation/reducers.ts | 3 +- assets/js/data/validation/test/reducers.ts | 6 +- assets/js/data/validation/test/selectors.ts | 6 +- assets/js/types/type-defs/index.ts | 1 + assets/js/types/type-defs/validation.ts | 16 ++++ assets/js/types/type-guards/observers.ts | 14 +++ .../js/types/type-guards/test/validation.ts | 57 +++++++++++++ assets/js/types/type-guards/validation.ts | 41 +++++++++ 18 files changed, 243 insertions(+), 62 deletions(-) delete mode 100644 assets/js/base/context/tsconfig.json delete mode 100644 assets/js/data/types.ts create mode 100644 assets/js/types/type-defs/validation.ts create mode 100644 assets/js/types/type-guards/observers.ts create mode 100644 assets/js/types/type-guards/test/validation.ts create mode 100644 assets/js/types/type-guards/validation.ts diff --git a/assets/js/base/components/cart-checkout/address-form/address-form.tsx b/assets/js/base/components/cart-checkout/address-form/address-form.tsx index 364fd819d9f..3a211ee37fe 100644 --- a/assets/js/base/components/cart-checkout/address-form/address-form.tsx +++ b/assets/js/base/components/cart-checkout/address-form/address-form.tsx @@ -22,10 +22,8 @@ import { ShippingAddress, } from '@woocommerce/settings'; import { useSelect, useDispatch } from '@wordpress/data'; -import { - VALIDATION_STORE_KEY, - FieldValidationStatus, -} from '@woocommerce/block-data'; +import { VALIDATION_STORE_KEY } from '@woocommerce/block-data'; +import { FieldValidationStatus } from '@woocommerce/types'; /** * Internal dependencies diff --git a/assets/js/base/context/event-emit/emitters.ts b/assets/js/base/context/event-emit/emitters.ts index 442812481b9..c4ce28ceb9e 100644 --- a/assets/js/base/context/event-emit/emitters.ts +++ b/assets/js/base/context/event-emit/emitters.ts @@ -5,8 +5,11 @@ import { getObserversByPriority, isErrorResponse, isFailResponse, + ObserverResponse, + responseTypes, } from './utils'; import type { EventObserversType } from './types'; +import { isObserverResponse } from '../../../types/type-guards/observers'; /** * Emits events on registered observers for the provided type and passes along @@ -64,13 +67,13 @@ export const emitEventWithAbort = async ( observers: EventObserversType, eventType: string, data: unknown -): Promise< Array< unknown > > => { - const observerResponses = []; +): Promise< ObserverResponse[] > => { + const observerResponses: ObserverResponse[] = []; const observersByType = getObserversByPriority( observers, eventType ); for ( const observer of observersByType ) { try { const response = await Promise.resolve( observer.callback( data ) ); - if ( typeof response !== 'object' || response === null ) { + if ( ! isObserverResponse( response ) ) { continue; } if ( ! response.hasOwnProperty( 'type' ) ) { @@ -90,7 +93,7 @@ export const emitEventWithAbort = async ( // We don't handle thrown errors but just console.log for troubleshooting. // eslint-disable-next-line no-console console.error( e ); - observerResponses.push( { type: 'error' } ); + observerResponses.push( { type: responseTypes.ERROR } ); return observerResponses; } } diff --git a/assets/js/base/context/event-emit/utils.ts b/assets/js/base/context/event-emit/utils.ts index 7cb4a5c036e..f9749e2d018 100644 --- a/assets/js/base/context/event-emit/utils.ts +++ b/assets/js/base/context/event-emit/utils.ts @@ -1,7 +1,7 @@ /** * External dependencies */ -import { isObject } from '@woocommerce/types'; +import { FieldValidationStatus, isObject } from '@woocommerce/types'; /** * Internal dependencies @@ -42,6 +42,16 @@ export interface ResponseType extends Record< string, unknown > { retry?: boolean; } +/** + * Observers of checkout/cart events can return a response object to indicate success/error/failure. They may also + * optionally pass metadata. + */ +export interface ObserverResponse { + type: responseTypes; + meta?: Record< string, unknown > | undefined; + validationErrors?: Record< string, FieldValidationStatus > | undefined; +} + const isResponseOf = ( response: unknown, type: string @@ -51,19 +61,27 @@ const isResponseOf = ( export const isSuccessResponse = ( response: unknown -): response is ResponseType => { +): response is ObserverFailResponse => { return isResponseOf( response, responseTypes.SUCCESS ); }; - +interface ObserverSuccessResponse extends ObserverResponse { + type: responseTypes.SUCCESS; +} export const isErrorResponse = ( response: unknown -): response is ResponseType => { +): response is ObserverSuccessResponse => { return isResponseOf( response, responseTypes.ERROR ); }; +interface ObserverErrorResponse extends ObserverResponse { + type: responseTypes.ERROR; +} +interface ObserverFailResponse extends ObserverResponse { + type: responseTypes.FAIL; +} export const isFailResponse = ( response: unknown -): response is ResponseType => { +): response is ObserverErrorResponse => { return isResponseOf( response, responseTypes.FAIL ); }; diff --git a/assets/js/base/context/tsconfig.json b/assets/js/base/context/tsconfig.json deleted file mode 100644 index 78b3b4d4549..00000000000 --- a/assets/js/base/context/tsconfig.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "extends": "../../../../tsconfig.base.json", - "compilerOptions": {}, - "include": [ - ".", - "../../blocks-registry/index.js", - "../../settings/shared/index.ts", - "../../settings/blocks/index.ts", - "../../base/hooks/index.js", - "../../base/utils/", - "../../utils", - "../../data/", - "../../types/", - "../components", - "../../blocks/cart-checkout-shared/payment-methods", - "../../settings/shared/default-address-fields.ts" - ], - "exclude": [ "**/test/**" ] -} diff --git a/assets/js/data/checkout/types.ts b/assets/js/data/checkout/types.ts index 6057407e1da..acec22130c6 100644 --- a/assets/js/data/checkout/types.ts +++ b/assets/js/data/checkout/types.ts @@ -3,6 +3,7 @@ */ import type { Notice } from '@wordpress/notices/'; import { DataRegistry } from '@wordpress/data'; +import { FieldValidationStatus } from '@woocommerce/types'; /** * Internal dependencies @@ -13,7 +14,6 @@ import type { PaymentState } from '../payment/default-state'; import type { DispatchFromMap, SelectFromMap } from '../mapped-types'; import * as selectors from './selectors'; import * as actions from './actions'; -import { FieldValidationStatus } from '../types'; export type CheckoutAfterProcessingWithErrorEventData = { redirectUrl: CheckoutState[ 'redirectUrl' ]; diff --git a/assets/js/data/index.ts b/assets/js/data/index.ts index 5367e06bb06..4cd685a5c09 100644 --- a/assets/js/data/index.ts +++ b/assets/js/data/index.ts @@ -15,5 +15,4 @@ export { VALIDATION_STORE_KEY } from './validation'; export { QUERY_STATE_STORE_KEY } from './query-state'; export { STORE_NOTICES_STORE_KEY } from './store-notices'; export * from './constants'; -export * from './types'; export * from './utils'; diff --git a/assets/js/data/payment/thunks.ts b/assets/js/data/payment/thunks.ts index 1ba23513727..ab4f9467b76 100644 --- a/assets/js/data/payment/thunks.ts +++ b/assets/js/data/payment/thunks.ts @@ -4,6 +4,7 @@ import { store as noticesStore } from '@wordpress/notices'; import deprecated from '@wordpress/deprecated'; import type { BillingAddress, ShippingAddress } from '@woocommerce/settings'; +import { isObject, isString, objectHasProp } from '@woocommerce/types'; /** * Internal dependencies @@ -14,6 +15,7 @@ import { isFailResponse, isSuccessResponse, noticeContexts, + ObserverResponse, } from '../../base/context/event-emit'; import { EMIT_TYPES } from '../../base/context/providers/cart-checkout/payment-events/event-emit'; import type { emitProcessingEventType } from './types'; @@ -22,6 +24,8 @@ import { isBillingAddress, isShippingAddress, } from '../../types/type-guards/address'; +import { isObserverResponse } from '../../types/type-guards/observers'; +import { isValidValidationErrorsObject } from '../../types/type-guards/validation'; export const __internalSetExpressPaymentError = ( message?: string ) => { return ( { registry } ) => { @@ -57,8 +61,8 @@ export const __internalEmitPaymentProcessingEvent: emitProcessingEventType = ( EMIT_TYPES.PAYMENT_PROCESSING, {} ).then( ( observerResponses ) => { - let successResponse, - errorResponse, + let successResponse: ObserverResponse | undefined, + errorResponse: ObserverResponse | undefined, billingAddress: BillingAddress | undefined, shippingAddress: ShippingAddress | undefined; observerResponses.forEach( ( response ) => { @@ -86,12 +90,13 @@ export const __internalEmitPaymentProcessingEvent: emitProcessingEventType = ( shippingData: shippingDataFromResponse, } = response?.meta || {}; - billingAddress = billingAddressFromResponse; - shippingAddress = shippingAddressFromResponse; + billingAddress = billingAddressFromResponse as BillingAddress; + shippingAddress = + shippingAddressFromResponse as ShippingAddress; if ( billingDataFromResponse ) { // Set this here so that old extensions still using billingData can set the billingAddress. - billingAddress = billingDataFromResponse; + billingAddress = billingDataFromResponse as BillingAddress; deprecated( 'returning billingData from an onPaymentProcessing observer in WooCommerce Blocks', { @@ -104,7 +109,8 @@ export const __internalEmitPaymentProcessingEvent: emitProcessingEventType = ( if ( shippingDataFromResponse ) { // Set this here so that old extensions still using shippingData can set the shippingAddress. - shippingAddress = shippingDataFromResponse; + shippingAddress = + shippingDataFromResponse as ShippingAddress; deprecated( 'returning shippingData from an onPaymentProcessing observer in WooCommerce Blocks', { @@ -119,9 +125,12 @@ export const __internalEmitPaymentProcessingEvent: emitProcessingEventType = ( const { setBillingAddress, setShippingAddress } = registry.dispatch( CART_STORE_KEY ); - if ( successResponse && ! errorResponse ) { + if ( + isObserverResponse( successResponse ) && + successResponse && + ! errorResponse + ) { const { paymentMethodData } = successResponse?.meta || {}; - if ( billingAddress && isBillingAddress( billingAddress ) ) { setBillingAddress( billingAddress ); } @@ -131,16 +140,29 @@ export const __internalEmitPaymentProcessingEvent: emitProcessingEventType = ( ) { setShippingAddress( shippingAddress ); } - dispatch.__internalSetPaymentMethodData( paymentMethodData ); + const paymentDataToSet = isObject( paymentMethodData ) + ? paymentMethodData + : {}; + dispatch.__internalSetPaymentMethodData( paymentDataToSet ); dispatch.__internalSetPaymentSuccess(); - } else if ( errorResponse && isFailResponse( errorResponse ) ) { - if ( errorResponse.message && errorResponse.message.length ) { + } else if ( isFailResponse( errorResponse ) ) { + if ( + objectHasProp( errorResponse, 'message' ) && + isString( errorResponse.message ) && + errorResponse.message.length + ) { + let context: string = noticeContexts.PAYMENTS; + if ( + objectHasProp( errorResponse, 'messageContext' ) && + isString( errorResponse.messageContext ) && + errorResponse.messageContext.length + ) { + context = errorResponse.messageContext; + } createErrorNotice( errorResponse.message, { id: 'wc-payment-error', isDismissible: false, - context: - errorResponse?.messageContext || - noticeContexts.PAYMENTS, + context, } ); } @@ -149,20 +171,41 @@ export const __internalEmitPaymentProcessingEvent: emitProcessingEventType = ( setBillingAddress( billingAddress ); } dispatch.__internalSetPaymentFailed(); - dispatch.__internalSetPaymentMethodData( paymentMethodData ); - } else if ( errorResponse ) { - if ( errorResponse.message && errorResponse.message.length ) { + + const paymentDataToSet = isObject( paymentMethodData ) + ? paymentMethodData + : {}; + dispatch.__internalSetPaymentMethodData( paymentDataToSet ); + } else if ( isErrorResponse( errorResponse ) ) { + if ( + objectHasProp( errorResponse, 'message' ) && + isString( errorResponse.message ) && + errorResponse.message.length + ) { + let context: string = noticeContexts.PAYMENTS; + if ( + objectHasProp( errorResponse, 'messageContext' ) && + isString( errorResponse.messageContext ) && + errorResponse.messageContext.length + ) { + context = errorResponse.messageContext; + } createErrorNotice( errorResponse.message, { id: 'wc-payment-error', isDismissible: false, - context: - errorResponse?.messageContext || - noticeContexts.PAYMENTS, + context, } ); } dispatch.__internalSetPaymentError(); - setValidationErrors( errorResponse?.validationErrors ); + + if ( + isValidValidationErrorsObject( + errorResponse.validationErrors + ) + ) { + setValidationErrors( errorResponse.validationErrors ); + } } else { // otherwise there are no payment methods doing anything so // just consider success diff --git a/assets/js/data/payment/types.ts b/assets/js/data/payment/types.ts index d4073c7c272..1a831713627 100644 --- a/assets/js/data/payment/types.ts +++ b/assets/js/data/payment/types.ts @@ -5,7 +5,11 @@ import { PlainPaymentMethods, PlainExpressPaymentMethods, } from '@woocommerce/types'; -import type { EmptyObjectType, ObjectType } from '@woocommerce/types'; +import type { + EmptyObjectType, + ObjectType, + FieldValidationStatus, +} from '@woocommerce/types'; import { DataRegistry } from '@wordpress/data'; /** @@ -14,7 +18,6 @@ import { DataRegistry } from '@wordpress/data'; import type { EventObserversType } from '../../base/context/event-emit'; import type { DispatchFromMap } from '../mapped-types'; import * as actions from './actions'; -import { FieldValidationStatus } from '../types'; export interface CustomerPaymentMethodConfiguration { gateway: string; diff --git a/assets/js/data/types.ts b/assets/js/data/types.ts deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/assets/js/data/validation/actions.ts b/assets/js/data/validation/actions.ts index 0dd850a5474..2aef1ab66da 100644 --- a/assets/js/data/validation/actions.ts +++ b/assets/js/data/validation/actions.ts @@ -2,13 +2,13 @@ * External dependencies */ import deprecated from '@wordpress/deprecated'; +import { FieldValidationStatus } from '@woocommerce/types'; /** * Internal dependencies */ import { ACTION_TYPES as types } from './action-types'; import { ReturnOrGeneratorYieldUnion } from '../mapped-types'; -import { FieldValidationStatus } from '../types'; export const setValidationErrors = ( errors: Record< string, FieldValidationStatus > diff --git a/assets/js/data/validation/reducers.ts b/assets/js/data/validation/reducers.ts index a16b8b83274..f3d1f07abac 100644 --- a/assets/js/data/validation/reducers.ts +++ b/assets/js/data/validation/reducers.ts @@ -4,14 +4,13 @@ import type { Reducer } from 'redux'; import { pickBy } from 'lodash'; import isShallowEqual from '@wordpress/is-shallow-equal'; -import { isString } from '@woocommerce/types'; +import { isString, FieldValidationStatus } from '@woocommerce/types'; /** * Internal dependencies */ import { ValidationAction } from './actions'; import { ACTION_TYPES as types } from './action-types'; -import { FieldValidationStatus } from '../types'; const reducer: Reducer< Record< string, FieldValidationStatus > > = ( state: Record< string, FieldValidationStatus > = {}, diff --git a/assets/js/data/validation/test/reducers.ts b/assets/js/data/validation/test/reducers.ts index b99530fcd1c..66ef3b8df71 100644 --- a/assets/js/data/validation/test/reducers.ts +++ b/assets/js/data/validation/test/reducers.ts @@ -1,8 +1,12 @@ +/** + * External dependencies + */ +import { FieldValidationStatus } from '@woocommerce/types'; + /** * Internal dependencies */ import reducer from '../reducers'; -import { FieldValidationStatus } from '../../types'; import { ACTION_TYPES as types } from '.././action-types'; import { ValidationAction } from '../actions'; diff --git a/assets/js/data/validation/test/selectors.ts b/assets/js/data/validation/test/selectors.ts index cb06f3e7660..1703ef76d8c 100644 --- a/assets/js/data/validation/test/selectors.ts +++ b/assets/js/data/validation/test/selectors.ts @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import { FieldValidationStatus } from '@woocommerce/types'; + /** * Internal dependencies */ @@ -6,7 +11,6 @@ import { getValidationError, hasValidationErrors, } from '../selectors'; -import { FieldValidationStatus } from '../../types'; describe( 'Validation selectors', () => { it( 'Gets the validation error', () => { diff --git a/assets/js/types/type-defs/index.ts b/assets/js/types/type-defs/index.ts index 2ee14e71373..3afb7f73155 100644 --- a/assets/js/types/type-defs/index.ts +++ b/assets/js/types/type-defs/index.ts @@ -17,3 +17,4 @@ export * from './utils'; export * from './taxes'; export * from './attributes'; export * from './stock-status'; +export * from './validation'; diff --git a/assets/js/types/type-defs/validation.ts b/assets/js/types/type-defs/validation.ts new file mode 100644 index 00000000000..70c9b4000dd --- /dev/null +++ b/assets/js/types/type-defs/validation.ts @@ -0,0 +1,16 @@ +/** + * An interface to describe the validity of a Checkout field. This is what will be stored in the wc/store/validation + * data store. + */ +export interface FieldValidationStatus { + /** + * The message to display to the user. + */ + message: string; + /** + * Whether this validation error should be hidden. Note, hidden errors still prevent checkout. Adding a hidden error + * allows required fields to be validated, but not show the error to the user until they interact with the input + * element, or try to submit the form. + */ + hidden: boolean; +} diff --git a/assets/js/types/type-guards/observers.ts b/assets/js/types/type-guards/observers.ts new file mode 100644 index 00000000000..8456473dc87 --- /dev/null +++ b/assets/js/types/type-guards/observers.ts @@ -0,0 +1,14 @@ +/** + * External dependencies + */ +import { ObserverResponse } from '@woocommerce/base-context'; +import { isObject, objectHasProp } from '@woocommerce/types'; + +/** + * Whether the passed object is an ObserverResponse. + */ +export const isObserverResponse = ( + response: unknown +): response is ObserverResponse => { + return isObject( response ) && objectHasProp( response, 'type' ); +}; diff --git a/assets/js/types/type-guards/test/validation.ts b/assets/js/types/type-guards/test/validation.ts new file mode 100644 index 00000000000..0a7e2c0f323 --- /dev/null +++ b/assets/js/types/type-guards/test/validation.ts @@ -0,0 +1,57 @@ +/** + * Internal dependencies + */ +import { + isValidFieldValidationStatus, + isValidValidationErrorsObject, +} from '../validation'; + +describe( 'validation type guards', () => { + describe( 'isValidFieldValidationStatus', () => { + it( 'identifies valid objects', () => { + const valid = { + message: 'message', + hidden: false, + }; + expect( isValidFieldValidationStatus( valid ) ).toBe( true ); + } ); + it( 'identifies invalid objects', () => { + const invalid = { + message: 'message', + hidden: 'string', + }; + expect( isValidFieldValidationStatus( invalid ) ).toBe( false ); + const noMessage = { + hidden: false, + }; + expect( isValidFieldValidationStatus( noMessage ) ).toBe( false ); + } ); + } ); + + describe( 'isValidValidationErrorsObject', () => { + it( 'identifies valid objects', () => { + const valid = { + 'billing.first-name': { + message: 'message', + hidden: false, + }, + }; + expect( isValidValidationErrorsObject( valid ) ).toBe( true ); + } ); + it( 'identifies invalid objects', () => { + const invalid = { + 'billing.first-name': { + message: 'message', + hidden: 'string', + }, + }; + expect( isValidValidationErrorsObject( invalid ) ).toBe( false ); + const noMessage = { + 'billing.first-name': { + hidden: false, + }, + }; + expect( isValidValidationErrorsObject( noMessage ) ).toBe( false ); + } ); + } ); +} ); diff --git a/assets/js/types/type-guards/validation.ts b/assets/js/types/type-guards/validation.ts new file mode 100644 index 00000000000..3387a0c7fe4 --- /dev/null +++ b/assets/js/types/type-guards/validation.ts @@ -0,0 +1,41 @@ +/** + * External dependencies + */ +import { + FieldValidationStatus, + isBoolean, + isObject, + isString, + objectHasProp, +} from '@woocommerce/types'; + +/** + * Whether the given status is a valid FieldValidationStatus. + */ +export const isValidFieldValidationStatus = ( + status: unknown +): status is FieldValidationStatus => { + return ( + isObject( status ) && + objectHasProp( status, 'message' ) && + objectHasProp( status, 'hidden' ) && + isString( status.message ) && + isBoolean( status.hidden ) + ); +}; + +/** + * Whether the passed object is a valid validation errors object. If this is true, it can be set on the + * wc/store/validation store without any issue. + */ +export const isValidValidationErrorsObject = ( + errors: unknown +): errors is Record< string, FieldValidationStatus > => { + return ( + isObject( errors ) && + Object.entries( errors ).every( + ( [ key, value ] ) => + isString( key ) && isValidFieldValidationStatus( value ) + ) + ); +}; From e15824fa0d3318348e9803f1806c2e42467bab68 Mon Sep 17 00:00:00 2001 From: Seghir Nadir Date: Mon, 13 Feb 2023 13:58:54 +0100 Subject: [PATCH 11/17] sync back email (#8410) Co-authored-by: Saad Tarhi --- src/StoreApi/Routes/V1/CartUpdateCustomer.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/StoreApi/Routes/V1/CartUpdateCustomer.php b/src/StoreApi/Routes/V1/CartUpdateCustomer.php index 658174a2dca..330c9c1c573 100644 --- a/src/StoreApi/Routes/V1/CartUpdateCustomer.php +++ b/src/StoreApi/Routes/V1/CartUpdateCustomer.php @@ -226,6 +226,7 @@ protected function get_customer_billing_address( \WC_Customer $customer ) { 'postcode' => $customer->get_billing_postcode(), 'country' => $customer->get_billing_country(), 'phone' => $customer->get_billing_phone(), + 'email' => $customer->get_billing_email(), ]; } From 85b7689d093e6bd380306767194e06a1fab077ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alba=20Rinc=C3=B3n?= Date: Mon, 13 Feb 2023 16:36:38 +0100 Subject: [PATCH 12/17] Set inherit default to true when products is inserted on archive product templates (#8375) * Set inherit default to true when products is inserted on archive products templates * Create new query attributes object Co-authored-by: kmanijak * Extract the products registration to a separate function * Bring back variation name * Move variation name * Unregister the block before registering it again * Use subscribe only on the site editor * Undo change * Try fixing tests * Fix test * Revert test only --------- Co-authored-by: kmanijak --- .../variations/product-query.tsx | 52 +++++++++++++++++-- .../product-query-with-templates.test.ts | 21 ++++---- .../e2e/specs/shopper/product-query/utils.ts | 5 +- 3 files changed, 63 insertions(+), 15 deletions(-) diff --git a/assets/js/blocks/product-query/variations/product-query.tsx b/assets/js/blocks/product-query/variations/product-query.tsx index 73362b8bf0d..b5804d87d17 100644 --- a/assets/js/blocks/product-query/variations/product-query.tsx +++ b/assets/js/blocks/product-query/variations/product-query.tsx @@ -1,11 +1,16 @@ /** * External dependencies */ -import { registerBlockVariation } from '@wordpress/blocks'; +import { + registerBlockVariation, + unregisterBlockVariation, +} from '@wordpress/blocks'; import { Icon } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { stacks } from '@woocommerce/icons'; import { isWpVersion } from '@woocommerce/settings'; +import { select, subscribe } from '@wordpress/data'; +import { QueryBlockAttributes } from '@woocommerce/blocks/product-query/types'; /** * Internal dependencies @@ -19,7 +24,15 @@ import { const VARIATION_NAME = 'woocommerce/product-query'; -if ( isWpVersion( '6.1', '>=' ) ) { +const ARCHIVE_PRODUCT_TEMPLATES = [ + 'woocommerce/woocommerce//archive-product', + 'woocommerce/woocommerce//taxonomy-product_cat', + 'woocommerce/woocommerce//taxonomy-product_tag', + 'woocommerce/woocommerce//taxonomy-product_attribute', + 'woocommerce/woocommerce//product-search-results', +]; + +const registerProductsBlock = ( attributes: QueryBlockAttributes ) => { registerBlockVariation( QUERY_LOOP_ID, { description: __( 'A block that displays a selection of products in your store.', @@ -37,7 +50,7 @@ if ( isWpVersion( '6.1', '>=' ) ) { /> ), attributes: { - ...QUERY_DEFAULT_ATTRIBUTES, + ...attributes, namespace: VARIATION_NAME, }, // Gutenberg doesn't support this type yet, discussion here: @@ -48,4 +61,37 @@ if ( isWpVersion( '6.1', '>=' ) ) { innerBlocks: INNER_BLOCKS_TEMPLATE, scope: [ 'inserter' ], } ); +}; + +if ( isWpVersion( '6.1', '>=' ) ) { + const store = select( 'core/edit-site' ); + + if ( store ) { + let currentTemplateId: string | undefined; + + subscribe( () => { + const previousTemplateId = currentTemplateId; + + currentTemplateId = store?.getEditedPostId(); + + if ( previousTemplateId === currentTemplateId ) { + return; + } + + const queryAttributes = { + ...QUERY_DEFAULT_ATTRIBUTES, + query: { + ...QUERY_DEFAULT_ATTRIBUTES.query, + inherit: + ARCHIVE_PRODUCT_TEMPLATES.includes( currentTemplateId ), + }, + }; + + unregisterBlockVariation( QUERY_LOOP_ID, VARIATION_NAME ); + + registerProductsBlock( queryAttributes ); + } ); + } else { + registerProductsBlock( QUERY_DEFAULT_ATTRIBUTES ); + } } diff --git a/tests/e2e/specs/shopper/product-query/product-query-with-templates.test.ts b/tests/e2e/specs/shopper/product-query/product-query-with-templates.test.ts index 26740c556d4..9328004f709 100644 --- a/tests/e2e/specs/shopper/product-query/product-query-with-templates.test.ts +++ b/tests/e2e/specs/shopper/product-query/product-query-with-templates.test.ts @@ -20,16 +20,17 @@ import { import { addProductQueryBlock, block, - configurateProductQueryBlock, + configureProductQueryBlock, getProductsNameFromClassicTemplate, getProductsNameFromProductQuery, + toggleInheritQueryFromTemplateSetting, } from './utils'; describe( `${ block.name } Block`, () => { useTheme( 'emptytheme' ); describe( 'with All Templates', () => { - beforeAll( async () => { + beforeEach( async () => { const productCatalogTemplateId = 'woocommerce/woocommerce//archive-product'; @@ -41,7 +42,9 @@ describe( `${ block.name } Block`, () => { await switchBlockInspectorTabWhenGutenbergIsInstalled( 'Settings' ); } ); - it( 'when Inherit Query from template is disabled all the settings that customize the query should be hide', async () => { + it( 'when Inherit Query from template is disabled all the settings that customize the query should be hidden', async () => { + await toggleInheritQueryFromTemplateSetting(); + await expect( page ).toMatchElement( block.selectors.editor.popularFilter ); @@ -51,8 +54,8 @@ describe( `${ block.name } Block`, () => { ); } ); - it( 'when Inherit Query from template is enabled all the settings that customize the query should be hide', async () => { - await configurateProductQueryBlock(); + it( 'when Inherit Query from template is enabled all the settings that customize the query should be hidden', async () => { + await configureProductQueryBlock(); const popularFilterEl = await page.$( block.selectors.editor.popularFilter @@ -75,7 +78,7 @@ describe( `${ block.name } Block`, () => { postId: productCatalogTemplateId, } ); await addProductQueryBlock(); - await configurateProductQueryBlock(); + await configureProductQueryBlock(); await page.waitForNetworkIdle(); await saveTemplate(); await page.waitForNetworkIdle(); @@ -104,7 +107,7 @@ describe( `${ block.name } Block`, () => { postId: taxonomyProductCategory, } ); await addProductQueryBlock(); - await configurateProductQueryBlock(); + await configureProductQueryBlock(); await page.waitForNetworkIdle(); await saveTemplate(); await page.waitForNetworkIdle(); @@ -136,7 +139,7 @@ describe( `${ block.name } Block`, () => { postId: tagProductCategory, } ); await addProductQueryBlock(); - await configurateProductQueryBlock(); + await configureProductQueryBlock(); await page.waitForNetworkIdle(); await saveTemplate(); await page.waitForNetworkIdle(); @@ -168,7 +171,7 @@ describe( `${ block.name } Block`, () => { postId: productSearchResults, } ); await addProductQueryBlock(); - await configurateProductQueryBlock(); + await configureProductQueryBlock(); await page.waitForNetworkIdle(); await saveTemplate(); await page.waitForNetworkIdle(); diff --git a/tests/e2e/specs/shopper/product-query/utils.ts b/tests/e2e/specs/shopper/product-query/utils.ts index 26b3d232b89..08c86c01b81 100644 --- a/tests/e2e/specs/shopper/product-query/utils.ts +++ b/tests/e2e/specs/shopper/product-query/utils.ts @@ -32,17 +32,16 @@ export const addProductQueryBlock = async () => { await page.waitForNetworkIdle(); }; -const enableInheritQueryFromTemplateSetting = async () => { +export const toggleInheritQueryFromTemplateSetting = async () => { const [ button ] = await page.$x( block.selectors.editor.inheritQueryFromTemplateSetting ); await button.click(); }; -export const configurateProductQueryBlock = async () => { +export const configureProductQueryBlock = async () => { await ensureSidebarOpened(); await switchBlockInspectorTabWhenGutenbergIsInstalled( 'Settings' ); - await enableInheritQueryFromTemplateSetting(); }; export const getProductsNameFromClassicTemplate = async () => { From e60607912b5a552b2c632fd31b7ad698d6805158 Mon Sep 17 00:00:00 2001 From: Raluca Stan Date: Mon, 13 Feb 2023 18:24:17 +0100 Subject: [PATCH 13/17] Remove newsletter repo example from the docs (#8417) * Remove newsletter repo example from the docs The newsletter is not maintained anymore and we use the @woocommerce/extend-cart-checkout-block to showcase our extensibility points * Update docs/README.md Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com> --------- Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com> --- docs/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/README.md b/docs/README.md index c94cf70585e..2d8780bbcb2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -123,8 +123,7 @@ The WooCommerce Blocks Handbook provides documentation for designers and develop ### Tools -- [@woocommerce/extend-cart-checkout-block](https://www.npmjs.com/package/@woocommerce/extend-cart-checkout-block) This is a template to be used with @wordpress/create-block to create a WooCommerce Blocks extension starting point. -- [How to integrate with inner blocks in the WooCommerce Blocks Checkout](https://github.com/woocommerce/newsletter-test) A repository with some example code showing how an extension can register an inner block for use in the Checkout Block. +- [@woocommerce/extend-cart-checkout-block](https://www.npmjs.com/package/@woocommerce/extend-cart-checkout-block) This is a template to be used with @wordpress/create-block to create a WooCommerce Blocks extension starting point. It also showcases how to use some extensibility points, e.g. registering an inner block in the Checkout Block, applying filters to certain texts such as the place order button, using Slot/Fill and how to change the behaviour of the Store API. ### Articles From 250c68379493d1fc71c7bf985a7ea98f5f396573 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Tue, 14 Feb 2023 10:08:10 +0000 Subject: [PATCH 14/17] Clarify __experimental_woocommerce_blocks_hidden (#8414) --- .../blocks/feature-flags-and-experimental-interfaces.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/internal-developers/blocks/feature-flags-and-experimental-interfaces.md b/docs/internal-developers/blocks/feature-flags-and-experimental-interfaces.md index 44e2e521cc0..349e7e65c89 100644 --- a/docs/internal-developers/blocks/feature-flags-and-experimental-interfaces.md +++ b/docs/internal-developers/blocks/feature-flags-and-experimental-interfaces.md @@ -95,7 +95,7 @@ We also have individual features or code blocks behind a feature flag, this is a ### Misc -- `__experimental_woocommerce_blocks_hidden` property in a Cart item data array that allows overwriting the `hidden` property. This is useful to make some cart item data visible/hidden depending if it needs to be displayed in Blocks or shortcode ([experimental property](https://github.com/woocommerce/woocommerce-gutenberg-products-block/blob/9c4288b0ee46960bdc2bf8ef351d05ac23073b0c/src/StoreApi/Schemas/CartItemSchema.php#L439-L441)). +- `__experimental_woocommerce_blocks_hidden` property allows overwriting the `hidden` property for cart item data. This is useful to make some cart item data visible/hidden depending if it needs to be displayed in the Cart Block or the Cart Shortcode ([experimental property](https://github.com/woocommerce/woocommerce-gutenberg-products-block/blob/9c4288b0ee46960bdc2bf8ef351d05ac23073b0c/src/StoreApi/Schemas/CartItemSchema.php#L439-L441)). This was added in [this PR](https://github.com/woocommerce/woocommerce-blocks/pull/3732) to resolve [this issue with Subscriptions](https://github.com/woocommerce/woocommerce-blocks/issues/3731). This property will not be needed if the blocks replace the shortcode experience, since in that scenario, the `hidden` property would be sufficient. ## Usages of `experimental` prefix From 5acac1fbd5ab3b75d20a796556af55ce3cc58c7a Mon Sep 17 00:00:00 2001 From: Darren Ethier Date: Tue, 14 Feb 2023 05:53:33 -0500 Subject: [PATCH 15/17] Fixes CSS spacing and availability issues for breadcrumb, catalog sort, and result count blocks. (#8391) * Reset margin on result count block * Reset margin for catalog sort block * reset margin and other fixes for breadcrumb block. * Move styles to relevant files --- assets/js/blocks/breadcrumbs/style.scss | 6 ++++++ assets/js/blocks/catalog-sorting/style.scss | 4 ++++ assets/js/blocks/product-results-count/style.scss | 3 +++ 3 files changed, 13 insertions(+) diff --git a/assets/js/blocks/breadcrumbs/style.scss b/assets/js/blocks/breadcrumbs/style.scss index a455e9008dc..bc01b5b8b10 100644 --- a/assets/js/blocks/breadcrumbs/style.scss +++ b/assets/js/blocks/breadcrumbs/style.scss @@ -1,3 +1,9 @@ .woocommerce.wc-block-breadcrumbs { font-size: inherit; } +.woocommerce.woocommerce-shop .wc-block-breadcrumbs { + .woocommerce-breadcrumb { + margin: auto; + display: block; + } +} diff --git a/assets/js/blocks/catalog-sorting/style.scss b/assets/js/blocks/catalog-sorting/style.scss index 85ecc5acf2f..5be096ed2ab 100644 --- a/assets/js/blocks/catalog-sorting/style.scss +++ b/assets/js/blocks/catalog-sorting/style.scss @@ -7,4 +7,8 @@ font-size: inherit; color: inherit; } + + .woocommerce-ordering { + margin: auto; + } } diff --git a/assets/js/blocks/product-results-count/style.scss b/assets/js/blocks/product-results-count/style.scss index 5ed82602ca6..54c9cfe3de8 100644 --- a/assets/js/blocks/product-results-count/style.scss +++ b/assets/js/blocks/product-results-count/style.scss @@ -5,5 +5,8 @@ .woocommerce-result-count { float: none; font-size: inherit; + // reset for margin + margin: auto; } } + From cffafeb6687dc4afee128bc402810fd1c4d414f5 Mon Sep 17 00:00:00 2001 From: Alex Florisca Date: Tue, 14 Feb 2023 12:08:19 +0000 Subject: [PATCH 16/17] Refactor payment status (#8110) * WIP * Change payment status from pristine to idle * Deprecate isPaymentStarted and isPaymentFinished * Correct comments * Deprecate isPaymentPristine and undeprecate isPaymentStarted * Set payment status to FAILED or SUCCESS when the storeAPI fetch returns * Remove FINISHED as a status * Remove ready status * Revert "Remove FINISHED as a status" This reverts commit 38d66ed1d9565756d2373533c7a7c5b107a68ddd. * Add payment status READY * Update use-payment-interface * Removed payment statuses pristine, failed and success * Remove deprecated selectors and update docs * Deprecate isPaymentStarted in favour of isExpressPaymentStarted * Fix tests * Update assets/js/base/context/providers/cart-checkout/payment-events/index.tsx Co-authored-by: Mike Jolley * Mikes suggestions * Change since version * Fix tests --------- Co-authored-by: Mike Jolley --- .../use-payment-method-interface.ts | 43 ++++++- .../cart-checkout/checkout-processor.ts | 6 +- .../cart-checkout/payment-events/index.tsx | 73 ++++++------ .../express-payment-methods.js | 12 +- assets/js/data/checkout/constants.ts | 2 - assets/js/data/checkout/reducers.ts | 8 -- assets/js/data/checkout/test/reducer.ts | 17 +-- assets/js/data/payment/action-types.ts | 7 +- assets/js/data/payment/actions.ts | 16 +-- assets/js/data/payment/constants.ts | 7 +- assets/js/data/payment/default-state.ts | 2 +- assets/js/data/payment/reducers.ts | 19 +--- assets/js/data/payment/selectors.ts | 107 ++++++++++++++---- .../test/set-default-payment-method.ts | 7 +- assets/js/data/payment/test/thunks.tsx | 6 +- assets/js/data/payment/thunks.ts | 48 ++++---- .../utils/set-default-payment-method.ts | 2 +- .../type-defs/payment-method-interface.ts | 1 + .../checkout/checkout-flow-and-events.md | 16 ++- .../payment-method-integration.md | 2 +- .../extensibility/data-store/payment.md | 77 +++++++++---- 21 files changed, 284 insertions(+), 194 deletions(-) diff --git a/assets/js/base/context/hooks/payment-methods/use-payment-method-interface.ts b/assets/js/base/context/hooks/payment-methods/use-payment-method-interface.ts index e8e2b720eb2..0a8ae83c3a8 100644 --- a/assets/js/base/context/hooks/payment-methods/use-payment-method-interface.ts +++ b/assets/js/base/context/hooks/payment-methods/use-payment-method-interface.ts @@ -60,13 +60,46 @@ export const usePaymentMethodInterface = (): PaymentMethodInterface => { return { // The paymentStatus is exposed to third parties via the payment method interface so the API must not be changed paymentStatus: { - isPristine: store.isPaymentPristine(), - isStarted: store.isPaymentStarted(), + get isPristine() { + deprecated( 'isPristine', { + since: '9.6.0', + alternative: 'isIdle', + plugin: 'WooCommerce Blocks', + link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8110', + } ); + return store.isPaymentIdle(); + }, // isPristine is the same as isIdle + isIdle: store.isPaymentIdle(), + isStarted: store.isExpressPaymentStarted(), isProcessing: store.isPaymentProcessing(), - isFinished: store.isPaymentFinished(), + get isFinished() { + deprecated( 'isFinished', { + since: '9.6.0', + plugin: 'WooCommerce Blocks', + link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8110', + } ); + return ( + store.hasPaymentError() || store.isPaymentReady() + ); + }, hasError: store.hasPaymentError(), - hasFailed: store.isPaymentFailed(), - isSuccessful: store.isPaymentSuccess(), + get hasFailed() { + deprecated( 'hasFailed', { + since: '9.6.0', + plugin: 'WooCommerce Blocks', + link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8110', + } ); + return store.hasPaymentError(); + }, + get isSuccessful() { + deprecated( 'isSuccessful', { + since: '9.6.0', + plugin: 'WooCommerce Blocks', + link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8110', + } ); + return store.isPaymentReady(); + }, + isReady: store.isPaymentReady(), isDoingExpressPayment: store.isExpressPaymentMethodActive(), }, activePaymentMethod: store.getActivePaymentMethod(), diff --git a/assets/js/base/context/providers/cart-checkout/checkout-processor.ts b/assets/js/base/context/providers/cart-checkout/checkout-processor.ts index 7024107f250..0428c215b5b 100644 --- a/assets/js/base/context/providers/cart-checkout/checkout-processor.ts +++ b/assets/js/base/context/providers/cart-checkout/checkout-processor.ts @@ -92,7 +92,7 @@ const CheckoutProcessor = () => { paymentMethodData, isExpressPaymentMethodActive, hasPaymentError, - isPaymentSuccess, + isPaymentReady, shouldSavePayment, } = useSelect( ( select ) => { const store = select( PAYMENT_STORE_KEY ); @@ -102,7 +102,7 @@ const CheckoutProcessor = () => { paymentMethodData: store.getPaymentMethodData(), isExpressPaymentMethodActive: store.isExpressPaymentMethodActive(), hasPaymentError: store.hasPaymentError(), - isPaymentSuccess: store.isPaymentSuccess(), + isPaymentReady: store.isPaymentReady(), shouldSavePayment: store.getShouldSavePaymentMethod(), }; }, [] ); @@ -130,7 +130,7 @@ const CheckoutProcessor = () => { const paidAndWithoutErrors = ! checkoutHasError && ! checkoutWillHaveError && - ( isPaymentSuccess || ! cartNeedsPayment ) && + ( isPaymentReady || ! cartNeedsPayment ) && checkoutIsProcessing; // Determine if checkout has an error. diff --git a/assets/js/base/context/providers/cart-checkout/payment-events/index.tsx b/assets/js/base/context/providers/cart-checkout/payment-events/index.tsx index 10a38b2cea2..d9b61713597 100644 --- a/assets/js/base/context/providers/cart-checkout/payment-events/index.tsx +++ b/assets/js/base/context/providers/cart-checkout/payment-events/index.tsx @@ -61,16 +61,18 @@ export const PaymentEventsProvider = ( { isCalculating: store.isCalculating(), }; } ); - const { isPaymentSuccess, isPaymentFinished, isPaymentProcessing } = - useSelect( ( select ) => { - const store = select( PAYMENT_STORE_KEY ); + const { isPaymentReady } = useSelect( ( select ) => { + const store = select( PAYMENT_STORE_KEY ); - return { - isPaymentSuccess: store.isPaymentSuccess(), - isPaymentFinished: store.isPaymentFinished(), - isPaymentProcessing: store.isPaymentProcessing(), - }; - } ); + return { + // The PROCESSING status represents befor the checkout runs the observers + // registered for the payment_setup event. + isPaymentProcessing: store.isPaymentProcessing(), + // the READY status represents when the observers have finished processing and payment data + // synced with the payment store, ready to be sent to the StoreApi + isPaymentReady: store.isPaymentReady(), + }; + } ); const { setValidationErrors } = useDispatch( VALIDATION_STORE_KEY ); const [ observers, observerDispatch ] = useReducer( emitReducer, {} ); @@ -84,59 +86,50 @@ export const PaymentEventsProvider = ( { const { __internalSetPaymentProcessing, - __internalSetPaymentPristine, + __internalSetPaymentIdle, __internalEmitPaymentProcessingEvent, } = useDispatch( PAYMENT_STORE_KEY ); - // flip payment to processing if checkout processing is complete, there are no errors, and payment status is started. + // flip payment to processing if checkout processing is complete and there are no errors useEffect( () => { if ( checkoutIsProcessing && ! checkoutHasError && - ! checkoutIsCalculating && - ! isPaymentFinished + ! checkoutIsCalculating ) { __internalSetPaymentProcessing(); + + // Note: the nature of this event emitter is that it will bail on any + // observer that returns a response that !== true. However, this still + // allows for other observers that return true for continuing through + // to the next observer (or bailing if there's a problem). + __internalEmitPaymentProcessingEvent( + currentObservers.current, + setValidationErrors + ); } }, [ checkoutIsProcessing, checkoutHasError, checkoutIsCalculating, - isPaymentFinished, __internalSetPaymentProcessing, + __internalEmitPaymentProcessingEvent, + setValidationErrors, ] ); - // When checkout is returned to idle, set payment status to pristine but only if payment status is already not finished. + // When checkout is returned to idle, and the payment setup has not completed, set payment status to idle useEffect( () => { - if ( checkoutIsIdle && ! isPaymentSuccess ) { - __internalSetPaymentPristine(); + if ( checkoutIsIdle && ! isPaymentReady ) { + __internalSetPaymentIdle(); } - }, [ checkoutIsIdle, isPaymentSuccess, __internalSetPaymentPristine ] ); + }, [ checkoutIsIdle, isPaymentReady, __internalSetPaymentIdle ] ); - // if checkout has an error sync payment status back to pristine. + // if checkout has an error sync payment status back to idle. useEffect( () => { - if ( checkoutHasError && isPaymentSuccess ) { - __internalSetPaymentPristine(); + if ( checkoutHasError && isPaymentReady ) { + __internalSetPaymentIdle(); } - }, [ checkoutHasError, isPaymentSuccess, __internalSetPaymentPristine ] ); - - // Emit the payment processing event - useEffect( () => { - // Note: the nature of this event emitter is that it will bail on any - // observer that returns a response that !== true. However, this still - // allows for other observers that return true for continuing through - // to the next observer (or bailing if there's a problem). - if ( isPaymentProcessing ) { - __internalEmitPaymentProcessingEvent( - currentObservers.current, - setValidationErrors - ); - } - }, [ - isPaymentProcessing, - setValidationErrors, - __internalEmitPaymentProcessingEvent, - ] ); + }, [ checkoutHasError, isPaymentReady, __internalSetPaymentIdle ] ); const paymentContextData = { onPaymentProcessing, diff --git a/assets/js/blocks/cart-checkout-shared/payment-methods/express-payment-methods.js b/assets/js/blocks/cart-checkout-shared/payment-methods/express-payment-methods.js index c8250967220..c30fa050be1 100644 --- a/assets/js/blocks/cart-checkout-shared/payment-methods/express-payment-methods.js +++ b/assets/js/blocks/cart-checkout-shared/payment-methods/express-payment-methods.js @@ -36,8 +36,8 @@ const ExpressPaymentMethods = () => { ); const { __internalSetActivePaymentMethod, - __internalSetPaymentStarted, - __internalSetPaymentPristine, + __internalSetExpressPaymentStarted, + __internalSetPaymentIdle, __internalSetPaymentError, __internalSetPaymentMethodData, __internalSetExpressPaymentError, @@ -58,14 +58,14 @@ const ExpressPaymentMethods = () => { ( paymentMethodId ) => () => { previousActivePaymentMethod.current = activePaymentMethod; previousPaymentMethodData.current = paymentMethodData; - __internalSetPaymentStarted(); + __internalSetExpressPaymentStarted(); __internalSetActivePaymentMethod( paymentMethodId ); }, [ activePaymentMethod, paymentMethodData, __internalSetActivePaymentMethod, - __internalSetPaymentStarted, + __internalSetExpressPaymentStarted, ] ); @@ -75,12 +75,12 @@ const ExpressPaymentMethods = () => { * This restores the active method and returns the state to pristine. */ const onExpressPaymentClose = useCallback( () => { - __internalSetPaymentPristine(); + __internalSetPaymentIdle(); __internalSetActivePaymentMethod( previousActivePaymentMethod.current, previousPaymentMethodData.current ); - }, [ __internalSetActivePaymentMethod, __internalSetPaymentPristine ] ); + }, [ __internalSetActivePaymentMethod, __internalSetPaymentIdle ] ); /** * onExpressPaymentError should be triggered when the express payment process errors. diff --git a/assets/js/data/checkout/constants.ts b/assets/js/data/checkout/constants.ts index 31bd2b71981..a5b16a1ca82 100644 --- a/assets/js/data/checkout/constants.ts +++ b/assets/js/data/checkout/constants.ts @@ -12,8 +12,6 @@ import { CheckoutResponseSuccess } from '@woocommerce/types'; export const STORE_KEY = 'wc/store/checkout'; export enum STATUS { - // Checkout is in its initialized state. - PRISTINE = 'pristine', // When checkout state has changed but there is no activity happening. IDLE = 'idle', // After the AFTER_PROCESSING event emitters have completed. This status triggers the checkout redirect. diff --git a/assets/js/data/checkout/reducers.ts b/assets/js/data/checkout/reducers.ts index 93cd3c02d6e..fa7845715a3 100644 --- a/assets/js/data/checkout/reducers.ts +++ b/assets/js/data/checkout/reducers.ts @@ -166,14 +166,6 @@ const reducer = ( state = defaultState, action: CheckoutAction ) => { } break; } - - if ( - newState !== state && - action.type !== types.SET_PRISTINE && - newState?.status === STATUS.PRISTINE - ) { - newState.status = STATUS.IDLE; - } return newState; }; diff --git a/assets/js/data/checkout/test/reducer.ts b/assets/js/data/checkout/test/reducer.ts index 4df3e787ef7..f83b5985a99 100644 --- a/assets/js/data/checkout/test/reducer.ts +++ b/assets/js/data/checkout/test/reducer.ts @@ -26,7 +26,6 @@ describe.only( 'Checkout Store Reducer', () => { const expectedState = { ...defaultState, redirectUrl: 'https://example.com', - status: STATUS.IDLE, }; expect( @@ -97,12 +96,15 @@ describe.only( 'Checkout Store Reducer', () => { } ); it( 'should handle SET_HAS_ERROR when status is anything else', () => { - const initialState = { ...defaultState, status: STATUS.PRISTINE }; + const initialState = { + ...defaultState, + status: STATUS.AFTER_PROCESSING, + }; const expectedState = { ...defaultState, hasError: false, - status: STATUS.IDLE, + status: STATUS.AFTER_PROCESSING, }; expect( @@ -135,7 +137,6 @@ describe.only( 'Checkout Store Reducer', () => { it( 'should handle INCREMENT_CALCULATING', () => { const expectedState = { ...defaultState, - status: STATUS.IDLE, calculatingCount: 1, }; @@ -152,7 +153,6 @@ describe.only( 'Checkout Store Reducer', () => { const expectedState = { ...defaultState, - status: STATUS.IDLE, calculatingCount: 0, }; @@ -164,7 +164,6 @@ describe.only( 'Checkout Store Reducer', () => { it( 'should handle SET_CUSTOMER_ID', () => { const expectedState = { ...defaultState, - status: STATUS.IDLE, customerId: 1, }; @@ -176,7 +175,6 @@ describe.only( 'Checkout Store Reducer', () => { it( 'should handle SET_USE_SHIPPING_AS_BILLING', () => { const expectedState = { ...defaultState, - status: STATUS.IDLE, useShippingAsBilling: false, }; @@ -191,7 +189,6 @@ describe.only( 'Checkout Store Reducer', () => { it( 'should handle SET_SHOULD_CREATE_ACCOUNT', () => { const expectedState = { ...defaultState, - status: STATUS.IDLE, shouldCreateAccount: true, }; @@ -206,7 +203,6 @@ describe.only( 'Checkout Store Reducer', () => { it( 'should handle SET_ORDER_NOTES', () => { const expectedState = { ...defaultState, - status: STATUS.IDLE, orderNotes: 'test', }; @@ -225,7 +221,6 @@ describe.only( 'Checkout Store Reducer', () => { }; const expectedState = { ...defaultState, - status: STATUS.IDLE, extensionData: mockExtensionData, }; expect( @@ -247,7 +242,6 @@ describe.only( 'Checkout Store Reducer', () => { }; const expectedState = { ...defaultState, - status: STATUS.IDLE, extensionData: mockExtensionData, }; const firstState = reducer( @@ -272,7 +266,6 @@ describe.only( 'Checkout Store Reducer', () => { }; const expectedState = { ...defaultState, - status: STATUS.IDLE, extensionData: mockExtensionData, }; const firstState = reducer( diff --git a/assets/js/data/payment/action-types.ts b/assets/js/data/payment/action-types.ts index f73208e3e83..5e4a57613a0 100644 --- a/assets/js/data/payment/action-types.ts +++ b/assets/js/data/payment/action-types.ts @@ -1,10 +1,9 @@ export enum ACTION_TYPES { - SET_PAYMENT_PRISTINE = 'SET_PAYMENT_PRISTINE', - SET_PAYMENT_STARTED = 'SET_PAYMENT_STARTED', + SET_PAYMENT_IDLE = 'SET_PAYMENT_IDLE', + SET_EXPRESS_PAYMENT_STARTED = 'SET_EXPRESS_PAYMENT_STARTED', + SET_PAYMENT_READY = 'SET_PAYMENT_READY', SET_PAYMENT_PROCESSING = 'SET_PAYMENT_PROCESSING', - SET_PAYMENT_FAILED = 'SET_PAYMENT_FAILED', SET_PAYMENT_ERROR = 'SET_PAYMENT_ERROR', - SET_PAYMENT_SUCCESS = 'SET_PAYMENT_SUCCESS', SET_PAYMENT_METHODS_INITIALIZED = 'SET_PAYMENT_METHODS_INITIALIZED', SET_EXPRESS_PAYMENT_METHODS_INITIALIZED = 'SET_EXPRESS_PAYMENT_METHODS_INITIALIZED', SET_ACTIVE_PAYMENT_METHOD = 'SET_ACTIVE_PAYMENT_METHOD', diff --git a/assets/js/data/payment/actions.ts b/assets/js/data/payment/actions.ts index 8ee3d412fee..f1c735111a5 100644 --- a/assets/js/data/payment/actions.ts +++ b/assets/js/data/payment/actions.ts @@ -17,28 +17,24 @@ import { setDefaultPaymentMethod } from './utils/set-default-payment-method'; // `Thunks are functions that can be dispatched, similar to actions creators export * from './thunks'; -export const __internalSetPaymentPristine = () => ( { - type: ACTION_TYPES.SET_PAYMENT_PRISTINE, +export const __internalSetPaymentIdle = () => ( { + type: ACTION_TYPES.SET_PAYMENT_IDLE, } ); -export const __internalSetPaymentStarted = () => ( { - type: ACTION_TYPES.SET_PAYMENT_STARTED, +export const __internalSetExpressPaymentStarted = () => ( { + type: ACTION_TYPES.SET_EXPRESS_PAYMENT_STARTED, } ); export const __internalSetPaymentProcessing = () => ( { type: ACTION_TYPES.SET_PAYMENT_PROCESSING, } ); -export const __internalSetPaymentFailed = () => ( { - type: ACTION_TYPES.SET_PAYMENT_FAILED, -} ); - export const __internalSetPaymentError = () => ( { type: ACTION_TYPES.SET_PAYMENT_ERROR, } ); -export const __internalSetPaymentSuccess = () => ( { - type: ACTION_TYPES.SET_PAYMENT_SUCCESS, +export const __internalSetPaymentReady = () => ( { + type: ACTION_TYPES.SET_PAYMENT_READY, } ); /** diff --git a/assets/js/data/payment/constants.ts b/assets/js/data/payment/constants.ts index f77e56d5aee..9f4ccd0f72b 100644 --- a/assets/js/data/payment/constants.ts +++ b/assets/js/data/payment/constants.ts @@ -1,10 +1,9 @@ export const STORE_KEY = 'wc/store/payment'; export enum STATUS { - PRISTINE = 'pristine', - STARTED = 'started', + IDLE = 'idle', + EXPRESS_STARTED = 'express_started', PROCESSING = 'processing', + READY = 'ready', ERROR = 'has_error', - FAILED = 'failed', - SUCCESS = 'success', } diff --git a/assets/js/data/payment/default-state.ts b/assets/js/data/payment/default-state.ts index 660951c0b04..21e121cb61a 100644 --- a/assets/js/data/payment/default-state.ts +++ b/assets/js/data/payment/default-state.ts @@ -32,7 +32,7 @@ export interface PaymentState { } export const defaultPaymentState: PaymentState = { - status: PAYMENT_STATUS.PRISTINE, + status: PAYMENT_STATUS.IDLE, activePaymentMethod: '', activeSavedToken: '', availablePaymentMethods: {}, diff --git a/assets/js/data/payment/reducers.ts b/assets/js/data/payment/reducers.ts index 18723fb3785..800db3f45fb 100644 --- a/assets/js/data/payment/reducers.ts +++ b/assets/js/data/payment/reducers.ts @@ -17,17 +17,17 @@ const reducer: Reducer< PaymentState > = ( ) => { let newState = state; switch ( action.type ) { - case ACTION_TYPES.SET_PAYMENT_PRISTINE: + case ACTION_TYPES.SET_PAYMENT_IDLE: newState = { ...state, - status: STATUS.PRISTINE, + status: STATUS.IDLE, }; break; - case ACTION_TYPES.SET_PAYMENT_STARTED: + case ACTION_TYPES.SET_EXPRESS_PAYMENT_STARTED: newState = { ...state, - status: STATUS.STARTED, + status: STATUS.EXPRESS_STARTED, }; break; @@ -38,10 +38,10 @@ const reducer: Reducer< PaymentState > = ( }; break; - case ACTION_TYPES.SET_PAYMENT_FAILED: + case ACTION_TYPES.SET_PAYMENT_READY: newState = { ...state, - status: STATUS.FAILED, + status: STATUS.READY, }; break; @@ -52,13 +52,6 @@ const reducer: Reducer< PaymentState > = ( }; break; - case ACTION_TYPES.SET_PAYMENT_SUCCESS: - newState = { - ...state, - status: STATUS.SUCCESS, - }; - break; - case ACTION_TYPES.SET_SHOULD_SAVE_PAYMENT_METHOD: newState = { ...state, diff --git a/assets/js/data/payment/selectors.ts b/assets/js/data/payment/selectors.ts index dd87cd4cdaf..afc815d85ce 100644 --- a/assets/js/data/payment/selectors.ts +++ b/assets/js/data/payment/selectors.ts @@ -14,6 +14,7 @@ import { filterActiveSavedPaymentMethods } from './utils/filter-active-saved-pay import { STATUS as PAYMENT_STATUS } from './constants'; const globalPaymentMethods: Record< string, string > = {}; + if ( getSetting( 'globalPaymentMethods' ) ) { getSetting< GlobalPaymentMethod[] >( 'globalPaymentMethods' ).forEach( ( method ) => { @@ -22,30 +23,62 @@ if ( getSetting( 'globalPaymentMethods' ) ) { ); } -export const isPaymentPristine = ( state: PaymentState ) => - state.status === PAYMENT_STATUS.PRISTINE; +export const isPaymentPristine = ( state: PaymentState ) => { + deprecated( 'isPaymentPristine', { + since: '9.6.0', + alternative: 'isPaymentIdle', + plugin: 'WooCommerce Blocks', + link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8110', + } ); + + return state.status === PAYMENT_STATUS.IDLE; +}; + +export const isPaymentIdle = ( state: PaymentState ) => + state.status === PAYMENT_STATUS.IDLE; -export const isPaymentStarted = ( state: PaymentState ) => - state.status === PAYMENT_STATUS.STARTED; +export const isPaymentStarted = ( state: PaymentState ) => { + deprecated( 'isPaymentStarted', { + since: '9.6.0', + alternative: 'isExpressPaymentStarted', + plugin: 'WooCommerce Blocks', + link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8110', + } ); + return state.status === PAYMENT_STATUS.EXPRESS_STARTED; +}; + +export const isExpressPaymentStarted = ( state: PaymentState ) => { + return state.status === PAYMENT_STATUS.EXPRESS_STARTED; +}; export const isPaymentProcessing = ( state: PaymentState ) => state.status === PAYMENT_STATUS.PROCESSING; -export const isPaymentSuccess = ( state: PaymentState ) => - state.status === PAYMENT_STATUS.SUCCESS; +export const isPaymentReady = ( state: PaymentState ) => + state.status === PAYMENT_STATUS.READY; + +export const isPaymentSuccess = ( state: PaymentState ) => { + deprecated( 'isPaymentSuccess', { + since: '9.6.0', + alternative: 'isPaymentReady', + plugin: 'WooCommerce Blocks', + link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8110', + } ); + + return state.status === PAYMENT_STATUS.READY; +}; export const hasPaymentError = ( state: PaymentState ) => state.status === PAYMENT_STATUS.ERROR; -export const isPaymentFailed = ( state: PaymentState ) => - state.status === PAYMENT_STATUS.FAILED; +export const isPaymentFailed = ( state: PaymentState ) => { + deprecated( 'isPaymentFailed', { + since: '9.6.0', + plugin: 'WooCommerce Blocks', + link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8110', + } ); -export const isPaymentFinished = ( state: PaymentState ) => { - return ( - state.status === PAYMENT_STATUS.SUCCESS || - state.status === PAYMENT_STATUS.ERROR || - state.status === PAYMENT_STATUS.FAILED - ); + return state.status === PAYMENT_STATUS.ERROR; }; export const isExpressPaymentMethodActive = ( state: PaymentState ) => { @@ -119,26 +152,54 @@ export const expressPaymentMethodsInitialized = ( state: PaymentState ) => { }; /** - * @deprecated - use these selectors instead: isPaymentPristine, isPaymentStarted, isPaymentProcessing, - * isPaymentFinished, hasPaymentError, isPaymentSuccess, isPaymentFailed + * @deprecated - Use these selectors instead: isPaymentIdle, isPaymentProcessing, + * hasPaymentError */ export const getCurrentStatus = ( state: PaymentState ) => { deprecated( 'getCurrentStatus', { since: '8.9.0', - alternative: - 'isPaymentPristine, isPaymentStarted, isPaymentProcessing, isPaymentFinished, hasPaymentError, isPaymentSuccess, isPaymentFailed', + alternative: 'isPaymentIdle, isPaymentProcessing, hasPaymentError', plugin: 'WooCommerce Blocks', link: 'https://github.com/woocommerce/woocommerce-blocks/pull/7666', } ); return { - isPristine: isPaymentPristine( state ), - isStarted: isPaymentStarted( state ), + get isPristine() { + deprecated( 'isPristine', { + since: '9.6.0', + alternative: 'isIdle', + plugin: 'WooCommerce Blocks', + } ); + return isPaymentIdle( state ); + }, // isPristine is the same as isIdle. + isIdle: isPaymentIdle( state ), + isStarted: isExpressPaymentStarted( state ), isProcessing: isPaymentProcessing( state ), - isFinished: isPaymentFinished( state ), + get isFinished() { + deprecated( 'isFinished', { + since: '9.6.0', + plugin: 'WooCommerce Blocks', + link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8110', + } ); + return hasPaymentError( state ) || isPaymentReady( state ); + }, hasError: hasPaymentError( state ), - hasFailed: isPaymentFailed( state ), - isSuccessful: isPaymentSuccess( state ), + get hasFailed() { + deprecated( 'hasFailed', { + since: '9.6.0', + plugin: 'WooCommerce Blocks', + link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8110', + } ); + return hasPaymentError( state ); + }, + get isSuccessful() { + deprecated( 'isSuccessful', { + since: '9.6.0', + plugin: 'WooCommerce Blocks', + link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8110', + } ); + return isPaymentReady( state ); + }, isDoingExpressPayment: isExpressPaymentMethodActive( state ), }; }; diff --git a/assets/js/data/payment/test/set-default-payment-method.ts b/assets/js/data/payment/test/set-default-payment-method.ts index c0ca7ae989c..aa1a21e82c2 100644 --- a/assets/js/data/payment/test/set-default-payment-method.ts +++ b/assets/js/data/payment/test/set-default-payment-method.ts @@ -126,11 +126,10 @@ describe( 'setDefaultPaymentMethod', () => { __internalSetActivePaymentMethod: setActivePaymentMethodMock, __internalSetPaymentError: () => void 0, - __internalSetPaymentFailed: () => void 0, - __internalSetPaymentSuccess: () => void 0, - __internalSetPaymentPristine: () => void 0, - __internalSetPaymentStarted: () => void 0, + __internalSetPaymentIdle: () => void 0, + __internalSetExpressPaymentStarted: () => void 0, __internalSetPaymentProcessing: () => void 0, + __internalSetPaymentReady: () => void 0, }; } return originalStore; diff --git a/assets/js/data/payment/test/thunks.tsx b/assets/js/data/payment/test/thunks.tsx index dfe1a755ec8..d3377df4303 100644 --- a/assets/js/data/payment/test/thunks.tsx +++ b/assets/js/data/payment/test/thunks.tsx @@ -191,7 +191,7 @@ describe( 'wc/store/payment thunks', () => { } ); const setPaymentErrorMock = jest.fn(); - const setPaymentSuccessMock = jest.fn(); + const setPaymentReadyMock = jest.fn(); const registryMock = { dispatch: jest .fn() @@ -211,14 +211,14 @@ describe( 'wc/store/payment thunks', () => { dispatch: { ...wpDataFunctions.dispatch( PAYMENT_STORE_KEY ), __internalSetPaymentError: setPaymentErrorMock, - __internalSetPaymentSuccess: setPaymentSuccessMock, + __internalSetPaymentReady: setPaymentReadyMock, }, } ); // The observer throwing will cause this. //expect( console ).toHaveErroredWith( new Error( 'test error' ) ); expect( setPaymentErrorMock ).toHaveBeenCalled(); - expect( setPaymentSuccessMock ).not.toHaveBeenCalled(); + expect( setPaymentReadyMock ).not.toHaveBeenCalled(); } ); } ); } ); diff --git a/assets/js/data/payment/thunks.ts b/assets/js/data/payment/thunks.ts index ab4f9467b76..d3fb85ab359 100644 --- a/assets/js/data/payment/thunks.ts +++ b/assets/js/data/payment/thunks.ts @@ -67,9 +67,11 @@ export const __internalEmitPaymentProcessingEvent: emitProcessingEventType = ( shippingAddress: ShippingAddress | undefined; observerResponses.forEach( ( response ) => { if ( isSuccessResponse( response ) ) { - // the last observer response always "wins" for success. + // The last observer response always "wins" for success. successResponse = response; } + + // We consider both failed and error responses as an error. if ( isErrorResponse( response ) || isFailResponse( response ) @@ -125,27 +127,24 @@ export const __internalEmitPaymentProcessingEvent: emitProcessingEventType = ( const { setBillingAddress, setShippingAddress } = registry.dispatch( CART_STORE_KEY ); - if ( - isObserverResponse( successResponse ) && - successResponse && - ! errorResponse - ) { + // Observer returned success, we sync the payment method data and billing address. + if ( isObserverResponse( successResponse ) && ! errorResponse ) { const { paymentMethodData } = successResponse?.meta || {}; - if ( billingAddress && isBillingAddress( billingAddress ) ) { + + if ( isBillingAddress( billingAddress ) ) { setBillingAddress( billingAddress ); } - if ( - typeof shippingAddress !== 'undefined' && - isShippingAddress( shippingAddress ) - ) { + if ( isShippingAddress( shippingAddress ) ) { setShippingAddress( shippingAddress ); } - const paymentDataToSet = isObject( paymentMethodData ) - ? paymentMethodData - : {}; - dispatch.__internalSetPaymentMethodData( paymentDataToSet ); - dispatch.__internalSetPaymentSuccess(); + + dispatch.__internalSetPaymentMethodData( + isObject( paymentMethodData ) ? paymentMethodData : {} + ); + dispatch.__internalSetPaymentReady(); } else if ( isFailResponse( errorResponse ) ) { + const { paymentMethodData } = errorResponse?.meta || {}; + if ( objectHasProp( errorResponse, 'message' ) && isString( errorResponse.message ) && @@ -166,16 +165,14 @@ export const __internalEmitPaymentProcessingEvent: emitProcessingEventType = ( } ); } - const { paymentMethodData } = errorResponse?.meta || {}; - if ( billingAddress && isBillingAddress( billingAddress ) ) { + if ( isBillingAddress( billingAddress ) ) { setBillingAddress( billingAddress ); } - dispatch.__internalSetPaymentFailed(); - const paymentDataToSet = isObject( paymentMethodData ) - ? paymentMethodData - : {}; - dispatch.__internalSetPaymentMethodData( paymentDataToSet ); + dispatch.__internalSetPaymentMethodData( + isObject( paymentMethodData ) ? paymentMethodData : {} + ); + dispatch.__internalSetPaymentError(); } else if ( isErrorResponse( errorResponse ) ) { if ( objectHasProp( errorResponse, 'message' ) && @@ -207,9 +204,8 @@ export const __internalEmitPaymentProcessingEvent: emitProcessingEventType = ( setValidationErrors( errorResponse.validationErrors ); } } else { - // otherwise there are no payment methods doing anything so - // just consider success - dispatch.__internalSetPaymentSuccess(); + // Otherwise there are no payment methods doing anything so just assume payment method is ready. + dispatch.__internalSetPaymentReady(); } } ); }; diff --git a/assets/js/data/payment/utils/set-default-payment-method.ts b/assets/js/data/payment/utils/set-default-payment-method.ts index 612284c6748..71fe94e7e76 100644 --- a/assets/js/data/payment/utils/set-default-payment-method.ts +++ b/assets/js/data/payment/utils/set-default-payment-method.ts @@ -60,7 +60,7 @@ export const setDefaultPaymentMethod = async ( return; } - dispatch( PAYMENT_STORE_KEY ).__internalSetPaymentPristine(); + dispatch( PAYMENT_STORE_KEY ).__internalSetPaymentIdle(); dispatch( PAYMENT_STORE_KEY ).__internalSetActivePaymentMethod( paymentMethodKeys[ 0 ] diff --git a/assets/js/types/type-defs/payment-method-interface.ts b/assets/js/types/type-defs/payment-method-interface.ts index b711c1552b0..4b05bb6e924 100644 --- a/assets/js/types/type-defs/payment-method-interface.ts +++ b/assets/js/types/type-defs/payment-method-interface.ts @@ -166,6 +166,7 @@ export type PaymentMethodInterface = { // Various payment status helpers. paymentStatus: { isPristine: boolean; + isIdle: boolean; isStarted: boolean; isProcessing: boolean; isFinished: boolean; diff --git a/docs/internal-developers/block-client-apis/checkout/checkout-flow-and-events.md b/docs/internal-developers/block-client-apis/checkout/checkout-flow-and-events.md index ad0556e6176..2f85077e151 100644 --- a/docs/internal-developers/block-client-apis/checkout/checkout-flow-and-events.md +++ b/docs/internal-developers/block-client-apis/checkout/checkout-flow-and-events.md @@ -109,14 +109,13 @@ import { select } from '@wordpress/data'; import { PAYMENT_STORE_KEY } from '@woocommerce/blocks-data'; const MyComponent = ( props ) => { - const isPaymentPristine = select( PAYMENT_STORE_KEY ).isPaymentPristine(); - const isPaymentStarted = select( PAYMENT_STORE_KEY ).isPaymentStarted(); + const isPaymentIdle = select( PAYMENT_STORE_KEY ).isPaymentIdle(); + const isExpressPaymentStarted = + select( PAYMENT_STORE_KEY ).isExpressPaymentStarted(); const isPaymentProcessing = select( PAYMENT_STORE_KEY ).isPaymentProcessing(); - const isPaymentSuccess = select( PAYMENT_STORE_KEY ).isPaymentSuccess(); - const isPaymentFailed = select( PAYMENT_STORE_KEY ).isPaymentFailed(); + const isPaymentReady = select( PAYMENT_STORE_KEY ).isPaymentReady(); const hasPaymentError = select( PAYMENT_STORE_KEY ).hasPaymentError(); - const hasPaymentFinished = select( PAYMENT_STORE_KEY ).hasPaymentFinished(); // do something with the boolean values }; @@ -126,11 +125,10 @@ The status here will help inform the current state of _client side_ processing f The possible _internal_ statuses that may be set are: -- `PRISTINE`: This is the status when checkout is initialized and there are payment methods that are not doing anything. This status is also set whenever the checkout status is changed to `IDLE`. -- `STARTED`: **Express Payment Methods Only** - This status is used when an express payment method has been triggered by the user clicking it's button. This flow happens before processing, usually in a modal window. +- `IDLE`: This is the status when checkout is initialized and there are payment methods that are not doing anything. This status is also set whenever the checkout status is changed to `IDLE`. +- `EXPRESS_STARTED`: **Express Payment Methods Only** - This status is used when an express payment method has been triggered by the user clicking it's button. This flow happens before processing, usually in a modal window. - `PROCESSING`: This status is set when the checkout status is `PROCESSING`, checkout `hasError` is false, checkout is not calculating, and the current payment status is not `FINISHED`. When this status is set, it will trigger the payment processing event emitter. -- `SUCCESS`: This status is set after all the observers hooked into the payment processing event have completed successfully. The `CheckoutProcessor` component uses this along with the checkout `PROCESSING` status to signal things are ready to send the order to the server with data for processing. -- `FAILED`: This status is set after an observer hooked into the payment processing event returns a fail response. This in turn will end up causing the checkout `hasError` flag to be set to true. +- `READY`: This status is set after all the observers hooked into the payment processing event have completed successfully. The `CheckoutProcessor` component uses this along with the checkout `PROCESSING` status to signal things are ready to send the order to the server with data for processing and to take payment - `ERROR`: This status is set after an observer hooked into the payment processing event returns an error response. This in turn will end up causing the checkout `hasError` flag to be set to true. ### Emitting Events diff --git a/docs/third-party-developers/extensibility/checkout-payment-methods/payment-method-integration.md b/docs/third-party-developers/extensibility/checkout-payment-methods/payment-method-integration.md index 3d086b0bb8b..57b7217b205 100644 --- a/docs/third-party-developers/extensibility/checkout-payment-methods/payment-method-integration.md +++ b/docs/third-party-developers/extensibility/checkout-payment-methods/payment-method-integration.md @@ -180,7 +180,7 @@ A big part of the payment method integration is the interface that is exposed fo | `shouldSavePayment` | Boolean | Indicates whether or not the shopper has selected to save their payment method details (for payment methods that support saved payments). True if selected, false otherwise. Defaults to false. | - | - `isPristine`: This is true when the current payment status is `PRISTINE`. -- `isStarted`: This is true when the current payment status is `STARTED`. +- `isStarted`: This is true when the current payment status is `EXPRESS_STARTED`. - `isProcessing`: This is true when the current payment status is `PROCESSING`. - `isFinished`: This is true when the current payment status is one of `ERROR`, `FAILED`, or`SUCCESS`. - `hasError`: This is true when the current payment status is `ERROR`. diff --git a/docs/third-party-developers/extensibility/data-store/payment.md b/docs/third-party-developers/extensibility/data-store/payment.md index 53d317142c4..be2313a5ccb 100644 --- a/docs/third-party-developers/extensibility/data-store/payment.md +++ b/docs/third-party-developers/extensibility/data-store/payment.md @@ -27,25 +27,42 @@ with. We do not encourage extensions to dispatch actions onto this data store ye ## Selectors -### isPaymentPristine +### (@deprecated) isPaymentPristine -Queries if the status is `pristine` +_**This selector is deprecated and will be removed in a future release. Please use isPaymentIdle instead**_ #### _Returns_ -`boolean`: True if the payment status is `pristine`, false otherwise. +`boolean`: True if the payment status is `idle`, false otherwise. #### _Example_ ```js const store = select( 'wc/store/payment' ); -const isPaymentPristine = store.isPaymentPristine(); +const isPaymentIdle = store.isPaymentIdle(); ``` -### isPaymentStarted +### isPaymentIdle + +Queries if the status is `idle` + +#### _Returns_ + +`boolean`: True if the payment status is `idle`, false otherwise. + +#### _Example_ + +```js +const store = select( 'wc/store/payment' ); +const isPaymentIdle = store.isPaymentIdle(); +``` + +### (@deprecated) isPaymentStarted Queries if the status is `started`. +_**This selector is deprecated and will be removed in a future release. Please use isExpressPaymentStarted instead**_ + #### _Returns_ `boolean`: True if the payment status is `started`, false otherwise. @@ -57,6 +74,23 @@ const store = select( 'wc/store/payment' ); const isPaymentStarted = store.isPaymentStarted(); ``` +### isExpressPaymentStarted + +Queries if an express payment method has been clicked. + +_**This selector is deprecated and will be removed in a future release. Please use isExpressPaymentStarted instead**_ + +#### _Returns_ + +`boolean`: True if the button for an express payment method has been clicked, false otherwise. + +#### _Example_ + +```js +const store = select( 'wc/store/payment' ); +const isPaymentStarted = store.isPaymentStarted(); +``` + ### isPaymentProcessing Queries if the status is `processing`. @@ -72,10 +106,12 @@ const store = select( 'wc/store/payment' ); const isPaymentProcessing = store.isPaymentProcessing(); ``` -### isPaymentSuccess +### (@deprecated) isPaymentSuccess Queries if the status is `success`. +_**This selector is deprecated and will be removed in a future release. Please use isPaymentReady instead**_ + #### _Returns_ `boolean`: True if the payment status is `success`, false otherwise. @@ -87,54 +123,57 @@ const store = select( 'wc/store/payment' ); const isPaymentSuccess = store.isPaymentSuccess(); ``` -### isPaymentFailed +### isPaymentReady -Queries if the status is `failed`. +Queries if the status is `ready`. #### _Returns_ -`boolean`: True if the payment status is `failed`, false otherwise. +`boolean`: True if the payment status is `ready`, false otherwise. #### _Example_ ```js const store = select( 'wc/store/payment' ); -const isPaymentFailed = store.isPaymentFailed(); +const isPaymentReady = store.isPaymentReady(); ``` -### hasPaymentError +### (@deprecated) isPaymentFailed -Queries if the status is `error`. +Queries if the status is `failed`. + +_**This selector is deprecated and will be removed in a future release. Please use hasPaymentError instead**_ #### _Returns_ -`boolean`: True if the payment status is `error`, false otherwise. +`boolean`: True if the payment status is `failed`, false otherwise. #### _Example_ ```js const store = select( 'wc/store/payment' ); -const hasPaymentError = store.hasPaymentError(); +const isPaymentFailed = store.isPaymentFailed(); ``` -### isPaymentFinished +### hasPaymentError -Checks wether the payment has finished processing. This includes failed payments, payments with errors or successful payments. +Queries if the status is `error`. #### _Returns_ -`boolean`: True if the payment status is `success`, `failed` or `error`, false otherwise. +`boolean`: True if the payment status is `error`, false otherwise. #### _Example_ ```js const store = select( 'wc/store/payment' ); -const isPaymentFinished = store.isPaymentFinished(); +const hasPaymentError = store.hasPaymentError(); ``` -### getCurrentStatus (deprecated) +### (@deprecated) getCurrentStatus Returns an object with booleans representing the payment status. + _**This selector is deprecated and will be removed in a future release. Please use the selectors above**_ #### _Returns_ From 7a4006a32249ce9984e2204da731dad2ad9c8fee Mon Sep 17 00:00:00 2001 From: Luigi Teschio Date: Tue, 14 Feb 2023 14:21:48 +0100 Subject: [PATCH 17/17] Add Product Image Gallery (#8235) * Add Product Image Gallery #8233 Add Product Image Gallery * Add Product Image Gallery block * remove support global styles * remove support global styles * address CSS feedback * add support for the custom classname * remove save function * add second parameter to the subscribe function * update @types/wordpress__data package * update placeholder, icon and description * update tsconfig --- assets/js/atomic/blocks/index.js | 1 + .../product-image-gallery/block.json | 17 +++++ .../product-image-gallery/edit.tsx | 64 +++++++++++++++++++ .../product-image-gallery/editor.scss | 13 ++++ .../product-image-gallery/index.ts | 26 ++++++++ assets/js/atomic/utils/index.js | 1 + .../register-block-single-product-template.ts | 46 +++++++++++++ assets/js/types/type-guards/object.ts | 6 ++ assets/js/types/type-guards/test/object.ts | 25 ++++++++ .../product-image-gallery.svg | 9 +++ package-lock.json | 11 ++-- package.json | 2 +- src/BlockTypes/ProductImageGallery.php | 60 +++++++++++++++++ src/BlockTypesController.php | 1 + tsconfig.json | 1 + 15 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 assets/js/atomic/blocks/product-elements/product-image-gallery/block.json create mode 100644 assets/js/atomic/blocks/product-elements/product-image-gallery/edit.tsx create mode 100644 assets/js/atomic/blocks/product-elements/product-image-gallery/editor.scss create mode 100644 assets/js/atomic/blocks/product-elements/product-image-gallery/index.ts create mode 100644 assets/js/atomic/utils/register-block-single-product-template.ts create mode 100644 assets/js/types/type-guards/test/object.ts create mode 100644 images/block-placeholders/product-image-gallery.svg create mode 100644 src/BlockTypes/ProductImageGallery.php diff --git a/assets/js/atomic/blocks/index.js b/assets/js/atomic/blocks/index.js index 5a1ff791cbd..6745263834d 100644 --- a/assets/js/atomic/blocks/index.js +++ b/assets/js/atomic/blocks/index.js @@ -13,3 +13,4 @@ import './product-elements/category-list'; import './product-elements/tag-list'; import './product-elements/stock-indicator'; import './product-elements/add-to-cart'; +import './product-elements/product-image-gallery'; diff --git a/assets/js/atomic/blocks/product-elements/product-image-gallery/block.json b/assets/js/atomic/blocks/product-elements/product-image-gallery/block.json new file mode 100644 index 00000000000..0a1247cbc34 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/product-image-gallery/block.json @@ -0,0 +1,17 @@ +{ + "name": "woocommerce/product-image-gallery", + "version": "1.0.0", + "title": "Product Image Gallery", + "icon": "gallery", + "description": "Display a product's images.", + "category": "woocommerce", + "supports": { + "align": true, + "reusable": false + }, + "keywords": [ "WooCommerce" ], + "usesContext": [ "postId", "postType", "queryId" ], + "textdomain": "woo-gutenberg-products-block", + "apiVersion": 2, + "$schema": "https://schemas.wp.org/trunk/block.json" +} diff --git a/assets/js/atomic/blocks/product-elements/product-image-gallery/edit.tsx b/assets/js/atomic/blocks/product-elements/product-image-gallery/edit.tsx new file mode 100644 index 00000000000..df6fb8ed95e --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/product-image-gallery/edit.tsx @@ -0,0 +1,64 @@ +/** + * External dependencies + */ +import { WC_BLOCKS_IMAGE_URL } from '@woocommerce/block-settings'; +import { isEmptyObject } from '@woocommerce/types'; +import { useBlockProps } from '@wordpress/block-editor'; +import { BlockAttributes } from '@wordpress/blocks'; +import { Disabled } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import './editor.scss'; + +const Placeholder = () => { + return ( +
    + Placeholder +
    + { [ ...Array( 4 ).keys() ].map( ( index ) => { + return ( + Placeholder + ); + } ) } +
    +
    + ); +}; + +type Context = { + postId: string; + postType: string; + queryId: string; +}; + +interface Props { + attributes: BlockAttributes; + context: Context; +} + +const Edit = ( { context }: Props ) => { + const blockProps = useBlockProps(); + + if ( isEmptyObject( context ) ) { + return ( +
    + + + +
    + ); + } + // We have work on this case when we will work on the Single Product block. + return ''; +}; + +export default Edit; diff --git a/assets/js/atomic/blocks/product-elements/product-image-gallery/editor.scss b/assets/js/atomic/blocks/product-elements/product-image-gallery/editor.scss new file mode 100644 index 00000000000..40696afdbba --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/product-image-gallery/editor.scss @@ -0,0 +1,13 @@ +.wc-block-editor-product-gallery { + img { + width: 500px; + height: 500px; + } + .wc-block-editor-product-gallery__other-images { + img { + width: 100px; + height: 100px; + margin: 5px; + } + } +} diff --git a/assets/js/atomic/blocks/product-elements/product-image-gallery/index.ts b/assets/js/atomic/blocks/product-elements/product-image-gallery/index.ts new file mode 100644 index 00000000000..ca81e18eee9 --- /dev/null +++ b/assets/js/atomic/blocks/product-elements/product-image-gallery/index.ts @@ -0,0 +1,26 @@ +/** + * External dependencies + */ +import { gallery as icon } from '@wordpress/icons'; +import { registerBlockType, unregisterBlockType } from '@wordpress/blocks'; +import { registerBlockSingleProductTemplate } from '@woocommerce/atomic-utils'; + +/** + * Internal dependencies + */ +import edit from './edit'; +import metadata from './block.json'; + +registerBlockSingleProductTemplate( { + registerBlockFn: () => { + // @ts-expect-error: `registerBlockType` is a function that is typed in WordPress core. + registerBlockType( metadata, { + icon, + edit, + } ); + }, + unregisterBlockFn: () => { + unregisterBlockType( metadata.name ); + }, + blockName: metadata.name, +} ); diff --git a/assets/js/atomic/utils/index.js b/assets/js/atomic/utils/index.js index f10a14b6858..06280bb433f 100644 --- a/assets/js/atomic/utils/index.js +++ b/assets/js/atomic/utils/index.js @@ -2,3 +2,4 @@ export * from './get-block-map'; export * from './create-blocks-from-template'; export * from './render-parent-block'; export * from './render-standalone-blocks'; +export * from './register-block-single-product-template'; diff --git a/assets/js/atomic/utils/register-block-single-product-template.ts b/assets/js/atomic/utils/register-block-single-product-template.ts new file mode 100644 index 00000000000..675e335b06d --- /dev/null +++ b/assets/js/atomic/utils/register-block-single-product-template.ts @@ -0,0 +1,46 @@ +/** + * External dependencies + */ +import { getBlockType } from '@wordpress/blocks'; +import { subscribe, select } from '@wordpress/data'; + +export const registerBlockSingleProductTemplate = ( { + registerBlockFn, + unregisterBlockFn, + blockName, +}: { + registerBlockFn: () => void; + unregisterBlockFn: () => void; + blockName: string; +} ) => { + let currentTemplateId: string | undefined; + + subscribe( () => { + const previousTemplateId = currentTemplateId; + const store = select( 'core/edit-site' ); + currentTemplateId = store?.getEditedPostId() as string | undefined; + + if ( previousTemplateId === currentTemplateId ) { + return; + } + + const parsedTemplate = currentTemplateId?.split( '//' )[ 1 ]; + + if ( parsedTemplate === null || parsedTemplate === undefined ) { + return; + } + + const block = getBlockType( blockName ); + + if ( + block === undefined && + parsedTemplate.includes( 'single-product' ) + ) { + registerBlockFn(); + } + + if ( block !== undefined ) { + unregisterBlockFn(); + } + }, 'core/edit-site' ); +}; diff --git a/assets/js/types/type-guards/object.ts b/assets/js/types/type-guards/object.ts index c90646df629..889ab7fc26a 100644 --- a/assets/js/types/type-guards/object.ts +++ b/assets/js/types/type-guards/object.ts @@ -21,3 +21,9 @@ export function objectHasProp< P extends PropertyKey >( // The `in` operator throws a `TypeError` for non-object values. return isObject( target ) && property in target; } + +export const isEmptyObject = < T extends { [ key: string ]: unknown } >( + object: T +) => { + return Object.keys( object ).length === 0; +}; diff --git a/assets/js/types/type-guards/test/object.ts b/assets/js/types/type-guards/test/object.ts new file mode 100644 index 00000000000..5ce4e73846c --- /dev/null +++ b/assets/js/types/type-guards/test/object.ts @@ -0,0 +1,25 @@ +/** + * External dependencies + */ +import { isEmptyObject, isObject } from '@woocommerce/types'; + +describe( 'Object type-guards', () => { + describe( 'Testing isObject()', () => { + it( 'Correctly identifies an object', () => { + expect( isObject( {} ) ).toBe( true ); + expect( isObject( { test: 'object' } ) ).toBe( true ); + } ); + it( 'Correctly rejects object-like things', () => { + expect( isObject( [] ) ).toBe( false ); + expect( isObject( null ) ).toBe( false ); + } ); + } ); + describe( 'Testing isEmptyObject()', () => { + it( 'Correctly identifies an empty object', () => { + expect( isEmptyObject( {} ) ).toBe( true ); + } ); + it( 'Correctly identifies an not empty object', () => { + expect( isEmptyObject( { name: 'Woo' } ) ).toBe( false ); + } ); + } ); +} ); diff --git a/images/block-placeholders/product-image-gallery.svg b/images/block-placeholders/product-image-gallery.svg new file mode 100644 index 00000000000..a9a75f82cf4 --- /dev/null +++ b/images/block-placeholders/product-image-gallery.svg @@ -0,0 +1,9 @@ + + + + Layer 1 + + + + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 96d1f2aea73..06fdcaaf453 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,7 +82,7 @@ "@types/wordpress__blocks": "11.0.7", "@types/wordpress__components": "^23.0.0", "@types/wordpress__core-data": "^2.4.5", - "@types/wordpress__data": "^6.0.1", + "@types/wordpress__data": "^6.0.2", "@types/wordpress__data-controls": "2.2.0", "@types/wordpress__editor": "^11.0.0", "@types/wordpress__notices": "^3.5.0", @@ -11680,9 +11680,10 @@ "license": "MIT" }, "node_modules/@types/wordpress__data": { - "version": "6.0.1", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@types/wordpress__data/-/wordpress__data-6.0.2.tgz", + "integrity": "sha512-Pu67knXXoTWgCpxTKwePNZz/iKkYe8AQbkkSD/Ba1mw8t4zgEM+jJs5IV5N5ij/awwjs4Subj8mkvS3jMTDwyw==", "dev": true, - "license": "MIT", "dependencies": { "@types/react": "*", "redux": "^4.1.0" @@ -58730,7 +58731,9 @@ "dev": true }, "@types/wordpress__data": { - "version": "6.0.1", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@types/wordpress__data/-/wordpress__data-6.0.2.tgz", + "integrity": "sha512-Pu67knXXoTWgCpxTKwePNZz/iKkYe8AQbkkSD/Ba1mw8t4zgEM+jJs5IV5N5ij/awwjs4Subj8mkvS3jMTDwyw==", "dev": true, "requires": { "@types/react": "*", diff --git a/package.json b/package.json index 6b24618f34c..9474f765df1 100644 --- a/package.json +++ b/package.json @@ -126,7 +126,7 @@ "@types/wordpress__blocks": "11.0.7", "@types/wordpress__components": "^23.0.0", "@types/wordpress__core-data": "^2.4.5", - "@types/wordpress__data": "^6.0.1", + "@types/wordpress__data": "^6.0.2", "@types/wordpress__data-controls": "2.2.0", "@types/wordpress__editor": "^11.0.0", "@types/wordpress__notices": "^3.5.0", diff --git a/src/BlockTypes/ProductImageGallery.php b/src/BlockTypes/ProductImageGallery.php new file mode 100644 index 00000000000..43e84ebbe32 --- /dev/null +++ b/src/BlockTypes/ProductImageGallery.php @@ -0,0 +1,60 @@ +context['postId']; + global $product; + $product = wc_get_product( $post_id ); + + if ( class_exists( 'WC_Frontend_Scripts' ) ) { + $frontend_scripts = new \WC_Frontend_Scripts(); + $frontend_scripts::load_scripts(); + } + + $classname = $attributes['className'] ?? ''; + + ob_start(); + woocommerce_show_product_images(); + $product_image_gallery_html = ob_get_clean(); + + return sprintf( + '', + esc_attr( $classname ), + $product_image_gallery_html + ); + + } +} diff --git a/src/BlockTypesController.php b/src/BlockTypesController.php index ad4ea3f4ee6..e9355759ec0 100644 --- a/src/BlockTypesController.php +++ b/src/BlockTypesController.php @@ -187,6 +187,7 @@ protected function get_block_types() { 'ProductCategory', 'ProductCategoryList', 'ProductImage', + 'ProductImageGallery', 'ProductNew', 'ProductOnSale', 'ProductPrice', diff --git a/tsconfig.json b/tsconfig.json index 2d88e8033f9..2a1a5b6c03b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "./assets/js/**/*", "./packages/checkout/**/*", "./assets/js/blocks/**/block.json", + "./assets/js/atomic/blocks/**/block.json", "./assets/js/blocks/mini-cart/mini-cart-contents/inner-blocks/**/block.json", "./storybook/**/*", "./tests/js/setup-after-env.ts"