Move checkout state code into thunks and rename `CheckoutState` context to `CheckoutEvents` (https://github.com/woocommerce/woocommerce-blocks/pull/6455)

* Add checkout data store

* wip on checkout data store

* CheckoutContext now uses the checkout store

* Investigated and removed setting the redirectUrl on the default state

* update extension and address hooks to use checkout data store

* use checkout data store in checkout-processor and use-checkout-button

* trim useCheckoutContext from use-payment-method-interface && use-store-cart-item-quantity

* Remove useCheckoutContext from shipping provider

* Remove isCalculating from state

* Removed useCheckoutContext from lots of places

* Remove useCheckoutContext from checkout-payment-block

* Remove useCheckoutContext in checkout-shipping-methods-block and checkout-shipping-address-block

* add isCart selector and action and update the checkoutstate context

* Fixed redirectUrl bug by using thunks

* Remove dispatchActions from checkout-state

* Change SET_HAS_ERROR action to be neater

* Thomas' feedback

* Tidy up

* Oops, deleted things I shouldn't have

* Typescript

* Fix types

* Fix tests

* Remove isCart

* Update docs and remove unecessary getRedirectUrl() selector

* validate event emitter button

* Added thunks in a separate file

* Call thunks from checkout-state

* Checkout logic tested and working

* Remove dependency injection as much as poss, tidy up and fix some TS errors

* Fix types in thunks.ts

* Fixed some ts errors

* WIP

* Fixed bug

* Shift side effects from checkout-state to checkout-processor

* Revert "Shift side effects from checkout-state to checkout-processor"

This reverts commit 059533da4eb34f9982f66cd4adacc7b2c24f939f.

* Rename CheckoutState to CheckoutEvents

* Move status check outside the thunk

* remove duplicate EVENTS constant

* remove temp buttons

* Remove console logs

* Augment @wordpress/data package with our new store types

* Add correct type for CheckoutAction

* Remove createErrorNotice arg from runCheckoutAfterProcessingWithErrorObservers

* Remove createErrorNotice from emit event types

* Use type keyword when importing types

* Add correct types for dispatch and select in thunks

* Update wordpress/data types

* Replace store creation with new preferred method

* Set correct action type on reducer

* Remove unnecessary async from thunk

* add CHECKOUT_ prefix to checkout events again

* export EVENTS with eveything else in checkout0-events/event-emit

* Remove duplicate SelectFromMap and TailParameters

* Updated type for paymentStatus

* TODO remove wp/data experimental thunks

* Remove `setCustomerId` from events and `processCheckoutResponseHeaders` (https://github.com/woocommerce/woocommerce-blocks/pull/6586)

* Prevent passing dispatch, instead get actions direct from store

* Get setCustomerId from the store instead of passing it to processCheckoutResponseHeaders

* Revert "Prevent passing dispatch, instead get actions direct from store"

This reverts commit 4479a2ef5599d9c8d99c3629616b3d662210fc08.

* Auto stash before revert of "Prevent passing dispatch, instead get actions direct from store"

* Remove duplicate dispatch

* Fix unit tests

Co-authored-by: Thomas Roberts <thomas.roberts@automattic.com>
Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com>
This commit is contained in:
Alex Florisca 2022-06-21 17:09:22 +03:00
parent 3630b7b03e
commit 015291ccf3
31 changed files with 18526 additions and 15964 deletions

View File

@ -20,7 +20,7 @@ import { ValidationInputError } from '../../providers/validation';
import { useStoreCart } from '../cart/use-store-cart';
import { useStoreCartCoupons } from '../cart/use-store-cart-coupons';
import { useEmitResponse } from '../use-emit-response';
import { useCheckoutContext } from '../../providers/cart-checkout/checkout-state';
import { useCheckoutEventsContext } from '../../providers/cart-checkout/checkout-events';
import { usePaymentMethodDataContext } from '../../providers/cart-checkout/payment-methods';
import { useShippingDataContext } from '../../providers/cart-checkout/shipping';
import { useCustomerDataContext } from '../../providers/cart-checkout/customer';
@ -37,7 +37,7 @@ export const usePaymentMethodInterface = (): PaymentMethodInterface => {
onCheckoutAfterProcessingWithSuccess,
onCheckoutAfterProcessingWithError,
onSubmit,
} = useCheckoutContext();
} = useCheckoutEventsContext();
const {
isCalculating,
isComplete,

View File

@ -13,7 +13,7 @@ import {
config as checkoutStoreConfig,
} from '../../../../data/checkout';
const mockUseCheckoutContext = {
const mockUseCheckoutEventsContext = {
onSubmit: jest.fn(),
};
const mockUsePaymentMethodDataContext = {
@ -23,8 +23,8 @@ const mockUsePaymentMethodDataContext = {
},
};
jest.mock( '../../providers/cart-checkout/checkout-state', () => ( {
useCheckoutContext: () => mockUseCheckoutContext,
jest.mock( '../../providers/cart-checkout/checkout-events', () => ( {
useCheckoutEventsContext: () => mockUseCheckoutEventsContext,
} ) );
jest.mock( '../../providers/cart-checkout/payment-methods', () => ( {
@ -66,6 +66,8 @@ describe( 'useCheckoutSubmit', () => {
onSubmit();
expect( mockUseCheckoutContext.onSubmit ).toHaveBeenCalledTimes( 1 );
expect( mockUseCheckoutEventsContext.onSubmit ).toHaveBeenCalledTimes(
1
);
} );
} );

View File

@ -8,7 +8,7 @@ import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { useCheckoutContext } from '../providers';
import { useCheckoutEventsContext } from '../providers';
import { usePaymentMethodDataContext } from '../providers/cart-checkout/payment-methods';
import { usePaymentMethods } from './payment-methods/use-payment-methods';
@ -36,7 +36,7 @@ export const useCheckoutSubmit = () => {
};
} );
const { onSubmit } = useCheckoutContext();
const { onSubmit } = useCheckoutEventsContext();
const { paymentMethods = {} } = usePaymentMethods();
const { activePaymentMethod, currentStatus: paymentStatus } =

View File

@ -14,7 +14,9 @@ import {
ActionType,
} from '../../../event-emit';
const EMIT_TYPES = {
// These events are emitted when the Checkout status is BEFORE_PROCESSING and AFTER_PROCESSING
// to enable third parties to hook into the checkout process
const EVENTS = {
CHECKOUT_VALIDATION_BEFORE_PROCESSING:
'checkout_validation_before_processing',
CHECKOUT_AFTER_PROCESSING_WITH_SUCCESS:
@ -42,15 +44,15 @@ const useEventEmitters = (
const eventEmitters = useMemo(
() => ( {
onCheckoutAfterProcessingWithSuccess: emitterCallback(
EMIT_TYPES.CHECKOUT_AFTER_PROCESSING_WITH_SUCCESS,
EVENTS.CHECKOUT_AFTER_PROCESSING_WITH_SUCCESS,
observerDispatch
),
onCheckoutAfterProcessingWithError: emitterCallback(
EMIT_TYPES.CHECKOUT_AFTER_PROCESSING_WITH_ERROR,
EVENTS.CHECKOUT_AFTER_PROCESSING_WITH_ERROR,
observerDispatch
),
onCheckoutValidationBeforeProcessing: emitterCallback(
EMIT_TYPES.CHECKOUT_VALIDATION_BEFORE_PROCESSING,
EVENTS.CHECKOUT_VALIDATION_BEFORE_PROCESSING,
observerDispatch
),
} ),
@ -59,4 +61,4 @@ const useEventEmitters = (
return eventEmitters;
};
export { EMIT_TYPES, useEventEmitters, reducer, emitEvent, emitEventWithAbort };
export { EVENTS, useEventEmitters, reducer, emitEvent, emitEventWithAbort };

View File

@ -0,0 +1,194 @@
/**
* External dependencies
*/
import {
createContext,
useContext,
useReducer,
useRef,
useMemo,
useEffect,
useCallback,
} from '@wordpress/element';
import { usePrevious } from '@woocommerce/base-hooks';
import deprecated from '@wordpress/deprecated';
import { useDispatch, useSelect } from '@wordpress/data';
import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import type { CheckoutEventsContextType } from './types';
import { useEventEmitters, reducer as emitReducer } from './event-emit';
import { STATUS } from '../../../../../data/checkout/constants';
import { useValidationContext } from '../../validation';
import { useStoreEvents } from '../../../hooks/use-store-events';
import { useCheckoutNotices } from '../../../hooks/use-checkout-notices';
import { useEmitResponse } from '../../../hooks/use-emit-response';
import { CheckoutState } from '../../../../../data/checkout/default-state';
const CheckoutEventsContext = createContext( {
onSubmit: () => void null,
onCheckoutAfterProcessingWithSuccess: () => () => void null,
onCheckoutAfterProcessingWithError: () => () => void null,
onCheckoutBeforeProcessing: () => () => void null, // deprecated for onCheckoutValidationBeforeProcessing
onCheckoutValidationBeforeProcessing: () => () => void null,
} );
export const useCheckoutEventsContext = () => {
return useContext( CheckoutEventsContext );
};
/**
* 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.
* @param {string} props.redirectUrl Initialize what the checkout will redirect to after successful submit.
*/
export const CheckoutEventsProvider = ( {
children,
redirectUrl,
}: {
children: React.ReactChildren;
redirectUrl: string;
} ): JSX.Element => {
const checkoutActions = useDispatch( CHECKOUT_STORE_KEY );
const checkoutState: CheckoutState = useSelect( ( select ) =>
select( CHECKOUT_STORE_KEY ).getCheckoutState()
);
if ( redirectUrl && redirectUrl !== checkoutState.redirectUrl ) {
checkoutActions.setRedirectUrl( redirectUrl );
}
const { setValidationErrors } = useValidationContext();
const { createErrorNotice } = useDispatch( 'core/notices' );
const { dispatchCheckoutEvent } = useStoreEvents();
const {
isSuccessResponse,
isErrorResponse,
isFailResponse,
shouldRetry,
} = useEmitResponse();
const {
checkoutNotices,
paymentNotices,
expressPaymentNotices,
} = useCheckoutNotices();
const [ observers, observerDispatch ] = useReducer( emitReducer, {} );
const currentObservers = useRef( observers );
const {
onCheckoutAfterProcessingWithSuccess,
onCheckoutAfterProcessingWithError,
onCheckoutValidationBeforeProcessing,
} = useEventEmitters( observerDispatch );
// set observers on ref so it's always current.
useEffect( () => {
currentObservers.current = observers;
}, [ observers ] );
/**
* @deprecated use onCheckoutValidationBeforeProcessing instead
*
* To prevent the deprecation message being shown at render time
* we need an extra function between useMemo and event emitters
* so that the deprecated message gets shown only at invocation time.
* (useMemo calls the passed function at render time)
* See: https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/4039/commits/a502d1be8828848270993264c64220731b0ae181
*/
const onCheckoutBeforeProcessing = useMemo( () => {
return function (
...args: Parameters< typeof onCheckoutValidationBeforeProcessing >
) {
deprecated( 'onCheckoutBeforeProcessing', {
alternative: 'onCheckoutValidationBeforeProcessing',
plugin: 'WooCommerce Blocks',
} );
return onCheckoutValidationBeforeProcessing( ...args );
};
}, [ onCheckoutValidationBeforeProcessing ] );
// Emit CHECKOUT_VALIDATE event and set the error state based on the response of
// the registered callbacks
useEffect( () => {
if ( checkoutState.status === STATUS.BEFORE_PROCESSING ) {
checkoutActions.emitValidateEvent( {
observers: currentObservers.current,
setValidationErrors,
} );
}
}, [
checkoutState.status,
setValidationErrors,
createErrorNotice,
checkoutActions,
] );
const previousStatus = usePrevious( checkoutState.status );
const previousHasError = usePrevious( checkoutState.hasError );
// Emit CHECKOUT_AFTER_PROCESSING_WITH_SUCCESS and CHECKOUT_AFTER_PROCESSING_WITH_ERROR events
// and set checkout errors according to the callback responses
useEffect( () => {
if (
checkoutState.status === previousStatus &&
checkoutState.hasError === previousHasError
) {
return;
}
if ( checkoutState.status === STATUS.AFTER_PROCESSING ) {
checkoutActions.emitAfterProcessingEvents( {
observers: currentObservers.current,
notices: {
checkoutNotices,
paymentNotices,
expressPaymentNotices,
},
} );
}
}, [
checkoutState.status,
checkoutState.hasError,
checkoutState.redirectUrl,
checkoutState.orderId,
checkoutState.customerId,
checkoutState.orderNotes,
checkoutState.processingResponse,
previousStatus,
previousHasError,
createErrorNotice,
isErrorResponse,
isFailResponse,
isSuccessResponse,
shouldRetry,
checkoutNotices,
expressPaymentNotices,
paymentNotices,
checkoutActions,
] );
const onSubmit = useCallback( () => {
dispatchCheckoutEvent( 'submit' );
checkoutActions.setBeforeProcessing();
}, [ dispatchCheckoutEvent, checkoutActions ] );
const checkoutEventHandlers: CheckoutEventsContextType = {
onSubmit,
onCheckoutBeforeProcessing,
onCheckoutValidationBeforeProcessing,
onCheckoutAfterProcessingWithSuccess,
onCheckoutAfterProcessingWithError,
};
return (
<CheckoutEventsContext.Provider value={ checkoutEventHandlers }>
{ children }
</CheckoutEventsContext.Provider>
);
};

View File

@ -0,0 +1,17 @@
/**
* Internal dependencies
*/
import type { emitterCallback } from '../../../event-emit';
export type CheckoutEventsContextType = {
// Submits the checkout and begins processing.
onSubmit: () => void;
// Used to register a callback that will fire after checkout has been processed and there are no errors.
onCheckoutAfterProcessingWithSuccess: ReturnType< typeof emitterCallback >;
// Used to register a callback that will fire when the checkout has been processed and has an error.
onCheckoutAfterProcessingWithError: ReturnType< typeof emitterCallback >;
// Deprecated in favour of onCheckoutValidationBeforeProcessing.
onCheckoutBeforeProcessing: ReturnType< typeof emitterCallback >;
// Used to register a callback that will fire when the checkout has been submitted before being sent off to the server.
onCheckoutValidationBeforeProcessing: ReturnType< typeof emitterCallback >;
};

View File

@ -21,7 +21,7 @@ import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
* Internal dependencies
*/
import { preparePaymentData, processCheckoutResponseHeaders } from './utils';
import { useCheckoutContext } from './checkout-state';
import { useCheckoutEventsContext } from './checkout-events';
import { useShippingDataContext } from './shipping';
import { useCustomerDataContext } from './customer';
import { usePaymentMethodDataContext } from './payment-methods';
@ -34,7 +34,7 @@ import { useStoreNoticesContext } from '../store-notices';
* Subscribes to checkout context and triggers processing via the API.
*/
const CheckoutProcessor = () => {
const { onCheckoutValidationBeforeProcessing } = useCheckoutContext();
const { onCheckoutValidationBeforeProcessing } = useCheckoutEventsContext();
const {
hasError: checkoutHasError,
@ -55,9 +55,8 @@ const CheckoutProcessor = () => {
};
} );
const { setCustomerId, setHasError, processCheckoutResponse } = useDispatch(
CHECKOUT_STORE_KEY
);
const { setHasError, processCheckoutResponse } =
useDispatch( CHECKOUT_STORE_KEY );
const { hasValidationErrors } = useValidationContext();
const { shippingErrorStatus } = useShippingDataContext();
@ -80,7 +79,10 @@ const CheckoutProcessor = () => {
const [ isProcessingOrder, setIsProcessingOrder ] = useState( false );
const paymentMethodId = useMemo( () => {
const merged = { ...expressPaymentMethods, ...paymentMethods };
const merged = {
...expressPaymentMethods,
...paymentMethods,
};
return merged?.[ activePaymentMethod ]?.paymentMethodId;
}, [ activePaymentMethod, expressPaymentMethods, paymentMethods ] );
@ -118,6 +120,7 @@ const CheckoutProcessor = () => {
setHasError,
] );
// Keep the billing, shipping and redirectUrl current
useEffect( () => {
currentBillingAddress.current = billingAddress;
currentShippingAddress.current = shippingAddress;
@ -152,6 +155,7 @@ const CheckoutProcessor = () => {
shippingErrorStatus.hasError,
] );
// Validate the checkout using the CHECKOUT_VALIDATION_BEFORE_PROCESSING event
useEffect( () => {
let unsubscribeProcessing;
if ( ! isExpressPaymentMethodActive ) {
@ -171,13 +175,14 @@ const CheckoutProcessor = () => {
isExpressPaymentMethodActive,
] );
// redirect when checkout is complete and there is a redirect url.
// Redirect when checkout is complete and there is a redirect url.
useEffect( () => {
if ( currentRedirectUrl.current ) {
window.location.href = currentRedirectUrl.current;
}
}, [ checkoutIsComplete ] );
// POST to the Store API and process and display any errors, or set order complete
const processOrder = useCallback( async () => {
if ( isProcessingOrder ) {
return;
@ -220,10 +225,7 @@ const CheckoutProcessor = () => {
parse: false,
} )
.then( ( response ) => {
processCheckoutResponseHeaders(
response.headers,
setCustomerId
);
processCheckoutResponseHeaders( response.headers );
if ( ! response.ok ) {
throw new Error( response );
}
@ -236,10 +238,7 @@ const CheckoutProcessor = () => {
.catch( ( errorResponse ) => {
try {
if ( errorResponse?.headers ) {
processCheckoutResponseHeaders(
errorResponse.headers,
setCustomerId
);
processCheckoutResponseHeaders( errorResponse.headers );
}
// This attempts to parse a JSON error response where the status code was 4xx/5xx.
errorResponse.json().then( ( response ) => {
@ -306,10 +305,9 @@ const CheckoutProcessor = () => {
receiveCart,
setHasError,
processCheckoutResponse,
setCustomerId,
] );
// process order if conditions are good.
// Process order if conditions are good.
useEffect( () => {
if ( paidAndWithoutErrors && ! isProcessingOrder ) {
processOrder();

View File

@ -10,7 +10,7 @@ import BlockErrorBoundary from '@woocommerce/base-components/block-error-boundar
import { PaymentMethodDataProvider } from './payment-methods';
import { ShippingDataProvider } from './shipping';
import { CustomerDataProvider } from './customer';
import { CheckoutStateProvider } from './checkout-state';
import { CheckoutEventsProvider } from './checkout-events';
import CheckoutProcessor from './checkout-processor';
/**
@ -27,7 +27,7 @@ import CheckoutProcessor from './checkout-processor';
*/
export const CheckoutProvider = ( { children, redirectUrl } ) => {
return (
<CheckoutStateProvider redirectUrl={ redirectUrl }>
<CheckoutEventsProvider redirectUrl={ redirectUrl }>
<CustomerDataProvider>
<ShippingDataProvider>
<PaymentMethodDataProvider>
@ -45,6 +45,6 @@ export const CheckoutProvider = ( { children, redirectUrl } ) => {
</PaymentMethodDataProvider>
</ShippingDataProvider>
</CustomerDataProvider>
</CheckoutStateProvider>
</CheckoutEventsProvider>
);
};

View File

@ -1,119 +0,0 @@
/**
* External dependencies
*/
import { PaymentResult } from '@woocommerce/types';
/**
* Internal dependencies
*/
import type { CheckoutStateContextState } from './types';
export enum ACTION {
SET_IDLE = 'set_idle',
SET_PRISTINE = 'set_pristine',
SET_REDIRECT_URL = 'set_redirect_url',
SET_COMPLETE = 'set_checkout_complete',
SET_BEFORE_PROCESSING = 'set_before_processing',
SET_AFTER_PROCESSING = 'set_after_processing',
SET_PROCESSING_RESPONSE = 'set_processing_response',
SET_PROCESSING = 'set_checkout_is_processing',
SET_HAS_ERROR = 'set_checkout_has_error',
SET_NO_ERROR = 'set_checkout_no_error',
SET_CUSTOMER_ID = 'set_checkout_customer_id',
SET_ORDER_ID = 'set_checkout_order_id',
SET_ORDER_NOTES = 'set_checkout_order_notes',
INCREMENT_CALCULATING = 'increment_calculating',
DECREMENT_CALCULATING = 'decrement_calculating',
SET_SHIPPING_ADDRESS_AS_BILLING_ADDRESS = 'set_shipping_address_as_billing_address',
SET_SHOULD_CREATE_ACCOUNT = 'set_should_create_account',
SET_EXTENSION_DATA = 'set_extension_data',
}
export interface ActionType extends Partial< CheckoutStateContextState > {
type: ACTION;
data?: Record< string, unknown > | Record< string, never > | PaymentResult;
}
/**
* All the actions that can be dispatched for the checkout.
*/
export const actions = {
setPristine: () =>
( {
type: ACTION.SET_PRISTINE,
} as const ),
setIdle: () =>
( {
type: ACTION.SET_IDLE,
} as const ),
setProcessing: () =>
( {
type: ACTION.SET_PROCESSING,
} as const ),
setRedirectUrl: ( redirectUrl: string ) =>
( {
type: ACTION.SET_REDIRECT_URL,
redirectUrl,
} as const ),
setProcessingResponse: ( data: PaymentResult ) =>
( {
type: ACTION.SET_PROCESSING_RESPONSE,
data,
} as const ),
setComplete: ( data: Record< string, unknown > = {} ) =>
( {
type: ACTION.SET_COMPLETE,
data,
} as const ),
setBeforeProcessing: () =>
( {
type: ACTION.SET_BEFORE_PROCESSING,
} as const ),
setAfterProcessing: () =>
( {
type: ACTION.SET_AFTER_PROCESSING,
} as const ),
setHasError: ( hasError = true ) =>
( {
type: hasError ? ACTION.SET_HAS_ERROR : ACTION.SET_NO_ERROR,
} as const ),
incrementCalculating: () =>
( {
type: ACTION.INCREMENT_CALCULATING,
} as const ),
decrementCalculating: () =>
( {
type: ACTION.DECREMENT_CALCULATING,
} as const ),
setCustomerId: ( customerId: number ) =>
( {
type: ACTION.SET_CUSTOMER_ID,
customerId,
} as const ),
setOrderId: ( orderId: number ) =>
( {
type: ACTION.SET_ORDER_ID,
orderId,
} as const ),
setUseShippingAsBilling: ( useShippingAsBilling: boolean ) =>
( {
type: ACTION.SET_SHIPPING_ADDRESS_AS_BILLING_ADDRESS,
useShippingAsBilling,
} as const ),
setShouldCreateAccount: ( shouldCreateAccount: boolean ) =>
( {
type: ACTION.SET_SHOULD_CREATE_ACCOUNT,
shouldCreateAccount,
} as const ),
setOrderNotes: ( orderNotes: string ) =>
( {
type: ACTION.SET_ORDER_NOTES,
orderNotes,
} as const ),
setExtensionData: (
extensionData: Record< string, Record< string, unknown > >
) =>
( {
type: ACTION.SET_EXTENSION_DATA,
extensionData,
} as const ),
};

View File

@ -1,66 +0,0 @@
/**
* External dependencies
*/
import { getSetting, EnteredAddress } from '@woocommerce/settings';
import { isSameAddress } from '@woocommerce/base-utils';
/**
* Internal dependencies
*/
import type {
CheckoutStateContextType,
CheckoutStateContextState,
} from './types';
export enum STATUS {
// Checkout is in it's initialized state.
PRISTINE = 'pristine',
// When checkout state has changed but there is no activity happening.
IDLE = 'idle',
// After BEFORE_PROCESSING status emitters have finished successfully. Payment processing is started on this checkout status.
PROCESSING = 'processing',
// After the AFTER_PROCESSING event emitters have completed. This status triggers the checkout redirect.
COMPLETE = 'complete',
// This is the state before checkout processing begins after the checkout button has been pressed/submitted.
BEFORE_PROCESSING = 'before_processing',
// After server side checkout processing is completed this status is set
AFTER_PROCESSING = 'after_processing',
}
const preloadedCheckoutData = getSetting( 'checkoutData', {} ) as Record<
string,
unknown
>;
const checkoutData = {
order_id: 0,
customer_id: 0,
billing_address: {} as EnteredAddress,
shipping_address: {} as EnteredAddress,
...( preloadedCheckoutData || {} ),
};
export const DEFAULT_CHECKOUT_STATE_DATA: CheckoutStateContextType = {
onSubmit: () => void null,
onCheckoutAfterProcessingWithSuccess: () => () => void null,
onCheckoutAfterProcessingWithError: () => () => void null,
onCheckoutBeforeProcessing: () => () => void null, // deprecated for onCheckoutValidationBeforeProcessing
onCheckoutValidationBeforeProcessing: () => () => void null,
};
export const DEFAULT_STATE: CheckoutStateContextState = {
redirectUrl: '',
status: STATUS.PRISTINE,
hasError: false,
calculatingCount: 0,
orderId: checkoutData.order_id,
orderNotes: '',
customerId: checkoutData.customer_id,
useShippingAsBilling: isSameAddress(
checkoutData.billing_address,
checkoutData.shipping_address
),
shouldCreateAccount: false,
processingResponse: null,
extensionData: {},
};

View File

@ -1,337 +0,0 @@
/**
* External dependencies
*/
import {
createContext,
useContext,
useReducer,
useRef,
useMemo,
useEffect,
useCallback,
} from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { usePrevious } from '@woocommerce/base-hooks';
import deprecated from '@wordpress/deprecated';
import { isObject, isString } from '@woocommerce/types';
import { useDispatch, useSelect } from '@wordpress/data';
import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import { STATUS, DEFAULT_CHECKOUT_STATE_DATA } from './constants';
import type { CheckoutStateContextType } from './types';
import {
EMIT_TYPES,
useEventEmitters,
emitEvent,
emitEventWithAbort,
reducer as emitReducer,
} from './event-emit';
import { useValidationContext } from '../../validation';
import { useStoreEvents } from '../../../hooks/use-store-events';
import { useCheckoutNotices } from '../../../hooks/use-checkout-notices';
import { useEmitResponse } from '../../../hooks/use-emit-response';
import { removeNoticesByStatus } from '../../../../../utils/notices';
import { CheckoutState } from '../../../../../data/checkout/default-state';
const CheckoutContext = createContext( DEFAULT_CHECKOUT_STATE_DATA );
export const useCheckoutContext = (): CheckoutStateContextType => {
return useContext( CheckoutContext );
};
/**
* Checkout state provider
* This provides an API interface exposing checkout state for use with cart or checkout blocks.
*
* @param {Object} props Incoming props for the provider.
* @param {Object} props.children The children being wrapped.
* @param {string} props.redirectUrl Initialize what the checkout will redirect to after successful submit.
*/
export const CheckoutStateProvider = ( {
children,
redirectUrl,
}: {
children: React.ReactChildren;
redirectUrl: string;
} ): JSX.Element => {
const checkoutActions = useDispatch( CHECKOUT_STORE_KEY );
const checkoutState: CheckoutState = useSelect( ( select ) =>
select( CHECKOUT_STORE_KEY ).getCheckoutState()
);
if ( redirectUrl && redirectUrl !== checkoutState.redirectUrl ) {
checkoutActions.setRedirectUrl( redirectUrl );
}
const { setValidationErrors } = useValidationContext();
const { createErrorNotice } = useDispatch( 'core/notices' );
const { dispatchCheckoutEvent } = useStoreEvents();
const { isSuccessResponse, isErrorResponse, isFailResponse, shouldRetry } =
useEmitResponse();
const { checkoutNotices, paymentNotices, expressPaymentNotices } =
useCheckoutNotices();
const [ observers, observerDispatch ] = useReducer( emitReducer, {} );
const currentObservers = useRef( observers );
const {
onCheckoutAfterProcessingWithSuccess,
onCheckoutAfterProcessingWithError,
onCheckoutValidationBeforeProcessing,
} = useEventEmitters( observerDispatch );
// set observers on ref so it's always current.
useEffect( () => {
currentObservers.current = observers;
}, [ observers ] );
/**
* @deprecated use onCheckoutValidationBeforeProcessing instead
*
* To prevent the deprecation message being shown at render time
* we need an extra function between useMemo and event emitters
* so that the deprecated message gets shown only at invocation time.
* (useMemo calls the passed function at render time)
* See: https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/4039/commits/a502d1be8828848270993264c64220731b0ae181
*/
const onCheckoutBeforeProcessing = useMemo( () => {
return function (
...args: Parameters< typeof onCheckoutValidationBeforeProcessing >
) {
deprecated( 'onCheckoutBeforeProcessing', {
alternative: 'onCheckoutValidationBeforeProcessing',
plugin: 'WooCommerce Blocks',
} );
return onCheckoutValidationBeforeProcessing( ...args );
};
}, [ onCheckoutValidationBeforeProcessing ] );
// emit events.
useEffect( () => {
const status = checkoutState.status;
if ( status === STATUS.BEFORE_PROCESSING ) {
removeNoticesByStatus( 'error' );
emitEvent(
currentObservers.current,
EMIT_TYPES.CHECKOUT_VALIDATION_BEFORE_PROCESSING,
{}
).then( ( response ) => {
if ( response !== true ) {
if ( Array.isArray( response ) ) {
response.forEach(
( { errorMessage, validationErrors } ) => {
createErrorNotice( errorMessage, {
context: 'wc/checkout',
} );
setValidationErrors( validationErrors );
}
);
}
checkoutActions.setIdle();
checkoutActions.setHasError();
} else {
checkoutActions.setProcessing();
}
} );
}
}, [
checkoutState.status,
setValidationErrors,
createErrorNotice,
checkoutActions,
] );
const previousStatus = usePrevious( checkoutState.status );
const previousHasError = usePrevious( checkoutState.hasError );
useEffect( () => {
if (
checkoutState.status === previousStatus &&
checkoutState.hasError === previousHasError
) {
return;
}
const handleErrorResponse = ( observerResponses: unknown[] ) => {
let errorResponse = null;
observerResponses.forEach( ( response ) => {
if (
isErrorResponse( response ) ||
isFailResponse( response )
) {
if ( response.message && isString( response.message ) ) {
const errorOptions =
response.messageContext &&
isString( response.messageContent )
? // The `as string` is OK here because of the type guard above.
{ context: response.messageContext as string }
: undefined;
errorResponse = response;
createErrorNotice( response.message, errorOptions );
}
}
} );
return errorResponse;
};
if ( checkoutState.status === STATUS.AFTER_PROCESSING ) {
const data = {
redirectUrl: checkoutState.redirectUrl,
orderId: checkoutState.orderId,
customerId: checkoutState.customerId,
orderNotes: checkoutState.orderNotes,
processingResponse: checkoutState.processingResponse,
};
if ( checkoutState.hasError ) {
// allow payment methods or other things to customize the error
// with a fallback if nothing customizes it.
emitEventWithAbort(
currentObservers.current,
EMIT_TYPES.CHECKOUT_AFTER_PROCESSING_WITH_ERROR,
data
).then( ( observerResponses ) => {
const errorResponse =
handleErrorResponse( observerResponses );
if ( errorResponse !== null ) {
// irrecoverable error so set complete
if ( ! shouldRetry( errorResponse ) ) {
checkoutActions.setComplete( errorResponse );
} else {
checkoutActions.setIdle();
}
} else {
const hasErrorNotices =
checkoutNotices.some(
( notice: { status: string } ) =>
notice.status === 'error'
) ||
expressPaymentNotices.some(
( notice: { status: string } ) =>
notice.status === 'error'
) ||
paymentNotices.some(
( notice: { status: string } ) =>
notice.status === 'error'
);
if ( ! hasErrorNotices ) {
// no error handling in place by anything so let's fall
// back to default
const message =
data.processingResponse?.message ||
__(
'Something went wrong. Please contact us for assistance.',
'woo-gutenberg-products-block'
);
createErrorNotice( message, {
id: 'checkout',
context: 'wc/checkout',
} );
}
checkoutActions.setIdle();
}
} );
} else {
emitEventWithAbort(
currentObservers.current,
EMIT_TYPES.CHECKOUT_AFTER_PROCESSING_WITH_SUCCESS,
data
).then( ( observerResponses: unknown[] ) => {
let successResponse = null as null | Record<
string,
unknown
>;
let errorResponse = null as null | Record<
string,
unknown
>;
observerResponses.forEach( ( response ) => {
if ( isSuccessResponse( response ) ) {
// the last observer response always "wins" for success.
successResponse = response;
}
if (
isErrorResponse( response ) ||
isFailResponse( response )
) {
errorResponse = response;
}
} );
if ( successResponse && ! errorResponse ) {
checkoutActions.setComplete( successResponse );
} else if ( isObject( errorResponse ) ) {
if (
errorResponse.message &&
isString( errorResponse.message )
) {
const errorOptions =
errorResponse.messageContext &&
isString( errorResponse.messageContext )
? { context: errorResponse.messageContext }
: undefined;
createErrorNotice(
errorResponse.message,
errorOptions
);
}
if ( ! shouldRetry( errorResponse ) ) {
checkoutActions.setComplete( errorResponse );
} else {
// this will set an error which will end up
// triggering the onCheckoutAfterProcessingWithError emitter.
// and then setting checkout to IDLE state.
checkoutActions.setHasError( true );
}
} else {
// nothing hooked in had any response type so let's just consider successful.
checkoutActions.setComplete();
}
} );
}
}
}, [
checkoutState.status,
checkoutState.hasError,
checkoutState.redirectUrl,
checkoutState.orderId,
checkoutState.customerId,
checkoutState.orderNotes,
checkoutState.processingResponse,
previousStatus,
previousHasError,
createErrorNotice,
isErrorResponse,
isFailResponse,
isSuccessResponse,
shouldRetry,
checkoutNotices,
expressPaymentNotices,
paymentNotices,
checkoutActions,
] );
const onSubmit = useCallback( () => {
dispatchCheckoutEvent( 'submit' );
checkoutActions.setBeforeProcessing();
}, [ dispatchCheckoutEvent, checkoutActions ] );
const checkoutData: CheckoutStateContextType = {
onSubmit,
onCheckoutBeforeProcessing,
onCheckoutValidationBeforeProcessing,
onCheckoutAfterProcessingWithSuccess,
onCheckoutAfterProcessingWithError,
};
return (
<CheckoutContext.Provider value={ checkoutData }>
{ children }
</CheckoutContext.Provider>
);
};

View File

@ -1,213 +0,0 @@
/**
* External dependencies
*/
import type { PaymentResult } from '@woocommerce/types';
/**
* Internal dependencies
*/
import { DEFAULT_STATE, STATUS } from './constants';
import { ActionType, ACTION } from './actions';
import type { CheckoutStateContextState } from './types';
/**
* Reducer for the checkout state
*/
export const reducer = (
state = DEFAULT_STATE,
{
redirectUrl,
type,
customerId,
orderId,
orderNotes,
extensionData,
useShippingAsBilling,
shouldCreateAccount,
data,
}: ActionType
): CheckoutStateContextState => {
let newState = state;
switch ( type ) {
case ACTION.SET_PRISTINE:
newState = DEFAULT_STATE;
break;
case ACTION.SET_IDLE:
newState =
state.status !== STATUS.IDLE
? {
...state,
status: STATUS.IDLE,
}
: state;
break;
case ACTION.SET_REDIRECT_URL:
newState =
redirectUrl !== undefined && redirectUrl !== state.redirectUrl
? {
...state,
redirectUrl,
}
: state;
break;
case ACTION.SET_PROCESSING_RESPONSE:
newState = {
...state,
processingResponse: data as PaymentResult,
};
break;
case ACTION.SET_COMPLETE:
newState =
state.status !== STATUS.COMPLETE
? {
...state,
status: STATUS.COMPLETE,
redirectUrl:
typeof data?.redirectUrl === 'string'
? data.redirectUrl
: state.redirectUrl,
}
: state;
break;
case ACTION.SET_PROCESSING:
newState =
state.status !== STATUS.PROCESSING
? {
...state,
status: STATUS.PROCESSING,
hasError: false,
}
: state;
// clear any error state.
newState =
newState.hasError === false
? newState
: { ...newState, hasError: false };
break;
case ACTION.SET_BEFORE_PROCESSING:
newState =
state.status !== STATUS.BEFORE_PROCESSING
? {
...state,
status: STATUS.BEFORE_PROCESSING,
hasError: false,
}
: state;
break;
case ACTION.SET_AFTER_PROCESSING:
newState =
state.status !== STATUS.AFTER_PROCESSING
? {
...state,
status: STATUS.AFTER_PROCESSING,
}
: state;
break;
case ACTION.SET_HAS_ERROR:
newState = state.hasError
? state
: {
...state,
hasError: true,
};
newState =
state.status === STATUS.PROCESSING ||
state.status === STATUS.BEFORE_PROCESSING
? {
...newState,
status: STATUS.IDLE,
}
: newState;
break;
case ACTION.SET_NO_ERROR:
newState = state.hasError
? {
...state,
hasError: false,
}
: state;
break;
case ACTION.INCREMENT_CALCULATING:
newState = {
...state,
calculatingCount: state.calculatingCount + 1,
};
break;
case ACTION.DECREMENT_CALCULATING:
newState = {
...state,
calculatingCount: Math.max( 0, state.calculatingCount - 1 ),
};
break;
case ACTION.SET_CUSTOMER_ID:
newState =
customerId !== undefined
? {
...state,
customerId,
}
: state;
break;
case ACTION.SET_ORDER_ID:
newState =
orderId !== undefined
? {
...state,
orderId,
}
: state;
break;
case ACTION.SET_SHIPPING_ADDRESS_AS_BILLING_ADDRESS:
if (
useShippingAsBilling !== undefined &&
useShippingAsBilling !== state.useShippingAsBilling
) {
newState = {
...state,
useShippingAsBilling,
};
}
break;
case ACTION.SET_SHOULD_CREATE_ACCOUNT:
if (
shouldCreateAccount !== undefined &&
shouldCreateAccount !== state.shouldCreateAccount
) {
newState = {
...state,
shouldCreateAccount,
};
}
break;
case ACTION.SET_ORDER_NOTES:
if ( orderNotes !== undefined && state.orderNotes !== orderNotes ) {
newState = {
...state,
orderNotes,
};
}
break;
case ACTION.SET_EXTENSION_DATA:
if (
extensionData !== undefined &&
state.extensionData !== extensionData
) {
newState = {
...state,
extensionData,
};
}
break;
}
// automatically update state to idle from pristine as soon as it
// initially changes.
if (
newState !== state &&
type !== ACTION.SET_PRISTINE &&
newState.status === STATUS.PRISTINE
) {
newState.status = STATUS.IDLE;
}
return newState;
};

View File

@ -1,75 +0,0 @@
/**
* External dependencies
*/
import { PaymentResult } from '@woocommerce/types';
/**
* Internal dependencies
*/
import { STATUS } from './constants';
import type { emitterCallback } from '../../../event-emit';
export interface CheckoutResponseError {
code: string;
message: string;
data: {
status: number;
};
}
export interface CheckoutResponseSuccess {
// eslint-disable-next-line camelcase
payment_result: {
// eslint-disable-next-line camelcase
payment_status: 'success' | 'failure' | 'pending' | 'error';
// eslint-disable-next-line camelcase
payment_details: Record< string, string > | Record< string, never >;
// eslint-disable-next-line camelcase
redirect_url: string;
};
}
export type CheckoutResponse = CheckoutResponseSuccess | CheckoutResponseError;
type extensionDataNamespace = string;
type extensionDataItem = Record< string, unknown >;
export type extensionData = Record< extensionDataNamespace, extensionDataItem >;
export interface CheckoutStateContextState {
redirectUrl: string;
status: STATUS;
hasError: boolean;
calculatingCount: number;
orderId: number;
orderNotes: string;
customerId: number;
useShippingAsBilling: boolean;
shouldCreateAccount: boolean;
processingResponse: PaymentResult | null;
extensionData: extensionData;
}
export type CheckoutStateDispatchActions = {
resetCheckout: () => void;
setRedirectUrl: ( url: string ) => void;
setHasError: ( hasError: boolean ) => void;
setAfterProcessing: ( response: CheckoutResponse ) => void;
incrementCalculating: () => void;
decrementCalculating: () => void;
setCustomerId: ( id: number ) => void;
setOrderId: ( id: number ) => void;
setOrderNotes: ( orderNotes: string ) => void;
setExtensionData: ( extensionData: extensionData ) => void;
};
export type CheckoutStateContextType = {
// Submits the checkout and begins processing.
onSubmit: () => void;
// Used to register a callback that will fire after checkout has been processed and there are no errors.
onCheckoutAfterProcessingWithSuccess: ReturnType< typeof emitterCallback >;
// Used to register a callback that will fire when the checkout has been processed and has an error.
onCheckoutAfterProcessingWithError: ReturnType< typeof emitterCallback >;
// Deprecated in favour of onCheckoutValidationBeforeProcessing.
onCheckoutBeforeProcessing: ReturnType< typeof emitterCallback >;
// Used to register a callback that will fire when the checkout has been submitted before being sent off to the server.
onCheckoutValidationBeforeProcessing: ReturnType< typeof emitterCallback >;
};

View File

@ -1,58 +0,0 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { decodeEntities } from '@wordpress/html-entities';
import type { PaymentResult, CheckoutResponse } from '@woocommerce/types';
/**
* Prepares the payment_result data from the server checkout endpoint response.
*/
export const getPaymentResultFromCheckoutResponse = (
response: CheckoutResponse
): PaymentResult => {
const paymentResult = {
message: '',
paymentStatus: '',
redirectUrl: '',
paymentDetails: {},
} as PaymentResult;
// payment_result is present in successful responses.
if ( 'payment_result' in response ) {
paymentResult.paymentStatus = response.payment_result.payment_status;
paymentResult.redirectUrl = response.payment_result.redirect_url;
if (
response.payment_result.hasOwnProperty( 'payment_details' ) &&
Array.isArray( response.payment_result.payment_details )
) {
response.payment_result.payment_details.forEach(
( { key, value }: { key: string; value: string } ) => {
paymentResult.paymentDetails[ key ] =
decodeEntities( value );
}
);
}
}
// message is present in error responses.
if ( 'message' in response ) {
paymentResult.message = decodeEntities( response.message );
}
// If there was an error code but no message, set a default message.
if (
! paymentResult.message &&
'data' in response &&
'status' in response.data &&
response.data.status > 299
) {
paymentResult.message = __(
'Something went wrong. Please contact us for assistance.',
'woo-gutenberg-products-block'
);
}
return paymentResult;
};

View File

@ -1,7 +1,7 @@
export * from './payment-methods';
export * from './shipping';
export * from './customer';
export * from './checkout-state';
export * from './checkout-events';
export * from './cart';
export * from './checkout-processor';
export * from './checkout-provider';

View File

@ -2,11 +2,8 @@
* External dependencies
*/
import triggerFetch from '@wordpress/api-fetch';
/**
* Internal dependencies
*/
import type { setCustomerId as setCheckoutCustomerId } from '../../../../data/checkout/actions';
import { dispatch } from '@wordpress/data';
import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
/**
* Utility function for preparing payment data for the request.
@ -34,10 +31,8 @@ export const preparePaymentData = (
/**
* Process headers from an API response an dispatch updates.
*/
export const processCheckoutResponseHeaders = (
headers: Headers,
setCustomerId: typeof setCheckoutCustomerId
): void => {
export const processCheckoutResponseHeaders = ( headers: Headers ): void => {
const { setCustomerId } = dispatch( CHECKOUT_STORE_KEY );
if (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore -- this does exist because it's monkey patched in

View File

@ -1,13 +1,16 @@
/**
* External dependencies
*/
import { CheckoutResponse, PaymentResult } from '@woocommerce/types';
import { PaymentResult } from '@woocommerce/types';
/**
* Internal dependencies
*/
import { getPaymentResultFromCheckoutResponse } from '../../base/context/providers/cart-checkout/checkout-state/utils';
import { ACTION_TYPES as types } from './action-types';
import { ReturnOrGeneratorYieldUnion } from '../mapped-types';
// `Thunks are functions that can be dispatched, similar to actions creators
export * from './thunks';
export const setPristine = () => ( {
type: types.SET_PRISTINE,
@ -44,15 +47,6 @@ export const setAfterProcessing = () => ( {
type: types.SET_AFTER_PROCESSING,
} );
export const processCheckoutResponse = ( response: CheckoutResponse ) => {
return async ( { dispatch }: { dispatch: React.Dispatch< Action > } ) => {
const paymentResult = getPaymentResultFromCheckoutResponse( response );
dispatch( setRedirectUrl( paymentResult?.redirectUrl || '' ) );
dispatch( setProcessingResponse( paymentResult ) );
dispatch( setAfterProcessing() );
};
};
export const setHasError = ( hasError = true ) => ( {
type: types.SET_HAS_ERROR,
hasError,
@ -98,7 +92,7 @@ export const setExtensionData = (
extensionData,
} );
type Action = ReturnType<
export type CheckoutAction = ReturnOrGeneratorYieldUnion<
| typeof setPristine
| typeof setIdle
| typeof setComplete

View File

@ -12,12 +12,12 @@ export enum STATUS {
PRISTINE = 'pristine',
// When checkout state has changed but there is no activity happening.
IDLE = 'idle',
// After BEFORE_PROCESSING status emitters have finished successfully. Payment processing is started on this checkout status.
PROCESSING = 'processing',
// After the AFTER_PROCESSING event emitters have completed. This status triggers the checkout redirect.
COMPLETE = 'complete',
// This is the state before checkout processing begins after the checkout button has been pressed/submitted.
BEFORE_PROCESSING = 'before_processing',
// After BEFORE_PROCESSING status emitters have finished successfully. Payment processing is started on this checkout status.
PROCESSING = 'processing',
// After server side checkout processing is completed this status is set
AFTER_PROCESSING = 'after_processing',
}

View File

@ -1,7 +1,7 @@
/**
* External dependencies
*/
import { registerStore } from '@wordpress/data';
import { createReduxStore, register } from '@wordpress/data';
/**
* Internal dependencies
@ -10,14 +10,29 @@ import { STORE_KEY } from './constants';
import * as selectors from './selectors';
import * as actions from './actions';
import reducer from './reducers';
import { DispatchFromMap, SelectFromMap } from '../mapped-types';
export const config = {
reducer,
selectors,
actions,
// TODO: Gutenberg with Thunks was released in WP 6.0. Once 6.1 is released, remove the experimental flag here
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore We pass this in case there is an older version of Gutenberg running.
__experimentalUseThunks: true,
};
registerStore( STORE_KEY, config );
const store = createReduxStore( STORE_KEY, config );
register( store );
export const CHECKOUT_STORE_KEY = STORE_KEY;
declare module '@wordpress/data' {
function dispatch(
key: typeof CHECKOUT_STORE_KEY
): DispatchFromMap< typeof actions >;
function select(
key: typeof CHECKOUT_STORE_KEY
): SelectFromMap< typeof selectors > & {
hasFinishedResolution: ( selector: string ) => boolean;
};
}

View File

@ -10,8 +10,9 @@ import { PaymentResult } from '@woocommerce/types';
import { ACTION_TYPES as types } from './action-types';
import { STATUS } from './constants';
import { defaultState } from './default-state';
import { CheckoutAction } from './actions';
const reducer: Reducer = ( state = defaultState, action ) => {
const reducer: Reducer = ( state = defaultState, action: CheckoutAction ) => {
let newState = state;
switch ( action.type ) {
case types.SET_PRISTINE:

View File

@ -0,0 +1,131 @@
/**
* External dependencies
*/
import type { CheckoutResponse } from '@woocommerce/types';
import { store as noticesStore } from '@wordpress/notices';
/**
* Internal dependencies
*/
import { removeNoticesByStatus } from '../../utils/notices';
import {
getPaymentResultFromCheckoutResponse,
runCheckoutAfterProcessingWithErrorObservers,
runCheckoutAfterProcessingWithSuccessObservers,
} from './utils';
import {
EVENTS,
emitEvent,
emitEventWithAbort,
} from '../../base/context/providers/cart-checkout/checkout-events/event-emit';
import type {
emitValidateEventType,
emitAfterProcessingEventsType,
} from './types';
import type { DispatchFromMap } from '../mapped-types';
import * as actions from './actions';
/**
* Based on the result of the payment, update the redirect url,
* set the payment processing response in the checkout data store
* and change the status to AFTER_PROCESSING
*/
export const processCheckoutResponse = ( response: CheckoutResponse ) => {
return ( {
dispatch,
}: {
dispatch: DispatchFromMap< typeof actions >;
} ) => {
const paymentResult = getPaymentResultFromCheckoutResponse( response );
dispatch.setRedirectUrl( paymentResult?.redirectUrl || '' );
dispatch.setProcessingResponse( paymentResult );
dispatch.setAfterProcessing();
};
};
/**
* Emit the CHECKOUT_VALIDATION_BEFORE_PROCESSING event and process all
* registered observers
*/
export const emitValidateEvent: emitValidateEventType = ( {
observers,
setValidationErrors, // TODO: Fix this type after we move to validation store
} ) => {
return ( { dispatch, registry } ) => {
const { createErrorNotice } = registry.dispatch( noticesStore );
removeNoticesByStatus( 'error' );
emitEvent(
observers,
EVENTS.CHECKOUT_VALIDATION_BEFORE_PROCESSING,
{}
).then( ( response ) => {
if ( response !== true ) {
if ( Array.isArray( response ) ) {
response.forEach(
( { errorMessage, validationErrors } ) => {
createErrorNotice( errorMessage, {
context: 'wc/checkout',
} );
setValidationErrors( validationErrors );
}
);
}
dispatch.setIdle();
dispatch.setHasError();
} else {
dispatch.setProcessing();
}
} );
};
};
/**
* Emit the CHECKOUT_AFTER_PROCESSING_WITH_ERROR if the checkout contains an error,
* or the CHECKOUT_AFTER_PROCESSING_WITH_SUCCESS if not. Set checkout errors according
* to the observer responses
*/
export const emitAfterProcessingEvents: emitAfterProcessingEventsType = ( {
observers,
notices,
} ) => {
return ( { select, dispatch, registry } ) => {
const { createErrorNotice } = registry.dispatch( noticesStore );
const state = select.getCheckoutState();
const data = {
redirectUrl: state.redirectUrl,
orderId: state.orderId,
customerId: state.customerId,
orderNotes: state.orderNotes,
processingResponse: state.processingResponse,
};
if ( state.hasError ) {
// allow payment methods or other things to customize the error
// with a fallback if nothing customizes it.
emitEventWithAbort(
observers,
EVENTS.CHECKOUT_AFTER_PROCESSING_WITH_ERROR,
data
).then( ( observerResponses ) => {
runCheckoutAfterProcessingWithErrorObservers( {
observerResponses,
notices,
dispatch,
createErrorNotice,
data,
} );
} );
} else {
emitEventWithAbort(
observers,
EVENTS.CHECKOUT_AFTER_PROCESSING_WITH_SUCCESS,
data
).then( ( observerResponses: unknown[] ) => {
runCheckoutAfterProcessingWithSuccessObservers( {
observerResponses,
dispatch,
createErrorNotice,
} );
} );
}
};
};

View File

@ -0,0 +1,63 @@
/**
* External dependencies
*/
import type { Notice } from '@wordpress/notices/';
import { DataRegistry } from '@wordpress/data';
/**
* Internal dependencies
*/
import type { EventObserversType } from '../../base/context/event-emit/types';
import type { CheckoutState } from './default-state';
import type { DispatchFromMap, SelectFromMap } from '../mapped-types';
import * as selectors from './selectors';
import * as actions from './actions';
export type CheckoutAfterProcessingWithErrorEventData = {
redirectUrl: CheckoutState[ 'redirectUrl' ];
orderId: CheckoutState[ 'orderId' ];
customerId: CheckoutState[ 'customerId' ];
orderNotes: CheckoutState[ 'orderNotes' ];
processingResponse: CheckoutState[ 'processingResponse' ];
};
export type CheckoutAndPaymentNotices = {
checkoutNotices: Notice[];
paymentNotices: Notice[];
expressPaymentNotices: Notice[];
};
/**
* Type for emitAfterProcessingEventsType() thunk
*/
export type emitAfterProcessingEventsType = ( {
observers,
notices,
}: {
observers: EventObserversType;
notices: CheckoutAndPaymentNotices;
} ) => ( {
select,
dispatch,
registry,
}: {
select: SelectFromMap< typeof selectors >;
dispatch: DispatchFromMap< typeof actions >;
registry: DataRegistry;
} ) => void;
/**
* Type for emitValidateEventType() thunk
*/
export type emitValidateEventType = ( {
observers,
setValidationErrors,
}: {
observers: EventObserversType;
setValidationErrors: ( errors: Array< unknown > ) => void;
} ) => ( {
dispatch,
registry,
}: {
dispatch: DispatchFromMap< typeof actions >;
registry: DataRegistry;
} ) => void;

View File

@ -0,0 +1,229 @@
/**
* External dependencies
*/
import { isString, isObject } from '@woocommerce/types';
import { __ } from '@wordpress/i18n';
import { decodeEntities } from '@wordpress/html-entities';
import type { PaymentResult, CheckoutResponse } from '@woocommerce/types';
import type { createErrorNotice as originalCreateErrorNotice } from '@wordpress/notices/store/actions';
/**
* Internal dependencies
*/
import { useEmitResponse } from '../../base/context/hooks/use-emit-response';
import {
CheckoutAndPaymentNotices,
CheckoutAfterProcessingWithErrorEventData,
} from './types';
import { DispatchFromMap } from '../mapped-types';
import * as actions from './actions';
const {
isErrorResponse,
isFailResponse,
isSuccessResponse,
shouldRetry,
} = useEmitResponse(); // eslint-disable-line react-hooks/rules-of-hooks
// TODO: `useEmitResponse` is not a react hook, it just exposes some functions as
// properties of an object. Refactor this to not be a hook, we could simply import
// those functions where needed
/**
* Based on the given observers, create Error Notices where necessary
* and return the error response of the last registered observer
*/
export const handleErrorResponse = ( {
observerResponses,
createErrorNotice,
}: {
observerResponses: unknown[];
createErrorNotice: typeof originalCreateErrorNotice;
} ) => {
let errorResponse = null;
observerResponses.forEach( ( response ) => {
if ( isErrorResponse( response ) || isFailResponse( response ) ) {
if ( response.message && isString( response.message ) ) {
const errorOptions =
response.messageContext &&
isString( response.messageContext )
? // The `as string` is OK here because of the type guard above.
{
context: response.messageContext as string,
}
: undefined;
errorResponse = response;
createErrorNotice( response.message, errorOptions );
}
}
} );
return errorResponse;
};
/**
* This functions runs after the CHECKOUT_AFTER_PROCESSING_WITH_ERROR event has been triggered and
* all observers have been processed. It sets any Error Notices and the status of the Checkout
* based on the observer responses
*/
export const runCheckoutAfterProcessingWithErrorObservers = ( {
observerResponses,
notices,
dispatch,
createErrorNotice,
data,
}: {
observerResponses: unknown[];
notices: CheckoutAndPaymentNotices;
dispatch: DispatchFromMap< typeof actions >;
data: CheckoutAfterProcessingWithErrorEventData;
createErrorNotice: typeof originalCreateErrorNotice;
} ) => {
const errorResponse = handleErrorResponse( {
observerResponses,
createErrorNotice,
} );
if ( errorResponse !== null ) {
// irrecoverable error so set complete
if ( ! shouldRetry( errorResponse ) ) {
dispatch.setComplete( errorResponse );
} else {
dispatch.setIdle();
}
} else {
const hasErrorNotices =
notices.checkoutNotices.some(
( notice: { status: string } ) => notice.status === 'error'
) ||
notices.expressPaymentNotices.some(
( notice: { status: string } ) => notice.status === 'error'
) ||
notices.paymentNotices.some(
( notice: { status: string } ) => notice.status === 'error'
);
if ( ! hasErrorNotices ) {
// no error handling in place by anything so let's fall
// back to default
const message =
data.processingResponse?.message ||
__(
'Something went wrong. Please contact us to get assistance.',
'woo-gutenberg-products-block'
);
createErrorNotice( message, {
id: 'checkout',
context: 'wc/checkout',
} );
}
dispatch.setIdle();
}
};
/**
* This functions runs after the CHECKOUT_AFTER_PROCESSING_WITH_SUCCESS event has been triggered and
* all observers have been processed. It sets any Error Notices and the status of the Checkout
* based on the observer responses
*/
export const runCheckoutAfterProcessingWithSuccessObservers = ( {
observerResponses,
dispatch,
createErrorNotice,
}: {
observerResponses: unknown[];
dispatch: DispatchFromMap< typeof actions >;
createErrorNotice: typeof originalCreateErrorNotice;
} ) => {
let successResponse = null as null | Record< string, unknown >;
let errorResponse = null as null | Record< string, unknown >;
observerResponses.forEach( ( response ) => {
if ( isSuccessResponse( response ) ) {
// the last observer response always "wins" for success.
successResponse = response;
}
if ( isErrorResponse( response ) || isFailResponse( response ) ) {
errorResponse = response;
}
} );
if ( successResponse && ! errorResponse ) {
dispatch.setComplete( successResponse );
} else if ( isObject( errorResponse ) ) {
if ( errorResponse.message && isString( errorResponse.message ) ) {
const errorOptions =
errorResponse.messageContext &&
isString( errorResponse.messageContext )
? {
context: errorResponse.messageContext,
}
: undefined;
createErrorNotice( errorResponse.message, errorOptions );
}
if ( ! shouldRetry( errorResponse ) ) {
dispatch.setComplete( errorResponse );
} else {
// this will set an error which will end up
// triggering the onCheckoutAfterProcessingWithError emitter.
// and then setting checkout to IDLE state.
dispatch.setHasError( true );
}
} else {
// nothing hooked in had any response type so let's just consider successful.
dispatch.setComplete();
}
};
/**
* Prepares the payment_result data from the server checkout endpoint response.
*/
export const getPaymentResultFromCheckoutResponse = (
response: CheckoutResponse
): PaymentResult => {
const paymentResult = {
message: '',
paymentStatus: 'not set',
redirectUrl: '',
paymentDetails: {},
} as PaymentResult;
// payment_result is present in successful responses.
if ( 'payment_result' in response ) {
paymentResult.paymentStatus = response.payment_result.payment_status;
paymentResult.redirectUrl = response.payment_result.redirect_url;
if (
response.payment_result.hasOwnProperty( 'payment_details' ) &&
Array.isArray( response.payment_result.payment_details )
) {
response.payment_result.payment_details.forEach(
( { key, value }: { key: string; value: string } ) => {
paymentResult.paymentDetails[ key ] = decodeEntities(
value
);
}
);
}
}
// message is present in error responses.
if ( 'message' in response ) {
paymentResult.message = decodeEntities( response.message );
}
// If there was an error code but no message, set a default message.
if (
! paymentResult.message &&
'data' in response &&
'status' in response.data &&
response.data.status > 299
) {
paymentResult.message = __(
'Something went wrong. Please contact us to get assistance.',
'woo-gutenberg-products-block'
);
}
return paymentResult;
};

View File

@ -7,6 +7,9 @@
"../mapped-types.ts",
"../settings/shared/index.js",
"../settings/blocks/index.js",
"../utils/notices.ts",
"../base/context/providers/cart-checkout/checkout-events/**.ts",
"../base/context/event-emit",
"../base/context/providers/cart-checkout/checkout-state/utils.ts"
],
"exclude": [ "**/test/**" ]

View File

@ -0,0 +1,13 @@
export interface Address {
address_1: string;
address_2?: string;
city?: string;
company?: string;
country: string;
email: string;
first_name: string;
last_name: string;
phone?: string;
postcode: string;
state?: string;
}

View File

@ -1,10 +1,10 @@
/**
* Internal dependencies
* External dependencies
*/
import { EnteredAddress } from '../../settings/shared/default-address-fields';
import { Address } from '@woocommerce/types';
export interface CheckoutResponseSuccess {
billing_address: EnteredAddress;
billing_address: Address;
customer_id: number;
customer_note: string;
extensions: Record< string, unknown >;
@ -16,7 +16,7 @@ export interface CheckoutResponseSuccess {
payment_status: 'success' | 'failure' | 'pending' | 'error';
redirect_url: string;
};
shipping_address: EnteredAddress;
shipping_address: Address;
status: string;
}

View File

@ -1,3 +1,4 @@
export * from './addresses';
export * from './blocks';
export * from './cart';
export * from './cart-response';

View File

@ -12,6 +12,7 @@ import {
CartResponseShippingAddress,
} from './cart-response';
import type { EmptyObjectType } from './objects';
import type { CheckoutResponseSuccess } from './checkout';
export interface SupportsConfiguration {
showSavedCards?: boolean;
@ -121,7 +122,9 @@ export interface ExpressPaymentMethodConfigInstance {
export interface PaymentResult {
message: string;
paymentStatus: string;
paymentStatus:
| CheckoutResponseSuccess[ 'payment_result' ][ 'payment_status' ]
| 'not set';
paymentDetails: Record< string, string > | Record< string, never >;
redirectUrl: string;
}

View File

@ -35,7 +35,7 @@ The following data is available:
- `orderId`: The order id for the order attached to the current checkout.
- `customerId`: The ID of the customer if the customer has an account, or `0` for guests.
- `calculatingCount`: If any of the totals, taxes, shipping, etc need to be calculated, the count will be increased here.
- `processingResponse`:The result of the payment processing
- `processingResponse`: The result of the payment processing.
- `useShippingAsBilling`: Should the billing form be hidden and inherit the shipping address?
- `shouldCreateAccount`: Should a user account be created with this order?
- `extensionData`: This is used by plugins that extend Cart & Checkout to pass custom data to the Store API on checkout processing

File diff suppressed because it is too large Load Diff

View File

@ -121,6 +121,7 @@
"@types/wordpress__data": "^6.0.1",
"@types/wordpress__data-controls": "2.2.0",
"@types/wordpress__editor": "^11.0.0",
"@types/wordpress__notices": "^3.3.0",
"@typescript-eslint/eslint-plugin": "5.30.5",
"@typescript-eslint/parser": "5.35.1",
"@woocommerce/api": "0.2.0",