initial commit

This commit is contained in:
2021-12-10 12:03:04 +00:00
commit c46c7ddbf0
3643 changed files with 582794 additions and 0 deletions

View File

@ -0,0 +1,16 @@
/**
* Internal dependencies
*/
import { actions } from './reducer';
import type { ActionType, ActionCallbackType } from './types';
export const emitterCallback = (
type: string,
observerDispatch: React.Dispatch< ActionType >
) => ( callback: ActionCallbackType, priority = 10 ): ( () => void ) => {
const action = actions.addEventCallback( type, callback, priority );
observerDispatch( action );
return () => {
observerDispatch( actions.removeEventCallback( type, action.id ) );
};
};

View File

@ -0,0 +1,95 @@
/**
* Internal dependencies
*/
import { getObserversByPriority } from './utils';
import type { EventObserversType } from './types';
import { isErrorResponse, isFailResponse } from '../hooks/use-emit-response';
/**
* Emits events on registered observers for the provided type and passes along
* the provided data.
*
* This event emitter will silently catch promise errors, but doesn't care
* otherwise if any errors are caused by observers. So events that do care
* should use `emitEventWithAbort` instead.
*
* @param {Object} observers The registered observers to omit to.
* @param {string} eventType The event type being emitted.
* @param {*} data Data passed along to the observer when it is invoked.
*
* @return {Promise} A promise that resolves to true after all observers have executed.
*/
export const emitEvent = async (
observers: EventObserversType,
eventType: string,
data: unknown
): Promise< unknown > => {
const observersByType = getObserversByPriority( observers, eventType );
const observerResponses = [];
for ( const observer of observersByType ) {
try {
const observerResponse = await Promise.resolve(
observer.callback( data )
);
if ( typeof observerResponse === 'object' ) {
observerResponses.push( observerResponse );
}
} catch ( e ) {
// we don't care about errors blocking execution, but will console.error for troubleshooting.
// eslint-disable-next-line no-console
console.error( e );
}
}
return observerResponses.length ? observerResponses : true;
};
/**
* Emits events on registered observers for the provided type and passes along
* the provided data. This event emitter will abort if an observer throws an
* error or if the response includes an object with an error type property.
*
* Any successful observer responses before abort will be included in the returned package.
*
* @param {Object} observers The registered observers to omit to.
* @param {string} eventType The event type being emitted.
* @param {*} data Data passed along to the observer when it is invoked.
*
* @return {Promise} Returns a promise that resolves to either boolean, or an array of responses
* from registered observers that were invoked up to the point of an error.
*/
export const emitEventWithAbort = async (
observers: EventObserversType,
eventType: string,
data: unknown
): Promise< Array< unknown > > => {
const observerResponses = [];
const observersByType = getObserversByPriority( observers, eventType );
for ( const observer of observersByType ) {
try {
const response = await Promise.resolve( observer.callback( data ) );
if ( typeof response !== 'object' || response === null ) {
continue;
}
if ( ! response.hasOwnProperty( 'type' ) ) {
throw new Error(
'Returned objects from event emitter observers must return an object with a type property'
);
}
if ( isErrorResponse( response ) || isFailResponse( response ) ) {
observerResponses.push( response );
// early abort.
return observerResponses;
}
// all potential abort conditions have been considered push the
// response to the array.
observerResponses.push( response );
} catch ( e ) {
// We don't handle thrown errors but just console.log for troubleshooting.
// eslint-disable-next-line no-console
console.error( e );
observerResponses.push( { type: 'error' } );
return observerResponses;
}
}
return observerResponses;
};

View File

@ -0,0 +1,4 @@
export * from './reducer';
export * from './emitters';
export * from './emitter-callback';
export * from './types';

View File

@ -0,0 +1,65 @@
/**
* External dependencies
*/
import { uniqueId } from 'lodash';
/**
* Internal dependencies
*/
import {
ACTION,
ActionType,
ActionCallbackType,
EventObserversType,
} from './types';
export const actions = {
addEventCallback: (
eventType: string,
callback: ActionCallbackType,
priority = 10
): ActionType => {
return {
id: uniqueId(),
type: ACTION.ADD_EVENT_CALLBACK,
eventType,
callback,
priority,
};
},
removeEventCallback: ( eventType: string, id: string ): ActionType => {
return {
id,
type: ACTION.REMOVE_EVENT_CALLBACK,
eventType,
};
},
};
const initialState = {} as EventObserversType;
/**
* Handles actions for emitters
*/
export const reducer = (
state = initialState,
{ type, eventType, id, callback, priority }: ActionType
): typeof initialState => {
const newEvents = state.hasOwnProperty( eventType )
? new Map( state[ eventType ] )
: new Map();
switch ( type ) {
case ACTION.ADD_EVENT_CALLBACK:
newEvents.set( id, { priority, callback } );
return {
...state,
[ eventType ]: newEvents,
};
case ACTION.REMOVE_EVENT_CALLBACK:
newEvents.delete( id );
return {
...state,
[ eventType ]: newEvents,
};
}
};

View File

@ -0,0 +1,119 @@
/**
* Internal dependencies
*/
import { emitEvent, emitEventWithAbort } from '../emitters';
describe( 'Testing emitters', () => {
let observerMocks = {};
let observerA;
let observerB;
let observerPromiseWithResolvedValue;
beforeEach( () => {
observerA = jest.fn().mockReturnValue( true );
observerB = jest.fn().mockReturnValue( true );
observerPromiseWithResolvedValue = jest.fn().mockResolvedValue( 10 );
observerMocks = new Map( [
[ 'observerA', { priority: 10, callback: observerA } ],
[ 'observerB', { priority: 10, callback: observerB } ],
[
'observerReturnValue',
{ priority: 10, callback: jest.fn().mockReturnValue( 10 ) },
],
[
'observerPromiseWithReject',
{
priority: 10,
callback: jest.fn().mockRejectedValue( 'an error' ),
},
],
[
'observerPromiseWithResolvedValue',
{ priority: 10, callback: observerPromiseWithResolvedValue },
],
[
'observerSuccessType',
{
priority: 10,
callback: jest.fn().mockReturnValue( { type: 'success' } ),
},
],
] );
} );
describe( 'Testing emitEvent()', () => {
it( 'invokes all observers', async () => {
const observers = { test: observerMocks };
const response = await emitEvent( observers, 'test', 'foo' );
expect( console ).toHaveErroredWith( 'an error' );
expect( observerA ).toHaveBeenCalledTimes( 1 );
expect( observerB ).toHaveBeenCalledWith( 'foo' );
expect( response ).toEqual( [ { type: 'success' } ] );
} );
} );
describe( 'Testing emitEventWithAbort()', () => {
it( 'does not abort on any return value other than an object with an error or fail type property', async () => {
observerMocks.delete( 'observerPromiseWithReject' );
const observers = { test: observerMocks };
const response = await emitEventWithAbort(
observers,
'test',
'foo'
);
expect( console ).not.toHaveErrored();
expect( observerB ).toHaveBeenCalledTimes( 1 );
expect( observerPromiseWithResolvedValue ).toHaveBeenCalled();
expect( response ).toEqual( [ { type: 'success' } ] );
} );
it( 'Aborts on a return value with an object that has a a fail type property', async () => {
const validObjectResponse = jest
.fn()
.mockReturnValue( { type: 'failure' } );
observerMocks.set( 'observerValidObject', {
priority: 5,
callback: validObjectResponse,
} );
const observers = { test: observerMocks };
const response = await emitEventWithAbort(
observers,
'test',
'foo'
);
expect( console ).not.toHaveErrored();
expect( validObjectResponse ).toHaveBeenCalledTimes( 1 );
expect( observerPromiseWithResolvedValue ).not.toHaveBeenCalled();
expect( response ).toEqual( [ { type: 'failure' } ] );
} );
it( 'throws an error on an object returned from observer without a type property', async () => {
const failingObjectResponse = jest.fn().mockReturnValue( {} );
observerMocks.set( 'observerInvalidObject', {
priority: 5,
callback: failingObjectResponse,
} );
const observers = { test: observerMocks };
const response = await emitEventWithAbort(
observers,
'test',
'foo'
);
expect( console ).toHaveErrored();
expect( failingObjectResponse ).toHaveBeenCalledTimes( 1 );
expect( observerPromiseWithResolvedValue ).not.toHaveBeenCalled();
expect( response ).toEqual( [ { type: 'error' } ] );
} );
} );
describe( 'Test Priority', () => {
it( 'executes observers in expected order by priority', async () => {
const a = jest.fn();
const b = jest.fn().mockReturnValue( { type: 'error' } );
const observers = {
test: new Map( [
[ 'observerA', { priority: 200, callback: a } ],
[ 'observerB', { priority: 10, callback: b } ],
] ),
};
await emitEventWithAbort( observers, 'test', 'foo' );
expect( console ).not.toHaveErrored();
expect( b ).toHaveBeenCalledTimes( 1 );
expect( a ).not.toHaveBeenCalled();
} );
} );
} );

View File

@ -0,0 +1,18 @@
export enum ACTION {
ADD_EVENT_CALLBACK = 'add_event_callback',
REMOVE_EVENT_CALLBACK = 'remove_event_callback',
}
export type ActionCallbackType = ( ...args: unknown[] ) => unknown;
export type ActionType = {
type: ACTION;
eventType: string;
id: string;
callback?: ActionCallbackType;
priority?: number;
};
export type ObserverType = { priority: number; callback: ActionCallbackType };
export type ObserversType = Map< string, ObserverType >;
export type EventObserversType = Record< string, ObserversType >;

View File

@ -0,0 +1,15 @@
/**
* Internal dependencies
*/
import type { EventObserversType, ObserverType } from './types';
export const getObserversByPriority = (
observers: EventObserversType,
eventType: string
): ObserverType[] => {
return observers[ eventType ]
? Array.from( observers[ eventType ].values() ).sort( ( a, b ) => {
return a.priority - b.priority;
} )
: [];
};

View File

@ -0,0 +1,3 @@
export * from './use-store-cart';
export * from './use-store-cart-coupons';
export * from './use-store-cart-item-quantity';

View File

@ -0,0 +1,206 @@
/**
* External dependencies
*/
import TestRenderer, { act } from 'react-test-renderer';
import { createRegistry, RegistryProvider } from '@wordpress/data';
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import * as mockUseStoreCart from '../use-store-cart';
import { useStoreCartItemQuantity } from '../use-store-cart-item-quantity';
jest.mock( '../use-store-cart', () => ( {
useStoreCart: jest.fn(),
} ) );
jest.mock( '@woocommerce/block-data', () => ( {
__esModule: true,
CART_STORE_KEY: 'test/store',
} ) );
// Make debounce instantaneous.
jest.mock( 'use-debounce', () => ( {
useDebounce: ( a ) => [ a ],
} ) );
describe( 'useStoreCartItemQuantity', () => {
let registry, renderer;
const getWrappedComponents = ( Component ) => (
<RegistryProvider value={ registry }>
<Component />
</RegistryProvider>
);
const getTestComponent = ( options ) => () => {
const props = useStoreCartItemQuantity( options );
return <div { ...props } />;
};
let mockRemoveItemFromCart;
let mockChangeCartItemQuantity;
const setupMocks = ( { isPendingDelete, isPendingQuantity } ) => {
mockRemoveItemFromCart = jest
.fn()
.mockReturnValue( { type: 'removeItemFromCartAction' } );
mockChangeCartItemQuantity = jest
.fn()
.mockReturnValue( { type: 'changeCartItemQuantityAction' } );
registry.registerStore( storeKey, {
reducer: () => ( {} ),
actions: {
removeItemFromCart: mockRemoveItemFromCart,
changeCartItemQuantity: mockChangeCartItemQuantity,
},
selectors: {
isItemPendingDelete: jest
.fn()
.mockReturnValue( isPendingDelete ),
isItemPendingQuantity: jest
.fn()
.mockReturnValue( isPendingQuantity ),
},
} );
};
beforeEach( () => {
registry = createRegistry();
renderer = null;
} );
afterEach( () => {
mockRemoveItemFromCart.mockReset();
mockChangeCartItemQuantity.mockReset();
} );
describe( 'with no errors and not pending', () => {
beforeEach( () => {
setupMocks( { isPendingDelete: false, isPendingQuantity: false } );
mockUseStoreCart.useStoreCart.mockReturnValue( {
cartErrors: {},
} );
} );
it( 'update quantity value should happen instantly', () => {
const TestComponent = getTestComponent( {
key: '123',
quantity: 1,
} );
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent )
);
} );
const { setItemQuantity, quantity } = renderer.root.findByType(
'div'
).props;
expect( quantity ).toBe( 1 );
act( () => {
setItemQuantity( 2 );
} );
const { quantity: newQuantity } = renderer.root.findByType(
'div'
).props;
expect( newQuantity ).toBe( 2 );
} );
it( 'removeItem should call the dispatch action', () => {
const TestComponent = getTestComponent( {
key: '123',
quantity: 1,
} );
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent )
);
} );
const { removeItem } = renderer.root.findByType( 'div' ).props;
act( () => {
removeItem();
} );
expect( mockRemoveItemFromCart ).toHaveBeenCalledWith( '123' );
} );
it( 'setItemQuantity should call the dispatch action', () => {
const TestComponent = getTestComponent( {
key: '123',
quantity: 1,
} );
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent )
);
} );
const { setItemQuantity } = renderer.root.findByType( 'div' ).props;
act( () => {
setItemQuantity( 2 );
} );
expect( mockChangeCartItemQuantity.mock.calls ).toEqual( [
[ '123', 2 ],
] );
} );
} );
it( 'should expose store errors', () => {
const mockCartErrors = [ { message: 'Test error' } ];
setupMocks( { isPendingDelete: false, isPendingQuantity: false } );
mockUseStoreCart.useStoreCart.mockReturnValue( {
cartErrors: mockCartErrors,
} );
const TestComponent = getTestComponent( {
key: '123',
quantity: 1,
} );
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent )
);
} );
const { cartItemQuantityErrors } = renderer.root.findByType(
'div'
).props;
expect( cartItemQuantityErrors ).toEqual( mockCartErrors );
} );
it( 'isPendingDelete should depend on the value provided by the store', () => {
setupMocks( { isPendingDelete: true, isPendingQuantity: false } );
mockUseStoreCart.useStoreCart.mockReturnValue( {
cartErrors: {},
} );
const TestComponent = getTestComponent( {
key: '123',
quantity: 1,
} );
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent )
);
} );
const { isPendingDelete } = renderer.root.findByType( 'div' ).props;
expect( isPendingDelete ).toBe( true );
} );
} );

View File

@ -0,0 +1,235 @@
/**
* External dependencies
*/
import TestRenderer, { act } from 'react-test-renderer';
import { createRegistry, RegistryProvider } from '@wordpress/data';
import { previewCart } from '@woocommerce/resource-previews';
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import { defaultCartData, useStoreCart } from '../use-store-cart';
import { useEditorContext } from '../../../providers/editor-context';
jest.mock( '../../../providers/editor-context', () => ( {
useEditorContext: jest.fn(),
} ) );
jest.mock( '@woocommerce/block-data', () => ( {
...jest.requireActual( '@woocommerce/block-data' ),
__esModule: true,
CART_STORE_KEY: 'test/store',
} ) );
describe( 'useStoreCart', () => {
let registry, renderer;
const receiveCartMock = () => {};
const previewCartData = {
cartCoupons: previewCart.coupons,
cartItems: previewCart.items,
cartFees: previewCart.fees,
cartItemsCount: previewCart.items_count,
cartItemsWeight: previewCart.items_weight,
cartNeedsPayment: previewCart.needs_payment,
cartNeedsShipping: previewCart.needs_shipping,
cartTotals: previewCart.totals,
cartIsLoading: false,
cartItemErrors: [],
cartErrors: [],
billingAddress: {
first_name: '',
last_name: '',
company: '',
address_1: '',
address_2: '',
city: '',
state: '',
postcode: '',
country: '',
email: '',
phone: '',
},
shippingAddress: {
first_name: '',
last_name: '',
company: '',
address_1: '',
address_2: '',
city: '',
state: '',
postcode: '',
country: '',
phone: '',
},
shippingRates: previewCart.shipping_rates,
extensions: {},
shippingRatesLoading: false,
cartHasCalculatedShipping: true,
};
const mockCartItems = [ { key: '1', id: 1, name: 'Lorem Ipsum' } ];
const mockShippingAddress = {
city: 'New York',
};
const mockCartData = {
coupons: [],
items: mockCartItems,
fees: [],
itemsCount: 1,
itemsWeight: 10,
needsPayment: true,
needsShipping: true,
billingAddress: {},
shippingAddress: mockShippingAddress,
shippingRates: [],
hasCalculatedShipping: true,
extensions: {},
errors: [],
receiveCart: undefined,
paymentRequirements: [],
};
const mockCartTotals = {
currency_code: 'USD',
};
const mockCartIsLoading = false;
const mockCartErrors = [];
const mockStoreCartData = {
cartCoupons: [],
cartItems: mockCartItems,
cartItemErrors: [],
cartItemsCount: 1,
cartItemsWeight: 10,
cartNeedsPayment: true,
cartNeedsShipping: true,
cartTotals: mockCartTotals,
cartIsLoading: mockCartIsLoading,
cartErrors: mockCartErrors,
cartFees: [],
billingAddress: {},
shippingAddress: mockShippingAddress,
shippingRates: [],
extensions: {},
shippingRatesLoading: false,
cartHasCalculatedShipping: true,
receiveCart: undefined,
paymentRequirements: [],
};
const getWrappedComponents = ( Component ) => (
<RegistryProvider value={ registry }>
<Component />
</RegistryProvider>
);
const getTestComponent = ( options ) => () => {
const { receiveCart, ...results } = useStoreCart( options );
return <div results={ results } receiveCart={ receiveCart } />;
};
const setUpMocks = () => {
const mocks = {
selectors: {
getCartData: jest.fn().mockReturnValue( mockCartData ),
getCartErrors: jest.fn().mockReturnValue( mockCartErrors ),
getCartTotals: jest.fn().mockReturnValue( mockCartTotals ),
hasFinishedResolution: jest
.fn()
.mockReturnValue( ! mockCartIsLoading ),
isCustomerDataUpdating: jest.fn().mockReturnValue( false ),
},
};
registry.registerStore( storeKey, {
reducer: () => ( {} ),
selectors: mocks.selectors,
} );
};
beforeEach( () => {
registry = createRegistry();
renderer = null;
setUpMocks();
} );
afterEach( () => {
useEditorContext.mockReset();
} );
describe( 'in frontend', () => {
beforeEach( () => {
useEditorContext.mockReturnValue( {
isEditor: false,
} );
} );
it( 'return default data when shouldSelect is false', () => {
const TestComponent = getTestComponent( { shouldSelect: false } );
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent )
);
} );
const { results, receiveCart } = renderer.root.findByType(
'div'
).props;
const {
receiveCart: defaultReceiveCart,
...remaining
} = defaultCartData;
expect( results ).toEqual( remaining );
expect( receiveCart ).toEqual( defaultReceiveCart );
} );
it( 'return store data when shouldSelect is true', () => {
const TestComponent = getTestComponent( { shouldSelect: true } );
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent )
);
} );
const { results, receiveCart } = renderer.root.findByType(
'div'
).props;
expect( results ).toEqual( mockStoreCartData );
expect( receiveCart ).toBeUndefined();
} );
} );
describe( 'in editor', () => {
beforeEach( () => {
useEditorContext.mockReturnValue( {
isEditor: true,
previewData: {
previewCart: {
...previewCart,
receiveCart: receiveCartMock,
},
},
} );
} );
it( 'return preview data in editor', () => {
const TestComponent = getTestComponent();
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent )
);
} );
const { results, receiveCart } = renderer.root.findByType(
'div'
).props;
expect( results ).toEqual( previewCartData );
expect( receiveCart ).toEqual( receiveCartMock );
} );
} );
} );

View File

@ -0,0 +1,126 @@
/** @typedef { import('@woocommerce/type-defs/hooks').StoreCartCoupon } StoreCartCoupon */
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { useSelect } from '@wordpress/data';
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { decodeEntities } from '@wordpress/html-entities';
import type { StoreCartCoupon } from '@woocommerce/types';
/**
* Internal dependencies
*/
import { useStoreCart } from './use-store-cart';
import { useStoreSnackbarNotices } from '../use-store-snackbar-notices';
import { useValidationContext } from '../../providers/validation';
import { useStoreNotices } from '../use-store-notices';
/**
* This is a custom hook for loading the Store API /cart/coupons endpoint and an
* action for adding a coupon _to_ the cart.
* See also: https://github.com/woocommerce/woocommerce-gutenberg-products-block/tree/trunk/src/RestApi/StoreApi
*
* @return {StoreCartCoupon} An object exposing data and actions from/for the
* store api /cart/coupons endpoint.
*/
export const useStoreCartCoupons = (): StoreCartCoupon => {
const { cartCoupons, cartIsLoading } = useStoreCart();
const { addErrorNotice } = useStoreNotices();
const { addSnackbarNotice } = useStoreSnackbarNotices();
const { setValidationErrors } = useValidationContext();
const results: Pick<
StoreCartCoupon,
'applyCoupon' | 'removeCoupon' | 'isApplyingCoupon' | 'isRemovingCoupon'
> = useSelect(
( select, { dispatch } ) => {
const store = select( storeKey );
const isApplyingCoupon = store.isApplyingCoupon();
const isRemovingCoupon = store.isRemovingCoupon();
const {
applyCoupon,
removeCoupon,
receiveApplyingCoupon,
}: {
applyCoupon: ( coupon: string ) => Promise< boolean >;
removeCoupon: ( coupon: string ) => Promise< boolean >;
receiveApplyingCoupon: ( coupon: string ) => void;
} = dispatch( storeKey );
const applyCouponWithNotices = ( couponCode: string ) => {
applyCoupon( couponCode )
.then( ( result ) => {
if ( result === true ) {
addSnackbarNotice(
sprintf(
/* translators: %s coupon code. */
__(
'Coupon code "%s" has been applied to your cart.',
'woo-gutenberg-products-block'
),
couponCode
),
{
id: 'coupon-form',
}
);
}
} )
.catch( ( error ) => {
setValidationErrors( {
coupon: {
message: decodeEntities( error.message ),
hidden: false,
},
} );
// Finished handling the coupon.
receiveApplyingCoupon( '' );
} );
};
const removeCouponWithNotices = ( couponCode: string ) => {
removeCoupon( couponCode )
.then( ( result ) => {
if ( result === true ) {
addSnackbarNotice(
sprintf(
/* translators: %s coupon code. */
__(
'Coupon code "%s" has been removed from your cart.',
'woo-gutenberg-products-block'
),
couponCode
),
{
id: 'coupon-form',
}
);
}
} )
.catch( ( error ) => {
addErrorNotice( error.message, {
id: 'coupon-form',
} );
// Finished handling the coupon.
receiveApplyingCoupon( '' );
} );
};
return {
applyCoupon: applyCouponWithNotices,
removeCoupon: removeCouponWithNotices,
isApplyingCoupon,
isRemovingCoupon,
};
},
[ addErrorNotice, addSnackbarNotice ]
);
return {
appliedCoupons: cartCoupons,
isLoading: cartIsLoading,
...results,
};
};

View File

@ -0,0 +1,146 @@
/**
* External dependencies
*/
import { useSelect, useDispatch } from '@wordpress/data';
import { useCallback, useState, useEffect } from '@wordpress/element';
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { useDebounce } from 'use-debounce';
import { usePrevious } from '@woocommerce/base-hooks';
import { triggerFragmentRefresh } from '@woocommerce/base-utils';
import {
CartItem,
StoreCartItemQuantity,
isNumber,
isObject,
isString,
objectHasProp,
} from '@woocommerce/types';
/**
* Internal dependencies
*/
import { useStoreCart } from './use-store-cart';
import { useCheckoutContext } from '../../providers/cart-checkout';
/**
* Ensures the object passed has props key: string and quantity: number
*/
const cartItemHasQuantityAndKey = (
cartItem: unknown /* Object that may have quantity and key */
): cartItem is Partial< CartItem > =>
isObject( cartItem ) &&
objectHasProp( cartItem, 'key' ) &&
objectHasProp( cartItem, 'quantity' ) &&
isString( cartItem.key ) &&
isNumber( cartItem.quantity );
/**
* This is a custom hook for loading the Store API /cart/ endpoint and actions for removing or changing item quantity.
*
* @see https://github.com/woocommerce/woocommerce-gutenberg-products-block/tree/trunk/src/RestApi/StoreApi
*
* @param {CartItem} cartItem The cartItem to get quantity info from and will have quantity updated on.
* @return {StoreCartItemQuantity} An object exposing data and actions relating to cart items.
*/
export const useStoreCartItemQuantity = (
cartItem: CartItem | Record< string, unknown >
): StoreCartItemQuantity => {
const verifiedCartItem = { key: '', quantity: 1 };
if ( cartItemHasQuantityAndKey( cartItem ) ) {
verifiedCartItem.key = cartItem.key;
verifiedCartItem.quantity = cartItem.quantity;
}
const {
key: cartItemKey = '',
quantity: cartItemQuantity = 1,
} = verifiedCartItem;
const { cartErrors } = useStoreCart();
const { dispatchActions } = useCheckoutContext();
// Store quantity in hook state. This is used to keep the UI updated while server request is updated.
const [ quantity, setQuantity ] = useState< number >( cartItemQuantity );
const [ debouncedQuantity ] = useDebounce< number >( quantity, 400 );
const previousDebouncedQuantity = usePrevious( debouncedQuantity );
const { removeItemFromCart, changeCartItemQuantity } = useDispatch(
storeKey
);
// Track when things are already pending updates.
const isPending = useSelect(
( select ) => {
if ( ! cartItemKey ) {
return {
quantity: false,
delete: false,
};
}
const store = select( storeKey );
return {
quantity: store.isItemPendingQuantity( cartItemKey ),
delete: store.isItemPendingDelete( cartItemKey ),
};
},
[ cartItemKey ]
);
const removeItem = useCallback( () => {
return cartItemKey
? removeItemFromCart( cartItemKey ).then( () => {
triggerFragmentRefresh();
return true;
} )
: Promise.resolve( false );
}, [ cartItemKey, removeItemFromCart ] );
// Observe debounced quantity value, fire action to update server on change.
useEffect( () => {
if (
cartItemKey &&
isNumber( previousDebouncedQuantity ) &&
Number.isFinite( previousDebouncedQuantity ) &&
previousDebouncedQuantity !== debouncedQuantity
) {
changeCartItemQuantity( cartItemKey, debouncedQuantity );
}
}, [
cartItemKey,
changeCartItemQuantity,
debouncedQuantity,
previousDebouncedQuantity,
] );
useEffect( () => {
if ( isPending.delete ) {
dispatchActions.incrementCalculating();
} else {
dispatchActions.decrementCalculating();
}
return () => {
if ( isPending.delete ) {
dispatchActions.decrementCalculating();
}
};
}, [ dispatchActions, isPending.delete ] );
useEffect( () => {
if ( isPending.quantity || debouncedQuantity !== quantity ) {
dispatchActions.incrementCalculating();
} else {
dispatchActions.decrementCalculating();
}
return () => {
if ( isPending.quantity || debouncedQuantity !== quantity ) {
dispatchActions.decrementCalculating();
}
};
}, [ dispatchActions, isPending.quantity, debouncedQuantity, quantity ] );
return {
isPendingDelete: isPending.delete,
quantity,
setItemQuantity: setQuantity,
removeItem,
cartItemQuantityErrors: cartErrors,
};
};

View File

