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 + strtr( + '
+ <{html_element} + href="{add_to_cart_url}" + class="{button_classes}" + style="{button_styles}" + {attributes} + {button_directives} + > + {add_to_cart_text} + + {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( + '', + 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/*" ] } }