Add `CartEventContext` and dispatch events when pressing proceed to checkout button (https://github.com/woocommerce/woocommerce-blocks/pull/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
This commit is contained in:
Thomas Roberts 2023-06-19 18:44:37 +03:00 committed by GitHub
parent bc6dc106ab
commit 3ebcd7f601
11 changed files with 305 additions and 8 deletions

View File

@ -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 };

View File

@ -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>
);
};

View File

@ -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 );
} );
} );
} );

View File

@ -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';

View File

@ -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 } ) => (
<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>

View File

@ -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 = (
<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 }

View File

@ -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', () => {
@ -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();
} );
} );
} );

View File

@ -2,6 +2,7 @@
* External dependencies
*/
import { DrawerCloseButton } from '@woocommerce/base-components/drawer';
import { CartEventsProvider } from '@woocommerce/base-context';
/**
* Internal dependencies
@ -20,8 +21,10 @@ export const MiniCartContentsBlock = (
return (
<>
<DrawerCloseButton />
{ children }
<CartEventsProvider>
<DrawerCloseButton />
{ children }
</CartEventsProvider>
</>
);
};

View File

@ -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
@ -24,6 +28,7 @@ const Block = ( {
style,
}: MiniCartCheckoutButtonBlockProps ): JSX.Element | null => {
const styleProps = useStyleProps( { style } );
const { dispatchOnProceedToCheckout } = useCartEventsContext();
if ( ! CHECKOUT_URL ) {
return null;
@ -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>

View File

@ -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 <div>Mock observer</div>;
};
render(
<CartEventsProvider>
<div>
<MockObserverComponent />
<Block
checkoutButtonLabel={ 'Proceed to Checkout' }
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();
} );
} );
} );

View File

@ -86,7 +86,7 @@ global.wcSettings = {
checkout: {
id: 0,
title: '',
permalink: '',
permalink: 'https://local/checkout/',
},
privacy: {
id: 0,