@ -0,0 +1,240 @@
/** @typedef { import('@woocommerce/type-defs/hooks').StoreCart } StoreCart */
/**
* External dependencies
*/
import { isEqual } from 'lodash';
import { useRef } from '@wordpress/element';
import {
CART_STORE_KEY as storeKey,
EMPTY_CART_COUPONS,
EMPTY_CART_ITEMS,
EMPTY_CART_FEES,
EMPTY_CART_ITEM_ERRORS,
EMPTY_CART_ERRORS,
EMPTY_SHIPPING_RATES,
EMPTY_TAX_LINES,
EMPTY_PAYMENT_REQUIREMENTS,
EMPTY_EXTENSIONS,
} from '@woocommerce/block-data';
import { useSelect } from '@wordpress/data';
import { decodeEntities } from '@wordpress/html-entities';
import type {
StoreCart,
CartResponseTotals,
CartResponseFeeItem,
CartResponseBillingAddress,
CartResponseShippingAddress,
CartResponseCouponItem,
CartResponseCoupons,
} from '@woocommerce/types';
import {
emptyHiddenAddressFields,
fromEntriesPolyfill,
} from '@woocommerce/base-utils';
/**
* Internal dependencies
*/
import { useEditorContext } from '../../providers/editor-context';
declare module '@wordpress/html-entities' {
// eslint-disable-next-line @typescript-eslint/no-shadow
export function decodeEntities< T >( coupon: T ): T;
}
const defaultShippingAddress: CartResponseShippingAddress = {
first_name: '',
last_name: '',
company: '',
address_1: '',
address_2: '',
city: '',
state: '',
postcode: '',
country: '',
phone: '',
};
const defaultBillingAddress: CartResponseBillingAddress = {
...defaultShippingAddress,
email: '',
};
const defaultCartTotals: CartResponseTotals = {
total_items: '',
total_items_tax: '',
total_fees: '',
total_fees_tax: '',
total_discount: '',
total_discount_tax: '',
total_shipping: '',
total_shipping_tax: '',
total_price: '',
total_tax: '',
tax_lines: EMPTY_TAX_LINES,
currency_code: '',
currency_symbol: '',
currency_minor_unit: 2,
currency_decimal_separator: '',
currency_thousand_separator: '',
currency_prefix: '',
currency_suffix: '',
};
const decodeValues = (
object: Record< string, unknown >
): Record< string, unknown > =>
fromEntriesPolyfill(
Object.entries( object ).map( ( [ key, value ] ) => [
key,
decodeEntities( value ),
] )
);
/**
* @constant
* @type {StoreCart} Object containing cart data.
*/
export const defaultCartData: StoreCart = {
cartCoupons: EMPTY_CART_COUPONS,
cartItems: EMPTY_CART_ITEMS,
cartFees: EMPTY_CART_FEES,
cartItemsCount: 0,
cartItemsWeight: 0,
cartNeedsPayment: true,
cartNeedsShipping: true,
cartItemErrors: EMPTY_CART_ITEM_ERRORS,
cartTotals: defaultCartTotals,
cartIsLoading: true,
cartErrors: EMPTY_CART_ERRORS,
billingAddress: defaultBillingAddress,
shippingAddress: defaultShippingAddress,
shippingRates: EMPTY_SHIPPING_RATES,
shippingRatesLoading: false,
cartHasCalculatedShipping: false,
paymentRequirements: EMPTY_PAYMENT_REQUIREMENTS,
receiveCart: () => undefined,
extensions: EMPTY_EXTENSIONS,
};
/**
* This is a custom hook that is wired up to the `wc/store/cart` data
* store.
*
* @param {Object} options An object declaring the various
* collection arguments.
* @param {boolean} options.shouldSelect If false, the previous results will be
* returned and internal selects will not
* fire.
*
* @return {StoreCart} Object containing cart data.
*/
export const useStoreCart = (
options: { shouldSelect: boolean } = { shouldSelect: true }
): StoreCart => {
const { isEditor, previewData } = useEditorContext();
const previewCart = previewData?.previewCart;
const { shouldSelect } = options;
const currentResults = useRef();
const results: StoreCart = useSelect(
( select, { dispatch } ) => {
if ( ! shouldSelect ) {
return defaultCartData;
}
if ( isEditor ) {
return {
cartCoupons: previewCart.coupons,
cartItems: previewCart.items,
cartFees: previewCart.fees,
cartItemsCount: previewCart.items_count,
cartItemsWeight: previewCart.items_weight,
cartNeedsPayment: previewCart.needs_payment,
cartNeedsShipping: previewCart.needs_shipping,
cartItemErrors: EMPTY_CART_ITEM_ERRORS,
cartTotals: previewCart.totals,
cartIsLoading: false,
cartErrors: EMPTY_CART_ERRORS,
billingAddress: defaultBillingAddress,
shippingAddress: defaultShippingAddress,
extensions: EMPTY_EXTENSIONS,
shippingRates: previewCart.shipping_rates,
shippingRatesLoading: false,
cartHasCalculatedShipping:
previewCart.has_calculated_shipping,
paymentRequirements: previewCart.paymentRequirements,
receiveCart:
typeof previewCart?.receiveCart === 'function'
? previewCart.receiveCart
: () => undefined,
};
}
const store = select( storeKey );
const cartData = store.getCartData();
const cartErrors = store.getCartErrors();
const cartTotals = store.getCartTotals();
const cartIsLoading = ! store.hasFinishedResolution(
'getCartData'
);
const shippingRatesLoading = store.isCustomerDataUpdating();
const { receiveCart } = dispatch( storeKey );
const billingAddress = decodeValues( cartData.billingAddress );
const shippingAddress = cartData.needsShipping
? decodeValues( cartData.shippingAddress )
: billingAddress;
const cartFees =
cartData.fees.length > 0
? cartData.fees.map( ( fee: CartResponseFeeItem ) =>
decodeValues( fee )
)
: EMPTY_CART_FEES;
// Add a text property to the coupon to allow extensions to modify
// the text used to display the coupon, without affecting the
// functionality when it comes to removing the coupon.
const cartCoupons: CartResponseCoupons =
cartData.coupons.length > 0
? cartData.coupons.map(
( coupon: CartResponseCouponItem ) => ( {
...coupon,
label: coupon.code,
} )
)
: EMPTY_CART_COUPONS;
return {
cartCoupons,
cartItems: cartData.items,
cartFees,
cartItemsCount: cartData.itemsCount,
cartItemsWeight: cartData.itemsWeight,
cartNeedsPayment: cartData.needsPayment,
cartNeedsShipping: cartData.needsShipping,
cartItemErrors: cartData.errors,
cartTotals,
cartIsLoading,
cartErrors,
billingAddress: emptyHiddenAddressFields( billingAddress ),
shippingAddress: emptyHiddenAddressFields( shippingAddress ),
extensions: cartData.extensions,
shippingRates: cartData.shippingRates,
shippingRatesLoading,
cartHasCalculatedShipping: cartData.hasCalculatedShipping,
paymentRequirements: cartData.paymentRequirements,
receiveCart,
};
},
[ shouldSelect ]
);
if (
! currentResults.current ||
! isEqual( currentResults.current, results )
) {
currentResults.current = results;
}
return currentResults.current;
};

View File

@ -0,0 +1,3 @@
export * from './use-collection-data';
export * from './use-collection-header';
export * from './use-collection';

View File

@ -0,0 +1,298 @@
/**
* External dependencies
*/
import TestRenderer, { act } from 'react-test-renderer';
import { createRegistry, RegistryProvider } from '@wordpress/data';
import { Component as ReactComponent } from '@wordpress/element';
import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import { useCollection } from '../use-collection';
jest.mock( '@woocommerce/block-data', () => ( {
__esModule: true,
COLLECTIONS_STORE_KEY: 'test/store',
} ) );
class TestErrorBoundary extends ReactComponent {
constructor( props ) {
super( props );
this.state = { hasError: false, error: {} };
}
static getDerivedStateFromError( error ) {
// Update state so the next render will show the fallback UI.
return { hasError: true, error };
}
render() {
if ( this.state.hasError ) {
return <div error={ this.state.error } />;
}
return this.props.children;
}
}
describe( 'useCollection', () => {
let registry, mocks, renderer;
const getProps = ( testRenderer ) => {
const { results, isLoading } = testRenderer.root.findByType(
'div'
).props;
return {
results,
isLoading,
};
};
const getWrappedComponents = ( Component, props ) => (
<RegistryProvider value={ registry }>
<TestErrorBoundary>
<Component { ...props } />
</TestErrorBoundary>
</RegistryProvider>
);
const getTestComponent = () => ( { options } ) => {
const items = useCollection( options );
return <div { ...items } />;
};
const setUpMocks = () => {
mocks = {
selectors: {
getCollectionError: jest.fn().mockReturnValue( false ),
getCollection: jest
.fn()
.mockImplementation( () => ( { foo: 'bar' } ) ),
hasFinishedResolution: jest.fn().mockReturnValue( true ),
},
};
registry.registerStore( storeKey, {
reducer: () => ( {} ),
selectors: mocks.selectors,
} );
};
beforeEach( () => {
registry = createRegistry();
mocks = {};
renderer = null;
setUpMocks();
} );
it(
'should throw an error if an options object is provided without ' +
'a namespace property',
() => {
const TestComponent = getTestComponent();
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent, {
options: {
resourceName: 'products',
query: { bar: 'foo' },
},
} )
);
} );
const props = renderer.root.findByType( 'div' ).props;
expect( props.error.message ).toMatch( /options object/ );
expect( console ).toHaveErrored( /your React components:/ );
renderer.unmount();
}
);
it(
'should throw an error if an options object is provided without ' +
'a resourceName property',
() => {
const TestComponent = getTestComponent();
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent, {
options: {
namespace: 'test/store',
query: { bar: 'foo' },
},
} )
);
} );
const props = renderer.root.findByType( 'div' ).props;
expect( props.error.message ).toMatch( /options object/ );
expect( console ).toHaveErrored( /your React components:/ );
renderer.unmount();
}
);
it(
'should return expected behaviour for equivalent query on props ' +
'across renders',
() => {
const TestComponent = getTestComponent();
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent, {
options: {
namespace: 'test/store',
resourceName: 'products',
query: { bar: 'foo' },
},
} )
);
} );
const { results } = getProps( renderer );
// rerender
act( () => {
renderer.update(
getWrappedComponents( TestComponent, {
options: {
namespace: 'test/store',
resourceName: 'products',
query: { bar: 'foo' },
},
} )
);
} );
// re-render should result in same products object because although
// query-state is a different instance, it's still equivalent.
const { results: newResults } = getProps( renderer );
expect( newResults ).toBe( results );
// now let's change the query passed through to verify new object
// is created.
// remember this won't actually change the results because the mock
// selector is returning an equivalent object when it is called,
// however it SHOULD be a new object instance.
act( () => {
renderer.update(
getWrappedComponents( TestComponent, {
options: {
namespace: 'test/store',
resourceName: 'products',
query: { foo: 'bar' },
},
} )
);
} );
const { results: resultsVerification } = getProps( renderer );
expect( resultsVerification ).not.toBe( results );
expect( resultsVerification ).toEqual( results );
renderer.unmount();
}
);
it(
'should return expected behaviour for equivalent resourceValues on' +
' props across renders',
() => {
const TestComponent = getTestComponent();
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent, {
options: {
namespace: 'test/store',
resourceName: 'products',
resourceValues: [ 10, 20 ],
},
} )
);
} );
const { results } = getProps( renderer );
// rerender
act( () => {
renderer.update(
getWrappedComponents( TestComponent, {
options: {
namespace: 'test/store',
resourceName: 'products',
resourceValues: [ 10, 20 ],
},
} )
);
} );
// re-render should result in same products object because although
// query-state is a different instance, it's still equivalent.
const { results: newResults } = getProps( renderer );
expect( newResults ).toBe( results );
// now let's change the query passed through to verify new object
// is created.
// remember this won't actually change the results because the mock
// selector is returning an equivalent object when it is called,
// however it SHOULD be a new object instance.
act( () => {
renderer.update(
getWrappedComponents( TestComponent, {
options: {
namespace: 'test/store',
resourceName: 'products',
resourceValues: [ 20, 10 ],
},
} )
);
} );
const { results: resultsVerification } = getProps( renderer );
expect( resultsVerification ).not.toBe( results );
expect( resultsVerification ).toEqual( results );
renderer.unmount();
}
);
it( 'should return previous query results if `shouldSelect` is false', () => {
mocks.selectors.getCollection.mockImplementation(
( state, ...args ) => {
return args;
}
);
const TestComponent = getTestComponent();
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent, {
options: {
namespace: 'test/store',
resourceName: 'products',
resourceValues: [ 10, 20 ],
},
} )
);
} );
const { results } = getProps( renderer );
// rerender but with shouldSelect to false
act( () => {
renderer.update(
getWrappedComponents( TestComponent, {
options: {
namespace: 'test/store',
resourceName: 'productsb',
resourceValues: [ 10, 30 ],
shouldSelect: false,
},
} )
);
} );
const { results: results2 } = getProps( renderer );
expect( results2 ).toBe( results );
// expect 2 calls because internally, useSelect invokes callback twice
// on mount.
expect( mocks.selectors.getCollection ).toHaveBeenCalledTimes( 2 );
// rerender again but set shouldSelect to true again and we should see
// new results
act( () => {
renderer.update(
getWrappedComponents( TestComponent, {
options: {
namespace: 'test/store',
resourceName: 'productsb',
resourceValues: [ 10, 30 ],
shouldSelect: true,
},
} )
);
} );
const { results: results3 } = getProps( renderer );
expect( results3 ).not.toEqual( results );
expect( results3 ).toEqual( [
'test/store',
'productsb',
{},
[ 10, 30 ],
] );
} );
} );

View File

@ -0,0 +1,140 @@
/**
* External dependencies
*/
import { useState, useEffect, useMemo } from '@wordpress/element';
import { useDebounce } from 'use-debounce';
import { sortBy } from 'lodash';
import { useShallowEqual } from '@woocommerce/base-hooks';
/**
* Internal dependencies
*/
import { useQueryStateByContext, useQueryStateByKey } from '../use-query-state';
import { useCollection } from './use-collection';
import { useQueryStateContext } from '../../providers/query-state-context';
const buildCollectionDataQuery = ( collectionDataQueryState ) => {
const query = collectionDataQueryState;
if ( collectionDataQueryState.calculate_attribute_counts ) {
query.calculate_attribute_counts = sortBy(
collectionDataQueryState.calculate_attribute_counts.map(
( { taxonomy, queryType } ) => {
return {
taxonomy,
query_type: queryType,
};
}
),
[ 'taxonomy', 'query_type' ]
);
}
return query;
};
export const useCollectionData = ( {
queryAttribute,
queryPrices,
queryStock,
queryState,
} ) => {
let context = useQueryStateContext();
context = `${ context }-collection-data`;
const [ collectionDataQueryState ] = useQueryStateByContext( context );
const [
calculateAttributesQueryState,
setCalculateAttributesQueryState,
] = useQueryStateByKey( 'calculate_attribute_counts', [], context );
const [
calculatePriceRangeQueryState,
setCalculatePriceRangeQueryState,
] = useQueryStateByKey( 'calculate_price_range', null, context );
const [
calculateStockStatusQueryState,
setCalculateStockStatusQueryState,
] = useQueryStateByKey( 'calculate_stock_status_counts', null, context );
const currentQueryAttribute = useShallowEqual( queryAttribute || {} );
const currentQueryPrices = useShallowEqual( queryPrices );
const currentQueryStock = useShallowEqual( queryStock );
useEffect( () => {
if (
typeof currentQueryAttribute === 'object' &&
Object.keys( currentQueryAttribute ).length
) {
const foundAttribute = calculateAttributesQueryState.find(
( attribute ) => {
return (
attribute.taxonomy === currentQueryAttribute.taxonomy
);
}
);
if ( ! foundAttribute ) {
setCalculateAttributesQueryState( [
...calculateAttributesQueryState,
currentQueryAttribute,
] );
}
}
}, [
currentQueryAttribute,
calculateAttributesQueryState,
setCalculateAttributesQueryState,
] );
useEffect( () => {
if (
calculatePriceRangeQueryState !== currentQueryPrices &&
currentQueryPrices !== undefined
) {
setCalculatePriceRangeQueryState( currentQueryPrices );
}
}, [
currentQueryPrices,
setCalculatePriceRangeQueryState,
calculatePriceRangeQueryState,
] );
useEffect( () => {
if (
calculateStockStatusQueryState !== currentQueryStock &&
currentQueryStock !== undefined
) {
setCalculateStockStatusQueryState( currentQueryStock );
}
}, [
currentQueryStock,
setCalculateStockStatusQueryState,
calculateStockStatusQueryState,
] );
// Defer the select query so all collection-data query vars can be gathered.
const [ shouldSelect, setShouldSelect ] = useState( false );
const [ debouncedShouldSelect ] = useDebounce( shouldSelect, 200 );
if ( ! shouldSelect ) {
setShouldSelect( true );
}
const collectionDataQueryVars = useMemo( () => {
return buildCollectionDataQuery( collectionDataQueryState );
}, [ collectionDataQueryState ] );
return useCollection( {
namespace: '/wc/store',
resourceName: 'products/collection-data',
query: {
...queryState,
page: undefined,
per_page: undefined,
orderby: undefined,
order: undefined,
...collectionDataQueryVars,
},
shouldSelect: debouncedShouldSelect,
} );
};

View File

@ -0,0 +1,86 @@
/**
* External dependencies
*/
import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { useSelect } from '@wordpress/data';
import { useShallowEqual } from '@woocommerce/base-hooks';
/**
* This is a custom hook that is wired up to the `wc/store/collections` data
* store. Given a header key and a collections option object, this will ensure a
* component is kept up to date with the collection header value matching that
* query in the store state.
*
* @param {string} headerKey Used to indicate which header value to
* return for the given collection query.
* Example: `'x-wp-total'`
* @param {Object} options An object declaring the various
* collection arguments.
* @param {string} options.namespace The namespace for the collection.
* Example: `'/wc/blocks'`
* @param {string} options.resourceName The name of the resource for the
* collection. Example:
* `'products/attributes'`
* @param {Array} options.resourceValues An array of values (in correct order)
* that are substituted in the route
* placeholders for the collection route.
* Example: `[10, 20]`
* @param {Object} options.query An object of key value pairs for the
* query to execute on the collection
* (optional). Example:
* `{ order: 'ASC', order_by: 'price' }`
*
* @return {Object} This hook will return an object with two properties:
* - value Whatever value is attached to the specified
* header.
* - isLoading A boolean indicating whether the header is
* loading (true) or not.
*/
export const useCollectionHeader = ( headerKey, options ) => {
const {
namespace,
resourceName,
resourceValues = [],
query = {},
} = options;
if ( ! namespace || ! resourceName ) {
throw new Error(
'The options object must have valid values for the namespace and ' +
'the resource name properties.'
);
}
// ensure we feed the previous reference if it's equivalent
const currentQuery = useShallowEqual( query );
const currentResourceValues = useShallowEqual( resourceValues );
const { value, isLoading = true } = useSelect(
( select ) => {
const store = select( storeKey );
// filter out query if it is undefined.
const args = [
headerKey,
namespace,
resourceName,
currentQuery,
currentResourceValues,
];
return {
value: store.getCollectionHeader( ...args ),
isLoading: store.hasFinishedResolution(
'getCollectionHeader',
args
),
};
},
[
headerKey,
namespace,
resourceName,
currentResourceValues,
currentQuery,
]
);
return {
value,
isLoading,
};
};

View File

@ -0,0 +1,100 @@
/**
* External dependencies
*/
import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { useSelect } from '@wordpress/data';
import { useRef } from '@wordpress/element';
import { useShallowEqual, useThrowError } from '@woocommerce/base-hooks';
/**
* This is a custom hook that is wired up to the `wc/store/collections` data
* store. Given a collections option object, this will ensure a component is
* kept up to date with the collection matching that query in the store state.
*
* @throws {Object} Throws an exception object if there was a problem with the
* API request, to be picked up by BlockErrorBoundry.
*
* @param {Object} options An object declaring the various
* collection arguments.
* @param {string} options.namespace The namespace for the collection.
* Example: `'/wc/blocks'`
* @param {string} options.resourceName The name of the resource for the
* collection. Example:
* `'products/attributes'`
* @param {Array} [options.resourceValues] An array of values (in correct order)
* that are substituted in the route
* placeholders for the collection route.
* Example: `[10, 20]`
* @param {Object} [options.query] An object of key value pairs for the
* query to execute on the collection
* Example:
* `{ order: 'ASC', order_by: 'price' }`
* @param {boolean} [options.shouldSelect] If false, the previous results will be
* returned and internal selects will not
* fire.
*
* @return {Object} This hook will return an object with two properties:
* - results An array of collection items returned.
* - isLoading A boolean indicating whether the collection is
* loading (true) or not.
*/
export const useCollection = ( options ) => {
const {
namespace,
resourceName,
resourceValues = [],
query = {},
shouldSelect = true,
} = options;
if ( ! namespace || ! resourceName ) {
throw new Error(
'The options object must have valid values for the namespace and ' +
'the resource properties.'
);
}
const currentResults = useRef( { results: [], isLoading: true } );
// ensure we feed the previous reference if it's equivalent
const currentQuery = useShallowEqual( query );
const currentResourceValues = useShallowEqual( resourceValues );
const throwError = useThrowError();
const results = useSelect(
( select ) => {
if ( ! shouldSelect ) {
return null;
}
const store = select( storeKey );
const args = [
namespace,
resourceName,
currentQuery,
currentResourceValues,
];
const error = store.getCollectionError( ...args );
if ( error ) {
throwError( error );
}
return {
results: store.getCollection( ...args ),
isLoading: ! store.hasFinishedResolution(
'getCollection',
args
),
};
},
[
namespace,
resourceName,
currentResourceValues,
currentQuery,
shouldSelect,
]
);
// if selector was not bailed, then update current results. Otherwise return
// previous results
if ( results !== null ) {
currentResults.current = results;
}
return currentResults.current;
};

View File

@ -0,0 +1,16 @@
export * from './cart';
export * from './collections';
export * from './shipping';
export * from './payment-methods';
export * from './use-store-notices';
export * from './use-store-events';
export * from './use-query-state';
export * from './use-store-products';
export * from './use-store-add-to-cart';
export * from './use-customer-data';
export * from './use-checkout-address';
export * from './use-checkout-notices';
export * from './use-checkout-submit';
export * from './use-emit-response';
export * from './use-checkout-extension-data';
export * from './use-validation';

View File

@ -0,0 +1,2 @@
export { usePaymentMethodInterface } from './use-payment-method-interface';
export * from './use-payment-methods';

View File

@ -0,0 +1,61 @@
/**
* Internal dependencies
*/
import { prepareTotalItems } from '../utils';
describe( 'prepareTotalItems', () => {
const fixture = {
total_items: '200',
total_items_tax: '20',
total_fees: '100',
total_fees_tax: '10',
total_discount: '350',
total_discount_tax: '50',
total_shipping: '50',
total_shipping_tax: '5',
total_tax: '30',
};
const expected = [
{
key: 'total_items',
label: 'Subtotal:',
value: 200,
valueWithTax: 220,
},
{
key: 'total_fees',
label: 'Fees:',
value: 100,
valueWithTax: 110,
},
{
key: 'total_discount',
label: 'Discount:',
value: 350,
valueWithTax: 400,
},
{
key: 'total_tax',
label: 'Taxes:',
value: 30,
valueWithTax: 30,
},
];
const expectedWithShipping = [
...expected,
{
key: 'total_shipping',
label: 'Shipping:',
value: 50,
valueWithTax: 55,
},
];
it( 'returns expected values when needsShipping is false', () => {
expect( prepareTotalItems( fixture, false ) ).toEqual( expected );
} );
it( 'returns expected values when needsShipping is true', () => {
expect( prepareTotalItems( fixture, true ) ).toEqual(
expectedWithShipping
);
} );
} );

View File

@ -0,0 +1,164 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { getCurrencyFromPriceResponse } from '@woocommerce/price-format';
import { useCallback, useEffect, useRef } from '@wordpress/element';
import PaymentMethodLabel from '@woocommerce/base-components/cart-checkout/payment-method-label';
import PaymentMethodIcons from '@woocommerce/base-components/cart-checkout/payment-method-icons';
import { getSetting } from '@woocommerce/settings';
import deprecated from '@wordpress/deprecated';
/**
* Internal dependencies
*/
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 { usePaymentMethodDataContext } from '../../providers/cart-checkout/payment-methods';
import { useShippingDataContext } from '../../providers/cart-checkout/shipping';
import { useCustomerDataContext } from '../../providers/cart-checkout/customer';
import { prepareTotalItems } from './utils';
/**
* Returns am interface to use as payment method props.
*/
export const usePaymentMethodInterface = (): Record< string, unknown > => {
const {
isCalculating,
isComplete,
isIdle,
isProcessing,
onCheckoutBeforeProcessing,
onCheckoutValidationBeforeProcessing,
onCheckoutAfterProcessingWithSuccess,
onCheckoutAfterProcessingWithError,
onSubmit,
customerId,
} = useCheckoutContext();
const {
currentStatus,
activePaymentMethod,
onPaymentProcessing,
setExpressPaymentError,
shouldSavePayment,
} = usePaymentMethodDataContext();
const {
shippingErrorStatus,
shippingErrorTypes,
shippingRates,
shippingRatesLoading,
selectedRates,
setSelectedRates,
isSelectingRate,
onShippingRateSuccess,
onShippingRateFail,
onShippingRateSelectSuccess,
onShippingRateSelectFail,
needsShipping,
} = useShippingDataContext();
const {
billingData,
shippingAddress,
setShippingAddress,
} = useCustomerDataContext();
const { cartTotals } = useStoreCart();
const { appliedCoupons } = useStoreCartCoupons();
const { noticeContexts, responseTypes } = useEmitResponse();
const currentCartTotals = useRef(
prepareTotalItems( cartTotals, needsShipping )
);
const currentCartTotal = useRef( {
label: __( 'Total', 'woo-gutenberg-products-block' ),
value: parseInt( cartTotals.total_price, 10 ),
} );
useEffect( () => {
currentCartTotals.current = prepareTotalItems(
cartTotals,
needsShipping
);
currentCartTotal.current = {
label: __( 'Total', 'woo-gutenberg-products-block' ),
value: parseInt( cartTotals.total_price, 10 ),
};
}, [ cartTotals, needsShipping ] );
const deprecatedSetExpressPaymentError = useCallback(
( errorMessage = '' ) => {
deprecated(
'setExpressPaymentError should only be used by Express Payment Methods (using the provided onError handler).',
{
alternative: '',
plugin: 'woocommerce-gutenberg-products-block',
link:
'https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/4228',
}
);
setExpressPaymentError( errorMessage );
},
[ setExpressPaymentError ]
);
return {
activePaymentMethod,
billing: {
billingData,
cartTotal: currentCartTotal.current,
currency: getCurrencyFromPriceResponse( cartTotals ),
cartTotalItems: currentCartTotals.current,
displayPricesIncludingTax: getSetting(
'displayCartPricesIncludingTax',
false
) as boolean,
appliedCoupons,
customerId,
},
checkoutStatus: {
isCalculating,
isComplete,
isIdle,
isProcessing,
},
components: {
ValidationInputError,
PaymentMethodIcons,
PaymentMethodLabel,
},
emitResponse: {
noticeContexts,
responseTypes,
},
eventRegistration: {
onCheckoutBeforeProcessing,
onCheckoutValidationBeforeProcessing,
onCheckoutAfterProcessingWithSuccess,
onCheckoutAfterProcessingWithError,
onShippingRateSuccess,
onShippingRateFail,
onShippingRateSelectSuccess,
onShippingRateSelectFail,
onPaymentProcessing,
},
onSubmit,
paymentStatus: currentStatus,
setExpressPaymentError: deprecatedSetExpressPaymentError,
shippingData: {
shippingRates,
shippingRatesLoading,
selectedRates,
setSelectedRates,
isSelectingRate,
shippingAddress,
setShippingAddress,
needsShipping,
},
shippingStatus: {
shippingErrorStatus,
shippingErrorTypes,
},
shouldSavePayment,
};
};

View File

@ -0,0 +1,53 @@
/**
* External dependencies
*/
import { useShallowEqual } from '@woocommerce/base-hooks';
import type {
PaymentMethods,
ExpressPaymentMethods,
} from '@woocommerce/type-defs/payments';
/**
* Internal dependencies
*/
import { usePaymentMethodDataContext } from '../../providers/cart-checkout/payment-methods';
interface PaymentMethodState {
paymentMethods: PaymentMethods;
isInitialized: boolean;
}
interface ExpressPaymentMethodState {
paymentMethods: ExpressPaymentMethods;
isInitialized: boolean;
}
const usePaymentMethodState = (
express = false
): PaymentMethodState | ExpressPaymentMethodState => {
const {
paymentMethods,
expressPaymentMethods,
paymentMethodsInitialized,
expressPaymentMethodsInitialized,
} = usePaymentMethodDataContext();
const currentPaymentMethods = useShallowEqual( paymentMethods );
const currentExpressPaymentMethods = useShallowEqual(
expressPaymentMethods
);
return {
paymentMethods: express
? currentExpressPaymentMethods
: currentPaymentMethods,
isInitialized: express
? expressPaymentMethodsInitialized
: paymentMethodsInitialized,
};
};
export const usePaymentMethods = ():
| PaymentMethodState
| ExpressPaymentMethodState => usePaymentMethodState( false );
export const useExpressPaymentMethods = (): ExpressPaymentMethodState =>
usePaymentMethodState( true );

