From 4253a5fe10b975abb9bd726d40046885ca1715cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Albert=20Juh=C3=A9=20Lluveras?= Date: Mon, 25 Oct 2021 18:05:01 +0200 Subject: [PATCH] Make Mini Cart block react to removed_from_cart events (https://github.com/woocommerce/woocommerce-blocks/pull/4947) * Make Mini Cart block react to removed_from_cart events * Move listening to add to cart and remove from cart events to the useStoreCart hook * Add tests --- .../cart/use-store-cart-event-listeners.ts | 87 +++++++++++++++++++ .../base/context/hooks/cart/use-store-cart.ts | 6 ++ .../js/blocks/cart-checkout/cart/block.js | 27 +----- .../blocks/cart-checkout/mini-cart/block.tsx | 20 ++--- .../mini-cart/component-frontend.tsx | 4 +- .../cart-checkout/mini-cart/frontend.ts | 42 +++++++-- .../cart-checkout/mini-cart/test/block.js | 85 ++++++++++++++---- .../with-mini-cart-conditional-hydration.tsx | 7 +- .../docs/extensibility/dom-events.md | 4 +- 9 files changed, 214 insertions(+), 68 deletions(-) create mode 100644 plugins/woocommerce-blocks/assets/js/base/context/hooks/cart/use-store-cart-event-listeners.ts diff --git a/plugins/woocommerce-blocks/assets/js/base/context/hooks/cart/use-store-cart-event-listeners.ts b/plugins/woocommerce-blocks/assets/js/base/context/hooks/cart/use-store-cart-event-listeners.ts new file mode 100644 index 00000000000..51896c843f5 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/base/context/hooks/cart/use-store-cart-event-listeners.ts @@ -0,0 +1,87 @@ +/** + * External dependencies + */ +import { useEffect } from '@wordpress/element'; +import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data'; +import { dispatch } from '@wordpress/data'; +import { translateJQueryEventToNative } from '@woocommerce/base-utils'; + +interface StoreCartListenersType { + count: number; + remove: () => void; +} + +declare global { + interface Window { + wcBlocksStoreCartListeners: StoreCartListenersType; + } +} + +const refreshData = ( e ): void => { + const eventDetail = e.detail; + if ( ! eventDetail || ! eventDetail.preserveCartData ) { + dispatch( storeKey ).invalidateResolutionForStore(); + } +}; + +const setUp = (): void => { + if ( ! window.wcBlocksStoreCartListeners ) { + window.wcBlocksStoreCartListeners = { + count: 0, + remove: () => void null, + }; + } +}; + +const addListeners = (): void => { + setUp(); + + if ( ! window.wcBlocksStoreCartListeners.count ) { + const removeJQueryAddedToCartEvent = translateJQueryEventToNative( + 'added_to_cart', + `wc-blocks_added_to_cart` + ) as () => () => void; + const removeJQueryRemovedFromCartEvent = translateJQueryEventToNative( + 'removed_from_cart', + `wc-blocks_removed_from_cart` + ) as () => () => void; + document.body.addEventListener( + `wc-blocks_added_to_cart`, + refreshData + ); + document.body.addEventListener( + `wc-blocks_removed_from_cart`, + refreshData + ); + + window.wcBlocksStoreCartListeners.count = 0; + window.wcBlocksStoreCartListeners.remove = () => { + removeJQueryAddedToCartEvent(); + removeJQueryRemovedFromCartEvent(); + document.body.removeEventListener( + `wc-blocks_added_to_cart`, + refreshData + ); + document.body.removeEventListener( + `wc-blocks_removed_from_cart`, + refreshData + ); + }; + } + window.wcBlocksStoreCartListeners.count++; +}; + +const removeListeners = (): void => { + if ( window.wcBlocksStoreCartListeners.count === 1 ) { + window.wcBlocksStoreCartListeners.remove(); + } + window.wcBlocksStoreCartListeners.count--; +}; + +export const useStoreCartEventListeners = (): void => { + useEffect( () => { + addListeners(); + + return removeListeners; + }, [] ); +}; diff --git a/plugins/woocommerce-blocks/assets/js/base/context/hooks/cart/use-store-cart.ts b/plugins/woocommerce-blocks/assets/js/base/context/hooks/cart/use-store-cart.ts index ac730f699be..c59602fed09 100644 --- a/plugins/woocommerce-blocks/assets/js/base/context/hooks/cart/use-store-cart.ts +++ b/plugins/woocommerce-blocks/assets/js/base/context/hooks/cart/use-store-cart.ts @@ -37,6 +37,7 @@ import { * Internal dependencies */ import { useEditorContext } from '../../providers/editor-context'; +import { useStoreCartEventListeners } from './use-store-cart-event-listeners'; declare module '@wordpress/html-entities' { // eslint-disable-next-line @typescript-eslint/no-shadow @@ -137,6 +138,11 @@ export const useStoreCart = ( const previewCart = previewData?.previewCart; const { shouldSelect } = options; const currentResults = useRef(); + + // This will keep track of jQuery and DOM events triggered by other blocks + // or components and will invalidate the store resolution accordingly. + useStoreCartEventListeners(); + const results: StoreCart = useSelect( ( select, { dispatch } ) => { if ( ! shouldSelect ) { diff --git a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/cart/block.js b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/cart/block.js index 5e508049199..cc6be272851 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/cart/block.js +++ b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/cart/block.js @@ -2,8 +2,6 @@ * External dependencies */ import { __ } from '@wordpress/i18n'; -import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data'; -import { dispatch } from '@wordpress/data'; import { useStoreCart } from '@woocommerce/base-context/hooks'; import { useEffect } from '@wordpress/element'; import LoadingMask from '@woocommerce/base-components/loading-mask'; @@ -48,44 +46,23 @@ const Cart = ( { children, attributes } ) => { const ScrollOnError = ( { scrollToTop } ) => { useEffect( () => { - const invalidateCartData = ( e ) => { - const eventDetail = e.detail; - if ( ! eventDetail || ! eventDetail.preserveCartData ) { - dispatch( storeKey ).invalidateResolutionForStore(); - } - scrollToTop(); - }; - // Make it so we can read jQuery events triggered by WC Core elements. const removeJQueryAddedToCartEvent = translateJQueryEventToNative( 'added_to_cart', 'wc-blocks_added_to_cart' ); - const removeJQueryRemovedFromCartEvent = translateJQueryEventToNative( - 'removed_from_cart', - 'wc-blocks_removed_from_cart' - ); document.body.addEventListener( 'wc-blocks_added_to_cart', - invalidateCartData - ); - document.body.addEventListener( - 'wc-blocks_removed_from_cart', - invalidateCartData + scrollToTop ); return () => { removeJQueryAddedToCartEvent(); - removeJQueryRemovedFromCartEvent(); document.body.removeEventListener( 'wc-blocks_added_to_cart', - invalidateCartData - ); - document.body.removeEventListener( - 'wc-blocks_removed_from_cart', - invalidateCartData + scrollToTop ); }; }, [ scrollToTop ] ); diff --git a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/mini-cart/block.tsx b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/mini-cart/block.tsx index ede73eace65..9b3f8effa02 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/mini-cart/block.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/mini-cart/block.tsx @@ -4,11 +4,9 @@ import classNames from 'classnames'; import { __, _n, sprintf } from '@wordpress/i18n'; import { useState, useEffect, useRef } from '@wordpress/element'; -import { dispatch } from '@wordpress/data'; import { translateJQueryEventToNative } from '@woocommerce/base-utils'; import { useStoreCart } from '@woocommerce/base-context/hooks'; import Drawer from '@woocommerce/base-components/drawer'; -import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data'; import { formatPrice, getCurrencyFromPriceResponse, @@ -22,11 +20,11 @@ import CartLineItemsTable from '../cart/cart-line-items-table'; import './style.scss'; interface MiniCartBlockProps { - isPlaceholderOpen?: boolean; + isInitiallyOpen?: boolean; } const MiniCartBlock = ( { - isPlaceholderOpen = false, + isInitiallyOpen = false, }: MiniCartBlockProps ): JSX.Element => { const { cartItems, @@ -34,20 +32,16 @@ const MiniCartBlock = ( { cartIsLoading, cartTotals, } = useStoreCart(); - const [ isOpen, setIsOpen ] = useState< boolean >( isPlaceholderOpen ); + const [ isOpen, setIsOpen ] = useState< boolean >( isInitiallyOpen ); const emptyCartRef = useRef< HTMLDivElement | null >( null ); // We already rendered the HTML drawer placeholder, so we want to skip the // slide in animation. const [ skipSlideIn, setSkipSlideIn ] = useState< boolean >( - isPlaceholderOpen + isInitiallyOpen ); useEffect( () => { - const openMiniCartAndRefreshData = ( e ) => { - const eventDetail = e.detail; - if ( ! eventDetail || ! eventDetail.preserveCartData ) { - dispatch( storeKey ).invalidateResolutionForStore(); - } + const openMiniCart = () => { setSkipSlideIn( false ); setIsOpen( true ); }; @@ -60,7 +54,7 @@ const MiniCartBlock = ( { document.body.addEventListener( 'wc-blocks_added_to_cart', - openMiniCartAndRefreshData + openMiniCart ); return () => { @@ -68,7 +62,7 @@ const MiniCartBlock = ( { document.body.removeEventListener( 'wc-blocks_added_to_cart', - openMiniCartAndRefreshData + openMiniCart ); }; }, [] ); diff --git a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/mini-cart/component-frontend.tsx b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/mini-cart/component-frontend.tsx index 1c8da908c72..3172ae3b1ef 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/mini-cart/component-frontend.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/mini-cart/component-frontend.tsx @@ -31,14 +31,14 @@ const renderMiniCartFrontend = () => { Block: withMiniCartConditionalHydration( MiniCartBlock ), getProps: ( el: HTMLElement ) => ( { isDataOutdated: el.dataset.isDataOutdated, - isPlaceholderOpen: el.dataset.isPlaceholderOpen === 'true', + isInitiallyOpen: el.dataset.isInitiallyOpen === 'true', } ), } ); // Refocus previously focused button if drawer is not open. if ( focusedMiniCartBlock instanceof HTMLElement && - ! focusedMiniCartBlock.dataset.isPlaceholderOpen + ! focusedMiniCartBlock.dataset.isInitiallyOpen ) { const innerButton = focusedMiniCartBlock.querySelector( '.wc-block-mini-cart__button' diff --git a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/mini-cart/frontend.ts b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/mini-cart/frontend.ts index 8578bb75969..5d14cf8acfd 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/mini-cart/frontend.ts +++ b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/mini-cart/frontend.ts @@ -46,6 +46,10 @@ window.onload = () => { 'added_to_cart', 'wc-blocks_added_to_cart' ); + const removeJQueryRemovedFromCartEvent = translateJQueryEventToNative( + 'removed_from_cart', + 'wc-blocks_removed_from_cart' + ); const loadScripts = async () => { // Ensure we only call loadScripts once. @@ -90,40 +94,62 @@ window.onload = () => { return; } - const showContents = () => { + const loadContents = () => { if ( ! wasLoadScriptsCalled ) { loadScripts(); } document.body.removeEventListener( 'wc-blocks_added_to_cart', // eslint-disable-next-line @typescript-eslint/no-use-before-define - showContentsAndUpdate + openDrawerWithRefresh ); - miniCartBlock.dataset.isPlaceholderOpen = 'true'; + document.body.removeEventListener( + 'wc-blocks_removed_from_cart', + // eslint-disable-next-line @typescript-eslint/no-use-before-define + loadContentsWithRefresh + ); + removeJQueryAddedToCartEvent(); + removeJQueryRemovedFromCartEvent(); + }; + + const openDrawer = () => { + miniCartBlock.dataset.isInitiallyOpen = 'true'; + miniCartDrawerPlaceholderOverlay.classList.add( 'wc-block-components-drawer__screen-overlay--with-slide-in' ); miniCartDrawerPlaceholderOverlay.classList.remove( 'wc-block-components-drawer__screen-overlay--is-hidden' ); - removeJQueryAddedToCartEvent(); + + loadContents(); }; - const showContentsAndUpdate = () => { + const openDrawerWithRefresh = () => { miniCartBlock.dataset.isDataOutdated = 'true'; - showContents(); + openDrawer(); + }; + + const loadContentsWithRefresh = () => { + miniCartBlock.dataset.isDataOutdated = 'true'; + miniCartBlock.dataset.isInitiallyOpen = 'false'; + loadContents(); }; miniCartButton.addEventListener( 'mouseover', loadScripts ); miniCartButton.addEventListener( 'focus', loadScripts ); - miniCartButton.addEventListener( 'click', showContents ); + miniCartButton.addEventListener( 'click', openDrawer ); // There might be more than one Mini Cart block in the page. Make sure // only one opens when adding a product to the cart. if ( i === 0 ) { document.body.addEventListener( 'wc-blocks_added_to_cart', - showContentsAndUpdate + openDrawerWithRefresh + ); + document.body.addEventListener( + 'wc-blocks_removed_from_cart', + loadContentsWithRefresh ); } } ); diff --git a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/mini-cart/test/block.js b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/mini-cart/test/block.js index 41cdba592ce..e6d3beb4a5a 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/mini-cart/test/block.js +++ b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/mini-cart/test/block.js @@ -1,7 +1,14 @@ /** * External dependencies */ -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { + act, + render, + screen, + fireEvent, + waitFor, + waitForElementToBeRemoved, +} from '@testing-library/react'; import { previewCart } from '@woocommerce/resource-previews'; import { dispatch } from '@wordpress/data'; import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data'; @@ -19,14 +26,30 @@ const MiniCartBlock = ( props ) => ( ); + +const mockEmptyCart = () => { + fetchMock.mockResponse( ( req ) => { + if ( req.url.match( /wc\/store\/cart/ ) ) { + return Promise.resolve( + JSON.stringify( defaultCartState.cartData ) + ); + } + return Promise.resolve( '' ); + } ); +}; + +const mockFullCart = () => { + fetchMock.mockResponse( ( req ) => { + if ( req.url.match( /wc\/store\/cart/ ) ) { + return Promise.resolve( JSON.stringify( previewCart ) ); + } + return Promise.resolve( '' ); + } ); +}; + describe( 'Testing cart', () => { beforeEach( async () => { - fetchMock.mockResponse( ( req ) => { - if ( req.url.match( /wc\/store\/cart/ ) ) { - return Promise.resolve( JSON.stringify( previewCart ) ); - } - return Promise.resolve( '' ); - } ); + mockFullCart(); // need to clear the store resolution state between tests. await dispatch( storeKey ).invalidateResolutionForStore(); await dispatch( storeKey ).receiveCart( defaultCartState.cartData ); @@ -48,14 +71,7 @@ describe( 'Testing cart', () => { } ); it( 'renders empty cart if there are no items in the cart', async () => { - fetchMock.mockResponse( ( req ) => { - if ( req.url.match( /wc\/store\/cart/ ) ) { - return Promise.resolve( - JSON.stringify( defaultCartState.cartData ) - ); - } - return Promise.resolve( '' ); - } ); + mockEmptyCart(); render( ); await waitFor( () => expect( fetchMock ).toHaveBeenCalled() ); @@ -64,4 +80,43 @@ describe( 'Testing cart', () => { expect( screen.getByText( /Cart is empty/i ) ).toBeInTheDocument(); expect( fetchMock ).toHaveBeenCalledTimes( 1 ); } ); + + it( 'updates contents when removed from cart event is triggered', async () => { + render( ); + await waitFor( () => expect( fetchMock ).toHaveBeenCalled() ); + + mockEmptyCart(); + // eslint-disable-next-line no-undef + const removedFromCartEvent = new Event( 'wc-blocks_removed_from_cart' ); + act( () => { + document.body.dispatchEvent( removedFromCartEvent ); + } ); + + await waitForElementToBeRemoved( () => + screen.queryByText( /3 items/i ) + ); + await waitFor( () => + expect( screen.getByText( /0 items/i ) ).toBeInTheDocument() + ); + } ); + + it( 'updates contents when added to cart event is triggered', async () => { + mockEmptyCart(); + render( ); + await waitFor( () => expect( fetchMock ).toHaveBeenCalled() ); + + mockFullCart(); + // eslint-disable-next-line no-undef + const removedFromCartEvent = new Event( 'wc-blocks_added_to_cart' ); + act( () => { + document.body.dispatchEvent( removedFromCartEvent ); + } ); + + await waitForElementToBeRemoved( () => + screen.queryByText( /0 items/i ) + ); + await waitFor( () => + expect( screen.getAllByText( /3 items/i ).length > 0 ) + ); + } ); } ); diff --git a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/mini-cart/with-mini-cart-conditional-hydration.tsx b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/mini-cart/with-mini-cart-conditional-hydration.tsx index 1a1b1c6eb9f..b04e2053cda 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/mini-cart/with-mini-cart-conditional-hydration.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/mini-cart/with-mini-cart-conditional-hydration.tsx @@ -10,9 +10,10 @@ interface MiniCartBlockInterface { // Signals whether the cart data is outdated. That happens when // opening the mini cart after adding a product to the cart. isDataOutdated?: boolean; - // Signals that the HTML placeholder drawer has been opened. Needed - // to know whether we have to skip the slide in animation. - isPlaceholderOpen?: boolean; + // Signals whether it should be open when the React component is loaded. For + // example, when adding a product to the cart, the Mini Cart should open + // when loaded, but when removing a product from the cart, it shouldn't. + isInitiallyOpen?: boolean; } // Custom HOC to conditionally hydrate API data depending on the isDataOutdated diff --git a/plugins/woocommerce-blocks/docs/extensibility/dom-events.md b/plugins/woocommerce-blocks/docs/extensibility/dom-events.md index 3cc2c68ae4e..f55fffa6340 100644 --- a/plugins/woocommerce-blocks/docs/extensibility/dom-events.md +++ b/plugins/woocommerce-blocks/docs/extensibility/dom-events.md @@ -18,7 +18,7 @@ _Example usage in WC Blocks:_ Mini Cart block listens to this event to append it This event is the equivalent to the jQuery event `added_to_cart` triggered by WooCommerce core. It indicates that the process of adding a product to the cart has finished with success. -_Example usage in WC Blocks:_ Cart and Mini Cart blocks listen to this event to know if they need to update their contents. +_Example usage in WC Blocks:_ Cart and Mini Cart blocks (via the `useStoreCart()` hook) listen to this event to know if they need to update their contents. #### `detail` parameters: @@ -30,5 +30,5 @@ _Example usage in WC Blocks:_ Cart and Mini Cart blocks listen to this event to This event is the equivalent to the jQuery event `removed_from_cart` triggered by WooCommerce core. It indicates that a product has been removed from the cart. -_Example usage in WC Blocks:_ Cart and Mini Cart blocks listen to this event to know if they need to update their contents. +_Example usage in WC Blocks:_ Cart and Mini Cart blocks (via the `useStoreCart()` hook) listen to this event to know if they need to update their contents.