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

Add CartEventsContext to dispatch events in the Cart block #7694

Merged
merged 13 commits into from
Nov 18, 2022
Merged
49 changes: 43 additions & 6 deletions assets/js/base/components/quantity-selector/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@
import { __, sprintf } from '@wordpress/i18n';
import { speak } from '@wordpress/a11y';
import classNames from 'classnames';
import { useCallback, useLayoutEffect } from '@wordpress/element';
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
} from '@wordpress/element';
import { DOWN, UP } from '@wordpress/keycodes';
import { useDebouncedCallback } from 'use-debounce';
import { ValidationInputError } from '@woocommerce/blocks-checkout';
Expand All @@ -16,6 +21,7 @@ import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
* Internal dependencies
*/
import './style.scss';
import { useCartEventsContext } from '../../context/providers/cart-checkout/cart-events';

export interface QuantitySelectorProps {
/**
Expand Down Expand Up @@ -76,13 +82,17 @@ const QuantitySelector = ( {
}: QuantitySelectorProps ): JSX.Element => {
const errorId = `wc-block-components-quantity-selector-error-${ instanceId }`;

const hasValidationErrors = useSelect( ( select ) => {
return !! select( VALIDATION_STORE_KEY ).getValidationError( errorId );
const { hasValidationError, validationError } = useSelect( ( select ) => {
const store = select( VALIDATION_STORE_KEY );
return {
hasValidationError: !! store.getValidationError( errorId ),
validationError: store.getValidationError( errorId ),
};
} );

const classes = classNames(
'wc-block-components-quantity-selector',
hasValidationErrors ? 'has-error' : '',
hasValidationError ? 'has-error' : '',
className
);

Expand Down Expand Up @@ -123,7 +133,7 @@ const QuantitySelector = ( {
onChange( value );
}
},
[ hasMaximum, maximum, minimum, onChange, step ]
[ hasMaximum, maximum, minimum, onChange, step, strictLimits ]
);

/*
Expand Down Expand Up @@ -248,10 +258,37 @@ const QuantitySelector = ( {
*/
const stepToUse = ! strictLimits && quantity % step !== 0 ? 1 : step;

const { onProceedToCheckout } = useCartEventsContext();

const inputRef = useRef< HTMLInputElement >( null );

useEffect( () => {
// onProceedToCheckout returns an unsubscribe function. By returning it here, we ensure that the
// observer is removed when the component is unmounted/this effect reruns.
return onProceedToCheckout( () => {
if ( ! inputRef.current ) {
return;
}
const isValid = inputRef.current.checkValidity();

if ( ! isValid || hasValidationError ) {
inputRef.current.focus();
return {
type: 'error',
message:
inputRef.current.validationMessage ||
validationError?.message,
};
}
return true;
} );
}, [ errorId, hasValidationError, onProceedToCheckout, validationError ] );

return (
<div>
<div className={ classes }>
<input
ref={ inputRef }
className="wc-block-components-quantity-selector__input"
disabled={ disabled }
type="number"
Expand Down Expand Up @@ -334,7 +371,7 @@ const QuantitySelector = ( {
&#65291;
</button>
</div>
{ hasValidationErrors && ! strictLimits && (
{ hasValidationError && ! strictLimits && (
<div>
<ValidationInputError propertyName={ errorId } />
</div>
Expand Down
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,45 @@
/**
* 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();
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' );
// 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
Expand Up @@ -2,6 +2,7 @@ export * from './payment-events';
export * from './shipping';
export * from './customer';
export * from './checkout-events';
export * from './cart-events';
export * from './cart';
export * from './checkout-processor';
export * from './checkout-provider';
11 changes: 8 additions & 3 deletions assets/js/blocks/cart/block.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ 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 { CartProvider } from '@woocommerce/base-context/providers';
import {
CartProvider,
CartEventsProvider,
} from '@woocommerce/base-context/providers';
import { SlotFillProvider } from '@woocommerce/blocks-checkout';

/**
Expand Down Expand Up @@ -87,8 +90,10 @@ const Block = ( { attributes, children, scrollToTop } ) => (
<StoreNoticesContainer context="wc/cart" />
<SlotFillProvider>
<CartProvider>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if we should combine Cart and CartEvents providers? At the moment, the Cart provider just passed the children down and has a redirectUrl

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please see Mike's comment below

<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,11 +10,13 @@ import { usePositionRelativeToViewport } from '@woocommerce/base-hooks';
import { getSetting } from '@woocommerce/settings';
import { useSelect } from '@wordpress/data';
import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
import { isErrorResponse } from '@woocommerce/base-context';

/**
* Internal dependencies
*/
import './style.scss';
import { useCartEventsContext } from '../../../../base/context/providers/cart-checkout/cart-events';

/**
* Checkout button rendered in the full cart page.
Expand Down Expand Up @@ -56,13 +58,25 @@ const Block = ( {
};
}, [] );

const { dispatchOnProceedToCheckout } = useCartEventsContext();

const submitContainerContents = (
<div>
<Button
className="wc-block-cart__submit-button"
href={ link || CHECKOUT_URL }
disabled={ isCalculating }
onClick={ () => setShowSpinner( true ) }
onClick={ ( e ) => {
dispatchOnProceedToCheckout().then(
( observerResponses ) => {
if ( observerResponses.some( isErrorResponse ) ) {
e.preventDefault();
return;
}
setShowSpinner( true );
}
);
} }
showSpinner={ showSpinner }
>
{ __( 'Proceed to Checkout', 'woo-gutenberg-products-block' ) }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* External dependencies
*/
import { render, screen, waitFor } from '@testing-library/react';
import { useCartEventsContext } from '@woocommerce/base-context';
import { useEffect } from '@wordpress/element';

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

describe( 'ProceedToCheckoutBlock', () => {
it( 'dispatches the onProceedToCheckout event when the button is clicked', async () => {
const mockObserver = jest.fn();
const MockObserverComponent = () => {
const { onProceedToCheckout } = useCartEventsContext();
useEffect( () => {
return onProceedToCheckout( mockObserver );
}, [ 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' );
button.click();
await waitFor( () => {
expect( mockObserver ).toHaveBeenCalled();
} );
} );
} );
Loading