View File

@ -0,0 +1,85 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
CartResponseTotals,
objectHasProp,
isString,
} from '@woocommerce/types';
export interface CartTotalItem {
key: string;
label: string;
value: number;
valueWithTax: number;
}
/**
* Prepares the total items into a shape usable for display as passed on to
* registered payment methods.
*
* @param {Object} totals Current cart total items
* @param {boolean} needsShipping Whether or not shipping is needed.
*/
export const prepareTotalItems = (
totals: CartResponseTotals,
needsShipping: boolean
): CartTotalItem[] => {
const newTotals = [];
const factory = ( label: string, property: string ): CartTotalItem => {
const taxProperty = property + '_tax';
const value =
objectHasProp( totals, property ) && isString( totals[ property ] )
? parseInt( totals[ property ] as string, 10 )
: 0;
const tax =
objectHasProp( totals, taxProperty ) &&
isString( totals[ taxProperty ] )
? parseInt( totals[ taxProperty ] as string, 10 )
: 0;
return {
key: property,
label,
value,
valueWithTax: value + tax,
};
};
newTotals.push(
factory(
__( 'Subtotal:', 'woo-gutenberg-products-block' ),
'total_items'
)
);
newTotals.push(
factory( __( 'Fees:', 'woo-gutenberg-products-block' ), 'total_fees' )
);
newTotals.push(
factory(
__( 'Discount:', 'woo-gutenberg-products-block' ),
'total_discount'
)
);
newTotals.push( {
key: 'total_tax',
label: __( 'Taxes:', 'woo-gutenberg-products-block' ),
value: parseInt( totals.total_tax, 10 ),
valueWithTax: parseInt( totals.total_tax, 10 ),
} );
if ( needsShipping ) {
newTotals.push(
factory(
__( 'Shipping:', 'woo-gutenberg-products-block' ),
'total_shipping'
)
);
}
return newTotals;
};

View File

@ -0,0 +1,2 @@
export * from './use-select-shipping-rate';
export * from './use-select-shipping-rates';

View File

@ -0,0 +1,81 @@
/**
* External dependencies
*/
import { useState, useEffect, useRef, useCallback } from '@wordpress/element';
import isShallowEqual from '@wordpress/is-shallow-equal';
import { CartShippingPackageShippingRate } from '@woocommerce/type-defs/cart';
/**
* Internal dependencies
*/
import { useSelectShippingRates } from './use-select-shipping-rates';
import { useStoreEvents } from '../use-store-events';
/**
* Selected rates are derived by looping over the shipping rates.
*
* @param {Array} shippingRates Array of shipping rates.
* @return {string} Selected rate id.
*/
// This will find the selected rate ID in an array of shipping rates.
const deriveSelectedRateId = (
shippingRates: CartShippingPackageShippingRate[]
) => shippingRates.find( ( rate ) => rate.selected )?.rate_id;
/**
* This is a custom hook for tracking selected shipping rates for a package and selecting a rate. State is used so
* changes are reflected in the UI instantly.
*
* @param {string} packageId Package ID to select rates for.
* @param {Array} shippingRates an array of packages with shipping rates.
* @return {Object} This hook will return an object with these properties:
* - selectShippingRate: A function that immediately returns the selected rate and dispatches an action generator.
* - selectedShippingRate: The selected rate id.
* - isSelectingRate: True when rates are being resolved to the API.
*/
export const useSelectShippingRate = (
packageId: string | number,
shippingRates: CartShippingPackageShippingRate[]
): {
selectShippingRate: ( newShippingRateId: string ) => unknown;
selectedShippingRate: string | undefined;
isSelectingRate: boolean;
} => {
const { dispatchCheckoutEvent } = useStoreEvents();
// Rates are selected via the shipping data context provider.
const { selectShippingRate, isSelectingRate } = useSelectShippingRates();
// Selected rates are stored in state. This allows shipping rates changes to be shown in the UI instantly.
// Defaults to the currently selected rate_id.
const [ selectedShippingRate, setSelectedShippingRate ] = useState( () =>
deriveSelectedRateId( shippingRates )
);
// This ref is used to track when changes come in via the props. When the incoming shipping rates change, update our local state if there are changes to selected methods.
const currentShippingRates = useRef( shippingRates );
useEffect( () => {
if ( ! isShallowEqual( currentShippingRates.current, shippingRates ) ) {
currentShippingRates.current = shippingRates;
setSelectedShippingRate( deriveSelectedRateId( shippingRates ) );
}
}, [ shippingRates ] );
// Sets a rate for a package in state (so changes are shown right away to consumers of the hook) and in the stores.
const setPackageRateId = useCallback(
( newShippingRateId ) => {
setSelectedShippingRate( newShippingRateId );
selectShippingRate( newShippingRateId, packageId );
dispatchCheckoutEvent( 'set-selected-shipping-rate', {
shippingRateId: newShippingRateId,
} );
},
[ packageId, selectShippingRate, dispatchCheckoutEvent ]
);
return {
selectShippingRate: setPackageRateId,
selectedShippingRate,
isSelectingRate,
};
};

View File

@ -0,0 +1,55 @@
/**
* External dependencies
*/
import { useDispatch, useSelect } from '@wordpress/data';
import { useCallback } from '@wordpress/element';
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { useThrowError } from '@woocommerce/base-hooks';
/**
* This is a custom hook for selecting shipping rates
*
* @return {Object} This hook will return an object with these properties:
* - selectShippingRate: A function that immediately returns the selected rate and dispatches an action generator.
* - isSelectingRate: True when rates are being resolved to the API.
*/
export const useSelectShippingRates = (): {
selectShippingRate: (
newShippingRateId: string,
packageId: string | number
) => unknown;
isSelectingRate: boolean;
} => {
const throwError = useThrowError();
const { selectShippingRate } = ( useDispatch( storeKey ) as {
selectShippingRate: unknown;
} ) as {
selectShippingRate: (
newShippingRateId: string,
packageId: string | number
) => Promise< unknown >;
};
// Sets a rate for a package in state (so changes are shown right away to consumers of the hook) and in the stores.
const setRate = useCallback(
( newShippingRateId, packageId ) => {
selectShippingRate( newShippingRateId, packageId ).catch(
( error ) => {
// we throw this error because an error on selecting a rate is problematic.
throwError( error );
}
);
},
[ throwError, selectShippingRate ]
);
// See if rates are being selected.
const isSelectingRate = useSelect< boolean >( ( select ) => {
return select( storeKey ).isShippingRateBeingSelected();
}, [] );
return {
selectShippingRate: setRate,
isSelectingRate,
};
};

View File

@ -0,0 +1,64 @@
/**
* External dependencies
*/
import TestRenderer, { act } from 'react-test-renderer';
import { createRegistry, RegistryProvider } from '@wordpress/data';
/**
* Internal dependencies
*/
import { useCheckoutSubmit } from '../use-checkout-submit';
const mockUseCheckoutContext = {
onSubmit: jest.fn(),
};
const mockUsePaymentMethodDataContext = {
activePaymentMethod: '',
currentStatus: {
isDoingExpressPayment: false,
},
};
jest.mock( '../../providers/cart-checkout/checkout-state', () => ( {
useCheckoutContext: () => mockUseCheckoutContext,
} ) );
jest.mock( '../../providers/cart-checkout/payment-methods', () => ( {
usePaymentMethodDataContext: () => mockUsePaymentMethodDataContext,
} ) );
describe( 'useCheckoutSubmit', () => {
let registry, renderer;
const getWrappedComponents = ( Component ) => (
<RegistryProvider value={ registry }>
<Component />
</RegistryProvider>
);
const getTestComponent = () => () => {
const data = useCheckoutSubmit();
return <div { ...data } />;
};
beforeEach( () => {
registry = createRegistry();
renderer = null;
} );
it( 'onSubmit calls the correct action in the checkout context', () => {
const TestComponent = getTestComponent();
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent )
);
} );
const { onSubmit } = renderer.root.findByType( 'div' ).props;
onSubmit();
expect( mockUseCheckoutContext.onSubmit ).toHaveBeenCalledTimes( 1 );
} );
} );

View File

@ -0,0 +1,259 @@
/**
* External dependencies
*/
import TestRenderer, { act } from 'react-test-renderer';
import { createRegistry, RegistryProvider } from '@wordpress/data';
import { QUERY_STATE_STORE_KEY as storeKey } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import {
useQueryStateByContext,
useQueryStateByKey,
useSynchronizedQueryState,
} from '../use-query-state';
jest.mock( '@woocommerce/block-data', () => ( {
__esModule: true,
QUERY_STATE_STORE_KEY: 'test/store',
} ) );
describe( 'Testing Query State Hooks', () => {
let registry, mocks;
beforeAll( () => {
registry = createRegistry();
mocks = {};
} );
/**
* Test helper to return a tuple containing the expected query value and the
* expected query state action creator from the given rendered test instance.
*
* @param {Object} testRenderer An instance of the created test component.
*
* @return {Array} A tuple containing the expected query value as the first
* element and the expected query action creator as the
* second argument.
*/
const getProps = ( testRenderer ) => {
const props = testRenderer.root.findByType( 'div' ).props;
return [ props.queryState, props.setQueryState ];
};
/**
* Returns the given component wrapped in the registry provider for
* instantiating using the TestRenderer using the current prepared registry
* for the TestRenderer to instantiate with.
*
* @param {*} Component The test component to wrap.
* @param {Object} props Props to feed the wrapped component.
*
* @return {*} Wrapped component.
*/
const getWrappedComponent = ( Component, props ) => (
<RegistryProvider value={ registry }>
<Component { ...props } />
</RegistryProvider>
);
/**
* Returns a TestComponent for the provided hook to test with, and the
* expected PropKeys for obtaining the values to be fed to the hook as
* arguments.
*
* @param {Function} hookTested The hook being tested to use in the
* test comopnent.
* @param {Array} propKeysForArgs An array of keys for the props that
* will be used on the test component that
* will have values fed to the tested
* hook.
*
* @return {*} A component ready for testing with!
*/
const getTestComponent = ( hookTested, propKeysForArgs ) => ( props ) => {
const args = propKeysForArgs.map( ( key ) => props[ key ] );
const [ queryValue, setQueryValue ] = hookTested( ...args );
return (
<div queryState={ queryValue } setQueryState={ setQueryValue } />
);
};
/**
* A helper for setting up the `mocks` object and the `registry` mock before
* each test.
*
* @param {string} actionMockName This should be the name of the action
* that the hook returns. This will be
* mocked using `mocks.action` when
* registered in the mock registry.
* @param {string} selectorMockName This should be the mame of the selector
* that the hook uses. This will be mocked
* using `mocks.selector` when registered
* in the mock registry.
*/
const setupMocks = ( actionMockName, selectorMockName ) => {
mocks.action = jest.fn().mockReturnValue( { type: 'testAction' } );
mocks.selector = jest.fn().mockReturnValue( { foo: 'bar' } );
registry.registerStore( storeKey, {
reducer: () => ( {} ),
actions: {
[ actionMockName ]: mocks.action,
},
selectors: {
[ selectorMockName ]: mocks.selector,
},
} );
};
describe( 'useQueryStateByContext', () => {
const TestComponent = getTestComponent( useQueryStateByContext, [
'context',
] );
let renderer;
beforeEach( () => {
renderer = null;
setupMocks( 'setValueForQueryContext', 'getValueForQueryContext' );
} );
afterEach( () => {
act( () => {
renderer.unmount();
} );
} );
it(
'calls useSelect with the provided context and returns expected' +
' values',
() => {
const { action, selector } = mocks;
act( () => {
renderer = TestRenderer.create(
getWrappedComponent( TestComponent, {
context: 'test-context',
} )
);
} );
const [ queryState, setQueryState ] = getProps( renderer );
// the {} is because all selectors are called internally in the
// registry with the first argument being the state which is empty.
expect( selector ).toHaveBeenLastCalledWith(
{},
'test-context',
undefined
);
expect( queryState ).toEqual( { foo: 'bar' } );
expect( action ).not.toHaveBeenCalled();
//execute dispatcher and make sure it's called.
act( () => {
setQueryState( { foo: 'bar' } );
} );
expect( action ).toHaveBeenCalledWith( 'test-context', {
foo: 'bar',
} );
}
);
} );
describe( 'useQueryStateByKey', () => {
const TestComponent = getTestComponent( useQueryStateByKey, [
'queryKey',
undefined,
'context',
] );
let renderer;
beforeEach( () => {
renderer = null;
setupMocks( 'setQueryValue', 'getValueForQueryKey' );
} );
afterEach( () => {
act( () => {
renderer.unmount();
} );
} );
it(
'calls useSelect with the provided context and returns expected' +
' values',
() => {
const { selector, action } = mocks;
act( () => {
renderer = TestRenderer.create(
getWrappedComponent( TestComponent, {
context: 'test-context',
queryKey: 'someValue',
} )
);
} );
const [ queryState, setQueryState ] = getProps( renderer );
// the {} is because all selectors are called internally in the
// registry with the first argument being the state which is empty.
expect( selector ).toHaveBeenLastCalledWith(
{},
'test-context',
'someValue',
undefined
);
expect( queryState ).toEqual( { foo: 'bar' } );
expect( action ).not.toHaveBeenCalled();
//execute dispatcher and make sure it's called.
act( () => {
setQueryState( { foo: 'bar' } );
} );
expect( action ).toHaveBeenCalledWith(
'test-context',
'someValue',
{ foo: 'bar' }
);
}
);
} );
// Note: these tests only add partial coverage because the state is not
// actually updated by the action dispatch via our mocks.
describe( 'useSynchronizedQueryState', () => {
const TestComponent = getTestComponent( useSynchronizedQueryState, [
'synchronizedQuery',
'context',
] );
const initialQuery = { a: 'b' };
let renderer;
beforeEach( () => {
setupMocks( 'setValueForQueryContext', 'getValueForQueryContext' );
} );
it( 'returns provided query state on initial render', () => {
const { action, selector } = mocks;
act( () => {
renderer = TestRenderer.create(
getWrappedComponent( TestComponent, {
context: 'test-context',
synchronizedQuery: initialQuery,
} )
);
} );
const [ queryState ] = getProps( renderer );
expect( queryState ).toBe( initialQuery );
expect( selector ).toHaveBeenLastCalledWith(
{},
'test-context',
undefined
);
expect( action ).toHaveBeenCalledWith( 'test-context', {
foo: 'bar',
a: 'b',
} );
} );
it( 'returns merged queryState on subsequent render', () => {
act( () => {
renderer.update(
getWrappedComponent( TestComponent, {
context: 'test-context',
synchronizedQuery: initialQuery,
} )
);
} );
// note our test doesn't interact with an actual reducer so the
// store state is not updated. Here we're just verifying that
// what is is returned by the state selector mock is returned.
// However we DO expect this to be a new object.
const [ queryState ] = getProps( renderer );
expect( queryState ).not.toBe( initialQuery );
expect( queryState ).toEqual( { foo: 'bar' } );
} );
} );
} );

View File

@ -0,0 +1,65 @@
/**
* External dependencies
*/
import { render, act } from '@testing-library/react';
import { StoreNoticesProvider } from '@woocommerce/base-context';
/**
* Internal dependencies
*/
import { useStoreNotices } from '../use-store-notices';
describe( 'useStoreNotices', () => {
function setup() {
const returnVal = {};
function TestComponent() {
Object.assign( returnVal, useStoreNotices() );
return null;
}
render(
<StoreNoticesProvider>
<TestComponent />
</StoreNoticesProvider>
);
return returnVal;
}
test( 'allows adding and removing notices and checking if there are notices of a specific type', () => {
const storeNoticesData = setup();
// Assert initial state.
expect( storeNoticesData.notices ).toEqual( [] );
expect( storeNoticesData.hasNoticesOfType( 'default' ) ).toBe( false );
// Add error notice.
act( () => {
storeNoticesData.addErrorNotice( 'Error notice' );
} );
expect( storeNoticesData.notices.length ).toBe( 1 );
expect( storeNoticesData.hasNoticesOfType( 'default' ) ).toBe( true );
expect( storeNoticesData.notices.length ).toBe( 1 );
expect( storeNoticesData.hasNoticesOfType( 'default' ) ).toBe( true );
// Remove error notice.
act( () => {
storeNoticesData.removeNotices( 'error' );
} );
expect( storeNoticesData.notices.length ).toBe( 0 );
expect( storeNoticesData.hasNoticesOfType( 'default' ) ).toBe( false );
// Remove all remaining notices.
act( () => {
storeNoticesData.removeNotices();
} );
expect( storeNoticesData.notices.length ).toBe( 0 );
expect( storeNoticesData.hasNoticesOfType( 'default' ) ).toBe( false );
} );
} );

View File

@ -0,0 +1,110 @@
/**
* External dependencies
*/
import TestRenderer, { act } from 'react-test-renderer';
import { createRegistry, RegistryProvider } from '@wordpress/data';
import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import { useStoreProducts } from '../use-store-products';
jest.mock( '@woocommerce/block-data', () => ( {
__esModule: true,
COLLECTIONS_STORE_KEY: 'test/store',
} ) );
describe( 'useStoreProducts', () => {
let registry, mocks, renderer;
const getProps = ( testRenderer ) => {
const {
products,
totalProducts,
productsLoading,
} = testRenderer.root.findByType( 'div' ).props;
return {
products,
totalProducts,
productsLoading,
};
};
const getWrappedComponents = ( Component, props ) => (
<RegistryProvider value={ registry }>
<Component { ...props } />
</RegistryProvider>
);
const getTestComponent = () => ( { query } ) => {
const items = useStoreProducts( query );
return <div { ...items } />;
};
const setUpMocks = () => {
mocks = {
selectors: {
getCollectionError: jest.fn().mockReturnValue( false ),
getCollection: jest
.fn()
.mockImplementation( () => ( { foo: 'bar' } ) ),
getCollectionHeader: jest.fn().mockReturnValue( 22 ),
hasFinishedResolution: jest.fn().mockReturnValue( true ),
},
};
registry.registerStore( storeKey, {
reducer: () => ( {} ),
selectors: mocks.selectors,
} );
};
beforeEach( () => {
registry = createRegistry();
mocks = {};
renderer = null;
setUpMocks();
} );
it(
'should return expected behaviour for equivalent query on props ' +
'across renders',
() => {
const TestComponent = getTestComponent();
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent, {
query: { bar: 'foo' },
} )
);
} );
const { products } = getProps( renderer );
// rerender
act( () => {
renderer.update(
getWrappedComponents( TestComponent, {
query: { bar: 'foo' },
} )
);
} );
// re-render should result in same products object because although
// query-state is a different instance, it's still equivalent.
const { products: newProducts } = getProps( renderer );
expect( newProducts ).toBe( products );
// now let's change the query passed through to verify new object
// is created.
// remember this won't actually change the results because the mock
// selector is returning an equivalent object when it is called,
// however it SHOULD be a new object instance.
act( () => {
renderer.update(
getWrappedComponents( TestComponent, {
query: { foo: 'bar' },
} )
);
} );
const { products: productsVerification } = getProps( renderer );
expect( productsVerification ).not.toBe( products );
expect( productsVerification ).toEqual( products );
renderer.unmount();
}
);
} );

View File

@ -0,0 +1,51 @@
/**
* External dependencies
*/
import { render, act } from '@testing-library/react';
import { StoreSnackbarNoticesProvider } from '@woocommerce/base-context/providers';
/**
* Internal dependencies
*/
import { useStoreSnackbarNotices } from '../use-store-snackbar-notices';
describe( 'useStoreNoticesWithSnackbar', () => {
function setup() {
const returnVal = {};
function TestComponent() {
Object.assign( returnVal, useStoreSnackbarNotices() );
return null;
}
render(
<StoreSnackbarNoticesProvider>
<TestComponent />
</StoreSnackbarNoticesProvider>
);
return returnVal;
}
test( 'allows adding and removing notices and checking if there are notices of a specific type', () => {
const storeNoticesData = setup();
// Assert initial state.
expect( storeNoticesData.notices ).toEqual( [] );
// Add snackbar notice.
act( () => {
storeNoticesData.addSnackbarNotice( 'Snackbar notice' );
} );
expect( storeNoticesData.notices.length ).toBe( 1 );
// Remove all remaining notices.
act( () => {
storeNoticesData.removeNotices();
} );
expect( storeNoticesData.notices.length ).toBe( 0 );
} );
} );

View File

@ -0,0 +1,125 @@
/**
* External dependencies
*/
import { defaultAddressFields } from '@woocommerce/settings';
import { useEffect, useCallback, useRef } from '@wordpress/element';
/**
* Internal dependencies
*/
import {
useShippingDataContext,
useCustomerDataContext,
} from '../providers/cart-checkout';
/**
* Custom hook for exposing address related functionality for the checkout address form.
*/
export const useCheckoutAddress = () => {
const { needsShipping } = useShippingDataContext();
const {
billingData,
setBillingData,
shippingAddress,
setShippingAddress,
shippingAsBilling,
setShippingAsBilling,
} = useCustomerDataContext();
const currentShippingAsBilling = useRef( shippingAsBilling );
const previousBillingData = useRef( billingData );
/**
* Sets shipping address data, and also billing if using the same address.
*/
const setShippingFields = useCallback(
( value ) => {
setShippingAddress( value );
if ( shippingAsBilling ) {
setBillingData( value );
}
},
[ shippingAsBilling, setShippingAddress, setBillingData ]
);
/**
* Sets billing address data, and also shipping if shipping is disabled.
*/
const setBillingFields = useCallback(
( value ) => {
setBillingData( value );
if ( ! needsShipping ) {
setShippingAddress( value );
}
},
[ needsShipping, setShippingAddress, setBillingData ]
);
// When the "Use same address" checkbox is toggled we need to update the current billing address to reflect this.
// This either sets the billing address to the shipping address, or restores the billing address to it's previous state.
useEffect( () => {
if ( currentShippingAsBilling.current !== shippingAsBilling ) {
if ( shippingAsBilling ) {
previousBillingData.current = billingData;
setBillingData( shippingAddress );
} else {
const {
// We need to pluck out email from previous billing data because they can be empty, causing the current email to get emptied. See issue #4155
/* eslint-disable no-unused-vars */
email,
/* eslint-enable no-unused-vars */
...billingAddress
} = previousBillingData.current;
setBillingData( {
...billingAddress,
} );
}
currentShippingAsBilling.current = shippingAsBilling;
}
}, [ shippingAsBilling, setBillingData, shippingAddress, billingData ] );
const setEmail = useCallback(
( value ) =>
void setBillingData( {
email: value,
} ),
[ setBillingData ]
);
const setPhone = useCallback(
( value ) =>
void setBillingData( {
phone: value,
} ),
[ setBillingData ]
);
const setShippingPhone = useCallback(
( value ) =>
void setShippingFields( {
phone: value,
} ),
[ setShippingFields ]
);
// Note that currentShippingAsBilling is returned rather than the current state of shippingAsBilling--this is so that
// the billing fields are not rendered before sync (billing field values are debounced and would be outdated)
return {
defaultAddressFields,
shippingFields: shippingAddress,
setShippingFields,
billingFields: billingData,
setBillingFields,
setEmail,
setPhone,
setShippingPhone,
shippingAsBilling,
setShippingAsBilling,
showShippingFields: needsShipping,
showBillingFields:
! needsShipping || ! currentShippingAsBilling.current,
};
};

View File

@ -0,0 +1,51 @@
/**
* External dependencies
*/
import { useCallback, useEffect, useRef } from '@wordpress/element';
import isShallowEqual from '@wordpress/is-shallow-equal';
/**
* Internal dependencies
*/
import { useCheckoutContext } from '../providers/cart-checkout/checkout-state';
import type { CheckoutStateContextState } from '../providers/cart-checkout/checkout-state/types';
/**
* Custom hook for setting custom checkout data which is passed to the wc/store/checkout endpoint when processing orders.
*/
export const useCheckoutExtensionData = (): {
extensionData: CheckoutStateContextState[ 'extensionData' ];
setExtensionData: (
namespace: string,
key: string,
value: unknown
) => void;
} => {
const { dispatchActions, extensionData } = useCheckoutContext();
const extensionDataRef = useRef( extensionData );
useEffect( () => {
if ( ! isShallowEqual( extensionData, extensionDataRef.current ) ) {
extensionDataRef.current = extensionData;
}
}, [ extensionData ] );
const setExtensionDataWithNamespace = useCallback(
( namespace, key, value ) => {
const currentData = extensionDataRef.current[ namespace ] || {};
dispatchActions.setExtensionData( {
...extensionDataRef.current,
[ namespace ]: {
...currentData,
[ key ]: value,
},
} );
},
[ dispatchActions ]
);
return {
extensionData: extensionDataRef.current,
setExtensionData: setExtensionDataWithNamespace,
};
};

View File

@ -0,0 +1,57 @@
/**
* External dependencies
*/
import { useSelect } from '@wordpress/data';
/**
* Internal dependencies
*/
import { useEmitResponse } from './use-emit-response';
/**
* @typedef {import('@woocommerce/type-defs/contexts').StoreNoticeObject} StoreNoticeObject
* @typedef {import('@woocommerce/type-defs/hooks').CheckoutNotices} CheckoutNotices
*/
/**
* A hook that returns all notices visible in the Checkout block.
*
* @return {CheckoutNotices} Notices from the checkout form or payment methods.
*/
export const useCheckoutNotices = () => {
const { noticeContexts } = useEmitResponse();
/**
* @type {StoreNoticeObject[]}
*/
const checkoutNotices = useSelect(
( select ) => select( 'core/notices' ).getNotices( 'wc/checkout' ),
[]
);
/**
* @type {StoreNoticeObject[]}
*/
const expressPaymentNotices = useSelect(
( select ) =>
select( 'core/notices' ).getNotices(
noticeContexts.EXPRESS_PAYMENTS
),
[ noticeContexts.EXPRESS_PAYMENTS ]
);
/**
* @type {StoreNoticeObject[]}
*/
const paymentNotices = useSelect(
( select ) =>
select( 'core/notices' ).getNotices( noticeContexts.PAYMENTS ),
[ noticeContexts.PAYMENTS ]
);
return {
checkoutNotices,
expressPaymentNotices,
paymentNotices,
};
};

View File

@ -0,0 +1,47 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { useCheckoutContext } from '../providers/cart-checkout/checkout-state';
import { usePaymentMethodDataContext } from '../providers/cart-checkout/payment-methods';
import { usePaymentMethods } from './payment-methods/use-payment-methods';
/**
* Returns the submitButtonText, onSubmit interface from the checkout context,
* and an indication of submission status.
*/
export const useCheckoutSubmit = () => {
const {
onSubmit,
isCalculating,
isBeforeProcessing,
isProcessing,
isAfterProcessing,
isComplete,
hasError,
} = useCheckoutContext();
const { paymentMethods = {} } = usePaymentMethods();
const {
activePaymentMethod,
currentStatus: paymentStatus,
} = usePaymentMethodDataContext();
const paymentMethod = paymentMethods[ activePaymentMethod ] || {};
const waitingForProcessing =
isProcessing || isAfterProcessing || isBeforeProcessing;
const waitingForRedirect = isComplete && ! hasError;
return {
submitButtonText:
paymentMethod?.placeOrderButtonLabel ||
__( 'Place Order', 'woocommerce' ),
onSubmit,
isCalculating,
isDisabled: isProcessing || paymentStatus.isDoingExpressPayment,
waitingForProcessing,
waitingForRedirect,
};
};

View File

