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,