diff --git a/plugins/woocommerce-blocks/.eslintrc.js b/plugins/woocommerce-blocks/.eslintrc.js
index dc20ccdcdf7..9e126556193 100644
--- a/plugins/woocommerce-blocks/.eslintrc.js
+++ b/plugins/woocommerce-blocks/.eslintrc.js
@@ -162,6 +162,7 @@ module.exports = {
'@wordpress/url',
'@woocommerce/blocks-test-utils',
'@woocommerce/e2e-utils',
+ '@woocommerce/e2e-mocks',
'babel-jest',
'dotenv',
'jest-environment-puppeteer',
diff --git a/plugins/woocommerce-blocks/.wp-env.json b/plugins/woocommerce-blocks/.wp-env.json
index 14c1c790faa..c3a5c2d1d6a 100644
--- a/plugins/woocommerce-blocks/.wp-env.json
+++ b/plugins/woocommerce-blocks/.wp-env.json
@@ -14,7 +14,8 @@
"wp-content/plugins/gutenberg-test-plugins": "./node_modules/@wordpress/e2e-tests/plugins",
"wp-content/plugins/woocommerce-blocks": ".",
"wp-content/plugins/woocommerce-gutenberg-products-block": ".",
- "wp-cli.yml": "./wp-cli.yml"
+ "wp-cli.yml": "./wp-cli.yml",
+ "custom-plugins" : "./tests/e2e/mocks/custom-plugins"
}
}
},
diff --git a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/button/block.json b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/button/block.json
index 62f1889af91..c69913bdee2 100644
--- a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/button/block.json
+++ b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/button/block.json
@@ -34,6 +34,7 @@
"background": false,
"link": true
},
+ "interactivity": true,
"html": false,
"typography": {
"fontSize": true,
@@ -57,6 +58,9 @@
"label": "Outline"
}
],
+ "viewScript": [
+ "wc-product-button-interactivity-frontend"
+ ],
"apiVersion": 2,
"$schema": "https://schemas.wp.org/trunk/block.json"
}
diff --git a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/button/frontend.tsx b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/button/frontend.tsx
new file mode 100644
index 00000000000..9f1c8bc995d
--- /dev/null
+++ b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/button/frontend.tsx
@@ -0,0 +1,279 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/**
+ * External dependencies
+ */
+import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
+import { store as interactivityStore } from '@woocommerce/interactivity';
+import { dispatch, select, subscribe } from '@wordpress/data';
+import { Cart } from '@woocommerce/type-defs/cart';
+import { createRoot } from '@wordpress/element';
+import NoticeBanner from '@woocommerce/base-components/notice-banner';
+
+type Context = {
+ woocommerce: {
+ isLoading: boolean;
+ addToCartText: string;
+ productId: number;
+ displayViewCart: boolean;
+ quantityToAdd: number;
+ temporaryNumberOfItems: number;
+ animationStatus: AnimationStatus;
+ };
+};
+
+enum AnimationStatus {
+ IDLE = 'IDLE',
+ SLIDE_OUT = 'SLIDE-OUT',
+ SLIDE_IN = 'SLIDE-IN',
+}
+
+type State = {
+ woocommerce: {
+ cart: Cart | undefined;
+ inTheCartText: string;
+ };
+};
+
+type Store = {
+ state: State;
+ context: Context;
+ selectors: any;
+ ref: HTMLElement;
+};
+
+const storeNoticeClass = '.wc-block-store-notices';
+
+const createNoticeContainer = () => {
+ const noticeContainer = document.createElement( 'div' );
+ noticeContainer.classList.add( storeNoticeClass.replace( '.', '' ) );
+ return noticeContainer;
+};
+
+const injectNotice = ( domNode: Element, errorMessage: string ) => {
+ const root = createRoot( domNode );
+
+ root.render(
+ root.unmount() }>
+ { errorMessage }
+
+ );
+
+ domNode?.scrollIntoView( {
+ behavior: 'smooth',
+ inline: 'nearest',
+ } );
+};
+
+const getProductById = ( cartState: Cart | undefined, productId: number ) => {
+ return cartState?.items.find( ( item ) => item.id === productId );
+};
+
+const getTextButton = ( {
+ addToCartText,
+ inTheCartText,
+ numberOfItems,
+}: {
+ addToCartText: string;
+ inTheCartText: string;
+ numberOfItems: number;
+} ) => {
+ if ( numberOfItems === 0 ) {
+ return addToCartText;
+ }
+ return inTheCartText.replace( '###', numberOfItems.toString() );
+};
+
+const productButtonSelectors = {
+ woocommerce: {
+ addToCartText: ( store: Store ) => {
+ const { context, state, selectors } = store;
+
+ // We use the temporary number of items when there's no animation, or the
+ // second part of the animation hasn't started.
+ if (
+ context.woocommerce.animationStatus === AnimationStatus.IDLE ||
+ context.woocommerce.animationStatus ===
+ AnimationStatus.SLIDE_OUT
+ ) {
+ return getTextButton( {
+ addToCartText: context.woocommerce.addToCartText,
+ inTheCartText: state.woocommerce.inTheCartText,
+ numberOfItems: context.woocommerce.temporaryNumberOfItems,
+ } );
+ }
+
+ return getTextButton( {
+ addToCartText: context.woocommerce.addToCartText,
+ inTheCartText: state.woocommerce.inTheCartText,
+ numberOfItems:
+ selectors.woocommerce.numberOfItemsInTheCart( store ),
+ } );
+ },
+ displayViewCart: ( store: Store ) => {
+ const { context, selectors } = store;
+ if ( ! context.woocommerce.displayViewCart ) return false;
+ if ( ! selectors.woocommerce.hasCartLoaded( store ) ) {
+ return context.woocommerce.temporaryNumberOfItems > 0;
+ }
+ return selectors.woocommerce.numberOfItemsInTheCart( store ) > 0;
+ },
+ hasCartLoaded: ( { state }: { state: State } ) => {
+ return state.woocommerce.cart !== undefined;
+ },
+ numberOfItemsInTheCart: ( { state, context }: Store ) => {
+ const product = getProductById(
+ state.woocommerce.cart,
+ context.woocommerce.productId
+ );
+ return product?.quantity || 0;
+ },
+ slideOutAnimation: ( { context }: Store ) =>
+ context.woocommerce.animationStatus === AnimationStatus.SLIDE_OUT,
+ slideInAnimation: ( { context }: Store ) =>
+ context.woocommerce.animationStatus === AnimationStatus.SLIDE_IN,
+ },
+};
+
+interactivityStore(
+ // @ts-expect-error: Store function isn't typed.
+ {
+ selectors: productButtonSelectors,
+ actions: {
+ woocommerce: {
+ addToCart: async ( store: Store ) => {
+ const { context, selectors, ref } = store;
+
+ if ( ! ref.classList.contains( 'ajax_add_to_cart' ) ) {
+ return;
+ }
+
+ context.woocommerce.isLoading = true;
+
+ // Allow 3rd parties to validate and quit early.
+ // https://github.com/woocommerce/woocommerce/blob/154dd236499d8a440edf3cde712511b56baa8e45/plugins/woocommerce/client/legacy/js/frontend/add-to-cart.js/#L74-L77
+ const event = new CustomEvent(
+ 'should_send_ajax_request.adding_to_cart',
+ { detail: [ ref ], cancelable: true }
+ );
+ const shouldSendRequest =
+ document.body.dispatchEvent( event );
+
+ if ( shouldSendRequest === false ) {
+ const ajaxNotSentEvent = new CustomEvent(
+ 'ajax_request_not_sent.adding_to_cart',
+ { detail: [ false, false, ref ] }
+ );
+ document.body.dispatchEvent( ajaxNotSentEvent );
+ return true;
+ }
+
+ try {
+ await dispatch( storeKey ).addItemToCart(
+ context.woocommerce.productId,
+ context.woocommerce.quantityToAdd
+ );
+
+ // After the cart has been updated, sync the temporary number of
+ // items again.
+ context.woocommerce.temporaryNumberOfItems =
+ selectors.woocommerce.numberOfItemsInTheCart(
+ store
+ );
+ } catch ( error ) {
+ const storeNoticeBlock =
+ document.querySelector( storeNoticeClass );
+
+ if ( ! storeNoticeBlock ) {
+ document
+ .querySelector( '.entry-content' )
+ ?.prepend( createNoticeContainer() );
+ }
+
+ const domNode =
+ storeNoticeBlock ??
+ document.querySelector( storeNoticeClass );
+
+ if ( domNode ) {
+ injectNotice( domNode, error.message );
+ }
+
+ // We don't care about errors blocking execution, but will
+ // console.error for troubleshooting.
+ // eslint-disable-next-line no-console
+ console.error( error );
+ } finally {
+ context.woocommerce.displayViewCart = true;
+ context.woocommerce.isLoading = false;
+ }
+ },
+ handleAnimationEnd: (
+ store: Store & { event: AnimationEvent }
+ ) => {
+ const { event, context, selectors } = store;
+ if ( event.animationName === 'slideOut' ) {
+ // When the first part of the animation (slide-out) ends, we move
+ // to the second part (slide-in).
+ context.woocommerce.animationStatus =
+ AnimationStatus.SLIDE_IN;
+ } else if ( event.animationName === 'slideIn' ) {
+ // When the second part of the animation ends, we update the
+ // temporary number of items to sync it with the cart and reset the
+ // animation status so it can be triggered again.
+ context.woocommerce.temporaryNumberOfItems =
+ selectors.woocommerce.numberOfItemsInTheCart(
+ store
+ );
+ context.woocommerce.animationStatus =
+ AnimationStatus.IDLE;
+ }
+ },
+ },
+ },
+ effects: {
+ woocommerce: {
+ startAnimation: ( store: Store ) => {
+ const { context, selectors } = store;
+ // We start the animation if the cart has loaded, the temporary number
+ // of items is out of sync with the number of items in the cart, the
+ // button is not loading (because that means the user started the
+ // interaction) and the animation hasn't started yet.
+ if (
+ selectors.woocommerce.hasCartLoaded( store ) &&
+ context.woocommerce.temporaryNumberOfItems !==
+ selectors.woocommerce.numberOfItemsInTheCart(
+ store
+ ) &&
+ ! context.woocommerce.isLoading &&
+ context.woocommerce.animationStatus ===
+ AnimationStatus.IDLE
+ ) {
+ context.woocommerce.animationStatus =
+ AnimationStatus.SLIDE_OUT;
+ }
+ },
+ },
+ },
+ },
+ {
+ afterLoad: ( store: Store ) => {
+ const { state, selectors } = store;
+ // Subscribe to changes in Cart data.
+ subscribe( () => {
+ const cartData = select( storeKey ).getCartData();
+ const isResolutionFinished =
+ select( storeKey ).hasFinishedResolution( 'getCartData' );
+ if ( isResolutionFinished ) {
+ state.woocommerce.cart = cartData;
+ }
+ }, storeKey );
+
+ // This selector triggers a fetch of the Cart data. It is done in a
+ // `requestIdleCallback` to avoid potential performance issues.
+ requestIdleCallback( () => {
+ if ( ! selectors.woocommerce.hasCartLoaded( store ) ) {
+ select( storeKey ).getCartData();
+ }
+ } );
+ },
+ }
+);
diff --git a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/button/style.scss b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/button/style.scss
index e0b95ed385e..6bcdaaf834d 100644
--- a/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/button/style.scss
+++ b/plugins/woocommerce-blocks/assets/js/atomic/blocks/product-elements/button/style.scss
@@ -1,15 +1,78 @@
.wp-block-button.wc-block-components-product-button {
word-break: break-word;
white-space: normal;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: $gap-small;
+
+ .wp-block-button__link {
+ word-break: break-word;
+ white-space: normal;
+ display: inline-flex;
+ justify-content: center;
+ text-align: center;
+ // Set button font size and padding so it inherits from parent.
+ padding: 0.5em 1em;
+ font-size: 1em;
+
+ &.loading {
+ opacity: 0.25;
+ }
+
+ &.loading::after {
+ font-family: WooCommerce; /* stylelint-disable-line */
+ content: "\e031";
+ animation: spin 2s linear infinite;
+ margin-left: 0.5em;
+ display: inline-block;
+ width: auto;
+ height: auto;
+ }
+ }
+
+ a[hidden] {
+ display: none;
+ }
+
+ @keyframes slideOut {
+ from {
+ transform: translateY(0);
+ }
+ to {
+ transform: translateY(-100%);
+ }
+ }
+
+ @keyframes slideIn {
+ from {
+ transform: translateY(90%);
+ opacity: 0;
+ }
+ to {
+ transform: translate(0);
+ opacity: 1;
+ }
+ }
.wc-block-components-product-button__button {
border-style: none;
display: inline-flex;
justify-content: center;
- margin-right: auto;
- margin-left: auto;
white-space: normal;
word-break: break-word;
+ width: 150px;
+ overflow: hidden;
+
+ span {
+
+ &.wc-block-slide-out {
+ animation: slideOut 0.1s linear 1 normal forwards;
+ }
+ &.wc-block-slide-in {
+ animation: slideIn 0.1s linear 1 normal;
+ }
+ }
}
.wc-block-components-product-button__button--placeholder {
diff --git a/plugins/woocommerce-blocks/assets/js/interactivity/index.js b/plugins/woocommerce-blocks/assets/js/interactivity/index.js
index 67d56e8e9cb..00008625110 100644
--- a/plugins/woocommerce-blocks/assets/js/interactivity/index.js
+++ b/plugins/woocommerce-blocks/assets/js/interactivity/index.js
@@ -1,7 +1,9 @@
import registerDirectives from './directives';
import { init } from './router';
-export { store } from './store';
+import { rawStore, afterLoads } from './store';
+
export { navigate } from './router';
+export { store } from './store';
/**
* Initialize the Interactivity API.
@@ -9,6 +11,7 @@ export { navigate } from './router';
document.addEventListener( 'DOMContentLoaded', async () => {
registerDirectives();
await init();
+ afterLoads.forEach( ( afterLoad ) => afterLoad( rawStore ) );
// eslint-disable-next-line no-console
console.log( 'Interactivity API started' );
} );
diff --git a/plugins/woocommerce-blocks/assets/js/interactivity/store.js b/plugins/woocommerce-blocks/assets/js/interactivity/store.js
index d00308f8012..de5c59db069 100644
--- a/plugins/woocommerce-blocks/assets/js/interactivity/store.js
+++ b/plugins/woocommerce-blocks/assets/js/interactivity/store.js
@@ -32,12 +32,15 @@ const getSerializedState = () => {
return {};
};
+export const afterLoads = new Set();
+
const rawState = getSerializedState();
export const rawStore = { state: deepSignal( rawState ) };
if ( typeof window !== 'undefined' ) window.store = rawStore;
-export const store = ( { state, ...block } ) => {
+export const store = ( { state, ...block }, { afterLoad } = {} ) => {
deepMerge( rawStore, block );
deepMerge( rawState, state );
+ if ( afterLoad ) afterLoads.add( afterLoad );
};
diff --git a/plugins/woocommerce-blocks/bin/webpack-entries.js b/plugins/woocommerce-blocks/bin/webpack-entries.js
index 52dff1b8072..0d92167f615 100644
--- a/plugins/woocommerce-blocks/bin/webpack-entries.js
+++ b/plugins/woocommerce-blocks/bin/webpack-entries.js
@@ -161,6 +161,8 @@ const entries = {
...getBlockEntries( 'frontend.{t,j}s{,x}' ),
'mini-cart-component':
'./assets/js/blocks/mini-cart/component-frontend.tsx',
+ 'product-button-interactivity':
+ './assets/js/atomic/blocks/product-elements/button/frontend.tsx',
},
payments: {
'wc-payment-method-cheque':
diff --git a/plugins/woocommerce-blocks/package-lock.json b/plugins/woocommerce-blocks/package-lock.json
index 3d8550d049f..351f91268d4 100644
--- a/plugins/woocommerce-blocks/package-lock.json
+++ b/plugins/woocommerce-blocks/package-lock.json
@@ -32,7 +32,7 @@
"compare-versions": "4.1.3",
"config": "3.3.7",
"dataloader": "2.1.0",
- "deepsignal": "^1.1.0",
+ "deepsignal": "1.3.6",
"dinero.js": "1.9.1",
"dompurify": "^2.4.0",
"downshift": "6.1.7",
@@ -25874,15 +25874,28 @@
}
},
"node_modules/deepsignal": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/deepsignal/-/deepsignal-1.1.1.tgz",
- "integrity": "sha512-ZcRi5KogA7hQiJcnUDGrDno7OCwfyBdcP96yf4BHF3JsUc8SRt3w24RZX6L0Ejijnol8Jt5cV5X8h0dotixHHA==",
- "dependencies": {
- "@preact/signals": "^1.0.0",
- "@preact/signals-core": "^1.0.0"
- },
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/deepsignal/-/deepsignal-1.3.6.tgz",
+ "integrity": "sha512-yjd+vtiznL6YaMptOsKnEKkPr60OEApa+LRe+Qe6Ile/RfCOrELKk/YM3qVpXFZiyOI3Ng67GDEyjAlqVc697g==",
"peerDependencies": {
- "preact": "10.x"
+ "@preact/signals": "^1.1.4",
+ "@preact/signals-core": "^1.3.1",
+ "@preact/signals-react": "^1.3.3",
+ "preact": "^10.16.0"
+ },
+ "peerDependenciesMeta": {
+ "@preact/signals": {
+ "optional": true
+ },
+ "@preact/signals-core": {
+ "optional": true
+ },
+ "@preact/signals-react": {
+ "optional": true
+ },
+ "preact": {
+ "optional": true
+ }
}
},
"node_modules/default-browser-id": {
@@ -43935,9 +43948,9 @@
"dev": true
},
"node_modules/preact": {
- "version": "10.11.3",
- "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz",
- "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==",
+ "version": "10.16.0",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-10.16.0.tgz",
+ "integrity": "sha512-XTSj3dJ4roKIC93pald6rWuB2qQJO9gO2iLLyTe87MrjQN+HklueLsmskbywEWqCHlclgz3/M4YLL2iBr9UmMA==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -73591,13 +73604,10 @@
"dev": true
},
"deepsignal": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/deepsignal/-/deepsignal-1.1.1.tgz",
- "integrity": "sha512-ZcRi5KogA7hQiJcnUDGrDno7OCwfyBdcP96yf4BHF3JsUc8SRt3w24RZX6L0Ejijnol8Jt5cV5X8h0dotixHHA==",
- "requires": {
- "@preact/signals": "^1.0.0",
- "@preact/signals-core": "^1.0.0"
- }
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/deepsignal/-/deepsignal-1.3.6.tgz",
+ "integrity": "sha512-yjd+vtiznL6YaMptOsKnEKkPr60OEApa+LRe+Qe6Ile/RfCOrELKk/YM3qVpXFZiyOI3Ng67GDEyjAlqVc697g==",
+ "requires": {}
},
"default-browser-id": {
"version": "1.0.4",
@@ -87596,9 +87606,9 @@
"dev": true
},
"preact": {
- "version": "10.11.3",
- "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz",
- "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg=="
+ "version": "10.16.0",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-10.16.0.tgz",
+ "integrity": "sha512-XTSj3dJ4roKIC93pald6rWuB2qQJO9gO2iLLyTe87MrjQN+HklueLsmskbywEWqCHlclgz3/M4YLL2iBr9UmMA=="
},
"prelude-ls": {
"version": "1.2.1",
diff --git a/plugins/woocommerce-blocks/package.json b/plugins/woocommerce-blocks/package.json
index 520f4003de3..f4da41e4d29 100644
--- a/plugins/woocommerce-blocks/package.json
+++ b/plugins/woocommerce-blocks/package.json
@@ -267,7 +267,7 @@
"compare-versions": "4.1.3",
"config": "3.3.7",
"dataloader": "2.1.0",
- "deepsignal": "^1.1.0",
+ "deepsignal": "1.3.6",
"dinero.js": "1.9.1",
"dompurify": "^2.4.0",
"downshift": "6.1.7",
diff --git a/plugins/woocommerce-blocks/src/BlockTypes/ProductButton.php b/plugins/woocommerce-blocks/src/BlockTypes/ProductButton.php
index a7e80073299..7787195ece3 100644
--- a/plugins/woocommerce-blocks/src/BlockTypes/ProductButton.php
+++ b/plugins/woocommerce-blocks/src/BlockTypes/ProductButton.php
@@ -15,11 +15,20 @@ class ProductButton extends AbstractBlock {
*/
protected $block_name = 'product-button';
+
/**
- * It is necessary to register and enqueue assets during the render phase because we want to load assets only if the block has the content.
+ * Get the frontend script handle for this block type.
+ *
+ * @param string $key Data to get, or default to everything.
*/
- protected function register_block_type_assets() {
- return null;
+ protected function get_block_type_script( $key = null ) {
+ $script = [
+ 'handle' => 'wc-' . $this->block_name . '-interactivity-frontend',
+ 'path' => $this->asset_api->get_block_asset_build_path( $this->block_name . '-interactivity-frontend' ),
+ 'dependencies' => [ 'wc-interactivity' ],
+ ];
+
+ return $key ? $script[ $key ] : $script;
}
/**
@@ -29,6 +38,32 @@ class ProductButton extends AbstractBlock {
return [ 'query', 'queryId', 'postId' ];
}
+ /**
+ * Enqueue frontend assets for this block, just in time for rendering.
+ *
+ * @param array $attributes Any attributes that currently are available from the block.
+ */
+ protected function enqueue_assets( array $attributes ) {
+ parent::enqueue_assets( $attributes );
+ if ( wc_current_theme_is_fse_theme() ) {
+ add_action(
+ 'wp_enqueue_scripts',
+ array( $this, 'dequeue_add_to_cart_scripts' )
+ );
+ } else {
+ $this->dequeue_add_to_cart_scripts();
+ }
+ }
+
+ /**
+ * Dequeue the add-to-cart script.
+ * The block uses Interactivity API, it isn't necessary enqueue the add-to-cart script.
+ */
+ public function dequeue_add_to_cart_scripts() {
+ wp_dequeue_script( 'wc-add-to-cart' );
+ wp_dequeue_script( 'wc-add-to-cart-variation' );
+ }
+
/**
* Include and render the block.
*
@@ -42,6 +77,13 @@ class ProductButton extends AbstractBlock {
$product = wc_get_product( $post_id );
if ( $product ) {
+ $number_of_items_in_cart = $this->get_cart_item_quantities_by_product_id( $product->get_id() );
+ $more_than_one_item = $number_of_items_in_cart > 0;
+ $initial_product_text = $more_than_one_item ? sprintf(
+ /* translators: %s: product number. */
+ __( '%s in cart', 'woo-gutenberg-products-block' ),
+ $number_of_items_in_cart
+ ) : $product->add_to_cart_text();
$cart_redirect_after_add = get_option( 'woocommerce_cart_redirect_after_add' ) === 'yes';
$ajax_add_to_cart_enabled = get_option( 'woocommerce_enable_ajax_add_to_cart' ) === 'yes';
$is_ajax_button = $ajax_add_to_cart_enabled && ! $cart_redirect_after_add && $product->supports( 'ajax_add_to_cart' ) && $product->is_purchasable() && $product->is_in_stock();
@@ -64,6 +106,40 @@ class ProductButton extends AbstractBlock {
)
)
);
+
+ wc_store(
+ array(
+ 'state' => array(
+ 'woocommerce' => array(
+ 'inTheCartText' => sprintf(
+ /* translators: %s: product number. */
+ __( '%s in cart', 'woo-gutenberg-products-block' ),
+ '###'
+ ),
+ ),
+ ),
+ )
+ );
+
+ $default_quantity = 1;
+
+ $context = array(
+ 'woocommerce' => array(
+ /**
+ * Filters the change the quantity to add to cart.
+ *
+ * @since $VID:$
+ * @param number $default_quantity The default quantity.
+ * @param number $product_id The product id.
+ */
+ 'quantityToAdd' => apply_filters( 'woocommerce_add_to_cart_quantity', $default_quantity, $product->get_id() ),
+ 'productId' => $product->get_id(),
+ 'addToCartText' => null !== $product->add_to_cart_text() ? $product->add_to_cart_text() : __( 'Add to cart', 'woo-gutenberg-products-block' ),
+ 'temporaryNumberOfItems' => $number_of_items_in_cart,
+ 'animationStatus' => 'IDLE',
+ ),
+ );
+
/**
* Allow filtering of the add to cart button arguments.
*
@@ -87,6 +163,25 @@ class ProductButton extends AbstractBlock {
$args['attributes']['aria-label'] = wp_strip_all_tags( $args['attributes']['aria-label'] );
}
+ if ( isset( WC()->cart ) && ! WC()->cart->is_empty() ) {
+ $this->prevent_cache();
+ }
+
+ $div_directives = 'data-wc-context=\'' . wp_json_encode( $context, JSON_NUMERIC_CHECK ) . '\'';
+
+ $button_directives = '
+ data-wc-on--click="actions.woocommerce.addToCart"
+ data-wc-class--loading="context.woocommerce.isLoading"
+ ';
+
+ $span_button_directives = '
+ data-wc-text="selectors.woocommerce.addToCartText"
+ data-wc-class--wc-block-slide-in="selectors.woocommerce.slideInAnimation"
+ data-wc-class--wc-block-slide-out="selectors.woocommerce.slideOutAnimation"
+ data-wc-effect="effects.woocommerce.startAnimation"
+ data-wc-on--animationend="actions.woocommerce.handleAnimationEnd"
+ ';
+
/**
* Filters the add to cart button class.
*
@@ -96,22 +191,83 @@ class ProductButton extends AbstractBlock {
*/
return apply_filters(
'woocommerce_loop_add_to_cart_link',
- sprintf(
- '
- <%3$s href="%4$s" class="%5$s" style="%6$s" %7$s>%8$s%3$s>
+ strtr(
+ '
+ <{html_element}
+ href="{add_to_cart_url}"
+ class="{button_classes}"
+ style="{button_styles}"
+ {attributes}
+ {button_directives}
+ >
+ {add_to_cart_text}
+ {html_element}>
+ {view_cart_html}
',
- esc_attr( $text_align_styles_and_classes['class'] ?? '' ),
- esc_attr( $classname . ' ' . $custom_width_classes ),
- $html_element,
- esc_url( $product->add_to_cart_url() ),
- isset( $args['class'] ) ? esc_attr( $args['class'] ) : '',
- esc_attr( $styles_and_classes['styles'] ),
- isset( $args['attributes'] ) ? wc_implode_html_attributes( $args['attributes'] ) : '',
- esc_html( $product->add_to_cart_text() )
+ array(
+ '{classes}' => esc_attr( $text_align_styles_and_classes['class'] ?? '' ),
+ '{custom_classes}' => esc_attr( $classname . ' ' . $custom_width_classes ),
+ '{html_element}' => $html_element,
+ '{add_to_cart_url}' => esc_url( $product->add_to_cart_url() ),
+ '{button_classes}' => isset( $args['class'] ) ? esc_attr( $args['class'] ) : '',
+ '{button_styles}' => esc_attr( $styles_and_classes['styles'] ),
+ '{attributes}' => isset( $args['attributes'] ) ? wc_implode_html_attributes( $args['attributes'] ) : '',
+ '{add_to_cart_text}' => esc_html( $initial_product_text ),
+ '{div_directives}' => $is_ajax_button ? $div_directives : '',
+ '{button_directives}' => $is_ajax_button ? $button_directives : '',
+ '{span_button_directives}' => $is_ajax_button ? $span_button_directives : '',
+ '{view_cart_html}' => $is_ajax_button ? $this->get_view_cart_html() : '',
+ )
),
$product,
$args
);
}
}
+
+ /**
+ * Get the number of items in the cart for a given product id.
+ *
+ * @param number $product_id The product id.
+ * @return number The number of items in the cart.
+ */
+ private function get_cart_item_quantities_by_product_id( $product_id ) {
+ if ( ! isset( WC()->cart ) ) {
+ return 0;
+ }
+
+ $cart = WC()->cart->get_cart_item_quantities();
+ return isset( $cart[ $product_id ] ) ? $cart[ $product_id ] : 0;
+ }
+
+ /**
+ * Prevent caching on certain pages
+ */
+ private function prevent_cache() {
+ \WC_Cache_Helper::set_nocache_constants();
+ nocache_headers();
+ }
+
+ /**
+ * Get the view cart link html.
+ *
+ * @return string The view cart html.
+ */
+ private function get_view_cart_html() {
+ return sprintf(
+ '
+
+ %2$s
+
+ ',
+ wc_get_cart_url(),
+ __( 'View cart', 'woo-gutenberg-products-block' )
+ );
+ }
}
diff --git a/plugins/woocommerce-blocks/tests/e2e/mocks/custom-plugins/index.ts b/plugins/woocommerce-blocks/tests/e2e/mocks/custom-plugins/index.ts
new file mode 100644
index 00000000000..04bca77e0de
--- /dev/null
+++ b/plugins/woocommerce-blocks/tests/e2e/mocks/custom-plugins/index.ts
@@ -0,0 +1 @@
+export * from './utils';
diff --git a/plugins/woocommerce-blocks/tests/e2e/mocks/custom-plugins/readme.md b/plugins/woocommerce-blocks/tests/e2e/mocks/custom-plugins/readme.md
new file mode 100644
index 00000000000..f37b4045c09
--- /dev/null
+++ b/plugins/woocommerce-blocks/tests/e2e/mocks/custom-plugins/readme.md
@@ -0,0 +1,70 @@
+# Testing WordPress actions and filters
+
+This documentation covers testing WordPress actions and filters when writing Playwright tests.
+
+## Table of Contents
+
+- [Description](#description)
+- [Usage](#usage)
+
+## Description
+
+You can test an action or filter (change) by creating a custom WordPress plugin, installing it and when you are done, removing it.
+
+The 3 functions responsible are located inside of `tests/e2e-pw/mocks/custom-plugins/utils.ts`:
+
+- `createPluginFromPHPFile()`
+- `installPluginFromPHPFile()`
+- `uninstallPluginFromPHPFile()`
+
+## Usage
+
+### Example: Testing a custom Add to Cart text
+
+1. Create the custom plugin file.
+
+`update-product-button-text.php`.
+
+```php
+ {
+ await installPluginFromPHPFile(
+ `${ __dirname }/update-product-button-text.php`
+ );
+ await frontendUtils.goToShop();
+ const blocks = await frontendUtils.getBlockByName( blockData.name );
+ const buttonWithNewText = await blocks.getByText( 'Buy Now' ).count();
+
+ const productsDisplayed = 16;
+ expect( buttonWithNewText ).toEqual( productsDisplayed );
+} );
+```
+
+3. Remove the plugin when done testing.
+
+```javascript
+test.afterAll( async () => {
+ await uninstallPluginFromPHPFile(
+ `${ __dirname }/update-product-button-text.php`
+ );
+} );
+```
+
+In the above example, the test checks whether the filter `woocommerce_product_add_to_cart_text` is applied correctly. It installs the "Custom Add to Cart Text" plugin, navigates to the shop page using `frontendUtils`, and verifies if the "Buy Now" button text appears as expected. Finally, it cleans the cart and uninstalls the plugin.
+
+You can adapt this example to test other filters and actions by modifying the code accordingly.
diff --git a/plugins/woocommerce-blocks/tests/e2e/mocks/custom-plugins/utils.ts b/plugins/woocommerce-blocks/tests/e2e/mocks/custom-plugins/utils.ts
new file mode 100644
index 00000000000..f63177c9998
--- /dev/null
+++ b/plugins/woocommerce-blocks/tests/e2e/mocks/custom-plugins/utils.ts
@@ -0,0 +1,31 @@
+/**
+ * External dependencies
+ */
+import { cli } from '@woocommerce/e2e-utils';
+import path from 'path';
+
+const createPluginFromPHPFile = async ( phpFilePath: string ) => {
+ const absolutePath = path.resolve( phpFilePath );
+ const directory = path.dirname( absolutePath );
+ const fileName = path.basename( phpFilePath );
+ const fileNameZip = fileName.replace( '.php', '' );
+ await cli(
+ `cd ${ directory } && zip ${ fileNameZip }.zip ${ fileName } && mv ${ fileNameZip }.zip ${ __dirname }`
+ );
+};
+
+export const installPluginFromPHPFile = async ( phpFilePath: string ) => {
+ await createPluginFromPHPFile( phpFilePath );
+ const fileName = path.basename( phpFilePath ).replace( '.php', '' );
+ await cli(
+ `npm run wp-env run tests-cli "wp plugin install /var/www/html/custom-plugins/${ fileName }.zip --activate"`
+ );
+};
+
+export const uninstallPluginFromPHPFile = async ( phpFilePath: string ) => {
+ const fileName = path.basename( phpFilePath ).replace( '.php', '' );
+ await cli(
+ `npm run wp-env run tests-cli "wp plugin delete ${ fileName }"`
+ );
+ await cli( `rm ${ __dirname }/${ fileName }.zip` );
+};
diff --git a/plugins/woocommerce-blocks/tests/e2e/playwright-utils/test.ts b/plugins/woocommerce-blocks/tests/e2e/playwright-utils/test.ts
index 7e0f10afa52..93fed08c81b 100644
--- a/plugins/woocommerce-blocks/tests/e2e/playwright-utils/test.ts
+++ b/plugins/woocommerce-blocks/tests/e2e/playwright-utils/test.ts
@@ -15,6 +15,7 @@ import {
STORAGE_STATE_PATH,
EditorUtils,
FrontendUtils,
+ StoreApiUtils,
} from '@woocommerce/e2e-utils';
/**
@@ -107,6 +108,7 @@ const test = base.extend<
templateApiUtils: TemplateApiUtils;
editorUtils: EditorUtils;
frontendUtils: FrontendUtils;
+ storeApiUtils: StoreApiUtils;
snapshotConfig: void;
},
{
@@ -142,6 +144,9 @@ const test = base.extend<
frontendUtils: async ( { page }, use ) => {
await use( new FrontendUtils( page ) );
},
+ storeApiUtils: async ( { requestUtils }, use ) => {
+ await use( new StoreApiUtils( requestUtils ) );
+ },
requestUtils: [
async ( {}, use, workerInfo ) => {
const requestUtils = await RequestUtils.setup( {
diff --git a/plugins/woocommerce-blocks/tests/e2e/tests/product-button/product-button.block_theme.side_effects.spec.ts b/plugins/woocommerce-blocks/tests/e2e/tests/product-button/product-button.block_theme.side_effects.spec.ts
new file mode 100644
index 00000000000..2c13ce636c1
--- /dev/null
+++ b/plugins/woocommerce-blocks/tests/e2e/tests/product-button/product-button.block_theme.side_effects.spec.ts
@@ -0,0 +1,127 @@
+/**
+ * External dependencies
+ */
+import { expect, test } from '@woocommerce/e2e-playwright-utils';
+import {
+ installPluginFromPHPFile,
+ uninstallPluginFromPHPFile,
+} from '@woocommerce/e2e-mocks/custom-plugins';
+
+/**
+ * Internal dependencies
+ */
+import { blockData, handleAddToCartAjaxSetting } from './utils';
+
+test.describe( `${ blockData.name } Block`, () => {
+ test.beforeEach( async ( { frontendUtils, storeApiUtils } ) => {
+ await storeApiUtils.cleanCart();
+ await frontendUtils.goToShop();
+ } );
+
+ test( 'should be visible', async ( { frontendUtils } ) => {
+ const blocks = await frontendUtils.getBlockByName( blockData.slug );
+ await expect( blocks ).toHaveCount(
+ blockData.selectors.frontend.productsToDisplay
+ );
+ } );
+ test( 'should add product to the cart', async ( {
+ frontendUtils,
+ page,
+ } ) => {
+ const blocks = await frontendUtils.getBlockByName( blockData.slug );
+ const block = blocks.first();
+
+ const productId = await block
+ .locator( '[data-product_id]' )
+ .getAttribute( 'data-product_id' );
+
+ const productName = await page
+ .locator( `li.post-${ productId } h3` )
+ .textContent();
+
+ // We want to fail the test if the product name is not found.
+ // eslint-disable-next-line playwright/no-conditional-in-test
+ if ( ! productName ) {
+ return test.fail( ! productName, 'Product name was not found' );
+ }
+
+ await block.locator( 'loading' ).waitFor( {
+ state: 'detached',
+ } );
+ await block.click();
+ await expect( block.getByRole( 'button' ) ).toHaveText( '1 in cart' );
+ await expect( block.getByRole( 'link' ) ).toBeVisible();
+
+ await frontendUtils.goToCheckout();
+ const productElement = page.getByText( productName, {
+ exact: true,
+ } );
+ await expect( productElement ).toBeVisible();
+ } );
+
+ test( 'should add product to the cart - with ajax disabled', async ( {
+ frontendUtils,
+ page,
+ admin,
+ } ) => {
+ await handleAddToCartAjaxSetting( admin, page, {
+ isChecked: true,
+ } );
+ await frontendUtils.goToShop();
+
+ const blocks = await frontendUtils.getBlockByName( blockData.slug );
+ const block = blocks.first();
+ const button = block.getByRole( 'link' );
+
+ const productId = await button.getAttribute( 'data-product_id' );
+
+ const productName = await page
+ .locator( `li.post-${ productId } h3` )
+ .textContent();
+
+ // We want to fail the test if the product name is not found.
+ // eslint-disable-next-line playwright/no-conditional-in-test
+ if ( ! productName ) {
+ return test.fail( ! productName, 'Product name was not found' );
+ }
+
+ await block.click();
+
+ await expect(
+ page.locator( `a[href*="cart=${ productId }"]` )
+ ).toBeVisible();
+
+ await frontendUtils.goToCheckout();
+
+ const productElement = page.getByText( productName, {
+ exact: true,
+ } );
+
+ await expect( productElement ).toBeVisible();
+
+ await handleAddToCartAjaxSetting( admin, page, {
+ isChecked: false,
+ } );
+ } );
+
+ test( 'the filter `woocommerce_product_add_to_cart_text` should be applied', async ( {
+ frontendUtils,
+ } ) => {
+ await installPluginFromPHPFile(
+ `${ __dirname }/update-product-button-text.php`
+ );
+ await frontendUtils.goToShop();
+ const blocks = await frontendUtils.getBlockByName( blockData.slug );
+ const buttonWithNewText = blocks.getByText( 'Buy Now' );
+ await expect( buttonWithNewText ).toHaveCount(
+ blockData.selectors.frontend.productsToDisplay
+ );
+ } );
+
+ test.afterAll( async ( { storeApiUtils } ) => {
+ await storeApiUtils.cleanCart();
+ await uninstallPluginFromPHPFile(
+ `${ __dirname }/update-product-button-text.php`
+ );
+ } );
+} );
diff --git a/plugins/woocommerce-blocks/tests/e2e/tests/product-button/update-product-button-text.php b/plugins/woocommerce-blocks/tests/e2e/tests/product-button/update-product-button-text.php
new file mode 100644
index 00000000000..37e44f2f208
--- /dev/null
+++ b/plugins/woocommerce-blocks/tests/e2e/tests/product-button/update-product-button-text.php
@@ -0,0 +1,18 @@
+ {
+ await admin.page.goto( 'wp-admin/admin.php?page=wc-settings&tab=products' );
+ await page
+ .getByRole( 'checkbox', {
+ name: 'Enable AJAX add to cart buttons on archives',
+ checked: isChecked,
+ } )
+ .click();
+
+ await page
+ .getByRole( 'button', {
+ name: 'save',
+ } )
+ .click();
+};
diff --git a/plugins/woocommerce-blocks/tests/e2e/utils/api/index.ts b/plugins/woocommerce-blocks/tests/e2e/utils/api/index.ts
index b1a3cbc357b..9c1e96b0294 100644
--- a/plugins/woocommerce-blocks/tests/e2e/utils/api/index.ts
+++ b/plugins/woocommerce-blocks/tests/e2e/utils/api/index.ts
@@ -1 +1 @@
-export * from './TemplateApiUtils';
+export * from './template-api-utils.page';
diff --git a/plugins/woocommerce-blocks/tests/e2e/utils/api/TemplateApiUtils.ts b/plugins/woocommerce-blocks/tests/e2e/utils/api/template-api-utils.page.ts
similarity index 100%
rename from plugins/woocommerce-blocks/tests/e2e/utils/api/TemplateApiUtils.ts
rename to plugins/woocommerce-blocks/tests/e2e/utils/api/template-api-utils.page.ts
diff --git a/plugins/woocommerce-blocks/tests/e2e/utils/editor/EditorUtils.ts b/plugins/woocommerce-blocks/tests/e2e/utils/editor/editor-utils.page.ts
similarity index 100%
rename from plugins/woocommerce-blocks/tests/e2e/utils/editor/EditorUtils.ts
rename to plugins/woocommerce-blocks/tests/e2e/utils/editor/editor-utils.page.ts
diff --git a/plugins/woocommerce-blocks/tests/e2e/utils/editor/index.ts b/plugins/woocommerce-blocks/tests/e2e/utils/editor/index.ts
index 9016ba8813c..015f175c5c8 100644
--- a/plugins/woocommerce-blocks/tests/e2e/utils/editor/index.ts
+++ b/plugins/woocommerce-blocks/tests/e2e/utils/editor/index.ts
@@ -1 +1 @@
-export * from './EditorUtils';
+export * from './editor-utils.page';
diff --git a/plugins/woocommerce-blocks/tests/e2e/utils/frontend/FrontendUtils.ts b/plugins/woocommerce-blocks/tests/e2e/utils/frontend/frontend-utils.page.ts
similarity index 95%
rename from plugins/woocommerce-blocks/tests/e2e/utils/frontend/FrontendUtils.ts
rename to plugins/woocommerce-blocks/tests/e2e/utils/frontend/frontend-utils.page.ts
index 1454a5fce1f..45b6ff2fd82 100644
--- a/plugins/woocommerce-blocks/tests/e2e/utils/frontend/FrontendUtils.ts
+++ b/plugins/woocommerce-blocks/tests/e2e/utils/frontend/frontend-utils.page.ts
@@ -44,6 +44,12 @@ export class FrontendUtils {
} );
}
+ async goToCheckout() {
+ await this.page.goto( '/checkout', {
+ waitUntil: 'commit',
+ } );
+ }
+
async isBlockEarlierThan< T >(
containerBlock: T,
firstBlock: string,
diff --git a/plugins/woocommerce-blocks/tests/e2e/utils/frontend/index.ts b/plugins/woocommerce-blocks/tests/e2e/utils/frontend/index.ts
index 67f31ea2b68..a7b5d426561 100644
--- a/plugins/woocommerce-blocks/tests/e2e/utils/frontend/index.ts
+++ b/plugins/woocommerce-blocks/tests/e2e/utils/frontend/index.ts
@@ -1 +1 @@
-export * from './FrontendUtils';
+export * from './frontend-utils.page';
diff --git a/plugins/woocommerce-blocks/tests/e2e/utils/index.ts b/plugins/woocommerce-blocks/tests/e2e/utils/index.ts
index 6b13546eeea..2183b2aab57 100644
--- a/plugins/woocommerce-blocks/tests/e2e/utils/index.ts
+++ b/plugins/woocommerce-blocks/tests/e2e/utils/index.ts
@@ -4,3 +4,4 @@ export * from './use-block-theme';
export * from './cli';
export * from './api';
export * from './editor';
+export * from './storeApi';
diff --git a/plugins/woocommerce-blocks/tests/e2e/utils/storeApi/index.ts b/plugins/woocommerce-blocks/tests/e2e/utils/storeApi/index.ts
new file mode 100644
index 00000000000..dccbd4a36e5
--- /dev/null
+++ b/plugins/woocommerce-blocks/tests/e2e/utils/storeApi/index.ts
@@ -0,0 +1 @@
+export * from './store-api-utils.page';
diff --git a/plugins/woocommerce-blocks/tests/e2e/utils/storeApi/store-api-utils.page.ts b/plugins/woocommerce-blocks/tests/e2e/utils/storeApi/store-api-utils.page.ts
new file mode 100644
index 00000000000..70477879a24
--- /dev/null
+++ b/plugins/woocommerce-blocks/tests/e2e/utils/storeApi/store-api-utils.page.ts
@@ -0,0 +1,35 @@
+/**
+ * External dependencies
+ */
+
+import { RequestUtils } from '@wordpress/e2e-test-utils-playwright';
+
+/**
+ * Internal dependencies
+ */
+
+export class StoreApiUtils {
+ private requestUtils: RequestUtils;
+
+ constructor( requestUtils: RequestUtils ) {
+ this.requestUtils = requestUtils;
+ }
+
+ // @todo: It is necessary work to a middleware to avoid this kind of code.
+ async cleanCart() {
+ const response = await this.requestUtils.request.get(
+ '/wp-json/wc/store/cart'
+ );
+
+ const { nonce } = response.headers();
+
+ await this.requestUtils.request.delete(
+ `/wp-json/wc/store/v1/cart/items`,
+ {
+ headers: {
+ nonce,
+ },
+ }
+ );
+ }
+}
diff --git a/plugins/woocommerce-blocks/tests/js/jest.config.json b/plugins/woocommerce-blocks/tests/js/jest.config.json
index 6c6d4be5889..037aa863206 100644
--- a/plugins/woocommerce-blocks/tests/js/jest.config.json
+++ b/plugins/woocommerce-blocks/tests/js/jest.config.json
@@ -29,7 +29,8 @@
"@woocommerce/shared-hocs": "assets/js/shared/hocs",
"@woocommerce/blocks-test-utils": "tests/utils",
"@woocommerce/types": "assets/js/types",
- "@woocommerce/utils": "assets/js/utils"
+ "@woocommerce/utils": "assets/js/utils",
+ "@woocommerce/interactivity": "assets/js/interactivity"
},
"setupFiles": [
"@wordpress/jest-preset-default/scripts/setup-globals.js",
diff --git a/plugins/woocommerce-blocks/tsconfig.base.json b/plugins/woocommerce-blocks/tsconfig.base.json
index 156236dc9cf..9eac14c0f72 100644
--- a/plugins/woocommerce-blocks/tsconfig.base.json
+++ b/plugins/woocommerce-blocks/tsconfig.base.json
@@ -65,6 +65,7 @@
"@woocommerce/e2e-utils": [ "tests/e2e/utils" ],
"@woocommerce/e2e-types": [ "tests/e2e/types" ],
"@woocommerce/e2e-playwright-utils": [ "tests/e2e/playwright-utils" ],
+ "@woocommerce/e2e-mocks/*": [ "tests/e2e/mocks/*" ],
"@woocommerce/templates/*": [ "assets/js/templates/*" ]
}
}