@ -0,0 +1,186 @@
/**
* External dependencies
*/
import { useDispatch } from '@wordpress/data';
import { useEffect, useState, useCallback, useRef } from '@wordpress/element';
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { useDebounce } from 'use-debounce';
import isShallowEqual from '@wordpress/is-shallow-equal';
import {
formatStoreApiErrorMessage,
pluckAddress,
pluckEmail,
} from '@woocommerce/base-utils';
import type {
CartResponseBillingAddress,
CartResponseShippingAddress,
} from '@woocommerce/types';
declare type CustomerData = {
billingData: CartResponseBillingAddress;
shippingAddress: CartResponseShippingAddress;
};
/**
* Internal dependencies
*/
import { useStoreCart } from './cart/use-store-cart';
import { useStoreNotices } from './use-store-notices';
function instanceOfCartResponseBillingAddress(
address: CartResponseBillingAddress | CartResponseShippingAddress
): address is CartResponseBillingAddress {
return 'email' in address;
}
/**
* Does a shallow compare of important address data to determine if the cart needs updating on the server.
*
* This takes the current and previous address into account, as well as the billing email field.
*
* @param {Object} previousAddress An object containing all previous address information
* @param {Object} address An object containing all address information
*
* @return {boolean} True if the store needs updating due to changed data.
*/
const shouldUpdateAddressStore = <
T extends CartResponseBillingAddress | CartResponseShippingAddress
>(
previousAddress: T,
address: T
): boolean => {
if (
instanceOfCartResponseBillingAddress( address ) &&
pluckEmail( address ) !==
pluckEmail( previousAddress as CartResponseBillingAddress )
) {
return true;
}
return (
!! address.country &&
! isShallowEqual(
pluckAddress( previousAddress ),
pluckAddress( address )
)
);
};
/**
* This is a custom hook for syncing customer address data (billing and shipping) with the server.
*/
export const useCustomerData = (): {
billingData: CartResponseBillingAddress;
shippingAddress: CartResponseShippingAddress;
setBillingData: ( data: CartResponseBillingAddress ) => void;
setShippingAddress: ( data: CartResponseShippingAddress ) => void;
} => {
const { updateCustomerData } = useDispatch( storeKey );
const { addErrorNotice, removeNotice } = useStoreNotices();
// Grab the initial values from the store cart hook.
const {
billingAddress: initialBillingAddress,
shippingAddress: initialShippingAddress,
}: Omit< CustomerData, 'billingData' > & {
billingAddress: CartResponseBillingAddress;
} = useStoreCart();
// State of customer data is tracked here from this point, using the initial values from the useStoreCart hook.
const [ customerData, setCustomerData ] = useState< CustomerData >( {
billingData: initialBillingAddress,
shippingAddress: initialShippingAddress,
} );
// Store values last sent to the server in a ref to avoid requests unless important fields are changed.
const previousCustomerData = useRef< CustomerData >( customerData );
// Debounce updates to the customerData state so it's not triggered excessively.
const [ debouncedCustomerData ] = useDebounce( customerData, 1000, {
// Default equalityFn is prevData === newData.
equalityFn: ( prevData, newData ) => {
return (
isShallowEqual( prevData.billingData, newData.billingData ) &&
isShallowEqual(
prevData.shippingAddress,
newData.shippingAddress
)
);
},
} );
/**
* Set billing data.
*
* Contains special handling for email so those fields are not overwritten if simply updating address.
*/
const setBillingData = useCallback( ( newData ) => {
setCustomerData( ( prevState ) => {
return {
...prevState,
billingData: {
...prevState.billingData,
...newData,
},
};
} );
}, [] );
/**
* Set shipping data.
*/
const setShippingAddress = useCallback( ( newData ) => {
setCustomerData( ( prevState ) => {
return {
...prevState,
shippingAddress: {
...prevState.shippingAddress,
...newData,
},
};
} );
}, [] );
/**
* This pushes changes to the API when the local state differs from the address in the cart.
*/
useEffect( () => {
// Only push updates when enough fields are populated.
if (
! shouldUpdateAddressStore(
previousCustomerData.current.billingData,
debouncedCustomerData.billingData
) &&
! shouldUpdateAddressStore(
previousCustomerData.current.shippingAddress,
debouncedCustomerData.shippingAddress
)
) {
return;
}
previousCustomerData.current = debouncedCustomerData;
updateCustomerData( {
billing_address: debouncedCustomerData.billingData,
shipping_address: debouncedCustomerData.shippingAddress,
} )
.then( () => {
removeNotice( 'checkout' );
} )
.catch( ( response ) => {
addErrorNotice( formatStoreApiErrorMessage( response ), {
id: 'checkout',
} );
} );
}, [
debouncedCustomerData,
addErrorNotice,
removeNotice,
updateCustomerData,
] );
return {
billingData: customerData.billingData,
shippingAddress: customerData.shippingAddress,
setBillingData,
setShippingAddress,
};
};

View File

@ -0,0 +1,66 @@
/**
* External dependencies
*/
import { isObject } from '@woocommerce/types';
export enum responseTypes {
SUCCESS = 'success',
FAIL = 'failure',
ERROR = 'error',
}
export enum noticeContexts {
PAYMENTS = 'wc/payment-area',
EXPRESS_PAYMENTS = 'wc/express-payment-area',
}
export interface ResponseType extends Record< string, unknown > {
type: responseTypes;
retry?: boolean;
}
const isResponseOf = (
response: unknown,
type: string
): response is ResponseType => {
return isObject( response ) && 'type' in response && response.type === type;
};
export const isSuccessResponse = (
response: unknown
): response is ResponseType => {
return isResponseOf( response, responseTypes.SUCCESS );
};
export const isErrorResponse = (
response: unknown
): response is ResponseType => {
return isResponseOf( response, responseTypes.ERROR );
};
export const isFailResponse = (
response: unknown
): response is ResponseType => {
return isResponseOf( response, responseTypes.FAIL );
};
export const shouldRetry = ( response: unknown ): boolean => {
return (
! isObject( response ) ||
typeof response.retry === 'undefined' ||
response.retry === true
);
};
/**
* A custom hook exposing response utilities for emitters.
*/
export const useEmitResponse = () =>
( {
responseTypes,
noticeContexts,
shouldRetry,
isSuccessResponse,
isErrorResponse,
isFailResponse,
} as const );

View File

@ -0,0 +1,149 @@
/**
* External dependencies
*/
import { QUERY_STATE_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { useSelect, useDispatch } from '@wordpress/data';
import { useRef, useEffect, useCallback } from '@wordpress/element';
import isShallowEqual from '@wordpress/is-shallow-equal';
import { useShallowEqual, usePrevious } from '@woocommerce/base-hooks';
/**
* Internal dependencies
*/
import { useQueryStateContext } from '../providers/query-state-context';
/**
* A custom hook that exposes the current query state and a setter for the query
* state store for the given context.
*
* "Query State" is a wp.data store that keeps track of an arbitrary object of
* query keys and their values.
*
* @param {string} [context] What context to retrieve the query state for. If not
* provided, this hook will attempt to get the context
* from the query state context provided by the
* QueryStateContextProvider
*
* @return {Array} An array that has two elements. The first element is the
* query state value for the given context. The second element
* is a dispatcher function for setting the query state.
*/
export const useQueryStateByContext = ( context ) => {
const queryStateContext = useQueryStateContext();
context = context || queryStateContext;
const queryState = useSelect(
( select ) => {
const store = select( storeKey );
return store.getValueForQueryContext( context, undefined );
},
[ context ]
);
const { setValueForQueryContext } = useDispatch( storeKey );
const setQueryState = useCallback(
( value ) => {
setValueForQueryContext( context, value );
},
[ context, setValueForQueryContext ]
);
return [ queryState, setQueryState ];
};
/**
* A custom hook that exposes the current query state value and a setter for the
* given context and query key.
*
* "Query State" is a wp.data store that keeps track of an arbitrary object of
* query keys and their values.
*
* @param {*} queryKey The specific query key to retrieve the value for.
* @param {*} [defaultValue] Default value if query does not exist.
* @param {string} [context] What context to retrieve the query state for. If
* not provided will attempt to use what is provided
* by query state context.
*
* @return {*} Whatever value is set at the query state index using the
* provided context and query key.
*/
export const useQueryStateByKey = ( queryKey, defaultValue, context ) => {
const queryStateContext = useQueryStateContext();
context = context || queryStateContext;
const queryValue = useSelect(
( select ) => {
const store = select( storeKey );
return store.getValueForQueryKey( context, queryKey, defaultValue );
},
[ context, queryKey ]
);
const { setQueryValue } = useDispatch( storeKey );
const setQueryValueByKey = useCallback(
( value ) => {
setQueryValue( context, queryKey, value );
},
[ context, queryKey, setQueryValue ]
);
return [ queryValue, setQueryValueByKey ];
};
/**
* A custom hook that works similarly to useQueryStateByContext. However, this
* hook allows for synchronizing with a provided queryState object.
*
* This hook does the following things with the provided `synchronizedQuery`
* object:
*
* - whenever synchronizedQuery varies between renders, the queryState will be
* updated to a merged object of the internal queryState and the provided
* object. Note, any values from the same properties between objects will
* be set from synchronizedQuery.
* - if there are no changes between renders, then the existing internal
* queryState is always returned.
* - on initial render, the synchronizedQuery value is returned.
*
* Typically, this hook would be used in a scenario where there may be external
* triggers for updating the query state (i.e. initial population of query
* state by hydration or component attributes, or routing url changes that
* affect query state).
*
* @param {Object} synchronizedQuery A provided query state object to
* synchronize internal query state with.
* @param {string} [context] What context to retrieve the query state
* for. If not provided, will be pulled from
* the QueryStateContextProvider in the tree.
*/
export const useSynchronizedQueryState = ( synchronizedQuery, context ) => {
const queryStateContext = useQueryStateContext();
context = context || queryStateContext;
const [ queryState, setQueryState ] = useQueryStateByContext( context );
const currentQueryState = useShallowEqual( queryState );
const currentSynchronizedQuery = useShallowEqual( synchronizedQuery );
const previousSynchronizedQuery = usePrevious( currentSynchronizedQuery );
// used to ensure we allow initial synchronization to occur before
// returning non-synced state.
const isInitialized = useRef( false );
// update queryState anytime incoming synchronizedQuery changes
useEffect( () => {
if (
! isShallowEqual(
previousSynchronizedQuery,
currentSynchronizedQuery
)
) {
setQueryState(
Object.assign( {}, currentQueryState, currentSynchronizedQuery )
);
isInitialized.current = true;
}
}, [
currentQueryState,
currentSynchronizedQuery,
previousSynchronizedQuery,
setQueryState,
] );
return isInitialized.current
? [ queryState, setQueryState ]
: [ synchronizedQuery, setQueryState ];
};

View File

@ -0,0 +1,96 @@
/**
* External dependencies
*/
import { useState, useEffect, useRef } from '@wordpress/element';
import { useDispatch } from '@wordpress/data';
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { decodeEntities } from '@wordpress/html-entities';
import type { CartItem } from '@woocommerce/types';
/**
* Internal dependencies
*/
import { useStoreCart } from './cart/use-store-cart';
import { useStoreNotices } from './use-store-notices';
/**
* @typedef {import('@woocommerce/type-defs/hooks').StoreCartItemAddToCart} StoreCartItemAddToCart
*/
interface StoreAddToCart {
cartQuantity: number;
addingToCart: boolean;
cartIsLoading: boolean;
addToCart: ( quantity?: number ) => Promise< boolean >;
}
/**
* Get the quantity of a product in the cart.
*
* @param {Object} cartItems Array of items.
* @param {number} productId The product id to look for.
* @return {number} Quantity in the cart.
*/
const getQuantityFromCartItems = (
cartItems: Array< CartItem >,
productId: number
): number => {
const productItem = cartItems.find( ( { id } ) => id === productId );
return productItem ? productItem.quantity : 0;
};
/**
* A custom hook for exposing cart related data for a given product id and an
* action for adding a single quantity of the product _to_ the cart.
*
*
* @param {number} productId The product id to be added to the cart.
*
* @return {StoreCartItemAddToCart} An object exposing data and actions relating
* to add to cart functionality.
*/
export const useStoreAddToCart = ( productId: number ): StoreAddToCart => {
const { addItemToCart } = useDispatch( storeKey );
const { cartItems, cartIsLoading } = useStoreCart();
const { addErrorNotice, removeNotice } = useStoreNotices();
const [ addingToCart, setAddingToCart ] = useState( false );
const currentCartItemQuantity = useRef(
getQuantityFromCartItems( cartItems, productId )
);
const addToCart = ( quantity = 1 ) => {
setAddingToCart( true );
return addItemToCart( productId, quantity )
.then( () => {
removeNotice( 'add-to-cart' );
} )
.catch( ( error ) => {
addErrorNotice( decodeEntities( error.message ), {
context: 'wc/all-products',
id: 'add-to-cart',
isDismissible: true,
} );
} )
.finally( () => {
setAddingToCart( false );
} );
};
useEffect( () => {
const quantity = getQuantityFromCartItems( cartItems, productId );
if ( quantity !== currentCartItemQuantity.current ) {
currentCartItemQuantity.current = quantity;
}
}, [ cartItems, productId ] );
return {
cartQuantity: Number.isFinite( currentCartItemQuantity.current )
? currentCartItemQuantity.current
: 0,
addingToCart,
cartIsLoading,
addToCart,
};
};

View File

@ -0,0 +1,66 @@
/**
* External dependencies
*/
import { doAction } from '@wordpress/hooks';
import { useCallback, useRef, useEffect } from '@wordpress/element';
/**
* Internal dependencies
*/
import { useStoreCart } from './cart/use-store-cart';
type StoreEvent = (
eventName: string,
eventParams?: Partial< Record< string, unknown > >
) => void;
/**
* Abstraction on top of @wordpress/hooks for dispatching events via doAction for 3rd parties to hook into.
*/
export const useStoreEvents = (): {
dispatchStoreEvent: StoreEvent;
dispatchCheckoutEvent: StoreEvent;
} => {
const storeCart = useStoreCart();
const currentStoreCart = useRef( storeCart );
// Track the latest version of the cart so we can use the current value in our callback function below without triggering
// other useEffect hooks using dispatchCheckoutEvent as a dependency.
useEffect( () => {
currentStoreCart.current = storeCart;
}, [ storeCart ] );
const dispatchStoreEvent = useCallback( ( eventName, eventParams = {} ) => {
try {
doAction(
`experimental__woocommerce_blocks-${ eventName }`,
eventParams
);
} catch ( e ) {
// We don't handle thrown errors but just console.log for troubleshooting.
// eslint-disable-next-line no-console
console.error( e );
}
}, [] );
const dispatchCheckoutEvent = useCallback(
( eventName, eventParams = {} ) => {
try {
doAction(
`experimental__woocommerce_blocks-checkout-${ eventName }`,
{
...eventParams,
storeCart: currentStoreCart.current,
}
);
} catch ( e ) {
// We don't handle thrown errors but just console.log for troubleshooting.
// eslint-disable-next-line no-console
console.error( e );
}
},
[]
);
return { dispatchStoreEvent, dispatchCheckoutEvent };
};

View File

@ -0,0 +1,116 @@
/**
* External dependencies
*/
import { useMemo, useRef, useEffect } from '@wordpress/element';
/**
* Internal dependencies
*/
import { useStoreNoticesContext } from '../providers/store-notices/context';
type WPNoticeAction = {
label: string;
url?: string;
onClick?: () => void;
};
type WPNotice = {
id: string;
status: 'success' | 'info' | 'error' | 'warning';
content: string;
spokenMessage: string;
// eslint-disable-next-line @typescript-eslint/naming-convention
__unstableHTML: string;
isDismissible: boolean;
type: 'default' | 'snackbar';
speak: boolean;
actions: WPNoticeAction[];
};
type NoticeOptions = {
id: string;
type?: string;
isDismissible: boolean;
};
type NoticeCreator = ( text: string, noticeProps: NoticeOptions ) => void;
export const useStoreNotices = (): {
notices: WPNotice[];
hasNoticesOfType: ( type: string ) => boolean;
removeNotices: ( status: string | null ) => void;
removeNotice: ( id: string, context: string ) => void;
addDefaultNotice: NoticeCreator;
addErrorNotice: NoticeCreator;
addWarningNotice: NoticeCreator;
addInfoNotice: NoticeCreator;
addSuccessNotice: NoticeCreator;
setIsSuppressed: ( isSuppressed: boolean ) => void;
} => {
const {
notices,
createNotice,
removeNotice,
setIsSuppressed,
} = useStoreNoticesContext();
// Added to a ref so the surface for notices doesn't change frequently
// and thus can be used as dependencies on effects.
const currentNotices = useRef( notices );
// Update notices ref whenever they change
useEffect( () => {
currentNotices.current = notices;
}, [ notices ] );
const noticesApi = useMemo(
() => ( {
hasNoticesOfType: ( type: 'default' | 'snackbar' ): boolean => {
return currentNotices.current.some(
( notice ) => notice.type === type
);
},
removeNotices: ( status = null ) => {
currentNotices.current.forEach( ( notice ) => {
if ( status === null || notice.status === status ) {
removeNotice( notice.id );
}
} );
},
removeNotice,
} ),
[ removeNotice ]
);
const noticeCreators = useMemo(
() => ( {
addDefaultNotice: ( text: string, noticeProps = {} ) =>
void createNotice( 'default', text, {
...noticeProps,
} ),
addErrorNotice: ( text: string, noticeProps = {} ) =>
void createNotice( 'error', text, {
...noticeProps,
} ),
addWarningNotice: ( text: string, noticeProps = {} ) =>
void createNotice( 'warning', text, {
...noticeProps,
} ),
addInfoNotice: ( text: string, noticeProps = {} ) =>
void createNotice( 'info', text, {
...noticeProps,
} ),
addSuccessNotice: ( text: string, noticeProps = {} ) =>
void createNotice( 'success', text, {
...noticeProps,
} ),
} ),
[ createNotice ]
);
return {
notices,
...noticesApi,
...noticeCreators,
setIsSuppressed,
};
};

View File

@ -0,0 +1,41 @@
/**
* Internal dependencies
*/
import { useCollectionHeader, useCollection } from './collections';
/**
* This is a custom hook that is wired up to the `wc/store/collections` data
* store for the `wc/store/products` route. Given a query object, this
* will ensure a component is kept up to date with the products matching that
* query in the store state.
*
* @param {Object} query An object containing any query arguments to be
* included with the collection request for the
* products. Does not have to be included.
*
* @return {Object} This hook will return an object with three properties:
* - products An array of product objects.
* - totalProducts The total number of products that match
* the given query parameters.
* - productsLoading A boolean indicating whether the products
* are still loading or not.
*/
export const useStoreProducts = ( query ) => {
const collectionOptions = {
namespace: '/wc/store',
resourceName: 'products',
};
const { results: products, isLoading: productsLoading } = useCollection( {
...collectionOptions,
query,
} );
const { value: totalProducts } = useCollectionHeader( 'x-wp-total', {
...collectionOptions,
query,
} );
return {
products,
totalProducts: parseInt( totalProducts, 10 ),
productsLoading,
};
};

View File

@ -0,0 +1,52 @@
/**
* External dependencies
*/
import { useMemo, useRef, useEffect } from '@wordpress/element';
import { useStoreSnackbarNoticesContext } from '@woocommerce/base-context/providers';
export const useStoreSnackbarNotices = () => {
const {
notices,
createSnackbarNotice,
removeSnackbarNotice,
setIsSuppressed,
} = useStoreSnackbarNoticesContext();
// Added to a ref so the surface for notices doesn't change frequently
// and thus can be used as dependencies on effects.
const currentNotices = useRef( notices );
// Update notices ref whenever they change
useEffect( () => {
currentNotices.current = notices;
}, [ notices ] );
const noticesApi = useMemo(
() => ( {
removeNotices: ( status = null ) => {
currentNotices.current.forEach( ( notice ) => {
if ( status === null || notice.status === status ) {
removeSnackbarNotice( notice.id );
}
} );
},
removeSnackbarNotice,
} ),
[ removeSnackbarNotice ]
);
const noticeCreators = useMemo(
() => ( {
addSnackbarNotice: ( text, noticeProps = {} ) => {
createSnackbarNotice( text, noticeProps );
},
} ),
[ createSnackbarNotice ]
);
return {
notices,
...noticesApi,
...noticeCreators,
setIsSuppressed,
};
};

View File

@ -0,0 +1,60 @@
/**
* External dependencies
*/
import { useCallback } from '@wordpress/element';
import type {
ValidationData,
ValidationContextError,
} from '@woocommerce/type-defs/contexts';
/**
* Internal dependencies
*/
import { useValidationContext } from '../providers/validation/';
/**
* Custom hook for setting for adding errors to the validation system.
*/
export const useValidation = (): ValidationData => {
const {
hasValidationErrors,
getValidationError,
clearValidationError,
hideValidationError,
setValidationErrors,
} = useValidationContext();
const prefix = 'extensions-errors';
return {
hasValidationErrors,
getValidationError: useCallback(
( validationErrorId: string ) =>
getValidationError( `${ prefix }-${ validationErrorId }` ),
[ getValidationError ]
),
clearValidationError: useCallback(
( validationErrorId: string ) =>
clearValidationError( `${ prefix }-${ validationErrorId }` ),
[ clearValidationError ]
),
hideValidationError: useCallback(
( validationErrorId: string ) =>
hideValidationError( `${ prefix }-${ validationErrorId }` ),
[ hideValidationError ]
),
setValidationErrors: useCallback(
( errorsObject: Record< string, ValidationContextError > ) =>
setValidationErrors(
Object.fromEntries(
Object.entries(
errorsObject
).map( ( [ validationErrorId, error ] ) => [
`${ prefix }-${ validationErrorId }`,
error,
] )
)
),
[ setValidationErrors ]
),
};
};

View File

@ -0,0 +1,3 @@
export * from './event-emit';
export * from './hooks';
export * from './providers';

View File

@ -0,0 +1,58 @@
/**
* Internal dependencies
*/
import { ACTION_TYPES } from './constants';
const {
SET_PRISTINE,
SET_IDLE,
SET_DISABLED,
SET_PROCESSING,
SET_BEFORE_PROCESSING,
SET_AFTER_PROCESSING,
SET_PROCESSING_RESPONSE,
SET_HAS_ERROR,
SET_NO_ERROR,
SET_QUANTITY,
SET_REQUEST_PARAMS,
} = ACTION_TYPES;
/**
* All the actions that can be dispatched for the checkout.
*/
export const actions = {
setPristine: () => ( {
type: SET_PRISTINE,
} ),
setIdle: () => ( {
type: SET_IDLE,
} ),
setDisabled: () => ( {
type: SET_DISABLED,
} ),
setProcessing: () => ( {
type: SET_PROCESSING,
} ),
setBeforeProcessing: () => ( {
type: SET_BEFORE_PROCESSING,
} ),
setAfterProcessing: () => ( {
type: SET_AFTER_PROCESSING,
} ),
setProcessingResponse: ( data ) => ( {
type: SET_PROCESSING_RESPONSE,
data,
} ),
setHasError: ( hasError = true ) => {
const type = hasError ? SET_HAS_ERROR : SET_NO_ERROR;
return { type };
},
setQuantity: ( quantity ) => ( {
type: SET_QUANTITY,
quantity,
} ),
setRequestParams: ( data ) => ( {
type: SET_REQUEST_PARAMS,
data,
} ),
};

View File

@ -0,0 +1,32 @@
/**
* @type {import("@woocommerce/type-defs/add-to-cart-form").AddToCartFormStatusConstants}
*/
export const STATUS = {
PRISTINE: 'pristine',
IDLE: 'idle',
DISABLED: 'disabled',
PROCESSING: 'processing',
BEFORE_PROCESSING: 'before_processing',
AFTER_PROCESSING: 'after_processing',
};
export const DEFAULT_STATE = {
status: STATUS.PRISTINE,
hasError: false,
quantity: 1,
processingResponse: null,
requestParams: {},
};
export const ACTION_TYPES = {
SET_PRISTINE: 'set_pristine',
SET_IDLE: 'set_idle',
SET_DISABLED: 'set_disabled',
SET_PROCESSING: 'set_processing',
SET_BEFORE_PROCESSING: 'set_before_processing',
SET_AFTER_PROCESSING: 'set_after_processing',
SET_PROCESSING_RESPONSE: 'set_processing_response',
SET_HAS_ERROR: 'set_has_error',
SET_NO_ERROR: 'set_no_error',
SET_QUANTITY: 'set_quantity',
SET_REQUEST_PARAMS: 'set_request_params',
};

View File

@ -0,0 +1,46 @@
/**
* Internal dependencies
*/
import {
emitterCallback,
reducer,
emitEvent,
emitEventWithAbort,
} from '../../../event-emit';
const EMIT_TYPES = {
ADD_TO_CART_BEFORE_PROCESSING: 'add_to_cart_before_processing',
ADD_TO_CART_AFTER_PROCESSING_WITH_SUCCESS:
'add_to_cart_after_processing_with_success',
ADD_TO_CART_AFTER_PROCESSING_WITH_ERROR:
'add_to_cart_after_processing_with_error',
};
/**
* Receives a reducer dispatcher and returns an object with the callback registration function for
* the add to cart emit 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} dispatcher The emitter reducer dispatcher.
*
* @return {Object} An object with the add to cart form emitter registration
*/
const emitterObservers = ( dispatcher ) => ( {
onAddToCartAfterProcessingWithSuccess: emitterCallback(
EMIT_TYPES.ADD_TO_CART_AFTER_PROCESSING_WITH_SUCCESS,
dispatcher
),
onAddToCartProcessingWithError: emitterCallback(
EMIT_TYPES.ADD_TO_CART_AFTER_PROCESSING_WITH_ERROR,
dispatcher
),
onAddToCartBeforeProcessing: emitterCallback(
EMIT_TYPES.ADD_TO_CART_BEFORE_PROCESSING,
dispatcher
),
} );
export { EMIT_TYPES, emitterObservers, reducer, emitEvent, emitEventWithAbort };

View File

@ -0,0 +1,322 @@
/**
* External dependencies
*/
import {
createContext,
useContext,
useReducer,
useMemo,
useEffect,
} from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { useShallowEqual } from '@woocommerce/base-hooks';
import {
productIsPurchasable,
productSupportsAddToCartForm,
} from '@woocommerce/base-utils';
/**
* Internal dependencies
*/
import { actions } from './actions';
import { reducer } from './reducer';
import { DEFAULT_STATE, STATUS } from './constants';
import {
EMIT_TYPES,
emitterObservers,
emitEvent,
emitEventWithAbort,
reducer as emitReducer,
} from './event-emit';
import { useValidationContext } from '../../validation';
import { useStoreNotices } from '../../../hooks/use-store-notices';
import { useEmitResponse } from '../../../hooks/use-emit-response';
/**
* @typedef {import('@woocommerce/type-defs/add-to-cart-form').AddToCartFormDispatchActions} AddToCartFormDispatchActions
* @typedef {import('@woocommerce/type-defs/add-to-cart-form').AddToCartFormEventRegistration} AddToCartFormEventRegistration
* @typedef {import('@woocommerce/type-defs/contexts').AddToCartFormContext} AddToCartFormContext
*/
const AddToCartFormContext = createContext( {
product: {},
productType: 'simple',
productIsPurchasable: true,
productHasOptions: false,
supportsFormElements: true,
showFormElements: false,
quantity: 0,
minQuantity: 1,
maxQuantity: 99,
requestParams: {},
isIdle: false,
isDisabled: false,
isProcessing: false,
isBeforeProcessing: false,
isAfterProcessing: false,
hasError: false,
eventRegistration: {
onAddToCartAfterProcessingWithSuccess: ( callback ) => void callback,
onAddToCartAfterProcessingWithError: ( callback ) => void callback,
onAddToCartBeforeProcessing: ( callback ) => void callback,
},
dispatchActions: {
resetForm: () => void null,
submitForm: () => void null,
setQuantity: ( quantity ) => void quantity,
setHasError: ( hasError ) => void hasError,
setAfterProcessing: ( response ) => void response,
setRequestParams: ( data ) => void data,
},
} );
/**
* @return {AddToCartFormContext} Returns the add to cart form data context value
*/
export const useAddToCartFormContext = () => {
// @ts-ignore
return useContext( AddToCartFormContext );
};
/**
* Add to cart form state provider.
*
* This provides provides an api interface exposing add to cart form state.
*
* @param {Object} props Incoming props for the provider.
* @param {Object} props.children The children being wrapped.
* @param {Object} [props.product] The product for which the form belongs to.
* @param {boolean} [props.showFormElements] Should form elements be shown.
*/
export const AddToCartFormStateContextProvider = ( {
children,
product,
showFormElements,
} ) => {
const [ addToCartFormState, dispatch ] = useReducer(
reducer,
DEFAULT_STATE
);
const [ observers, observerDispatch ] = useReducer( emitReducer, {} );
const currentObservers = useShallowEqual( observers );
const { addErrorNotice, removeNotices } = useStoreNotices();
const { setValidationErrors } = useValidationContext();
const {
isSuccessResponse,
isErrorResponse,
isFailResponse,
} = useEmitResponse();
/**
* @type {AddToCartFormEventRegistration}
*/
const eventRegistration = useMemo(
() => ( {
onAddToCartAfterProcessingWithSuccess: emitterObservers(
observerDispatch
).onAddToCartAfterProcessingWithSuccess,
onAddToCartAfterProcessingWithError: emitterObservers(
observerDispatch
).onAddToCartAfterProcessingWithError,
onAddToCartBeforeProcessing: emitterObservers( observerDispatch )
.onAddToCartBeforeProcessing,
} ),
[ observerDispatch ]
);
/**
* @type {AddToCartFormDispatchActions}
*/
const dispatchActions = useMemo(
() => ( {
resetForm: () => void dispatch( actions.setPristine() ),
submitForm: () => void dispatch( actions.setBeforeProcessing() ),
setQuantity: ( quantity ) =>
void dispatch( actions.setQuantity( quantity ) ),
setHasError: ( hasError ) =>
void dispatch( actions.setHasError( hasError ) ),
setRequestParams: ( data ) =>
void dispatch( actions.setRequestParams( data ) ),
setAfterProcessing: ( response ) => {
dispatch( actions.setProcessingResponse( response ) );
void dispatch( actions.setAfterProcessing() );
},
} ),
[]
);
/**
* This Effect is responsible for disabling or enabling the form based on the provided product.
*/
useEffect( () => {
const status = addToCartFormState.status;
const willBeDisabled =
! product.id || ! productIsPurchasable( product );
if ( status === STATUS.DISABLED && ! willBeDisabled ) {
dispatch( actions.setIdle() );
} else if ( status !== STATUS.DISABLED && willBeDisabled ) {
dispatch( actions.setDisabled() );
}
}, [ addToCartFormState.status, product, dispatch ] );
/**
* This Effect performs events before processing starts.
*/
useEffect( () => {
const status = addToCartFormState.status;
if ( status === STATUS.BEFORE_PROCESSING ) {
removeNotices( 'error' );
emitEvent(
currentObservers,
EMIT_TYPES.ADD_TO_CART_BEFORE_PROCESSING,
{}
).then( ( response ) => {
if ( response !== true ) {
if ( Array.isArray( response ) ) {
response.forEach(
( { errorMessage, validationErrors } ) => {
if ( errorMessage ) {
addErrorNotice( errorMessage );
}
if ( validationErrors ) {
setValidationErrors( validationErrors );
}
}
);
}
dispatch( actions.setIdle() );
} else {
dispatch( actions.setProcessing() );
}
} );
}
}, [
addToCartFormState.status,
setValidationErrors,
addErrorNotice,
removeNotices,
dispatch,
currentObservers,
] );
/**
* This Effect performs events after processing is complete.
*/
useEffect( () => {
if ( addToCartFormState.status === STATUS.AFTER_PROCESSING ) {
// @todo: This data package differs from what is passed through in
// the checkout state context. Should we introduce a "context"
// property in the data package for this emitted event so that
// observers are able to know what context the event is firing in?
const data = {
processingResponse: addToCartFormState.processingResponse,
};
const handleErrorResponse = ( observerResponses ) => {
let handled = false;
observerResponses.forEach( ( response ) => {
const { message, messageContext } = response;
if (
( isErrorResponse( response ) ||
isFailResponse( response ) ) &&
message
) {
const errorOptions = messageContext
? { context: messageContext }
: undefined;
handled = true;
addErrorNotice( message, errorOptions );
}
} );
return handled;
};
if ( addToCartFormState.hasError ) {
// allow things to customize the error with a fallback if nothing customizes it.
emitEventWithAbort(
currentObservers,
EMIT_TYPES.ADD_TO_CART_AFTER_PROCESSING_WITH_ERROR,
data
).then( ( observerResponses ) => {
if ( ! handleErrorResponse( observerResponses ) ) {
// 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.',
'woocommerce'
);
addErrorNotice( message, {
id: 'add-to-cart',
} );
}
dispatch( actions.setIdle() );
} );
return;
}
emitEventWithAbort(
currentObservers,
EMIT_TYPES.ADD_TO_CART_AFTER_PROCESSING_WITH_SUCCESS,
data
).then( ( observerResponses ) => {
if ( handleErrorResponse( observerResponses ) ) {
// this will set an error which will end up
// triggering the onAddToCartAfterProcessingWithError emitter.
// and then setting to IDLE state.
dispatch( actions.setHasError( true ) );
} else {
dispatch( actions.setIdle() );
}
} );
}
}, [
addToCartFormState.status,
addToCartFormState.hasError,
addToCartFormState.processingResponse,
dispatchActions,
addErrorNotice,
isErrorResponse,
isFailResponse,
isSuccessResponse,
currentObservers,
] );
const supportsFormElements = productSupportsAddToCartForm( product );
/**
* @type {AddToCartFormContext}
*/
const contextData = {
product,
productType: product.type || 'simple',
productIsPurchasable: productIsPurchasable( product ),
productHasOptions: product.has_options || false,
supportsFormElements,
showFormElements: showFormElements && supportsFormElements,
quantity: addToCartFormState.quantity,
minQuantity: 1,
maxQuantity: product.quantity_limit || 99,
requestParams: addToCartFormState.requestParams,
isIdle: addToCartFormState.status === STATUS.IDLE,
isDisabled: addToCartFormState.status === STATUS.DISABLED,
isProcessing: addToCartFormState.status === STATUS.PROCESSING,
isBeforeProcessing:
addToCartFormState.status === STATUS.BEFORE_PROCESSING,
isAfterProcessing:
addToCartFormState.status === STATUS.AFTER_PROCESSING,
hasError: addToCartFormState.hasError,
eventRegistration,
dispatchActions,
};
return (
<AddToCartFormContext.Provider
// @ts-ignore
value={ contextData }
>
{ children }
</AddToCartFormContext.Provider>
);
};

