From ec5d9e97245acdc6bd086d6d4a800bca8c86f70b Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Tue, 26 Sep 2023 10:52:46 +0200 Subject: [PATCH 01/25] Refactor Product Button with new store() API --- .../product-elements/button/frontend.tsx | 395 +++++++----------- src/BlockTypes/ProductButton.php | 82 ++-- 2 files changed, 199 insertions(+), 278 deletions(-) diff --git a/assets/js/atomic/blocks/product-elements/button/frontend.tsx b/assets/js/atomic/blocks/product-elements/button/frontend.tsx index ff757565e2f..73d20638660 100644 --- a/assets/js/atomic/blocks/product-elements/button/frontend.tsx +++ b/assets/js/atomic/blocks/product-elements/button/frontend.tsx @@ -1,25 +1,22 @@ -/* 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 { store, getContext as getContextFn } from '@woocommerce/interactivity'; import { dispatch, select, subscribe } from '@wordpress/data'; +import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data'; import { Cart } from '@woocommerce/type-defs/cart'; -import { createRoot } from '@wordpress/element'; -import NoticeBanner from '@woocommerce/base-components/notice-banner'; +// 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; - }; -}; +interface Context { + isLoading: boolean; + addToCartText: string; + productId: number; + displayViewCart: boolean; + quantityToAdd: number; + temporaryNumberOfItems: number; + animationStatus: AnimationStatus; +} enum AnimationStatus { IDLE = 'IDLE', @@ -27,267 +24,181 @@ enum AnimationStatus { SLIDE_IN = 'SLIDE-IN', } -type State = { - woocommerce: { - cart: Cart | undefined; - inTheCartText: string; +interface Store { + state: { + cart?: Cart; + 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 } - - ); + selectors: { + numberOfItemsInTheCart: number; + hasCartLoaded: boolean; + slideInAnimation: boolean; + slideOutAnimation: boolean; + addToCartText: string; + displayViewCart: boolean; + }; + actions: { + addToCart: () => void; + handleAnimationEnd: ( event: AnimationEvent ) => void; + }; + callbacks: { + startAnimation: () => void; + syncTemporaryNumberOfItemsOnLoad: () => void; + }; +} - domNode?.scrollIntoView( { - behavior: 'smooth', - inline: 'nearest', - } ); -}; +const getContext = () => getContextFn< Context >( 'woo' ); 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 getTextButton = ( + addToCart: string, + inTheCart: string, + numberOfItems: number +): string => { + if ( numberOfItems === 0 ) return addToCart; + return inTheCart.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; +const { state, selectors } = store< Store >( + 'woo', + { + state: { + inTheCartText: '### in cart', // TODO replace with SSR version }, - numberOfItemsInTheCart: ( { state, context }: Store ) => { - const product = getProductById( - state.woocommerce.cart, - context.woocommerce.productId - ); - return product?.quantity || 0; + selectors: { + get numberOfItemsInTheCart() { + const { productId } = getContext(); + const product = getProductById( state.cart, productId ); + return product?.quantity || 0; + }, + get hasCartLoaded(): boolean { + return !! state.cart; + }, + get slideInAnimation() { + const { animationStatus } = getContext(); + return animationStatus === AnimationStatus.SLIDE_IN; + }, + get slideOutAnimation() { + const { animationStatus } = getContext(); + return animationStatus === AnimationStatus.SLIDE_OUT; + }, + get addToCartText(): string { + const context = getContext(); + // We use the temporary number of items when there's no animation, or the + // second part of the animation hasn't started. + if ( + context.animationStatus === AnimationStatus.IDLE || + context.animationStatus === AnimationStatus.SLIDE_OUT + ) { + return getTextButton( + context.addToCartText, + state.inTheCartText!, + context.temporaryNumberOfItems + ); + } + return getTextButton( + context.addToCartText, + state.inTheCartText!, + selectors.numberOfItemsInTheCart + ); + }, + get displayViewCart(): boolean { + const context = getContext(); + if ( ! context.displayViewCart ) return false; + if ( ! selectors.hasCartLoaded ) { + return context.temporaryNumberOfItems > 0; + } + return selectors.numberOfItemsInTheCart > 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; - } + addToCart: function* () { + const context = getContext(); + const { productId, quantityToAdd } = context; - context.woocommerce.isLoading = true; + context.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 } + try { + yield dispatch( storeKey ).addItemToCart( + productId, + quantityToAdd ); - 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; - } - }, + // After the cart is updated, sync the temporary number of items again. + context.temporaryNumberOfItems = + selectors.numberOfItemsInTheCart; + } catch ( error ) { + console.error( error ); + } finally { + context.displayViewCart = true; + context.isLoading = false; + } }, - }, - init: { - woocommerce: { - syncTemporaryNumberOfItemsOnLoad: ( store: Store ) => { - const { selectors, context } = store; - // If the cart has loaded when we instantiate this element, we sync - // the temporary number of items with the number of items in the cart - // to avoid triggering the animation. We do this only once, but we - // use useLayoutEffect to avoid the useEffect flickering. - if ( selectors.woocommerce.hasCartLoaded( store ) ) { - context.woocommerce.temporaryNumberOfItems = - selectors.woocommerce.numberOfItemsInTheCart( - store - ); - } - }, + handleAnimationEnd: ( event ) => { + const context = getContext(); + if ( event.animationName === 'slideOut' ) { + // When the first part of the animation (slide-out) ends, we move + // to the second part (slide-in). + context.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.temporaryNumberOfItems = + selectors.numberOfItemsInTheCart; + context.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; - } - }, + callbacks: { + startAnimation: () => { + const context = getContext(); + // 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.hasCartLoaded && + context.temporaryNumberOfItems !== + selectors.numberOfItemsInTheCart && + ! context.isLoading && + context.animationStatus === AnimationStatus.IDLE + ) { + context.animationStatus = AnimationStatus.SLIDE_OUT; + } + }, + syncTemporaryNumberOfItemsOnLoad: () => { + const context = getContext(); + // If the cart has loaded when we instantiate this element, we sync + // the temporary number of items with the number of items in the cart + // to avoid triggering the animation. We do this only once, but we + // use useLayoutEffect to avoid the useEffect flickering. + if ( selectors.hasCartLoaded ) { + context.temporaryNumberOfItems = + selectors.numberOfItemsInTheCart; + } }, }, }, { - afterLoad: ( store: Store ) => { - const { state, selectors } = store; + afterLoad: () => { // Subscribe to changes in Cart data. subscribe( () => { const cartData = select( storeKey ).getCartData(); const isResolutionFinished = select( storeKey ).hasFinishedResolution( 'getCartData' ); if ( isResolutionFinished ) { - state.woocommerce.cart = cartData; + state.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 ) ) { + if ( ! selectors.hasCartLoaded ) { select( storeKey ).getCartData(); } } ); diff --git a/src/BlockTypes/ProductButton.php b/src/BlockTypes/ProductButton.php index 353b722ab49..56d1d167dc4 100644 --- a/src/BlockTypes/ProductButton.php +++ b/src/BlockTypes/ProductButton.php @@ -123,22 +123,21 @@ protected function render( $attributes, $content, $block ) { ); $default_quantity = 1; + /** + * Filters the change the quantity to add to cart. + * + * @since 10.9.0 + * @param number $default_quantity The default quantity. + * @param number $product_id The product id. + */ + $quantity_to_add = apply_filters( 'woocommerce_add_to_cart_quantity', $default_quantity, $product->get_id() ); $context = array( - 'woocommerce' => array( - /** - * Filters the change the quantity to add to cart. - * - * @since 10.9.0 - * @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', - ), + 'quantityToAdd' => $quantity_to_add, + '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', ); /** @@ -168,20 +167,27 @@ protected function render( $attributes, $content, $block ) { $this->prevent_cache(); } - $div_directives = 'data-wc-context=\'' . wp_json_encode( $context, JSON_NUMERIC_CHECK ) . '\''; + $interactive = array( + 'namespace' => 'woo' + ); + + $div_directives = ' + data-wc-interactive=\'' . wp_json_encode( $interactive, JSON_NUMERIC_CHECK ) . '\' + 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" + data-wc-on--click="actions.addToCart" + data-wc-class--loading="context.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-layout-init="init.woocommerce.syncTemporaryNumberOfItemsOnLoad" - data-wc-effect="effects.woocommerce.startAnimation" - data-wc-on--animationend="actions.woocommerce.handleAnimationEnd" + data-wc-text="selectors.addToCartText" + data-wc-class--wc-block-slide-in="selectors.slideInAnimation" + data-wc-class--wc-block-slide-out="selectors.slideOutAnimation" + data-wc-on--animationend="actions.handleAnimationEnd" + data-wc-watch="callbacks.startAnimation" + data-wc-layout-init="callbacks.syncTemporaryNumberOfItemsOnLoad" '; /** @@ -194,20 +200,21 @@ protected function render( $attributes, $content, $block ) { return apply_filters( 'woocommerce_loop_add_to_cart_link', strtr( - '
- <{html_element} - href="{add_to_cart_url}" - class="{button_classes}" - style="{button_styles}" - {attributes} - {button_directives} - > - {add_to_cart_text} - - {view_cart_html} -
', + <{html_element} + href="{add_to_cart_url}" + class="{button_classes}" + style="{button_styles}" + {attributes} + {button_directives} + > + {add_to_cart_text} + + {view_cart_html} + ', array( '{classes}' => esc_attr( $text_align_styles_and_classes['class'] ?? '' ), '{custom_classes}' => esc_attr( $classname . ' ' . $custom_width_classes . ' ' . $custom_align_classes ), @@ -259,7 +266,10 @@ private function prevent_cache() { */ private function get_view_cart_html() { return sprintf( - '