From a953700ea07dcc4c0c3ed4509eeeb991e0990949 Mon Sep 17 00:00:00 2001 From: Thomas Roberts <5656702+opr@users.noreply.github.com> Date: Mon, 19 Jun 2023 18:44:37 +0300 Subject: [PATCH] Add `CartEventContext` and dispatch events when pressing proceed to checkout button (#7809) * Add CartEventsContext with onProceedToCheckout event * Wrap Cart in CartEventsProvider * Dispatch onProceedToCheckout event when button is pressed * Update type of children on CartEventsProvider * Add test for ProceedToCheckout block * Add tests for CartEventProvider * Remove superfluous div * Fix incorrect nesting after rebase * Wrap mini cart in CartEventsProvider * Dispatch onProceedToCheckout event when clicking button in mini cart * Add tests for mini cart onProceedToCheckout emitter * Make observer fail so navigation isn't attempted * Prevent console error on navigation * Try preventing navigation in unit tests * Try preventing navigation in unit tests * Try preventing navigation in unit tests * Try preventing navigation in unit tests * Try preventing navigation in unit tests * Try preventing navigation in unit tests * Try preventing navigation in unit tests --- .../cart-checkout/cart-events/event-emit.ts | 51 ++++++++++++ .../cart-checkout/cart-events/index.tsx | 78 +++++++++++++++++++ .../cart-checkout/cart-events/test/index.tsx | 49 ++++++++++++ .../context/providers/cart-checkout/index.js | 1 + assets/js/blocks/cart/block.js | 12 ++- .../proceed-to-checkout-block/block.tsx | 14 +++- .../proceed-to-checkout-block/test/block.tsx | 39 +++++++++- .../mini-cart/mini-cart-contents/block.tsx | 7 +- .../mini-cart-checkout-button-block/block.tsx | 12 +++ .../test/block.tsx | 48 ++++++++++++ tests/js/setup-globals.js | 2 +- 11 files changed, 305 insertions(+), 8 deletions(-) create mode 100644 assets/js/base/context/providers/cart-checkout/cart-events/event-emit.ts create mode 100644 assets/js/base/context/providers/cart-checkout/cart-events/index.tsx create mode 100644 assets/js/base/context/providers/cart-checkout/cart-events/test/index.tsx create mode 100644 assets/js/blocks/mini-cart/mini-cart-contents/inner-blocks/mini-cart-checkout-button-block/test/block.tsx diff --git a/assets/js/base/context/providers/cart-checkout/cart-events/event-emit.ts b/assets/js/base/context/providers/cart-checkout/cart-events/event-emit.ts new file mode 100644 index 00000000000..ac6ed876084 --- /dev/null +++ b/assets/js/base/context/providers/cart-checkout/cart-events/event-emit.ts @@ -0,0 +1,51 @@ +/** + * External dependencies + */ +import { useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { + emitterCallback, + reducer, + emitEvent, + emitEventWithAbort, + ActionType, +} from '../../../event-emit'; + +// These events are emitted when the Cart status is BEFORE_PROCESSING and AFTER_PROCESSING +// to enable third parties to hook into the cart process +const EVENTS = { + PROCEED_TO_CHECKOUT: 'cart_proceed_to_checkout', +}; + +type EventEmittersType = Record< string, ReturnType< typeof emitterCallback > >; + +/** + * Receives a reducer dispatcher and returns an object with the + * various event emitters for the payment processing events. + * + * Calling the event registration function with the callback will register it + * for the event emitter and will return a dispatcher for removing the + * registered callback (useful for implementation in `useEffect`). + * + * @param {Function} observerDispatch The emitter reducer dispatcher. + * @return {Object} An object with the various payment event emitter registration functions + */ +const useEventEmitters = ( + observerDispatch: React.Dispatch< ActionType > +): EventEmittersType => { + const eventEmitters = useMemo( + () => ( { + onProceedToCheckout: emitterCallback( + EVENTS.PROCEED_TO_CHECKOUT, + observerDispatch + ), + } ), + [ observerDispatch ] + ); + return eventEmitters; +}; + +export { EVENTS, useEventEmitters, reducer, emitEvent, emitEventWithAbort }; diff --git a/assets/js/base/context/providers/cart-checkout/cart-events/index.tsx b/assets/js/base/context/providers/cart-checkout/cart-events/index.tsx new file mode 100644 index 00000000000..90a4998f94a --- /dev/null +++ b/assets/js/base/context/providers/cart-checkout/cart-events/index.tsx @@ -0,0 +1,78 @@ +/** + * External dependencies + */ + +import { + createContext, + useContext, + useReducer, + useRef, + useEffect, +} from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { + useEventEmitters, + reducer as emitReducer, + emitEventWithAbort, + EVENTS, +} from './event-emit'; +import type { emitterCallback } from '../../../event-emit'; + +type CartEventsContextType = { + // Used to register a callback that will fire when the cart has been processed and has an error. + onProceedToCheckout: ReturnType< typeof emitterCallback >; + // Used to register a callback that will fire when the cart has been processed and has an error. + dispatchOnProceedToCheckout: () => Promise< unknown[] >; +}; + +const CartEventsContext = createContext< CartEventsContextType >( { + onProceedToCheckout: () => () => void null, + dispatchOnProceedToCheckout: () => new Promise( () => void null ), +} ); + +export const useCartEventsContext = () => { + return useContext( CartEventsContext ); +}; + +/** + * Checkout Events provider + * Emit Checkout events and provide access to Checkout event handlers + * + * @param {Object} props Incoming props for the provider. + * @param {Object} props.children The children being wrapped. + */ +export const CartEventsProvider = ( { + children, +}: { + children: React.ReactNode; +} ): JSX.Element => { + const [ observers, observerDispatch ] = useReducer( emitReducer, {} ); + const currentObservers = useRef( observers ); + const { onProceedToCheckout } = useEventEmitters( observerDispatch ); + + // set observers on ref so it's always current. + useEffect( () => { + currentObservers.current = observers; + }, [ observers ] ); + + const dispatchOnProceedToCheckout = async () => { + return await emitEventWithAbort( + currentObservers.current, + EVENTS.PROCEED_TO_CHECKOUT, + null + ); + }; + + const cartEvents = { + onProceedToCheckout, + dispatchOnProceedToCheckout, + }; + return ( + + { children } + + ); +}; diff --git a/assets/js/base/context/providers/cart-checkout/cart-events/test/index.tsx b/assets/js/base/context/providers/cart-checkout/cart-events/test/index.tsx new file mode 100644 index 00000000000..48ab11154a7 --- /dev/null +++ b/assets/js/base/context/providers/cart-checkout/cart-events/test/index.tsx @@ -0,0 +1,49 @@ +/** + * External dependencies + */ +import { useCartEventsContext } from '@woocommerce/base-context'; +import { useEffect } from '@wordpress/element'; +import { render, screen, waitFor } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import { CartEventsProvider } from '../index'; +import Block from '../../../../../../blocks/cart/inner-blocks/proceed-to-checkout-block/block'; + +describe( 'CartEventsProvider', () => { + it( 'allows observers to unsubscribe', async () => { + const mockObserver = jest.fn().mockReturnValue( { type: 'error' } ); + const MockObserverComponent = () => { + const { onProceedToCheckout } = useCartEventsContext(); + useEffect( () => { + const unsubscribe = onProceedToCheckout( () => { + mockObserver(); + unsubscribe(); + } ); + }, [ onProceedToCheckout ] ); + return
Mock observer
; + }; + + render( + +
+ + +
+
+ ); + expect( screen.getByText( 'Mock observer' ) ).toBeInTheDocument(); + const button = screen.getByText( 'Proceed to Checkout' ); + + // Forcibly set the button URL to # to prevent JSDOM error: `["Error: Not implemented: navigation (except hash changes)` + button.parentElement?.removeAttribute( 'href' ); + + // Click twice. The observer should unsubscribe after the first click. + button.click(); + button.click(); + await waitFor( () => { + expect( mockObserver ).toHaveBeenCalledTimes( 1 ); + } ); + } ); +} ); diff --git a/assets/js/base/context/providers/cart-checkout/index.js b/assets/js/base/context/providers/cart-checkout/index.js index 7868f66d6bf..67d6fb0b8e6 100644 --- a/assets/js/base/context/providers/cart-checkout/index.js +++ b/assets/js/base/context/providers/cart-checkout/index.js @@ -1,6 +1,7 @@ export * from './payment-events'; export * from './shipping'; export * from './checkout-events'; +export * from './cart-events'; export * from './cart'; export * from './checkout-processor'; export * from './checkout-provider'; diff --git a/assets/js/blocks/cart/block.js b/assets/js/blocks/cart/block.js index e2b2fc829e6..d3afcc43057 100644 --- a/assets/js/blocks/cart/block.js +++ b/assets/js/blocks/cart/block.js @@ -5,11 +5,15 @@ import { __ } from '@wordpress/i18n'; import { useStoreCart } from '@woocommerce/base-context/hooks'; import { useEffect } from '@wordpress/element'; import LoadingMask from '@woocommerce/base-components/loading-mask'; -import { CartProvider, noticeContexts } from '@woocommerce/base-context'; import { CURRENT_USER_IS_ADMIN } from '@woocommerce/settings'; import BlockErrorBoundary from '@woocommerce/base-components/block-error-boundary'; import { translateJQueryEventToNative } from '@woocommerce/base-utils'; import withScrollToTop from '@woocommerce/base-hocs/with-scroll-to-top'; +import { + CartEventsProvider, + CartProvider, + noticeContexts, +} from '@woocommerce/base-context'; import { SlotFillProvider, StoreNoticesContainer, @@ -85,8 +89,10 @@ const Block = ( { attributes, children, scrollToTop } ) => ( - { children } - + + { children } + + diff --git a/assets/js/blocks/cart/inner-blocks/proceed-to-checkout-block/block.tsx b/assets/js/blocks/cart/inner-blocks/proceed-to-checkout-block/block.tsx index 10668a08cb4..6dc2e34644a 100644 --- a/assets/js/blocks/cart/inner-blocks/proceed-to-checkout-block/block.tsx +++ b/assets/js/blocks/cart/inner-blocks/proceed-to-checkout-block/block.tsx @@ -10,6 +10,8 @@ import { getSetting } from '@woocommerce/settings'; import { useSelect } from '@wordpress/data'; import { CART_STORE_KEY, CHECKOUT_STORE_KEY } from '@woocommerce/block-data'; import { applyCheckoutFilter } from '@woocommerce/blocks-checkout'; +import { isErrorResponse } from '@woocommerce/base-context'; +import { useCartEventsContext } from '@woocommerce/base-context/providers'; /** * Internal dependencies @@ -74,12 +76,22 @@ const Block = ( { arg: { cart }, } ); + const { dispatchOnProceedToCheckout } = useCartEventsContext(); + const submitContainerContents = ( diff --git a/assets/js/blocks/mini-cart/mini-cart-contents/inner-blocks/mini-cart-checkout-button-block/test/block.tsx b/assets/js/blocks/mini-cart/mini-cart-contents/inner-blocks/mini-cart-checkout-button-block/test/block.tsx new file mode 100644 index 00000000000..4c977529836 --- /dev/null +++ b/assets/js/blocks/mini-cart/mini-cart-contents/inner-blocks/mini-cart-checkout-button-block/test/block.tsx @@ -0,0 +1,48 @@ +/** + * External dependencies + */ +import { + CartEventsProvider, + useCartEventsContext, +} from '@woocommerce/base-context'; +import { useEffect } from '@wordpress/element'; +import { render, screen, waitFor } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import Block from '../block'; + +describe( 'Mini Cart Checkout Button Block', () => { + it( 'dispatches the onProceedToCheckout event when the button is clicked', async () => { + const mockObserver = jest.fn().mockReturnValue( { type: 'error' } ); + const MockObserverComponent = () => { + const { onProceedToCheckout } = useCartEventsContext(); + useEffect( () => { + return onProceedToCheckout( mockObserver ); + }, [ onProceedToCheckout ] ); + return
Mock observer
; + }; + + render( + +
+ + +
+
+ ); + expect( screen.getByText( 'Mock observer' ) ).toBeInTheDocument(); + const button = screen.getByText( 'Proceed to Checkout' ); + + // Forcibly set the button URL to # to prevent JSDOM error: `["Error: Not implemented: navigation (except hash changes)` + button.parentElement?.removeAttribute( 'href' ); + button.click(); + await waitFor( () => { + expect( mockObserver ).toHaveBeenCalled(); + } ); + } ); +} ); diff --git a/tests/js/setup-globals.js b/tests/js/setup-globals.js index caea4aae7a4..72ccbff2db1 100644 --- a/tests/js/setup-globals.js +++ b/tests/js/setup-globals.js @@ -86,7 +86,7 @@ global.wcSettings = { checkout: { id: 0, title: '', - permalink: '', + permalink: 'https://local/checkout/', }, privacy: { id: 0,