View File

@ -0,0 +1,154 @@
/**
* Internal dependencies
*/
import { ACTION_TYPES, DEFAULT_STATE, STATUS } from './constants';
const {
SET_PRISTINE,
SET_IDLE,
SET_DISABLED,
SET_PROCESSING,
SET_BEFORE_PROCESSING,
SET_AFTER_PROCESSING,
SET_PROCESSING_RESPONSE,
SET_HAS_ERROR,
SET_NO_ERROR,
SET_QUANTITY,
SET_REQUEST_PARAMS,
} = ACTION_TYPES;
const {
PRISTINE,
IDLE,
DISABLED,
PROCESSING,
BEFORE_PROCESSING,
AFTER_PROCESSING,
} = STATUS;
/**
* Reducer for the checkout state
*
* @param {Object} state Current state.
* @param {Object} action Incoming action object.
* @param {number} action.quantity Incoming quantity.
* @param {string} action.type Type of action.
* @param {Object} action.data Incoming payload for action.
*/
export const reducer = ( state = DEFAULT_STATE, { quantity, type, data } ) => {
let newState;
switch ( type ) {
case SET_PRISTINE:
newState = DEFAULT_STATE;
break;
case SET_IDLE:
newState =
state.status !== IDLE
? {
...state,
status: IDLE,
}
: state;
break;
case SET_DISABLED:
newState =
state.status !== DISABLED
? {
...state,
status: DISABLED,
}
: state;
break;
case SET_QUANTITY:
newState =
quantity !== state.quantity
? {
...state,
quantity,
}
: state;
break;
case SET_REQUEST_PARAMS:
newState = {
...state,
requestParams: {
...state.requestParams,
...data,
},
};
break;
case SET_PROCESSING_RESPONSE:
newState = {
...state,
processingResponse: data,
};
break;
case SET_PROCESSING:
newState =
state.status !== PROCESSING
? {
...state,
status: PROCESSING,
hasError: false,
}
: state;
// clear any error state.
newState =
newState.hasError === false
? newState
: { ...newState, hasError: false };
break;
case SET_BEFORE_PROCESSING:
newState =
state.status !== BEFORE_PROCESSING
? {
...state,
status: BEFORE_PROCESSING,
hasError: false,
}
: state;
break;
case SET_AFTER_PROCESSING:
newState =
state.status !== AFTER_PROCESSING
? {
...state,
status: AFTER_PROCESSING,
}
: state;
break;
case SET_HAS_ERROR:
newState = state.hasError
? state
: {
...state,
hasError: true,
};
newState =
state.status === PROCESSING ||
state.status === BEFORE_PROCESSING
? {
...newState,
status: IDLE,
}
: newState;
break;
case SET_NO_ERROR:
newState = state.hasError
? {
...state,
hasError: false,
}
: state;
break;
}
// automatically update state to idle from pristine as soon as it initially changes.
if (
newState !== state &&
type !== SET_PRISTINE &&
newState.status === PRISTINE
) {
newState.status = IDLE;
}
return newState;
};

View File

@ -0,0 +1,34 @@
/**
* Internal dependencies
*/
import { AddToCartFormStateContextProvider } from '../form-state';
import { ValidationContextProvider } from '../../validation';
import FormSubmit from './submit';
/**
* Add to cart form provider.
*
* This wraps the add to cart form and provides an api interface for children via various hooks.
*
* @param {Object} props Incoming props for the provider.
* @param {Object} props.children The children being wrapped.
* @param {Object} [props.product] The product for which the form belongs to.
* @param {boolean} [props.showFormElements] Should form elements be shown.
*/
export const AddToCartFormContextProvider = ( {
children,
product,
showFormElements,
} ) => {
return (
<ValidationContextProvider>
<AddToCartFormStateContextProvider
product={ product }
showFormElements={ showFormElements }
>
{ children }
<FormSubmit />
</AddToCartFormStateContextProvider>
</ValidationContextProvider>
);
};

View File

@ -0,0 +1,144 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import triggerFetch from '@wordpress/api-fetch';
import { useEffect, useCallback, useState } from '@wordpress/element';
import { decodeEntities } from '@wordpress/html-entities';
/**
* Internal dependencies
*/
import { useAddToCartFormContext } from '../../form-state';
import { useValidationContext } from '../../../validation';
import { useStoreCart } from '../../../../hooks/cart/use-store-cart';
import { useStoreNotices } from '../../../../hooks/use-store-notices';
/**
* FormSubmit.
*
* Subscribes to add to cart form context and triggers processing via the API.
*/
const FormSubmit = () => {
const {
dispatchActions,
product,
quantity,
eventRegistration,
hasError,
isProcessing,
requestParams,
} = useAddToCartFormContext();
const {
hasValidationErrors,
showAllValidationErrors,
} = useValidationContext();
const { addErrorNotice, removeNotice } = useStoreNotices();
const { receiveCart } = useStoreCart();
const [ isSubmitting, setIsSubmitting ] = useState( false );
const doSubmit = ! hasError && isProcessing;
const checkValidationContext = useCallback( () => {
if ( hasValidationErrors ) {
showAllValidationErrors();
return {
type: 'error',
};
}
return true;
}, [ hasValidationErrors, showAllValidationErrors ] );
// Subscribe to emitter before processing.
useEffect( () => {
const unsubscribeProcessing = eventRegistration.onAddToCartBeforeProcessing(
checkValidationContext,
0
);
return () => {
unsubscribeProcessing();
};
}, [ eventRegistration, checkValidationContext ] );
// Triggers form submission to the API.
const submitFormCallback = useCallback( () => {
setIsSubmitting( true );
removeNotice( 'add-to-cart' );
const fetchData = {
id: product.id || 0,
quantity,
...requestParams,
};
triggerFetch( {
path: '/wc/store/cart/add-item',
method: 'POST',
data: fetchData,
cache: 'no-store',
parse: false,
} )
.then( ( fetchResponse ) => {
// Update nonce.
triggerFetch.setNonce( fetchResponse.headers );
// Handle response.
fetchResponse.json().then( function ( response ) {
if ( ! fetchResponse.ok ) {
// We received an error response.
if ( response.body && response.body.message ) {
addErrorNotice(
decodeEntities( response.body.message ),
{
id: 'add-to-cart',
}
);
} else {
addErrorNotice(
__(
'Something went wrong. Please contact us to get assistance.',
'woocommerce'
),
{
id: 'add-to-cart',
}
);
}
dispatchActions.setHasError();
} else {
receiveCart( response );
}
dispatchActions.setAfterProcessing( response );
setIsSubmitting( false );
} );
} )
.catch( ( error ) => {
error.json().then( function ( response ) {
// If updated cart state was returned, also update that.
if ( response.data?.cart ) {
receiveCart( response.data.cart );
}
dispatchActions.setHasError();
dispatchActions.setAfterProcessing( response );
setIsSubmitting( false );
} );
} );
}, [
product,
addErrorNotice,
removeNotice,
receiveCart,
dispatchActions,
quantity,
requestParams,
] );
useEffect( () => {
if ( doSubmit && ! isSubmitting ) {
submitFormCallback();
}
}, [ doSubmit, submitFormCallback, isSubmitting ] );
return null;
};
export default FormSubmit;

View File

@ -0,0 +1,2 @@
export * from './form';
export * from './form-state';

View File

@ -0,0 +1,23 @@
/**
* Internal dependencies
*/
import { CheckoutProvider } from '../checkout-provider';
/**
* Cart provider
* This wraps the Cart and provides an api interface for the Cart to
* children via various hooks.
*
* @param {Object} props Incoming props for the provider.
* @param {Object} [props.children] The children being wrapped.
* @param {string} [props.redirectUrl] Initialize what the cart will
* redirect to after successful
* submit.
*/
export const CartProvider = ( { children, redirectUrl } ) => {
return (
<CheckoutProvider isCart={ true } redirectUrl={ redirectUrl }>
{ children }
</CheckoutProvider>
);
};

View File

@ -0,0 +1,269 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import triggerFetch from '@wordpress/api-fetch';
import {
useEffect,
useRef,
useCallback,
useState,
useMemo,
} from '@wordpress/element';
import {
emptyHiddenAddressFields,
formatStoreApiErrorMessage,
} from '@woocommerce/base-utils';
/**
* Internal dependencies
*/
import { preparePaymentData, processCheckoutResponseHeaders } from './utils';
import { useCheckoutContext } from './checkout-state';
import { useShippingDataContext } from './shipping';
import { useCustomerDataContext } from './customer';
import { usePaymentMethodDataContext } from './payment-methods';
import { useValidationContext } from '../validation';
import { useStoreCart } from '../../hooks/cart/use-store-cart';
import { useStoreNotices } from '../../hooks/use-store-notices';
/**
* CheckoutProcessor component.
*
* Subscribes to checkout context and triggers processing via the API.
*/
const CheckoutProcessor = () => {
const {
hasError: checkoutHasError,
onCheckoutValidationBeforeProcessing,
dispatchActions,
redirectUrl,
isProcessing: checkoutIsProcessing,
isBeforeProcessing: checkoutIsBeforeProcessing,
isComplete: checkoutIsComplete,
orderNotes,
shouldCreateAccount,
extensionData,
} = useCheckoutContext();
const { hasValidationErrors } = useValidationContext();
const { shippingErrorStatus } = useShippingDataContext();
const { billingData, shippingAddress } = useCustomerDataContext();
const { cartNeedsPayment, receiveCart } = useStoreCart();
const {
activePaymentMethod,
isExpressPaymentMethodActive,
currentStatus: currentPaymentStatus,
paymentMethodData,
expressPaymentMethods,
paymentMethods,
shouldSavePayment,
} = usePaymentMethodDataContext();
const { addErrorNotice, removeNotice, setIsSuppressed } = useStoreNotices();
const currentBillingData = useRef( billingData );
const currentShippingAddress = useRef( shippingAddress );
const currentRedirectUrl = useRef( redirectUrl );
const [ isProcessingOrder, setIsProcessingOrder ] = useState( false );
const paymentMethodId = useMemo( () => {
const merged = { ...expressPaymentMethods, ...paymentMethods };
return merged?.[ activePaymentMethod ]?.paymentMethodId;
}, [ activePaymentMethod, expressPaymentMethods, paymentMethods ] );
const checkoutWillHaveError =
( hasValidationErrors && ! isExpressPaymentMethodActive ) ||
currentPaymentStatus.hasError ||
shippingErrorStatus.hasError;
const paidAndWithoutErrors =
! checkoutHasError &&
! checkoutWillHaveError &&
( currentPaymentStatus.isSuccessful || ! cartNeedsPayment ) &&
checkoutIsProcessing;
// If express payment method is active, let's suppress notices
useEffect( () => {
setIsSuppressed( isExpressPaymentMethodActive );
}, [ isExpressPaymentMethodActive, setIsSuppressed ] );
// Determine if checkout has an error.
useEffect( () => {
if (
checkoutWillHaveError !== checkoutHasError &&
( checkoutIsProcessing || checkoutIsBeforeProcessing ) &&
! isExpressPaymentMethodActive
) {
dispatchActions.setHasError( checkoutWillHaveError );
}
}, [
checkoutWillHaveError,
checkoutHasError,
checkoutIsProcessing,
checkoutIsBeforeProcessing,
isExpressPaymentMethodActive,
dispatchActions,
] );
useEffect( () => {
currentBillingData.current = billingData;
currentShippingAddress.current = shippingAddress;
currentRedirectUrl.current = redirectUrl;
}, [ billingData, shippingAddress, redirectUrl ] );
const checkValidation = useCallback( () => {
if ( hasValidationErrors ) {
return false;
}
if ( currentPaymentStatus.hasError ) {
return {
errorMessage: __(
'There was a problem with your payment option.',
'woocommerce'
),
};
}
if ( shippingErrorStatus.hasError ) {
return {
errorMessage: __(
'There was a problem with your shipping option.',
'woocommerce'
),
};
}
return true;
}, [
hasValidationErrors,
currentPaymentStatus.hasError,
shippingErrorStatus.hasError,
] );
useEffect( () => {
let unsubscribeProcessing;
if ( ! isExpressPaymentMethodActive ) {
unsubscribeProcessing = onCheckoutValidationBeforeProcessing(
checkValidation,
0
);
}
return () => {
if ( ! isExpressPaymentMethodActive ) {
unsubscribeProcessing();
}
};
}, [
onCheckoutValidationBeforeProcessing,
checkValidation,
isExpressPaymentMethodActive,
] );
// redirect when checkout is complete and there is a redirect url.
useEffect( () => {
if ( currentRedirectUrl.current ) {
window.location.href = currentRedirectUrl.current;
}
}, [ checkoutIsComplete ] );
const processOrder = useCallback( async () => {
if ( isProcessingOrder ) {
return;
}
setIsProcessingOrder( true );
removeNotice( 'checkout' );
const paymentData = cartNeedsPayment
? {
payment_method: paymentMethodId,
payment_data: preparePaymentData(
paymentMethodData,
shouldSavePayment,
activePaymentMethod
),
}
: {};
const data = {
billing_address: emptyHiddenAddressFields(
currentBillingData.current
),
shipping_address: emptyHiddenAddressFields(
currentShippingAddress.current
),
customer_note: orderNotes,
should_create_account: shouldCreateAccount,
...paymentData,
extensions: { ...extensionData },
};
triggerFetch( {
path: '/wc/store/checkout',
method: 'POST',
data,
cache: 'no-store',
parse: false,
} )
.then( ( response ) => {
processCheckoutResponseHeaders(
response.headers,
dispatchActions
);
if ( ! response.ok ) {
throw new Error( response );
}
return response.json();
} )
.then( ( response ) => {
dispatchActions.setAfterProcessing( response );
setIsProcessingOrder( false );
} )
.catch( ( fetchResponse ) => {
processCheckoutResponseHeaders(
fetchResponse.headers,
dispatchActions
);
fetchResponse.json().then( ( response ) => {
// If updated cart state was returned, update the store.
if ( response.data?.cart ) {
receiveCart( response.data.cart );
}
addErrorNotice( formatStoreApiErrorMessage( response ), {
id: 'checkout',
} );
response.additional_errors?.forEach?.(
( additionalError ) => {
addErrorNotice( additionalError.message, {
id: additionalError.error_code,
} );
}
);
dispatchActions.setHasError( true );
dispatchActions.setAfterProcessing( response );
setIsProcessingOrder( false );
} );
} );
}, [
isProcessingOrder,
removeNotice,
orderNotes,
shouldCreateAccount,
cartNeedsPayment,
paymentMethodId,
paymentMethodData,
shouldSavePayment,
activePaymentMethod,
extensionData,
dispatchActions,
addErrorNotice,
receiveCart,
] );
// process order if conditions are good.
useEffect( () => {
if ( paidAndWithoutErrors && ! isProcessingOrder ) {
processOrder();
}
}, [ processOrder, paidAndWithoutErrors, isProcessingOrder ] );
return null;
};
export default CheckoutProcessor;

View File

@ -0,0 +1,55 @@
/**
* External dependencies
*/
import { PluginArea } from '@wordpress/plugins';
import { CURRENT_USER_IS_ADMIN } from '@woocommerce/settings';
import BlockErrorBoundary from '@woocommerce/base-components/block-error-boundary';
/**
* Internal dependencies
*/
import { PaymentMethodDataProvider } from './payment-methods';
import { ShippingDataProvider } from './shipping';
import { CustomerDataProvider } from './customer';
import { CheckoutStateProvider } from './checkout-state';
import CheckoutProcessor from './checkout-processor';
/**
* Checkout provider
* This wraps the checkout and provides an api interface for the checkout to
* children via various hooks.
*
* @param {Object} props Incoming props for the provider.
* @param {Object} props.children The children being wrapped.
* @param {boolean} [props.isCart] Whether it's rendered in the Cart
* component.
* @param {string} [props.redirectUrl] Initialize what the checkout will
* redirect to after successful
* submit.
*/
export const CheckoutProvider = ( {
children,
isCart = false,
redirectUrl,
} ) => {
return (
<CheckoutStateProvider redirectUrl={ redirectUrl } isCart={ isCart }>
<CustomerDataProvider>
<ShippingDataProvider>
<PaymentMethodDataProvider>
{ children }
{ /* If the current user is an admin, we let BlockErrorBoundary render
the error, or we simply die silently. */ }
<BlockErrorBoundary
renderError={
CURRENT_USER_IS_ADMIN ? null : () => null
}
>
<PluginArea scope="woocommerce-checkout" />
</BlockErrorBoundary>
<CheckoutProcessor />
</PaymentMethodDataProvider>
</ShippingDataProvider>
</CustomerDataProvider>
</CheckoutStateProvider>
);
};

View File

@ -0,0 +1,112 @@
/**
* Internal dependencies
*/
import type { PaymentResultDataType, 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_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 >
| PaymentResultDataType;
}
/**
* 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: PaymentResultDataType ) =>
( {
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 ),
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

@ -0,0 +1,87 @@
/**
* External dependencies
*/
import { getSetting } from '@woocommerce/settings';
/**
* 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 preloadedApiRequests = getSetting( 'preloadedApiRequests', {} ) as Record<
string,
{ body: Record< string, unknown > }
>;
const checkoutData = {
order_id: 0,
customer_id: 0,
...( preloadedApiRequests[ '/wc/store/checkout' ]?.body || {} ),
};
export const DEFAULT_CHECKOUT_STATE_DATA: CheckoutStateContextType = {
dispatchActions: {
resetCheckout: () => void null,
setRedirectUrl: ( url ) => void url,
setHasError: ( hasError ) => void hasError,
setAfterProcessing: ( response ) => void response,
incrementCalculating: () => void null,
decrementCalculating: () => void null,
setCustomerId: ( id ) => void id,
setOrderId: ( id ) => void id,
setOrderNotes: ( orderNotes ) => void orderNotes,
setExtensionData: ( extensionData ) => void extensionData,
},
onSubmit: () => void null,
isComplete: false,
isIdle: false,
isCalculating: false,
isProcessing: false,
isBeforeProcessing: false,
isAfterProcessing: false,
hasError: false,
redirectUrl: '',
orderId: 0,
orderNotes: '',
customerId: 0,
onCheckoutAfterProcessingWithSuccess: () => () => void null,
onCheckoutAfterProcessingWithError: () => () => void null,
onCheckoutBeforeProcessing: () => () => void null, // deprecated for onCheckoutValidationBeforeProcessing
onCheckoutValidationBeforeProcessing: () => () => void null,
hasOrder: false,
isCart: false,
shouldCreateAccount: false,
setShouldCreateAccount: ( value ) => void value,
extensionData: {},
};
export const DEFAULT_STATE: CheckoutStateContextState = {
redirectUrl: '',
status: STATUS.PRISTINE,
hasError: false,
calculatingCount: 0,
orderId: checkoutData.order_id,
orderNotes: '',
customerId: checkoutData.customer_id,
shouldCreateAccount: false,
processingResponse: null,
extensionData: {},
};

View File

@ -0,0 +1,62 @@
/**
* External dependencies
*/
import { useMemo } from '@wordpress/element';
/**
* Internal dependencies
*/
import {
emitterCallback,
reducer,
emitEvent,
emitEventWithAbort,
ActionType,
} from '../../../event-emit';
const EMIT_TYPES = {
CHECKOUT_VALIDATION_BEFORE_PROCESSING:
'checkout_validation_before_processing',
CHECKOUT_AFTER_PROCESSING_WITH_SUCCESS:
'checkout_after_processing_with_success',
CHECKOUT_AFTER_PROCESSING_WITH_ERROR:
'checkout_after_processing_with_error',
};
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(
() => ( {
onCheckoutAfterProcessingWithSuccess: emitterCallback(
EMIT_TYPES.CHECKOUT_AFTER_PROCESSING_WITH_SUCCESS,
observerDispatch
),
onCheckoutAfterProcessingWithError: emitterCallback(
EMIT_TYPES.CHECKOUT_AFTER_PROCESSING_WITH_ERROR,
observerDispatch
),
onCheckoutValidationBeforeProcessing: emitterCallback(
EMIT_TYPES.CHECKOUT_VALIDATION_BEFORE_PROCESSING,
observerDispatch
),
} ),
[ observerDispatch ]
);
return eventEmitters;
};
export { EMIT_TYPES, useEventEmitters, reducer, emitEvent, emitEventWithAbort };

View File

