diff --git a/assets/js/base/components/quantity-selector/index.tsx b/assets/js/base/components/quantity-selector/index.tsx index 74ed741b28c..0b3c492c1f8 100644 --- a/assets/js/base/components/quantity-selector/index.tsx +++ b/assets/js/base/components/quantity-selector/index.tsx @@ -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'; @@ -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 { /** @@ -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 ); @@ -123,7 +133,7 @@ const QuantitySelector = ( { onChange( value ); } }, - [ hasMaximum, maximum, minimum, onChange, step ] + [ hasMaximum, maximum, minimum, onChange, step, strictLimits ] ); /* @@ -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 (
- { hasValidationErrors && ! strictLimits && ( + { hasValidationError && ! strictLimits && (
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..cd74494123c --- /dev/null +++ b/assets/js/base/context/providers/cart-checkout/cart-events/test/index.tsx @@ -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
Mock observer
; + }; + + render( + +
+ + +
+
+ ); + 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 ); + } ); + } ); +} ); diff --git a/assets/js/base/context/providers/cart-checkout/index.js b/assets/js/base/context/providers/cart-checkout/index.js index 39f7e4f97ec..90fce40ddc0 100644 --- a/assets/js/base/context/providers/cart-checkout/index.js +++ b/assets/js/base/context/providers/cart-checkout/index.js @@ -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'; diff --git a/assets/js/blocks/cart/block.js b/assets/js/blocks/cart/block.js index acb264e9d0d..ba4a79ed483 100644 --- a/assets/js/blocks/cart/block.js +++ b/assets/js/blocks/cart/block.js @@ -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'; /** @@ -87,8 +90,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 441fda5a41b..d0b8b907a8c 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,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. @@ -56,13 +58,25 @@ const Block = ( { }; }, [] ); + const { dispatchOnProceedToCheckout } = useCartEventsContext(); + const submitContainerContents = (