Skip to content
This repository has been archived by the owner on Feb 23, 2024. It is now read-only.

Commit

Permalink
Add CartEventContext and dispatch events when pressing proceed to c…
Browse files Browse the repository at this point in the history
…heckout 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
  • Loading branch information
opr authored Jun 19, 2023
1 parent 889d085 commit a953700
Show file tree
Hide file tree
Showing 11 changed files with 305 additions and 8 deletions.
Original file line number Diff line number Diff line change
@@ -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 };
Original file line number Diff line number Diff line change
@@ -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 (
<CartEventsContext.Provider value={ cartEvents }>
{ children }
</CartEventsContext.Provider>
);
};
Original file line number Diff line number Diff line change
@@ -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 <div>Mock observer</div>;
};

render(
<CartEventsProvider>
<div>
<MockObserverComponent />
<Block checkoutPageId={ 0 } className="test-block" />
</div>
</CartEventsProvider>
);
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 );
} );
} );
} );
1 change: 1 addition & 0 deletions assets/js/base/context/providers/cart-checkout/index.js
Original file line number Diff line number Diff line change
@@ -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';
12 changes: 9 additions & 3 deletions assets/js/blocks/cart/block.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -85,8 +89,10 @@ const Block = ( { attributes, children, scrollToTop } ) => (
<StoreNoticesContainer context={ noticeContexts.CART } />
<SlotFillProvider>
<CartProvider>
<Cart attributes={ attributes }>{ children }</Cart>
<ScrollOnError scrollToTop={ scrollToTop } />
<CartEventsProvider>
<Cart attributes={ attributes }>{ children }</Cart>
<ScrollOnError scrollToTop={ scrollToTop } />
</CartEventsProvider>
</CartProvider>
</SlotFillProvider>
</BlockErrorBoundary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -74,12 +76,22 @@ const Block = ( {
arg: { cart },
} );

const { dispatchOnProceedToCheckout } = useCartEventsContext();

const submitContainerContents = (
<Button
className="wc-block-cart__submit-button"
href={ filteredLink }
disabled={ isCalculating }
onClick={ () => setShowSpinner( true ) }
onClick={ ( e ) => {
dispatchOnProceedToCheckout().then( ( observerResponses ) => {
if ( observerResponses.some( isErrorResponse ) ) {
e.preventDefault();
return;
}
setShowSpinner( true );
} );
} }
showSpinner={ showSpinner }
>
{ label }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';

import { render, screen, waitFor } from '@testing-library/react';
import { registerCheckoutFilters } from '@woocommerce/blocks-checkout';
import { useCartEventsContext } from '@woocommerce/base-context';
import { useEffect } from '@wordpress/element';

/**
* Internal dependencies
*/
import Block from '../block';
import { CartEventsProvider } from '../../../../../base/context/providers';

describe( 'Proceed to checkout block', () => {
it( 'allows the text to be filtered', () => {
Expand Down Expand Up @@ -49,4 +53,37 @@ describe( 'Proceed to checkout block', () => {
//@todo When https://github.com/WordPress/gutenberg/issues/22850 is complete use that new matcher here for more specific error message assertion.
expect( console ).toHaveErrored();
} );
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 <div>Mock observer</div>;
};

render(
<CartEventsProvider>
<div>
<MockObserverComponent />
<Block
buttonLabel={ 'Proceed to Checkout' }
checkoutPageId={ 0 }
className="test-block"
/>
</div>
</CartEventsProvider>
);
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();
} );
} );
} );
7 changes: 5 additions & 2 deletions assets/js/blocks/mini-cart/mini-cart-contents/block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* External dependencies
*/
import { DrawerCloseButton } from '@woocommerce/base-components/drawer';
import { CartEventsProvider } from '@woocommerce/base-context';

/**
* Internal dependencies
Expand All @@ -20,8 +21,10 @@ export const MiniCartContentsBlock = (

return (
<>
<DrawerCloseButton />
{ children }
<CartEventsProvider>
<DrawerCloseButton />
{ children }
</CartEventsProvider>
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { CHECKOUT_URL } from '@woocommerce/block-settings';
import Button from '@woocommerce/base-components/button';
import classNames from 'classnames';
import { useStyleProps } from '@woocommerce/base-hooks';
import {
isErrorResponse,
useCartEventsContext,
} from '@woocommerce/base-context';

/**
* Internal dependencies
Expand All @@ -24,6 +28,7 @@ const Block = ( {
style,
}: MiniCartCheckoutButtonBlockProps ): JSX.Element | null => {
const styleProps = useStyleProps( { style } );
const { dispatchOnProceedToCheckout } = useCartEventsContext();

if ( ! CHECKOUT_URL ) {
return null;
Expand All @@ -39,6 +44,13 @@ const Block = ( {
variant={ getVariant( className, 'contained' ) }
style={ styleProps.style }
href={ CHECKOUT_URL }
onClick={ ( e ) => {
dispatchOnProceedToCheckout().then( ( observerResponses ) => {
if ( observerResponses.some( isErrorResponse ) ) {
e.preventDefault();
}
} );
} }
>
{ checkoutButtonLabel || defaultCheckoutButtonLabel }
</Button>
Expand Down
Loading

0 comments on commit a953700

Please sign in to comment.