@ -0,0 +1,397 @@
/**
* 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 } from '@woocommerce/types';
/**
* Internal dependencies
*/
import { actions } from './actions';
import { reducer } from './reducer';
import { getPaymentResultFromCheckoutResponse } from './utils';
import {
DEFAULT_STATE,
STATUS,
DEFAULT_CHECKOUT_STATE_DATA,
} from './constants';
import type {
CheckoutStateDispatchActions,
CheckoutStateContextType,
} from './types';
import {
EMIT_TYPES,
useEventEmitters,
emitEvent,
emitEventWithAbort,
reducer as emitReducer,
} from './event-emit';
import { useValidationContext } from '../../validation';
import { useStoreNotices } from '../../../hooks/use-store-notices';
import { useStoreEvents } from '../../../hooks/use-store-events';
import { useCheckoutNotices } from '../../../hooks/use-checkout-notices';
import { useEmitResponse } from '../../../hooks/use-emit-response';
/**
* @typedef {import('@woocommerce/type-defs/contexts').CheckoutDataContext} CheckoutDataContext
*/
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.
* @param {boolean} props.isCart If context provider is being used in cart context.
*/
export const CheckoutStateProvider = ( {
children,
redirectUrl,
isCart = false,
}: {
children: React.ReactChildren;
redirectUrl: string;
isCart: boolean;
} ): JSX.Element => {
// note, this is done intentionally so that the default state now has
// the redirectUrl for when checkout is reset to PRISTINE state.
DEFAULT_STATE.redirectUrl = redirectUrl;
const [ checkoutState, dispatch ] = useReducer( reducer, DEFAULT_STATE );
const { setValidationErrors } = useValidationContext();
const { addErrorNotice, removeNotices } = useStoreNotices();
const { dispatchCheckoutEvent } = useStoreEvents();
const isCalculating = checkoutState.calculatingCount > 0;
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 ] );
const dispatchActions = useMemo(
(): CheckoutStateDispatchActions => ( {
resetCheckout: () => void dispatch( actions.setPristine() ),
setRedirectUrl: ( url ) =>
void dispatch( actions.setRedirectUrl( url ) ),
setHasError: ( hasError ) =>
void dispatch( actions.setHasError( hasError ) ),
incrementCalculating: () =>
void dispatch( actions.incrementCalculating() ),
decrementCalculating: () =>
void dispatch( actions.decrementCalculating() ),
setCustomerId: ( id ) =>
void dispatch( actions.setCustomerId( id ) ),
setOrderId: ( orderId ) =>
void dispatch( actions.setOrderId( orderId ) ),
setOrderNotes: ( orderNotes ) =>
void dispatch( actions.setOrderNotes( orderNotes ) ),
setExtensionData: ( extensionData ) =>
void dispatch( actions.setExtensionData( extensionData ) ),
setAfterProcessing: ( response ) => {
const paymentResult = getPaymentResultFromCheckoutResponse(
response
);
if ( paymentResult.redirectUrl ) {
dispatch(
actions.setRedirectUrl( paymentResult.redirectUrl )
);
}
dispatch( actions.setProcessingResponse( paymentResult ) );
dispatch( actions.setAfterProcessing() );
},
} ),
[]
);
// emit events.
useEffect( () => {
const status = checkoutState.status;
if ( status === STATUS.BEFORE_PROCESSING ) {
removeNotices( 'error' );
emitEvent(
currentObservers.current,
EMIT_TYPES.CHECKOUT_VALIDATION_BEFORE_PROCESSING,
{}
).then( ( response ) => {
if ( response !== true ) {
if ( Array.isArray( response ) ) {
response.forEach(
( { errorMessage, validationErrors } ) => {
addErrorNotice( errorMessage );
setValidationErrors( validationErrors );
}
);
}
dispatch( actions.setIdle() );
dispatch( actions.setHasError() );
} else {
dispatch( actions.setProcessing() );
}
} );
}
}, [
checkoutState.status,
setValidationErrors,
addErrorNotice,
removeNotices,
dispatch,
] );
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 ) {
const errorOptions = response.messageContext
? { context: response.messageContext }
: undefined;
errorResponse = response;
addErrorNotice( 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 ) ) {
dispatch( actions.setComplete( errorResponse ) );
} else {
dispatch( actions.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 to get assistance.',
'woo-gutenberg-products-block'
);
addErrorNotice( message, {
id: 'checkout',
} );
}
dispatch( actions.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 ) {
dispatch( actions.setComplete( successResponse ) );
} else if ( isObject( errorResponse ) ) {
if ( errorResponse.message ) {
const errorOptions = errorResponse.messageContext
? { context: errorResponse.messageContext }
: undefined;
addErrorNotice(
errorResponse.message,
errorOptions
);
}
if ( ! shouldRetry( errorResponse ) ) {
dispatch( actions.setComplete( errorResponse ) );
} else {
// this will set an error which will end up
// triggering the onCheckoutAfterProcessingWithError emitter.
// and then setting checkout to IDLE state.
dispatch( actions.setHasError( true ) );
}
} else {
// nothing hooked in had any response type so let's just
// consider successful
dispatch( actions.setComplete() );
}
} );
}
}
}, [
checkoutState.status,
checkoutState.hasError,
checkoutState.redirectUrl,
checkoutState.orderId,
checkoutState.customerId,
checkoutState.orderNotes,
checkoutState.processingResponse,
previousStatus,
previousHasError,
dispatchActions,
addErrorNotice,
isErrorResponse,
isFailResponse,
isSuccessResponse,
shouldRetry,
checkoutNotices,
expressPaymentNotices,
paymentNotices,
] );
const onSubmit = useCallback( () => {
dispatchCheckoutEvent( 'submit' );
dispatch( actions.setBeforeProcessing() );
}, [ dispatchCheckoutEvent ] );
const checkoutData: CheckoutStateContextType = {
onSubmit,
isComplete: checkoutState.status === STATUS.COMPLETE,
isIdle: checkoutState.status === STATUS.IDLE,
isCalculating,
isProcessing: checkoutState.status === STATUS.PROCESSING,
isBeforeProcessing: checkoutState.status === STATUS.BEFORE_PROCESSING,
isAfterProcessing: checkoutState.status === STATUS.AFTER_PROCESSING,
hasError: checkoutState.hasError,
redirectUrl: checkoutState.redirectUrl,
onCheckoutBeforeProcessing,
onCheckoutValidationBeforeProcessing,
onCheckoutAfterProcessingWithSuccess,
onCheckoutAfterProcessingWithError,
dispatchActions,
isCart,
orderId: checkoutState.orderId,
hasOrder: !! checkoutState.orderId,
customerId: checkoutState.customerId,
orderNotes: checkoutState.orderNotes,
shouldCreateAccount: checkoutState.shouldCreateAccount,
setShouldCreateAccount: ( value ) =>
dispatch( actions.setShouldCreateAccount( value ) ),
extensionData: checkoutState.extensionData,
};
return (
<CheckoutContext.Provider value={ checkoutData }>
{ children }
</CheckoutContext.Provider>
);
};

View File

@ -0,0 +1,199 @@
/**
* Internal dependencies
*/
import { DEFAULT_STATE, STATUS } from './constants';
import { ActionType, ACTION } from './actions';
import type { CheckoutStateContextState, PaymentResultDataType } from './types';
/**
* Reducer for the checkout state
*/
export const reducer = (
state = DEFAULT_STATE,
{
redirectUrl,
type,
customerId,
orderId,
orderNotes,
extensionData,
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 PaymentResultDataType,
};
break;
case ACTION.SET_COMPLETE:
newState =
state.status !== STATUS.COMPLETE
? {
...state,
status: STATUS.COMPLETE,
// @todo Investigate why redirectURL could be non-truthy and whether this would cause a bug if multiple gateways were used for payment e.g. 1st set the redirect URL but failed, and then the 2nd did not provide a redirect URL and succeeded.
redirectUrl:
data !== undefined &&
typeof data.redirectUrl === 'string' &&
data.redirectUrl
? 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_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

@ -0,0 +1,111 @@
/**
* 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;
export interface PaymentResultDataType {
message: string;
paymentStatus: string;
paymentDetails: Record< string, string > | Record< string, never >;
redirectUrl: string;
}
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;
shouldCreateAccount: boolean;
processingResponse: PaymentResultDataType | 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 = {
// Dispatch actions to the checkout provider.
dispatchActions: CheckoutStateDispatchActions;
// Submits the checkout and begins processing.
onSubmit: () => void;
// True when checkout is complete and ready for redirect.
isComplete: boolean;
// True when the checkout state has changed and checkout has no activity.
isIdle: boolean;
// True when something in the checkout is resulting in totals being calculated.
isCalculating: boolean;
// True when checkout has been submitted and is being processed. Note, payment related processing happens during this state. When payment status is success, processing happens on the server.
isProcessing: boolean;
// True during any observers executing logic before checkout processing (eg. validation).
isBeforeProcessing: boolean;
// True when checkout status is AFTER_PROCESSING.
isAfterProcessing: boolean;
// 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 >;
// Set if user account should be created.
setShouldCreateAccount: ( shouldCreateAccount: boolean ) => void;
// True when the checkout has a draft order from the API.
hasOrder: boolean;
// When true, means the provider is providing data for the cart.
isCart: boolean;
// True when the checkout is in an error state. Whatever caused the error (validation/payment method) will likely have triggered a notice.
hasError: CheckoutStateContextState[ 'hasError' ];
// This is the url that checkout will redirect to when it's ready.
redirectUrl: CheckoutStateContextState[ 'redirectUrl' ];
// This is the ID for the draft order if one exists.
orderId: CheckoutStateContextState[ 'orderId' ];
// Order notes introduced by the user in the checkout form.
orderNotes: CheckoutStateContextState[ 'orderNotes' ];
// This is the ID of the customer the draft order belongs to.
customerId: CheckoutStateContextState[ 'customerId' ];
// Should a user account be created?
shouldCreateAccount: CheckoutStateContextState[ 'shouldCreateAccount' ];
// Custom checkout data passed to the store API on processing.
extensionData: CheckoutStateContextState[ 'extensionData' ];
};

View File

@ -0,0 +1,63 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { decodeEntities } from '@wordpress/html-entities';
/**
* Internal dependencies
*/
import type { PaymentResultDataType, CheckoutResponse } from './types';
/**
* Prepares the payment_result data from the server checkout endpoint response.
*/
export const getPaymentResultFromCheckoutResponse = (
response: CheckoutResponse
): PaymentResultDataType => {
const paymentResult = {
message: '',
paymentStatus: '',
redirectUrl: '',
paymentDetails: {},
} as PaymentResultDataType;
// 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

@ -0,0 +1,122 @@
/**
* External dependencies
*/
import { createContext, useContext, useState } from '@wordpress/element';
import { defaultAddressFields } from '@woocommerce/settings';
/**
* Internal dependencies
*/
import { useCustomerData } from '../../../hooks/use-customer-data';
import { useCheckoutContext } from '../checkout-state';
import { useStoreCart } from '../../../hooks/cart/use-store-cart';
/**
* @typedef {import('@woocommerce/type-defs/contexts').CustomerDataContext} CustomerDataContext
* @typedef {import('@woocommerce/type-defs/billing').BillingData} BillingData
* @typedef {import('@woocommerce/type-defs/shipping').ShippingAddress} ShippingAddress
*/
/**
* @type {BillingData}
*/
const defaultBillingData = {
first_name: '',
last_name: '',
company: '',
address_1: '',
address_2: '',
city: '',
state: '',
postcode: '',
country: '',
email: '',
phone: '',
};
/**
* @type {ShippingAddress}
*/
export const defaultShippingAddress = {
first_name: '',
last_name: '',
company: '',
address_1: '',
address_2: '',
city: '',
state: '',
postcode: '',
country: '',
phone: '',
};
/**
* Creates CustomerDataContext
*/
const CustomerDataContext = createContext( {
billingData: defaultBillingData,
shippingAddress: defaultShippingAddress,
setBillingData: () => null,
setShippingAddress: () => null,
shippingAsBilling: true,
setShippingAsBilling: () => null,
} );
/**
* @return {CustomerDataContext} Returns data and functions related to customer billing and shipping addresses.
*/
export const useCustomerDataContext = () => {
return useContext( CustomerDataContext );
};
/**
* Compare two addresses and see if they are the same.
*
* @param {Object} address1 First address.
* @param {Object} address2 Second address.
*/
const isSameAddress = ( address1, address2 ) => {
return Object.keys( defaultAddressFields ).every(
( field ) => address1[ field ] === address2[ field ]
);
};
/**
* Customer Data context provider.
*
* @param {Object} props Incoming props for the provider.
* @param {Object} props.children The children being wrapped.
*/
export const CustomerDataProvider = ( { children } ) => {
const {
billingData,
shippingAddress,
setBillingData,
setShippingAddress,
} = useCustomerData();
const { cartNeedsShipping: needsShipping } = useStoreCart();
const { customerId } = useCheckoutContext();
const [ shippingAsBilling, setShippingAsBilling ] = useState(
() =>
needsShipping &&
( ! customerId || isSameAddress( shippingAddress, billingData ) )
);
/**
* @type {CustomerDataContext}
*/
const contextValue = {
billingData,
shippingAddress,
setBillingData,
setShippingAddress,
shippingAsBilling,
setShippingAsBilling,
};
return (
<CustomerDataContext.Provider value={ contextValue }>
{ children }
</CustomerDataContext.Provider>
);
};

View File

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

View File

@ -0,0 +1,85 @@
/**
* External dependencies
*/
import {
PaymentMethods,
ExpressPaymentMethods,
} from '@woocommerce/type-defs/payments';
/**
* Internal dependencies
*/
import { ACTION, STATUS } from './constants';
export interface ActionType {
type: ACTION | STATUS;
errorMessage?: string;
paymentMethodData?: Record< string, unknown >;
paymentMethods?: PaymentMethods | ExpressPaymentMethods;
shouldSavePaymentMethod?: boolean;
}
/**
* All the actions that can be dispatched for payment methods.
*/
export const actions = {
statusOnly: ( type: STATUS ): { type: STATUS } => ( { type } as const ),
error: ( errorMessage: string ): ActionType =>
( {
type: STATUS.ERROR,
errorMessage,
} as const ),
failed: ( {
errorMessage,
paymentMethodData,
}: {
errorMessage: string;
paymentMethodData: Record< string, unknown >;
} ): ActionType =>
( {
type: STATUS.FAILED,
errorMessage,
paymentMethodData,
} as const ),
success: ( {
paymentMethodData,
}: {
paymentMethodData?: Record< string, unknown >;
} ): ActionType =>
( {
type: STATUS.SUCCESS,
paymentMethodData,
} as const ),
started: ( {
paymentMethodData,
}: {
paymentMethodData?: Record< string, unknown >;
} ): ActionType =>
( {
type: STATUS.STARTED,
paymentMethodData,
} as const ),
setRegisteredPaymentMethods: (
paymentMethods: PaymentMethods
): ActionType =>
( {
type: ACTION.SET_REGISTERED_PAYMENT_METHODS,
paymentMethods,
} as const ),
setRegisteredExpressPaymentMethods: (
paymentMethods: ExpressPaymentMethods
): ActionType =>
( {
type: ACTION.SET_REGISTERED_EXPRESS_PAYMENT_METHODS,
paymentMethods,
} as const ),
setShouldSavePaymentMethod: (
shouldSavePaymentMethod: boolean
): ActionType =>
( {
type: ACTION.SET_SHOULD_SAVE_PAYMENT_METHOD,
shouldSavePaymentMethod,
} as const ),
};
export default actions;

View File

@ -0,0 +1,77 @@
/**
* Internal dependencies
*/
import type {
PaymentMethodDataContextType,
PaymentMethodDataContextState,
} from './types';
export enum STATUS {
PRISTINE = 'pristine',
STARTED = 'started',
PROCESSING = 'processing',
ERROR = 'has_error',
FAILED = 'failed',
SUCCESS = 'success',
COMPLETE = 'complete',
}
export enum ACTION {
SET_REGISTERED_PAYMENT_METHODS = 'set_registered_payment_methods',
SET_REGISTERED_EXPRESS_PAYMENT_METHODS = 'set_registered_express_payment_methods',
SET_SHOULD_SAVE_PAYMENT_METHOD = 'set_should_save_payment_method',
}
// Note - if fields are added/shape is changed, you may want to update PRISTINE reducer clause to preserve your new field.
export const DEFAULT_PAYMENT_DATA_CONTEXT_STATE: PaymentMethodDataContextState = {
currentStatus: STATUS.PRISTINE,
shouldSavePaymentMethod: false,
paymentMethodData: {
payment_method: '',
},
hasSavedToken: false,
errorMessage: '',
paymentMethods: {},
expressPaymentMethods: {},
};
export const DEFAULT_PAYMENT_METHOD_DATA: PaymentMethodDataContextType = {
setPaymentStatus: () => ( {
pristine: () => void null,
started: () => void null,
processing: () => void null,
completed: () => void null,
error: ( errorMessage: string ) => void errorMessage,
failed: ( errorMessage, paymentMethodData ) =>
void [ errorMessage, paymentMethodData ],
success: ( paymentMethodData, billingData ) =>
void [ paymentMethodData, billingData ],
} ),
currentStatus: {
isPristine: true,
isStarted: false,
isProcessing: false,
isFinished: false,
hasError: false,
hasFailed: false,
isSuccessful: false,
isDoingExpressPayment: false,
},
paymentStatuses: STATUS,
paymentMethodData: {},
errorMessage: '',
activePaymentMethod: '',
setActivePaymentMethod: () => void null,
activeSavedToken: '',
setActiveSavedToken: () => void null,
customerPaymentMethods: {},
paymentMethods: {},
expressPaymentMethods: {},
paymentMethodsInitialized: false,
expressPaymentMethodsInitialized: false,
onPaymentProcessing: () => () => () => void null,
setExpressPaymentError: () => void null,
isExpressPaymentMethodActive: false,
setShouldSavePayment: () => void null,
shouldSavePayment: false,
};

View File

@ -0,0 +1,49 @@
/**
* External dependencies
*/
import { useMemo } from '@wordpress/element';
/**
* Internal dependencies
*/
import {
reducer,
emitEvent,
emitEventWithAbort,
emitterCallback,
ActionType,
} from '../../../event-emit';
const EMIT_TYPES = {
PAYMENT_PROCESSING: 'payment_processing',
};
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(
() => ( {
onPaymentProcessing: emitterCallback(
EMIT_TYPES.PAYMENT_PROCESSING,
observerDispatch
),
} ),
[ observerDispatch ]
);
return eventEmitters;
};
export { EMIT_TYPES, useEventEmitters, reducer, emitEvent, emitEventWithAbort };

View File

@ -0,0 +1,4 @@
export {
PaymentMethodDataProvider,
usePaymentMethodDataContext,
} from './payment-method-data-context';

View File

@ -0,0 +1,355 @@
/**
* External dependencies
*/
import {
createContext,
useContext,
useReducer,
useCallback,
useRef,
useEffect,
useMemo,
} from '@wordpress/element';
/**
* Internal dependencies
*/
import type {
CustomerPaymentMethods,
PaymentMethodDataContextType,
} from './types';
import {
STATUS,
DEFAULT_PAYMENT_DATA_CONTEXT_STATE,
DEFAULT_PAYMENT_METHOD_DATA,
} from './constants';
import reducer from './reducer';
import {
usePaymentMethods,
useExpressPaymentMethods,
} from './use-payment-method-registration';
import { usePaymentMethodDataDispatchers } from './use-payment-method-dispatchers';
import { useActivePaymentMethod } from './use-active-payment-method';
import { useCheckoutContext } from '../checkout-state';
import { useEditorContext } from '../../editor-context';
import {
EMIT_TYPES,
useEventEmitters,
emitEventWithAbort,
reducer as emitReducer,
} from './event-emit';
import { useValidationContext } from '../../validation';
import { useStoreNotices } from '../../../hooks/use-store-notices';
import { useEmitResponse } from '../../../hooks/use-emit-response';
import { getCustomerPaymentMethods } from './utils';
const PaymentMethodDataContext = createContext( DEFAULT_PAYMENT_METHOD_DATA );
export const usePaymentMethodDataContext = (): PaymentMethodDataContextType => {
return useContext( PaymentMethodDataContext );
};
/**
* PaymentMethodDataProvider is automatically included in the CheckoutDataProvider.
*
* This provides the api interface (via the context hook) for payment method status and data.
*
* @param {Object} props Incoming props for provider
* @param {Object} props.children The wrapped components in this provider.
*/
export const PaymentMethodDataProvider = ( {
children,
}: {
children: React.ReactChildren;
} ): JSX.Element => {
const {
isProcessing: checkoutIsProcessing,
isIdle: checkoutIsIdle,
isCalculating: checkoutIsCalculating,
hasError: checkoutHasError,
} = useCheckoutContext();
const { isEditor, getPreviewData } = useEditorContext();
const { setValidationErrors } = useValidationContext();
const { addErrorNotice, removeNotice } = useStoreNotices();
const {
isSuccessResponse,
isErrorResponse,
isFailResponse,
noticeContexts,
} = useEmitResponse();
const [ observers, observerDispatch ] = useReducer( emitReducer, {} );
const { onPaymentProcessing } = useEventEmitters( observerDispatch );
const currentObservers = useRef( observers );
// ensure observers are always current.
useEffect( () => {
currentObservers.current = observers;
}, [ observers ] );
const [ paymentData, dispatch ] = useReducer(
reducer,
DEFAULT_PAYMENT_DATA_CONTEXT_STATE
);
const {
dispatchActions,
setPaymentStatus,
} = usePaymentMethodDataDispatchers( dispatch );
const paymentMethodsInitialized = usePaymentMethods(
dispatchActions.setRegisteredPaymentMethods
);
const expressPaymentMethodsInitialized = useExpressPaymentMethods(
dispatchActions.setRegisteredExpressPaymentMethods
);
const {
activePaymentMethod,
activeSavedToken,
setActivePaymentMethod,
setActiveSavedToken,
} = useActivePaymentMethod();
const customerPaymentMethods = useMemo( (): CustomerPaymentMethods => {
if ( isEditor ) {
return getPreviewData(
'previewSavedPaymentMethods'
) as CustomerPaymentMethods;
}
return paymentMethodsInitialized
? getCustomerPaymentMethods( paymentData.paymentMethods )
: {};
}, [
isEditor,
getPreviewData,
paymentMethodsInitialized,
paymentData.paymentMethods,
] );
const setExpressPaymentError = useCallback(
( message ) => {
if ( message ) {
addErrorNotice( message, {
id: 'wc-express-payment-error',
context: noticeContexts.EXPRESS_PAYMENTS,
} );
} else {
removeNotice(
'wc-express-payment-error',
noticeContexts.EXPRESS_PAYMENTS
);
}
},
[ addErrorNotice, noticeContexts.EXPRESS_PAYMENTS, removeNotice ]
);
const isExpressPaymentMethodActive = Object.keys(
paymentData.expressPaymentMethods
).includes( activePaymentMethod );
const currentStatus = useMemo(
() => ( {
isPristine: paymentData.currentStatus === STATUS.PRISTINE,
isStarted: paymentData.currentStatus === STATUS.STARTED,
isProcessing: paymentData.currentStatus === STATUS.PROCESSING,
isFinished: [
STATUS.ERROR,
STATUS.FAILED,
STATUS.SUCCESS,
].includes( paymentData.currentStatus ),
hasError: paymentData.currentStatus === STATUS.ERROR,
hasFailed: paymentData.currentStatus === STATUS.FAILED,
isSuccessful: paymentData.currentStatus === STATUS.SUCCESS,
isDoingExpressPayment:
paymentData.currentStatus !== STATUS.PRISTINE &&
isExpressPaymentMethodActive,
} ),
[ paymentData.currentStatus, isExpressPaymentMethodActive ]
);
// Update the active (selected) payment method when it is empty, or invalid.
useEffect( () => {
const paymentMethodKeys = Object.keys( paymentData.paymentMethods );
const allPaymentMethodKeys = [
...paymentMethodKeys,
...Object.keys( paymentData.expressPaymentMethods ),
];
if ( ! paymentMethodsInitialized || ! paymentMethodKeys.length ) {
return;
}
setActivePaymentMethod( ( currentActivePaymentMethod ) => {
// If there's no active payment method, or the active payment method has
// been removed (e.g. COD vs shipping methods), set one as active.
// Note: It's possible that the active payment method might be an
// express payment method. So registered express payment methods are
// included in the check here.
if (
! currentActivePaymentMethod ||
! allPaymentMethodKeys.includes( currentActivePaymentMethod )
) {
setPaymentStatus().pristine();
return Object.keys( paymentData.paymentMethods )[ 0 ];
}
return currentActivePaymentMethod;
} );
}, [
paymentMethodsInitialized,
paymentData.paymentMethods,
paymentData.expressPaymentMethods,
setActivePaymentMethod,
setPaymentStatus,
] );
// flip payment to processing if checkout processing is complete, there are no errors, and payment status is started.
useEffect( () => {
if (
checkoutIsProcessing &&
! checkoutHasError &&
! checkoutIsCalculating &&
! currentStatus.isFinished
) {
setPaymentStatus().processing();
}
}, [
checkoutIsProcessing,
checkoutHasError,
checkoutIsCalculating,
currentStatus.isFinished,
setPaymentStatus,
] );
// When checkout is returned to idle, set payment status to pristine but only if payment status is already not finished.
useEffect( () => {
if ( checkoutIsIdle && ! currentStatus.isSuccessful ) {
setPaymentStatus().pristine();
}
}, [ checkoutIsIdle, currentStatus.isSuccessful, setPaymentStatus ] );
// if checkout has an error and payment is not being made with a saved token and payment status is success, then let's sync payment status back to pristine.
useEffect( () => {
if (
checkoutHasError &&
currentStatus.isSuccessful &&
! paymentData.hasSavedToken
) {
setPaymentStatus().pristine();
}
}, [
checkoutHasError,
currentStatus.isSuccessful,
paymentData.hasSavedToken,
setPaymentStatus,
] );
useEffect( () => {
// Note: the nature of this event emitter is that it will bail on any
// observer that returns a response that !== true. However, this still
// allows for other observers that return true for continuing through
// to the next observer (or bailing if there's a problem).
if ( currentStatus.isProcessing ) {
removeNotice( 'wc-payment-error', noticeContexts.PAYMENTS );
emitEventWithAbort(
currentObservers.current,
EMIT_TYPES.PAYMENT_PROCESSING,
{}
).then( ( observerResponses ) => {
let successResponse, errorResponse;
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 ) {
setPaymentStatus().success(
successResponse?.meta?.paymentMethodData,
successResponse?.meta?.billingData,
successResponse?.meta?.shippingData
);
} else if ( errorResponse && isFailResponse( errorResponse ) ) {
if (
errorResponse.message &&
errorResponse.message.length
) {
addErrorNotice( errorResponse.message, {
id: 'wc-payment-error',
isDismissible: false,
context:
errorResponse?.messageContext ||
noticeContexts.PAYMENTS,
} );
}
setPaymentStatus().failed(
errorResponse?.message,
errorResponse?.meta?.paymentMethodData,
errorResponse?.meta?.billingData
);
} else if ( errorResponse ) {
if (
errorResponse.message &&
errorResponse.message.length
) {
addErrorNotice( errorResponse.message, {
id: 'wc-payment-error',
isDismissible: false,
context:
errorResponse?.messageContext ||
noticeContexts.PAYMENTS,
} );
}
setPaymentStatus().error( errorResponse.message );
setValidationErrors( errorResponse?.validationErrors );
} else {
// otherwise there are no payment methods doing anything so
// just consider success
setPaymentStatus().success();
}
} );
}
}, [
currentStatus.isProcessing,
setValidationErrors,
setPaymentStatus,
removeNotice,
noticeContexts.PAYMENTS,
isSuccessResponse,
isFailResponse,
isErrorResponse,
addErrorNotice,
] );
const paymentContextData: PaymentMethodDataContextType = {
setPaymentStatus,
currentStatus,
paymentStatuses: STATUS,
paymentMethodData: paymentData.paymentMethodData,
errorMessage: paymentData.errorMessage,
activePaymentMethod,
setActivePaymentMethod,
activeSavedToken,
setActiveSavedToken,
onPaymentProcessing,
customerPaymentMethods,
paymentMethods: paymentData.paymentMethods,
expressPaymentMethods: paymentData.expressPaymentMethods,
paymentMethodsInitialized,
expressPaymentMethodsInitialized,
setExpressPaymentError,
isExpressPaymentMethodActive,
shouldSavePayment: paymentData.shouldSavePaymentMethod,
setShouldSavePayment: dispatchActions.setShouldSavePayment,
};
return (
<PaymentMethodDataContext.Provider value={ paymentContextData }>
{ children }
</PaymentMethodDataContext.Provider>
);
};

View File

@ -0,0 +1,120 @@
/**
* Internal dependencies
*/
import {
ACTION,
STATUS,
DEFAULT_PAYMENT_DATA_CONTEXT_STATE,
} from './constants';
import type { PaymentMethodDataContextState } from './types';
import type { ActionType } from './actions';
const hasSavedPaymentToken = (
paymentMethodData: Record< string, unknown > | undefined
): boolean => {
return !! (
typeof paymentMethodData === 'object' && paymentMethodData.isSavedToken
);
};
/**
* Reducer for payment data state
*/
const reducer = (
state = DEFAULT_PAYMENT_DATA_CONTEXT_STATE,
{
type,
paymentMethodData,
shouldSavePaymentMethod = false,
errorMessage = '',
paymentMethods = {},
}: ActionType
): PaymentMethodDataContextState => {
switch ( type ) {
case STATUS.STARTED:
return {
...state,
currentStatus: STATUS.STARTED,
paymentMethodData: paymentMethodData || state.paymentMethodData,
hasSavedToken: hasSavedPaymentToken(
paymentMethodData || state.paymentMethodData
),
};
case STATUS.ERROR:
return state.currentStatus !== STATUS.ERROR
? {
...state,
currentStatus: STATUS.ERROR,
errorMessage: errorMessage || state.errorMessage,
}
: state;
case STATUS.FAILED:
return state.currentStatus !== STATUS.FAILED
? {
...state,
currentStatus: STATUS.FAILED,
paymentMethodData:
paymentMethodData || state.paymentMethodData,
errorMessage: errorMessage || state.errorMessage,
}
: state;
case STATUS.SUCCESS:
return state.currentStatus !== STATUS.SUCCESS
? {
...state,
currentStatus: STATUS.SUCCESS,
paymentMethodData:
paymentMethodData || state.paymentMethodData,
hasSavedToken: hasSavedPaymentToken(
paymentMethodData || state.paymentMethodData
),
}
: state;
case STATUS.PROCESSING:
return state.currentStatus !== STATUS.PROCESSING
? {
...state,
currentStatus: STATUS.PROCESSING,
errorMessage: '',
}
: state;
case STATUS.COMPLETE:
return state.currentStatus !== STATUS.COMPLETE
? {
...state,
currentStatus: STATUS.COMPLETE,
}
: state;
case STATUS.PRISTINE:
return {
...DEFAULT_PAYMENT_DATA_CONTEXT_STATE,
currentStatus: STATUS.PRISTINE,
// keep payment method registration state
paymentMethods: {
...state.paymentMethods,
},
expressPaymentMethods: {
...state.expressPaymentMethods,
},
shouldSavePaymentMethod: state.shouldSavePaymentMethod,
};
case ACTION.SET_REGISTERED_PAYMENT_METHODS:
return {
...state,
paymentMethods,
};
case ACTION.SET_REGISTERED_EXPRESS_PAYMENT_METHODS:
return {
...state,
expressPaymentMethods: paymentMethods,
};
case ACTION.SET_SHOULD_SAVE_PAYMENT_METHOD:
return {
...state,
shouldSavePaymentMethod,
};
}
};
export default reducer;

View File

@ -0,0 +1,251 @@
/**
* External dependencies
*/
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { previewCart } from '@woocommerce/resource-previews';
import { dispatch } from '@wordpress/data';
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
import {
registerPaymentMethod,
registerExpressPaymentMethod,
__experimentalDeRegisterPaymentMethod,
__experimentalDeRegisterExpressPaymentMethod,
} from '@woocommerce/blocks-registry';
import { default as fetchMock } from 'jest-fetch-mock';
/**
* Internal dependencies
*/
import {
usePaymentMethodDataContext,
PaymentMethodDataProvider,
} from '../payment-method-data-context';
import {
CheckoutExpressPayment,
SavedPaymentMethodOptions,
} from '../../../../../../blocks/cart-checkout/payment-methods';
import { defaultCartState } from '../../../../../../data/default-states';
jest.mock( '@woocommerce/settings', () => {
const originalModule = jest.requireActual( '@woocommerce/settings' );
return {
// @ts-ignore We know @woocommerce/settings is an object.
...originalModule,
getSetting: ( setting, ...rest ) => {
if ( setting === 'customerPaymentMethods' ) {
return {
cc: [
{
method: {
gateway: 'stripe',
last4: '4242',
brand: 'Visa',
},
expires: '12/22',
is_default: true,
tokenId: 1,
},
],
};
}
return originalModule.getSetting( setting, ...rest );
},
};
} );
const registerMockPaymentMethods = () => {
[ 'cheque', 'bacs' ].forEach( ( name ) => {
registerPaymentMethod( {
name,
label: name,
content: <div>A payment method</div>,
edit: <div>A payment method</div>,
icons: null,
canMakePayment: () => true,
supports: {
features: [ 'products' ],
},
ariaLabel: name,
} );
} );
[ 'stripe' ].forEach( ( name ) => {
registerPaymentMethod( {
name,
label: name,
content: <div>A payment method</div>,
edit: <div>A payment method</div>,
icons: null,
canMakePayment: () => true,
supports: {
showSavedCards: true,
showSaveOption: true,
features: [ 'products' ],
},
ariaLabel: name,
} );
} );
[ 'express-payment' ].forEach( ( name ) => {
const Content = ( {
onClose = () => void null,
onClick = () => void null,
} ) => {
return (
<>
<button onClick={ onClick }>
{ name + ' express payment method' }
</button>
<button onClick={ onClose }>
{ name + ' express payment method close' }
</button>
</>
);
};
registerExpressPaymentMethod( {
name,
content: <Content />,
edit: <div>An express payment method</div>,
canMakePayment: () => true,
paymentMethodId: name,
supports: {
features: [ 'products' ],
},
} );
} );
};
const resetMockPaymentMethods = () => {
[ 'cheque', 'bacs', 'stripe' ].forEach( ( name ) => {
__experimentalDeRegisterPaymentMethod( name );
} );
[ 'express-payment' ].forEach( ( name ) => {
__experimentalDeRegisterExpressPaymentMethod( name );
} );
};
describe( 'Testing Payment Method Data Context Provider', () => {
beforeEach( async () => {
registerMockPaymentMethods();
fetchMock.mockResponse( ( req ) => {
if ( req.url.match( /wc\/store\/cart/ ) ) {
return Promise.resolve( JSON.stringify( previewCart ) );
}
return Promise.resolve( '' );
} );
// need to clear the store resolution state between tests.
await dispatch( storeKey ).invalidateResolutionForStore();
await dispatch( storeKey ).receiveCart( defaultCartState.cartData );
} );
afterEach( async () => {
resetMockPaymentMethods();
fetchMock.resetMocks();
} );
it( 'toggles active payment method correctly for express payment activation and close', async () => {
const TriggerActiveExpressPaymentMethod = () => {
const { activePaymentMethod } = usePaymentMethodDataContext();
return (
<>
<CheckoutExpressPayment />
{ 'Active Payment Method: ' + activePaymentMethod }
</>
);
};
const TestComponent = () => {
return (
<PaymentMethodDataProvider>
<TriggerActiveExpressPaymentMethod />
</PaymentMethodDataProvider>
);
};
render( <TestComponent /> );
// should initialize by default the first payment method.
await waitFor( () => {
const activePaymentMethod = screen.queryByText(
/Active Payment Method: cheque/
);
expect( activePaymentMethod ).not.toBeNull();
} );
// Express payment method clicked.
fireEvent.click(
screen.getByText( 'express-payment express payment method' )
);
await waitFor( () => {
const activePaymentMethod = screen.queryByText(
/Active Payment Method: express-payment/
);
expect( activePaymentMethod ).not.toBeNull();
} );
// Express payment method closed.
fireEvent.click(
screen.getByText( 'express-payment express payment method close' )
);
await waitFor( () => {
const activePaymentMethod = screen.queryByText(
/Active Payment Method: cheque/
);
expect( activePaymentMethod ).not.toBeNull();
} );
// ["`select` control in `@wordpress/data-controls` is deprecated. Please use built-in `resolveSelect` control in `@wordpress/data` instead."]
expect( console ).toHaveWarned();
} );
it( 'resets saved payment method data after starting and closing an express payment method', async () => {
const TriggerActiveExpressPaymentMethod = () => {
const {
activePaymentMethod,
paymentMethodData,
} = usePaymentMethodDataContext();
return (
<>
<CheckoutExpressPayment />
<SavedPaymentMethodOptions onChange={ () => void null } />
{ 'Active Payment Method: ' + activePaymentMethod }
{ paymentMethodData[ 'wc-stripe-payment-token' ] && (
<span>Stripe token</span>
) }
</>
);
};
const TestComponent = () => {
return (
<PaymentMethodDataProvider>
<TriggerActiveExpressPaymentMethod />
</PaymentMethodDataProvider>
);
};
render( <TestComponent /> );
// Should initialize by default the default saved payment method.
await waitFor( () => {
const activePaymentMethod = screen.queryByText(
/Active Payment Method: stripe/
);
const stripeToken = screen.queryByText( /Stripe token/ );
expect( activePaymentMethod ).not.toBeNull();
expect( stripeToken ).not.toBeNull();
} );
// Express payment method clicked.
fireEvent.click(
screen.getByText( 'express-payment express payment method' )
);
await waitFor( () => {
const activePaymentMethod = screen.queryByText(
/Active Payment Method: express-payment/
);
const stripeToken = screen.queryByText( /Stripe token/ );
expect( activePaymentMethod ).not.toBeNull();
expect( stripeToken ).toBeNull();
} );
// Express payment method closed.
fireEvent.click(
screen.getByText( 'express-payment express payment method close' )
);
await waitFor( () => {
const activePaymentMethod = screen.queryByText(
/Active Payment Method: stripe/
);
const stripeToken = screen.queryByText( /Stripe token/ );
expect( activePaymentMethod ).not.toBeNull();
expect( stripeToken ).not.toBeNull();
} );
} );
} );

View File

@ -0,0 +1,130 @@
/**
* External dependencies
*/
import {
PaymentMethodConfiguration,
PaymentMethods,
ExpressPaymentMethods,
} from '@woocommerce/type-defs/payments';
import type {
EmptyObjectType,
ObjectType,
} from '@woocommerce/type-defs/objects';
/**
* Internal dependencies
*/
import type { emitterCallback } from '../../../event-emit';
import { STATUS } from './constants';
export interface CustomerPaymentMethod {
method: PaymentMethodConfiguration;
expires: string;
is_default: boolean;
tokenId: number;
actions: ObjectType;
}
export type CustomerPaymentMethods =
| Record< string, CustomerPaymentMethod >
| EmptyObjectType;
export type PaymentMethodDispatchers = {
setRegisteredPaymentMethods: ( paymentMethods: PaymentMethods ) => void;
setRegisteredExpressPaymentMethods: (
paymentMethods: ExpressPaymentMethods
) => void;
setShouldSavePayment: ( shouldSave: boolean ) => void;
};
export interface PaymentStatusDispatchers {
pristine: () => void;
started: ( paymentMethodData?: ObjectType | EmptyObjectType ) => void;
processing: () => void;
completed: () => void;
error: ( error: string ) => void;
failed: (
error?: string,
paymentMethodData?: ObjectType | EmptyObjectType,
billingData?: ObjectType | EmptyObjectType
) => void;
success: (
paymentMethodData?: ObjectType | EmptyObjectType,
billingData?: ObjectType | EmptyObjectType,
shippingData?: ObjectType | EmptyObjectType
) => void;
}
export interface PaymentMethodDataContextState {
currentStatus: STATUS;
shouldSavePaymentMethod: boolean;
paymentMethodData: ObjectType | EmptyObjectType;
hasSavedToken: boolean;
errorMessage: string;
paymentMethods: PaymentMethods;
expressPaymentMethods: ExpressPaymentMethods;
}
export type PaymentMethodCurrentStatusType = {
// If true then the payment method state in checkout is pristine.
isPristine: boolean;
// If true then the payment method has been initialized and has started.
isStarted: boolean;
// If true then the payment method is processing payment.
isProcessing: boolean;
// If true then the payment method is in a finished state (which may mean it's status is either error, failed, or success).
isFinished: boolean;
// If true then the payment method is in an error state.
hasError: boolean;
// If true then the payment method has failed (usually indicates a problem with the payment method used, not logic error).
hasFailed: boolean;
// If true then the payment method has completed it's processing successfully.
isSuccessful: boolean;
// If true, an express payment is in progress.
isDoingExpressPayment: boolean;
};
export type PaymentMethodDataContextType = {
// Sets the payment status for the payment method.
setPaymentStatus: () => PaymentStatusDispatchers;
// The current payment status.
currentStatus: PaymentMethodCurrentStatusType;
// An object of payment status constants.
paymentStatuses: ObjectType;
// Arbitrary data to be passed along for processing by the payment method on the server.
paymentMethodData: ObjectType | EmptyObjectType;
// An error message provided by the payment method if there is an error.
errorMessage: string;
// The active payment method slug.
activePaymentMethod: string;
// A function for setting the active payment method.
setActivePaymentMethod: ( paymentMethod: string ) => void;
// Current active token.
activeSavedToken: string;
// A function for setting the active payment method token.
setActiveSavedToken: ( activeSavedToken: string ) => void;
// Returns the customer payment for the customer if it exists.
customerPaymentMethods:
| Record< string, CustomerPaymentMethod >
| EmptyObjectType;
// Registered payment methods.
paymentMethods: PaymentMethods;
// Registered express payment methods.
expressPaymentMethods: ExpressPaymentMethods;
// True when all registered payment methods have been initialized.
paymentMethodsInitialized: boolean;
// True when all registered express payment methods have been initialized.
expressPaymentMethodsInitialized: boolean;
// Event registration callback for registering observers for the payment processing event.
onPaymentProcessing: ReturnType< typeof emitterCallback >;
// A function used by express payment methods to indicate an error for checkout to handle. It receives an error message string. Does not change payment status.
setExpressPaymentError: ( error: string ) => void;
// True if an express payment method is active.
isExpressPaymentMethodActive: boolean;
// A function used to set the shouldSavePayment value.
setShouldSavePayment: ( shouldSavePayment: boolean ) => void;
// True means that the configured payment method option is saved for the customer.
shouldSavePayment: boolean;
};
export type PaymentMethodsDispatcherType = (
paymentMethods: PaymentMethods
) => undefined;

View File

@ -0,0 +1,38 @@
/**
* External dependencies
*/
import { useState, useEffect } from '@wordpress/element';
/**
* Internal dependencies
*/
import { useStoreEvents } from '../../../hooks/use-store-events';
export const useActivePaymentMethod = (): {
activePaymentMethod: string;
activeSavedToken: string;
setActivePaymentMethod: React.Dispatch< React.SetStateAction< string > >;
setActiveSavedToken: ( token: string ) => void;
} => {
const { dispatchCheckoutEvent } = useStoreEvents();
// The active payment method - e.g. Stripe CC or BACS.
const [ activePaymentMethod, setActivePaymentMethod ] = useState( '' );
// If a previously saved payment method is active, the token for that method. For example, a for a Stripe CC card saved to user account.
const [ activeSavedToken, setActiveSavedToken ] = useState( '' );
// Trigger event on change.
useEffect( () => {
dispatchCheckoutEvent( 'set-active-payment-method', {
activePaymentMethod,
} );
}, [ dispatchCheckoutEvent, activePaymentMethod ] );
return {
activePaymentMethod,
activeSavedToken,
setActivePaymentMethod,
setActiveSavedToken,
};
};

View File

@ -0,0 +1,105 @@
/**
* External dependencies
*/
import { useCallback, useMemo } from '@wordpress/element';
/**
* Internal dependencies
*/
import { actions, ActionType } from './actions';
import { STATUS } from './constants';
import { useCustomerDataContext } from '../customer';
import { useShippingDataContext } from '../shipping';
import type {
PaymentStatusDispatchers,
PaymentMethodDispatchers,
} from './types';
export const usePaymentMethodDataDispatchers = (
dispatch: React.Dispatch< ActionType >
): {
dispatchActions: PaymentMethodDispatchers;
setPaymentStatus: () => PaymentStatusDispatchers;
} => {
const { setBillingData } = useCustomerDataContext();
const { setShippingAddress } = useShippingDataContext();
const dispatchActions = useMemo(
(): PaymentMethodDispatchers => ( {
setRegisteredPaymentMethods: ( paymentMethods ) =>
void dispatch(
actions.setRegisteredPaymentMethods( paymentMethods )
),
setRegisteredExpressPaymentMethods: ( paymentMethods ) =>
void dispatch(
actions.setRegisteredExpressPaymentMethods( paymentMethods )
),
setShouldSavePayment: ( shouldSave ) =>
void dispatch(
actions.setShouldSavePaymentMethod( shouldSave )
),
} ),
[ dispatch ]
);
const setPaymentStatus = useCallback(
(): PaymentStatusDispatchers => ( {
pristine: () => dispatch( actions.statusOnly( STATUS.PRISTINE ) ),
started: ( paymentMethodData ) => {
dispatch(
actions.started( {
paymentMethodData,
} )
);
},
processing: () =>
dispatch( actions.statusOnly( STATUS.PROCESSING ) ),
completed: () => dispatch( actions.statusOnly( STATUS.COMPLETE ) ),
error: ( errorMessage ) =>
dispatch( actions.error( errorMessage ) ),
failed: (
errorMessage,
paymentMethodData,
billingData = undefined
) => {
if ( billingData ) {
setBillingData( billingData );
}
dispatch(
actions.failed( {
errorMessage: errorMessage || '',
paymentMethodData: paymentMethodData || {},
} )
);
},
success: (
paymentMethodData,
billingData = undefined,
shippingData = undefined
) => {
if ( billingData ) {
setBillingData( billingData );
}
if (
typeof shippingData !== undefined &&
shippingData?.address
) {
setShippingAddress(
shippingData.address as Record< string, unknown >
);
}
dispatch(
actions.success( {
paymentMethodData,
} )
);
},
} ),
[ dispatch, setBillingData, setShippingAddress ]
);
return {
dispatchActions,
setPaymentStatus,
};
};

View File

@ -0,0 +1,229 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import {
getPaymentMethods,
getExpressPaymentMethods,
} from '@woocommerce/blocks-registry';
import { useState, useEffect, useRef, useCallback } from '@wordpress/element';
import { useShallowEqual } from '@woocommerce/base-hooks';
import { CURRENT_USER_IS_ADMIN, getSetting } from '@woocommerce/settings';
import type {
PaymentMethods,
ExpressPaymentMethods,
PaymentMethodConfigInstance,
ExpressPaymentMethodConfigInstance,
} from '@woocommerce/type-defs/payments';
import { useDebouncedCallback } from 'use-debounce';
/**
* Internal dependencies
*/
import { useEditorContext } from '../../editor-context';
import { useShippingDataContext } from '../shipping';
import { useCustomerDataContext } from '../customer';
import { useStoreCart } from '../../../hooks/cart/use-store-cart';
import { useStoreNotices } from '../../../hooks/use-store-notices';
import { useEmitResponse } from '../../../hooks/use-emit-response';
import type { PaymentMethodsDispatcherType } from './types';
/**
* This hook handles initializing registered payment methods and exposing all
* registered payment methods that can be used in the current environment (via
* the payment method's `canMakePayment` property).
*
* @param {function(Object):undefined} dispatcher A dispatcher for setting registered payment methods to an external state.
* @param {Object} registeredPaymentMethods Registered payment methods to process.
* @param {Array} paymentMethodsSortOrder Array of payment method names to sort by. This should match keys of registeredPaymentMethods.
* @param {string} noticeContext Id of the context to append notices to.
*
* @return {boolean} Whether the payment methods have been initialized or not. True when all payment methods have been initialized.
*/
const usePaymentMethodRegistration = (
dispatcher: PaymentMethodsDispatcherType,
registeredPaymentMethods: PaymentMethods | ExpressPaymentMethods,
paymentMethodsSortOrder: string[],
noticeContext: string
) => {
const [ isInitialized, setIsInitialized ] = useState( false );
const { isEditor } = useEditorContext();
const { selectedRates } = useShippingDataContext();
const { billingData, shippingAddress } = useCustomerDataContext();
const selectedShippingMethods = useShallowEqual( selectedRates );
const paymentMethodsOrder = useShallowEqual( paymentMethodsSortOrder );
const cart = useStoreCart();
const { cartTotals, cartNeedsShipping, paymentRequirements } = cart;
const canPayArgument = useRef( {
cart,
cartTotals,
cartNeedsShipping,
billingData,
shippingAddress,
selectedShippingMethods,
paymentRequirements,
} );
const { addErrorNotice } = useStoreNotices();
useEffect( () => {
canPayArgument.current = {
cart,
cartTotals,
cartNeedsShipping,
billingData,
shippingAddress,
selectedShippingMethods,
paymentRequirements,
};
}, [
cart,
cartTotals,
cartNeedsShipping,
billingData,
shippingAddress,
selectedShippingMethods,
paymentRequirements,
] );
const refreshCanMakePayments = useCallback( async () => {
let availablePaymentMethods = {};
const addAvailablePaymentMethod = (
paymentMethod:
| PaymentMethodConfigInstance
| ExpressPaymentMethodConfigInstance
) => {
availablePaymentMethods = {
...availablePaymentMethods,
[ paymentMethod.name ]: paymentMethod,
};
};
for ( let i = 0; i < paymentMethodsOrder.length; i++ ) {
const paymentMethodName = paymentMethodsOrder[ i ];
const paymentMethod = registeredPaymentMethods[ paymentMethodName ];
if ( ! paymentMethod ) {
continue;
}
// See if payment method should be available. This always evaluates to true in the editor context.
try {
const canPay = isEditor
? true
: await Promise.resolve(
paymentMethod.canMakePayment(
canPayArgument.current
)
);
if ( canPay ) {
if (
typeof canPay === 'object' &&
canPay !== null &&
canPay.error
) {
throw new Error( canPay.error.message );
}
addAvailablePaymentMethod( paymentMethod );
}
} catch ( e ) {
if ( CURRENT_USER_IS_ADMIN || isEditor ) {
const errorText = sprintf(
/* translators: %s the id of the payment method being registered (bank transfer, Stripe...) */
__(
`There was an error registering the payment method with id '%s': `,
'woo-gutenberg-products-block'
),
paymentMethod.paymentMethodId
);
addErrorNotice( `${ errorText } ${ e }`, {
context: noticeContext,
id: `wc-${ paymentMethod.paymentMethodId }-registration-error`,
} );
}
}
}
// Re-dispatch available payment methods to store.
dispatcher( availablePaymentMethods );
// Note: some payment methods use the `canMakePayment` callback to initialize / setup.
// Example: Stripe CC, Stripe Payment Request.
// That's why we track "is initialized" state here.
setIsInitialized( true );
}, [
addErrorNotice,
dispatcher,
isEditor,
noticeContext,
paymentMethodsOrder,
registeredPaymentMethods,
] );
const [ debouncedRefreshCanMakePayments ] = useDebouncedCallback(
refreshCanMakePayments,
500
);
// Determine which payment methods are available initially and whenever
// shipping methods, cart or the billing data change.
// Some payment methods (e.g. COD) can be disabled for specific shipping methods.
useEffect( () => {
debouncedRefreshCanMakePayments();
}, [
debouncedRefreshCanMakePayments,
cart,
selectedShippingMethods,
billingData,
] );
return isInitialized;
};
/**
* Custom hook for setting up payment methods (standard, non-express).
*
* @param {function(Object):undefined} dispatcher
*
* @return {boolean} True when standard payment methods have been initialized.
*/
export const usePaymentMethods = (
dispatcher: PaymentMethodsDispatcherType
): boolean => {
const standardMethods: PaymentMethods = getPaymentMethods() as PaymentMethods;
const { noticeContexts } = useEmitResponse();
// Ensure all methods are present in order.
// Some payment methods may not be present in paymentGatewaySortOrder if they
// depend on state, e.g. COD can depend on shipping method.
const displayOrder = new Set( [
...( getSetting( 'paymentGatewaySortOrder', [] ) as [ ] ),
...Object.keys( standardMethods ),
] );
return usePaymentMethodRegistration(
dispatcher,
standardMethods,
Array.from( displayOrder ),
noticeContexts.PAYMENTS
);
};
/**
* Custom hook for setting up express payment methods.
*
* @param {function(Object):undefined} dispatcher
*
* @return {boolean} True when express payment methods have been initialized.
*/
export const useExpressPaymentMethods = (
dispatcher: PaymentMethodsDispatcherType
): boolean => {
const expressMethods: ExpressPaymentMethods = getExpressPaymentMethods() as ExpressPaymentMethods;
const { noticeContexts } = useEmitResponse();
return usePaymentMethodRegistration(
dispatcher,
expressMethods,
Object.keys( expressMethods ),
noticeContexts.EXPRESS_PAYMENTS
);
};

View File

@ -0,0 +1,43 @@
/**
* External dependencies
*/
import { getSetting } from '@woocommerce/settings';
/**
* Internal dependencies
*/
import type { PaymentMethods, CustomerPaymentMethod } from './types';
/**
* Gets the payment methods saved for the current user after filtering out disabled ones.
*/
export const getCustomerPaymentMethods = (
availablePaymentMethods: PaymentMethods = {}
): Record< string, CustomerPaymentMethod > => {
if ( Object.keys( availablePaymentMethods ).length === 0 ) {
return {};
}
const customerPaymentMethods = getSetting( 'customerPaymentMethods', {} );
const paymentMethodKeys = Object.keys( customerPaymentMethods );
const enabledCustomerPaymentMethods = {} as Record<
string,
CustomerPaymentMethod
>;
paymentMethodKeys.forEach( ( type ) => {
const methods = customerPaymentMethods[ type ].filter(
( {
method: { gateway },
}: {
method: {
gateway: string;
};
} ) =>
gateway in availablePaymentMethods &&
availablePaymentMethods[ gateway ].supports?.showSavedCards
);
if ( methods.length ) {
enabledCustomerPaymentMethods[ type ] = methods;
}
} );
return enabledCustomerPaymentMethods;
};

View File

@ -0,0 +1,60 @@
/**
* @typedef {import('@woocommerce/type-defs/contexts').ShippingErrorTypes} ShippingErrorTypes
* @typedef {import('@woocommerce/type-defs/shipping').ShippingAddress} ShippingAddress
* @typedef {import('@woocommerce/type-defs/contexts').ShippingDataContext} ShippingDataContext
*/
/**
* @type {ShippingErrorTypes}
*/
export const ERROR_TYPES = {
NONE: 'none',
INVALID_ADDRESS: 'invalid_address',
UNKNOWN: 'unknown_error',
};
export const shippingErrorCodes = {
INVALID_COUNTRY: 'woocommerce_rest_cart_shipping_rates_invalid_country',
MISSING_COUNTRY: 'woocommerce_rest_cart_shipping_rates_missing_country',
INVALID_STATE: 'woocommerce_rest_cart_shipping_rates_invalid_state',
};
/**
* @type {ShippingAddress}
*/
export const DEFAULT_SHIPPING_ADDRESS = {
first_name: '',
last_name: '',
company: '',
address_1: '',
address_2: '',
city: '',
state: '',
postcode: '',
country: '',
};
/**
* @type {ShippingDataContext}
*/
export const DEFAULT_SHIPPING_CONTEXT_DATA = {
shippingErrorStatus: {
isPristine: true,
isValid: false,
hasInvalidAddress: false,
hasError: false,
},
dispatchErrorStatus: () => null,
shippingErrorTypes: ERROR_TYPES,
shippingRates: [],
shippingRatesLoading: false,
selectedRates: [],
setSelectedRates: () => null,
shippingAddress: DEFAULT_SHIPPING_ADDRESS,
setShippingAddress: () => null,
onShippingRateSuccess: () => null,
onShippingRateFail: () => null,
onShippingRateSelectSuccess: () => null,
onShippingRateSelectFail: () => null,
needsShipping: false,
};

View File

@ -0,0 +1,37 @@
/**
* Internal dependencies
*/
import { emitterCallback, reducer, emitEvent } from '../../../event-emit';
const EMIT_TYPES = {
SHIPPING_RATES_SUCCESS: 'shipping_rates_success',
SHIPPING_RATES_FAIL: 'shipping_rates_fail',
SHIPPING_RATE_SELECT_SUCCESS: 'shipping_rate_select_success',
SHIPPING_RATE_SELECT_FAIL: 'shipping_rate_select_fail',
};
/**
* Receives a reducer dispatcher and returns an object with the onSuccess and
* onFail callback registration points for the shipping option emit 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} dispatcher A reducer dispatcher
* @return {Object} An object with `onSuccess` and `onFail` emitter registration.
*/
const emitterObservers = ( dispatcher ) => ( {
onSuccess: emitterCallback( EMIT_TYPES.SHIPPING_RATES_SUCCESS, dispatcher ),
onFail: emitterCallback( EMIT_TYPES.SHIPPING_RATES_FAIL, dispatcher ),
onSelectSuccess: emitterCallback(
EMIT_TYPES.SHIPPING_RATE_SELECT_SUCCESS,
dispatcher
),
onSelectFail: emitterCallback(
EMIT_TYPES.SHIPPING_RATE_SELECT_FAIL,
dispatcher
),
} );
export { EMIT_TYPES, emitterObservers, reducer, emitEvent };

View File

@ -0,0 +1,232 @@
/**
* External dependencies
*/
import {
createContext,
useContext,
useReducer,
useEffect,
useMemo,
useRef,
} from '@wordpress/element';
import isShallowEqual from '@wordpress/is-shallow-equal';
import { deriveSelectedShippingRates } from '@woocommerce/base-utils';
/**
* Internal dependencies
*/
import { ERROR_TYPES, DEFAULT_SHIPPING_CONTEXT_DATA } from './constants';
import { hasInvalidShippingAddress } from './utils';
import { errorStatusReducer } from './reducers';
import {
EMIT_TYPES,
emitterObservers,
reducer as emitReducer,
emitEvent,
} from './event-emit';
import { useCheckoutContext } from '../checkout-state';
import { useCustomerDataContext } from '../customer';
import { useStoreCart } from '../../../hooks/cart/use-store-cart';
import { useSelectShippingRates } from '../../../hooks/shipping/use-select-shipping-rates';
/**
* @typedef {import('@woocommerce/type-defs/contexts').ShippingDataContext} ShippingDataContext
* @typedef {import('react')} React
*/
const { NONE, INVALID_ADDRESS, UNKNOWN } = ERROR_TYPES;
const ShippingDataContext = createContext( DEFAULT_SHIPPING_CONTEXT_DATA );
/**
* @return {ShippingDataContext} Returns data and functions related to shipping methods.
*/
export const useShippingDataContext = () => {
return useContext( ShippingDataContext );
};
/**
* The shipping data provider exposes the interface for shipping in the checkout/cart.
*
* @param {Object} props Incoming props for provider
* @param {React.ReactElement} props.children
*/
export const ShippingDataProvider = ( { children } ) => {
const { dispatchActions } = useCheckoutContext();
const { shippingAddress, setShippingAddress } = useCustomerDataContext();
const {
cartNeedsShipping: needsShipping,
cartHasCalculatedShipping: hasCalculatedShipping,
shippingRates,
shippingRatesLoading,
cartErrors,
} = useStoreCart();
const { selectShippingRate, isSelectingRate } = useSelectShippingRates();
const [ shippingErrorStatus, dispatchErrorStatus ] = useReducer(
errorStatusReducer,
NONE
);
const [ observers, observerDispatch ] = useReducer( emitReducer, {} );
const currentObservers = useRef( observers );
const eventObservers = useMemo(
() => ( {
onShippingRateSuccess: emitterObservers( observerDispatch )
.onSuccess,
onShippingRateFail: emitterObservers( observerDispatch ).onFail,
onShippingRateSelectSuccess: emitterObservers( observerDispatch )
.onSelectSuccess,
onShippingRateSelectFail: emitterObservers( observerDispatch )
.onSelectFail,
} ),
[ observerDispatch ]
);
// set observers on ref so it's always current.
useEffect( () => {
currentObservers.current = observers;
}, [ observers ] );
// set selected rates on ref so it's always current.
const selectedRates = useRef( () =>
deriveSelectedShippingRates( shippingRates )
);
useEffect( () => {
const derivedSelectedRates = deriveSelectedShippingRates(
shippingRates
);
if ( ! isShallowEqual( selectedRates.current, derivedSelectedRates ) ) {
selectedRates.current = derivedSelectedRates;
}
}, [ shippingRates ] );
// increment/decrement checkout calculating counts when shipping is loading.
useEffect( () => {
if ( shippingRatesLoading ) {
dispatchActions.incrementCalculating();
} else {
dispatchActions.decrementCalculating();
}
}, [ shippingRatesLoading, dispatchActions ] );
// increment/decrement checkout calculating counts when shipping rates are being selected.
useEffect( () => {
if ( isSelectingRate ) {
dispatchActions.incrementCalculating();
} else {
dispatchActions.decrementCalculating();
}
}, [ isSelectingRate, dispatchActions ] );
// set shipping error status if there are shipping error codes
useEffect( () => {
if (
cartErrors.length > 0 &&
hasInvalidShippingAddress( cartErrors )
) {
dispatchErrorStatus( { type: INVALID_ADDRESS } );
} else {
dispatchErrorStatus( { type: NONE } );
}
}, [ cartErrors ] );
const currentErrorStatus = useMemo(
() => ( {
isPristine: shippingErrorStatus === NONE,
isValid: shippingErrorStatus === NONE,
hasInvalidAddress: shippingErrorStatus === INVALID_ADDRESS,
hasError:
shippingErrorStatus === UNKNOWN ||
shippingErrorStatus === INVALID_ADDRESS,
} ),
[ shippingErrorStatus ]
);
// emit events.
useEffect( () => {
if (
! shippingRatesLoading &&
( shippingRates.length === 0 || currentErrorStatus.hasError )
) {
emitEvent(
currentObservers.current,
EMIT_TYPES.SHIPPING_RATES_FAIL,
{
hasInvalidAddress: currentErrorStatus.hasInvalidAddress,
hasError: currentErrorStatus.hasError,
}
);
}
}, [
shippingRates,
shippingRatesLoading,
currentErrorStatus.hasError,
currentErrorStatus.hasInvalidAddress,
] );
useEffect( () => {
if (
! shippingRatesLoading &&
shippingRates.length > 0 &&
! currentErrorStatus.hasError
) {
emitEvent(
currentObservers.current,
EMIT_TYPES.SHIPPING_RATES_SUCCESS,
shippingRates
);
}
}, [ shippingRates, shippingRatesLoading, currentErrorStatus.hasError ] );
// emit shipping rate selection events.
useEffect( () => {
if ( isSelectingRate ) {
return;
}
if ( currentErrorStatus.hasError ) {
emitEvent(
currentObservers.current,
EMIT_TYPES.SHIPPING_RATE_SELECT_FAIL,
{
hasError: currentErrorStatus.hasError,
hasInvalidAddress: currentErrorStatus.hasInvalidAddress,
}
);
} else {
emitEvent(
currentObservers.current,
EMIT_TYPES.SHIPPING_RATE_SELECT_SUCCESS,
selectedRates.current
);
}
}, [
isSelectingRate,
currentErrorStatus.hasError,
currentErrorStatus.hasInvalidAddress,
] );
/**
* @type {ShippingDataContext}
*/
const ShippingData = {
shippingErrorStatus: currentErrorStatus,
dispatchErrorStatus,
shippingErrorTypes: ERROR_TYPES,
shippingRates,
shippingRatesLoading,
selectedRates: selectedRates.current,
setSelectedRates: selectShippingRate,
isSelectingRate,
shippingAddress,
setShippingAddress,
needsShipping,
hasCalculatedShipping,
...eventObservers,
};
return (
<>
<ShippingDataContext.Provider value={ ShippingData }>
{ children }
</ShippingDataContext.Provider>
</>
);
};

View File

@ -0,0 +1,18 @@
/**
* Internal dependencies
*/
import { ERROR_TYPES } from './constants';
/**
* Reducer for shipping status state
*
* @param {string} state The current status.
* @param {Object} action The incoming action.
* @param {string} action.type The type of action.
*/
export const errorStatusReducer = ( state, { type } ) => {
if ( Object.values( ERROR_TYPES ).includes( type ) ) {
return type;
}
return state;
};

View File

@ -0,0 +1,16 @@
/**
* Internal dependencies
*/
import { shippingErrorCodes } from './constants';
export const hasInvalidShippingAddress = ( errors ) => {
return errors.some( ( error ) => {
if (
error.code &&
Object.values( shippingErrorCodes ).includes( error.code )
) {
return true;
}
return false;
} );
};

View File

@ -0,0 +1,63 @@
/**
* External dependencies
*/
import triggerFetch from '@wordpress/api-fetch';
/**
* Internal dependencies
*/
import type { CheckoutStateDispatchActions } from './checkout-state/types';
/**
* Utility function for preparing payment data for the request.
*/
export const preparePaymentData = (
//Arbitrary payment data provided by the payment method.
paymentData: Record< string, unknown >,
//Whether to save the payment method info to user account.
shouldSave: boolean,
//The current active payment method.
activePaymentMethod: string
): { key: string; value: unknown }[] => {
const apiData = Object.keys( paymentData ).map( ( property ) => {
const value = paymentData[ property ];
return { key: property, value };
}, [] );
const savePaymentMethodKey = `wc-${ activePaymentMethod }-new-payment-method`;
apiData.push( {
key: savePaymentMethodKey,
value: shouldSave,
} );
return apiData;
};
/**
* Process headers from an API response an dispatch updates.
*/
export const processCheckoutResponseHeaders = (
headers: Headers,
dispatchActions: CheckoutStateDispatchActions
): void => {
if (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore -- this does exist because it's monkey patched in
// middleware/store-api-nonce.
triggerFetch.setNonce &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore -- this does exist because it's monkey patched in
// middleware/store-api-nonce.
typeof triggerFetch.setNonce === 'function'
) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore -- this does exist because it's monkey patched in
// middleware/store-api-nonce.
triggerFetch.setNonce( headers );
}
// Update user using headers.
if ( headers?.get( 'X-WC-Store-API-User' ) ) {
dispatchActions.setCustomerId(
parseInt( headers.get( 'X-WC-Store-API-User' ) || '0', 10 )
);
}
};

View File

@ -0,0 +1,68 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import { createContext, useContext } from '@wordpress/element';
import { useContainerQueries } from '@woocommerce/base-hooks';
import classNames from 'classnames';
/**
* @typedef {import('@woocommerce/type-defs/contexts').ContainerWidthContext} ContainerWidthContext
* @typedef {import('react')} React
*/
const ContainerWidthContext = createContext( {
hasContainerWidth: false,
containerClassName: '',
isMobile: false,
isSmall: false,
isMedium: false,
isLarge: false,
} );
/**
* @return {ContainerWidthContext} Returns the container width context value
*/
export const useContainerWidthContext = () => {
return useContext( ContainerWidthContext );
};
/**
* Provides an interface to useContainerQueries so children can see what size is being used by the
* container.
*
* @param {Object} props Incoming props for the component.
* @param {React.ReactChildren} props.children React elements wrapped by the component.
* @param {string} props.className CSS class in use.
*/
export const ContainerWidthContextProvider = ( {
children,
className = '',
} ) => {
const [ resizeListener, containerClassName ] = useContainerQueries();
const contextValue = {
hasContainerWidth: containerClassName !== '',
containerClassName,
isMobile: containerClassName === 'is-mobile',
isSmall: containerClassName === 'is-small',
isMedium: containerClassName === 'is-medium',
isLarge: containerClassName === 'is-large',
};
/**
* @type {ContainerWidthContext}
*/
return (
<ContainerWidthContext.Provider value={ contextValue }>
<div className={ classNames( className, containerClassName ) }>
{ resizeListener }
{ children }
</div>
</ContainerWidthContext.Provider>
);
};
ContainerWidthContextProvider.propTypes = {
children: PropTypes.node,
};

View File

@ -0,0 +1,78 @@
/**
* External dependencies
*/
import { createContext, useContext, useCallback } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
/**
* @typedef {import('@woocommerce/type-defs/contexts').EditorDataContext} EditorDataContext
* @typedef {import('@woocommerce/type-defs/cart').CartData} CartData
*/
const EditorContext = createContext( {
isEditor: false,
currentPostId: 0,
previewData: {},
getPreviewData: () => void null,
} );
/**
* @return {EditorDataContext} Returns the editor data context value
*/
export const useEditorContext = () => {
return useContext( EditorContext );
};
/**
* Editor provider
*
* @param {Object} props Incoming props for the provider.
* @param {*} props.children The children being wrapped.
* @param {Object} [props.previewData] The preview data for editor.
* @param {number} [props.currentPostId] The post being edited.
*/
export const EditorProvider = ( {
children,
currentPostId = 0,
previewData = {},
} ) => {
/**
* @type {number} editingPostId
*/
const editingPostId = useSelect(
( select ) => {
if ( ! currentPostId ) {
const store = select( 'core/editor' );
return store.getCurrentPostId();
}
return currentPostId;
},
[ currentPostId ]
);
const getPreviewData = useCallback(
( name ) => {
if ( name in previewData ) {
return previewData[ name ];
}
return {};
},
[ previewData ]
);
/**
* @type {EditorDataContext}
*/
const editorData = {
isEditor: true,
currentPostId: editingPostId,
previewData,
getPreviewData,
};
return (
<EditorContext.Provider value={ editorData }>
{ children }
</EditorContext.Provider>
);
};

View File

@ -0,0 +1,9 @@
export * from './editor-context';
export * from './add-to-cart-form';
export * from './cart-checkout';
export * from './store-notices';
export * from './store-snackbar-notices';
export * from './validation';
export * from './container-width-context';
export * from './editor-context';
export * from './query-state-context';

View File

@ -0,0 +1,19 @@
/**
* External dependencies
*/
import { createContext, useContext } from '@wordpress/element';
/**
* Query state context is the index for used for a query state store. By
* exposing this via context, it allows for all children blocks to be
* synchronized to the same query state defined by the parent in the tree.
*
* Defaults to 'page' for general global query state shared among all blocks
* in a view.
*
* @member {Object} QueryStateContext A react context object
*/
const QueryStateContext = createContext( 'page' );
export const useQueryStateContext = () => useContext( QueryStateContext );
export const QueryStateContextProvider = QueryStateContext.Provider;

View File

@ -0,0 +1,73 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { Notice } from 'wordpress-components';
/**
* Internal dependencies
*/
import './style.scss';
const getWooClassName = ( { status = 'default' } ) => {
switch ( status ) {
case 'error':
return 'woocommerce-error';
case 'success':
return 'woocommerce-message';
case 'info':
case 'warning':
return 'woocommerce-info';
}
return '';
};
const StoreNoticesContainer = ( { className, notices, removeNotice } ) => {
const regularNotices = notices.filter(
( notice ) => notice.type !== 'snackbar'
);
if ( ! regularNotices.length ) {
return null;
}
const wrapperClass = classnames( className, 'wc-block-components-notices' );
return (
<div className={ wrapperClass }>
{ regularNotices.map( ( props ) => (
<Notice
key={ 'store-notice-' + props.id }
{ ...props }
className={ classnames(
'wc-block-components-notices__notice',
getWooClassName( props )
) }
onRemove={ () => {
if ( props.isDismissible ) {
removeNotice( props.id );
}
} }
>
{ props.content }
</Notice>
) ) }
</div>
);
};
StoreNoticesContainer.propTypes = {
className: PropTypes.string,
notices: PropTypes.arrayOf(
PropTypes.shape( {
content: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
isDismissible: PropTypes.bool,
type: PropTypes.oneOf( [ 'default', 'snackbar' ] ),
} )
),
};
export default StoreNoticesContainer;

View File

@ -0,0 +1,32 @@
.wc-block-components-notices {
display: block;
margin-bottom: 2em;
.wc-block-components-notices__notice {
margin: 0;
display: flex;
flex-wrap: nowrap;
.components-notice__dismiss {
background: transparent none;
padding: 0;
margin: 0 0 0 auto;
border: 0;
outline: 0;
color: currentColor;
svg {
fill: currentColor;
vertical-align: text-top;
}
}
}
.wc-block-components-notices__notice + .wc-block-components-notices__notice {
margin-top: 1em;
}
}
// @todo Either move notice style fixes to Woo core, or take full control over notice component styling in blocks.
.theme-twentytwentyone,
.theme-twentytwenty {
.wc-block-components-notices__notice {
padding: 1.5rem 3rem;
}
}

View File

@ -0,0 +1,130 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import {
createContext,
useContext,
useCallback,
useState,
} from '@wordpress/element';
import { useSelect, useDispatch } from '@wordpress/data';
/**
* Internal dependencies
*/
import { useStoreEvents } from '../../hooks/use-store-events';
import { useEditorContext } from '../editor-context';
import StoreNoticesContainer from './components/store-notices-container';
/**
* @typedef {import('@woocommerce/type-defs/contexts').NoticeContext} NoticeContext
* @typedef {import('react')} React
*/
const StoreNoticesContext = createContext( {
notices: [],
createNotice: ( status, text, props ) => void { status, text, props },
removeNotice: ( id, ctxt ) => void { id, ctxt },
setIsSuppressed: ( val ) => void { val },
context: 'wc/core',
} );
/**
* Returns the notices context values.
*
* @return {NoticeContext} The notice context value from the notice context.
*/
export const useStoreNoticesContext = () => {
return useContext( StoreNoticesContext );
};
/**
* Provides an interface for blocks to add notices to the frontend UI.
*
* Statuses map to https://github.com/WordPress/gutenberg/tree/master/packages/components/src/notice
* - Default (no status)
* - Error
* - Warning
* - Info
* - Success
*
* @param {Object} props Incoming props for the component.
* @param {JSX.Element} props.children The Elements wrapped by this component.
* @param {string} [props.className] CSS class used.
* @param {boolean} [props.createNoticeContainer] Whether to create a notice container or not.
* @param {string} [props.context] The notice context for notices being rendered.
*/
export const StoreNoticesProvider = ( {
children,
className = '',
createNoticeContainer = true,
context = 'wc/core',
} ) => {
const { createNotice, removeNotice } = useDispatch( 'core/notices' );
const [ isSuppressed, setIsSuppressed ] = useState( false );
const { dispatchStoreEvent } = useStoreEvents();
const { isEditor } = useEditorContext();
const createNoticeWithContext = useCallback(
( status = 'default', content = '', options = {} ) => {
createNotice( status, content, {
...options,
context: options.context || context,
} );
dispatchStoreEvent( 'store-notice-create', {
status,
content,
options,
} );
},
[ createNotice, dispatchStoreEvent, context ]
);
const removeNoticeWithContext = useCallback(
( id, ctxt = context ) => {
removeNotice( id, ctxt );
},
[ removeNotice, context ]
);
const { notices } = useSelect(
( select ) => {
return {
notices: select( 'core/notices' ).getNotices( context ),
};
},
[ context ]
);
const contextValue = {
notices,
createNotice: createNoticeWithContext,
removeNotice: removeNoticeWithContext,
context,
setIsSuppressed,
};
const noticeOutput = isSuppressed ? null : (
<StoreNoticesContainer
className={ className }
notices={ contextValue.notices }
removeNotice={ contextValue.removeNotice }
isEditor={ isEditor }
/>
);
return (
<StoreNoticesContext.Provider value={ contextValue }>
{ createNoticeContainer && noticeOutput }
{ children }
</StoreNoticesContext.Provider>
);
};
StoreNoticesProvider.propTypes = {
className: PropTypes.string,
createNoticeContainer: PropTypes.bool,
children: PropTypes.node,
context: PropTypes.string,
};

View File

@ -0,0 +1,2 @@
export * from './components/store-notices-container';
export * from './context';

View File

@ -0,0 +1,55 @@
/**
* External dependencies
*/
import { SnackbarList } from 'wordpress-components';
import classnames from 'classnames';
import { __experimentalApplyCheckoutFilter } from '@woocommerce/blocks-checkout';
const EMPTY_SNACKBAR_NOTICES = {};
const SnackbarNoticesContainer = ( {
className,
notices,
removeNotice,
isEditor,
} ) => {
if ( isEditor ) {
return null;
}
const snackbarNotices = notices.filter(
( notice ) => notice.type === 'snackbar'
);
const noticeVisibility =
snackbarNotices.length > 0
? snackbarNotices.reduce( ( acc, { content } ) => {
acc[ content ] = true;
return acc;
}, {} )
: EMPTY_SNACKBAR_NOTICES;
const filteredNotices = __experimentalApplyCheckoutFilter( {
filterName: 'snackbarNoticeVisibility',
defaultValue: noticeVisibility,
} );
const visibleNotices = snackbarNotices.filter(
( notice ) => filteredNotices[ notice.content ] === true
);
const wrapperClass = classnames(
className,
'wc-block-components-notices__snackbar'
);
return (
<SnackbarList
notices={ visibleNotices }
className={ wrapperClass }
onRemove={ removeNotice }
/>
);
};
export default SnackbarNoticesContainer;

View File

@ -0,0 +1,20 @@
.wc-block-components-notices__snackbar {
position: fixed;
bottom: 20px;
left: 16px;
width: auto;
@include breakpoint("<782px") {
position: fixed;
top: 10px;
left: 0;
bottom: auto;
}
.components-snackbar-list__notice-container {
@include breakpoint("<782px") {
margin-left: 10px;
margin-right: 10px;
}
}
}

View File

@ -0,0 +1,125 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import {
createContext,
useContext,
useCallback,
useState,
} from '@wordpress/element';
import { useSelect, useDispatch } from '@wordpress/data';
import SnackbarNoticesContainer from '@woocommerce/base-context/providers/store-snackbar-notices/components/snackbar-notices-container';
/**
* Internal dependencies
*/
import { useStoreEvents } from '../../hooks/use-store-events';
import { useEditorContext } from '../editor-context';
/**
* @typedef {import('@woocommerce/type-defs/contexts').NoticeContext} NoticeContext
* @typedef {import('react')} React
*/
const StoreSnackbarNoticesContext = createContext( {
notices: [],
createSnackbarNotice: ( content, options ) => void { content, options },
removeSnackbarNotice: ( id, ctxt ) => void { id, ctxt },
setIsSuppressed: ( val ) => void { val },
context: 'wc/core',
} );
/**
* Returns the notices context values.
*
* @return {NoticeContext} The notice context value from the notice context.
*/
export const useStoreSnackbarNoticesContext = () => {
return useContext( StoreSnackbarNoticesContext );
};
/**
* Provides an interface for blocks to add notices to the frontend UI.
*
* Statuses map to https://github.com/WordPress/gutenberg/tree/master/packages/components/src/notice
* - Default (no status)
* - Error
* - Warning
* - Info
* - Success
*
* @param {Object} props Incoming props for the component.
* @param {React.ReactChildren} props.children The Elements wrapped by this component.
* @param {string} props.context The notice context for notices being rendered.
*/
export const StoreSnackbarNoticesProvider = ( {
children,
context = 'wc/core',
} ) => {
const { createNotice, removeNotice } = useDispatch( 'core/notices' );
const [ isSuppressed, setIsSuppressed ] = useState( false );
const { dispatchStoreEvent } = useStoreEvents();
const { isEditor } = useEditorContext();
const createSnackbarNotice = useCallback(
( content = '', options = {} ) => {
createNotice( 'default', content, {
...options,
type: 'snackbar',
context: options.context || context,
} );
dispatchStoreEvent( 'store-notice-create', {
status: 'default',
content,
options,
} );
},
[ createNotice, dispatchStoreEvent, context ]
);
const removeSnackbarNotice = useCallback(
( id, ctxt = context ) => {
removeNotice( id, ctxt );
},
[ removeNotice, context ]
);
const { notices } = useSelect(
( select ) => {
return {
notices: select( 'core/notices' ).getNotices( context ),
};
},
[ context ]
);
const contextValue = {
notices,
createSnackbarNotice,
removeSnackbarNotice,
context,
setIsSuppressed,
};
const snackbarNoticeOutput = isSuppressed ? null : (
<SnackbarNoticesContainer
notices={ contextValue.notices }
removeNotice={ contextValue.removeSnackbarNotice }
isEditor={ isEditor }
/>
);
return (
<StoreSnackbarNoticesContext.Provider value={ contextValue }>
{ children }
{ snackbarNoticeOutput }
</StoreSnackbarNoticesContext.Provider>
);
};
StoreSnackbarNoticesProvider.propTypes = {
className: PropTypes.string,
children: PropTypes.node,
context: PropTypes.string,
};

View File

@ -0,0 +1 @@
export * from './context';

View File

@ -0,0 +1 @@
export * from './validation-input-error';

View File

@ -0,0 +1,41 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import { useValidationContext } from '../../context';
import './style.scss';
export const ValidationInputError = ( {
errorMessage = '',
propertyName = '',
elementId = '',
} ) => {
const { getValidationError, getValidationErrorId } = useValidationContext();
if ( ! errorMessage || typeof errorMessage !== 'string' ) {
const error = getValidationError( propertyName ) || {};
if ( error.message && ! error.hidden ) {
errorMessage = error.message;
} else {
return null;
}
}
return (
<div className="wc-block-components-validation-error" role="alert">
<p id={ getValidationErrorId( elementId ) }>{ errorMessage }</p>
</div>
);
};
ValidationInputError.propTypes = {
errorMessage: PropTypes.string,
propertyName: PropTypes.string,
elementId: PropTypes.string,
};
export default ValidationInputError;

View File

@ -0,0 +1,15 @@
.wc-block-components-validation-error {
@include font-size(smaller);
color: $alert-red;
max-width: 100%;
white-space: normal;
> p {
margin: 0;
padding: 0;
}
}
.wc-block-components-select + .wc-block-components-validation-error {
margin-bottom: $gap-large;
}

View File

@ -0,0 +1,257 @@
/**
* External dependencies
*/
import {
createContext,
useCallback,
useContext,
useState,
} from '@wordpress/element';
import { pickBy } from 'lodash';
import isShallowEqual from '@wordpress/is-shallow-equal';
/**
* @typedef { import('@woocommerce/type-defs/contexts').ValidationContext } ValidationContext
* @typedef {import('react')} React
*/
const ValidationContext = createContext( {
getValidationError: () => '',
setValidationErrors: ( errors ) => void errors,
clearValidationError: ( property ) => void property,
clearAllValidationErrors: () => void null,
hideValidationError: () => void null,
showValidationError: () => void null,
showAllValidationErrors: () => void null,
hasValidationErrors: false,
getValidationErrorId: ( errorId ) => errorId,
} );
/**
* @return {ValidationContext} The context values for the validation context.
*/
export const useValidationContext = () => {
return useContext( ValidationContext );
};
/**
* Validation context provider
*
* Any children of this context will be exposed to validation state and helpers
* for tracking validation.
*
* @param {Object} props Incoming props for the component.
* @param {JSX.Element} props.children What react elements are wrapped by this component.
*/
export const ValidationContextProvider = ( { children } ) => {
const [ validationErrors, updateValidationErrors ] = useState( {} );
/**
* This retrieves any validation error message that exists in state for the
* given property name.
*
* @param {string} property The property the error message is for.
*
* @return {Object} The error object for the given property.
*/
const getValidationError = useCallback(
( property ) => validationErrors[ property ],
[ validationErrors ]
);
/**
* Provides an id for the validation error that can be used to fill out
* aria-describedby attribute values.
*
* @param {string} errorId The input css id the validation error is related
* to.
* @return {string} The id to use for the validation error container.
*/
const getValidationErrorId = useCallback(
( errorId ) => {
const error = validationErrors[ errorId ];
if ( ! error || error.hidden ) {
return '';
}
return `validate-error-${ errorId }`;
},
[ validationErrors ]
);
/**
* Clears any validation error that exists in state for the given property
* name.
*
* @param {string} property The name of the property to clear if exists in
* validation error state.
*/
const clearValidationError = useCallback(
/**
* Callback that is memoized.
*
* @param {string} property
*/
( property ) => {
updateValidationErrors(
/**
* Callback for validation Errors handling.
*
* @param {Object} prevErrors
*/
( prevErrors ) => {
if ( ! prevErrors[ property ] ) {
return prevErrors;
}
const {
// eslint-disable-next-line no-unused-vars -- this is intentional to omit the dynamic property from the returned object.
[ property ]: clearedProperty,
...newErrors
} = prevErrors;
return newErrors;
}
);
},
[]
);
/**
* Clears the entire validation error state.
*/
const clearAllValidationErrors = useCallback(
() => void updateValidationErrors( {} ),
[]
);
/**
* Used to record new validation errors in the state.
*
* @param {Object} newErrors An object where keys are the property names the
* validation error is for and values are the
* validation error message displayed to the user.
*/
const setValidationErrors = useCallback( ( newErrors ) => {
if ( ! newErrors ) {
return;
}
updateValidationErrors( ( prevErrors ) => {
newErrors = pickBy( newErrors, ( error, property ) => {
if ( typeof error.message !== 'string' ) {
return false;
}
if ( prevErrors.hasOwnProperty( property ) ) {
return ! isShallowEqual( prevErrors[ property ], error );
}
return true;
} );
if ( Object.values( newErrors ).length === 0 ) {
return prevErrors;
}
return {
...prevErrors,
...newErrors,
};
} );
}, [] );
/**
* Used to update a validation error.
*
* @param {string} property The name of the property to update.
* @param {Object} newError New validation error object.
*/
const updateValidationError = useCallback( ( property, newError ) => {
updateValidationErrors( ( prevErrors ) => {
if ( ! prevErrors.hasOwnProperty( property ) ) {
return prevErrors;
}
const updatedError = {
...prevErrors[ property ],
...newError,
};
return isShallowEqual( prevErrors[ property ], updatedError )
? prevErrors
: {
...prevErrors,
[ property ]: updatedError,
};
} );
}, [] );
/**
* Given a property name and if an associated error exists, it sets its
* `hidden` value to true.
*
* @param {string} property The name of the property to set the `hidden`
* value to true.
*/
const hideValidationError = useCallback(
( property ) =>
void updateValidationError( property, {
hidden: true,
} ),
[ updateValidationError ]
);
/**
* Given a property name and if an associated error exists, it sets its
* `hidden` value to false.
*
* @param {string} property The name of the property to set the `hidden`
* value to false.
*/
const showValidationError = useCallback(
( property ) =>
void updateValidationError( property, {
hidden: false,
} ),
[ updateValidationError ]
);
/**
* Sets the `hidden` value of all errors to `false`.
*/
const showAllValidationErrors = useCallback(
() =>
void updateValidationErrors( ( prevErrors ) => {
const updatedErrors = {};
Object.keys( prevErrors ).forEach( ( property ) => {
if ( prevErrors[ property ].hidden ) {
updatedErrors[ property ] = {
...prevErrors[ property ],
hidden: false,
};
}
} );
if ( Object.values( updatedErrors ).length === 0 ) {
return prevErrors;
}
return {
...prevErrors,
...updatedErrors,
};
} ),
[]
);
const context = {
getValidationError,
setValidationErrors,
clearValidationError,
clearAllValidationErrors,
hideValidationError,
showValidationError,
showAllValidationErrors,
hasValidationErrors: Object.keys( validationErrors ).length > 0,
getValidationErrorId,
};
return (
<ValidationContext.Provider value={ context }>
{ children }
</ValidationContext.Provider>
);
};

Some files were not shown because too many files have changed in this diff Show More