first commit

This commit is contained in:
2024-11-10 21:08:49 +01:00
commit 0d932ce5ee
14455 changed files with 2567501 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
/**
* 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,209 @@
/**
* 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; //eslint-disable-line testing-library/await-async-query
expect( quantity ).toBe( 1 );
act( () => {
setItemQuantity( 2 );
} );
const { quantity: newQuantity } =
renderer.root.findByType( 'div' ).props; //eslint-disable-line testing-library/await-async-query
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; //eslint-disable-line testing-library/await-async-query
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; //eslint-disable-line testing-library/await-async-query
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; //eslint-disable-line testing-library/await-async-query
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; //eslint-disable-line testing-library/await-async-query
expect( isPendingDelete ).toBe( true );
} );
} );

View File

@@ -0,0 +1,248 @@
/**
* 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: [],
billingData: {
first_name: '',
last_name: '',
company: '',
address_1: '',
address_2: '',
city: '',
state: '',
postcode: '',
country: '',
email: '',
phone: '',
},
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: {},
isLoadingRates: 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: [],
billingData: {},
billingAddress: {},
shippingAddress: mockShippingAddress,
shippingRates: [],
extensions: {},
isLoadingRates: 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; //eslint-disable-line testing-library/await-async-query
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; //eslint-disable-line testing-library/await-async-query
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; //eslint-disable-line testing-library/await-async-query
expect( results ).toEqual( previewCartData );
expect( receiveCart ).toEqual( receiveCartMock );
} );
} );
} );

View File

@@ -0,0 +1,131 @@
/** @typedef { import('@woocommerce/type-defs/hooks').StoreCartCoupon } StoreCartCoupon */
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { useDispatch, 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 { useValidationContext } from '../../providers/validation';
/**
* 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 = ( context = '' ): StoreCartCoupon => {
const { cartCoupons, cartIsLoading } = useStoreCart();
const { createErrorNotice } = useDispatch( 'core/notices' );
const { createNotice } = useDispatch( 'core/notices' );
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 ) {
createNotice(
'info',
sprintf(
/* translators: %s coupon code. */
__(
'Coupon code "%s" has been applied to your cart.',
'woo-gutenberg-products-block'
),
couponCode
),
{
id: 'coupon-form',
type: 'snackbar',
context,
}
);
}
} )
.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 ) {
createNotice(
'info',
sprintf(
/* translators: %s coupon code. */
__(
'Coupon code "%s" has been removed from your cart.',
'woo-gutenberg-products-block'
),
couponCode
),
{
id: 'coupon-form',
type: 'snackbar',
context,
}
);
}
} )
.catch( ( error ) => {
createErrorNotice( error.message, {
id: 'coupon-form',
context,
} );
// Finished handling the coupon.
receiveApplyingCoupon( '' );
} );
};
return {
applyCoupon: applyCouponWithNotices,
removeCoupon: removeCouponWithNotices,
isApplyingCoupon,
isRemovingCoupon,
};
},
[ createErrorNotice, createNotice ]
);
return {
appliedCoupons: cartCoupons,
isLoading: cartIsLoading,
...results,
};
};

View File

@@ -0,0 +1,87 @@
/**
* External dependencies
*/
import { useEffect } from '@wordpress/element';
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { dispatch } from '@wordpress/data';
import { translateJQueryEventToNative } from '@woocommerce/base-utils';
interface StoreCartListenersType {
count: number;
remove: () => void;
}
declare global {
interface Window {
wcBlocksStoreCartListeners: StoreCartListenersType;
}
}
const refreshData = ( e ): void => {
const eventDetail = e.detail;
if ( ! eventDetail || ! eventDetail.preserveCartData ) {
dispatch( storeKey ).invalidateResolutionForStore();
}
};
const setUp = (): void => {
if ( ! window.wcBlocksStoreCartListeners ) {
window.wcBlocksStoreCartListeners = {
count: 0,
remove: () => void null,
};
}
};
const addListeners = (): void => {
setUp();
if ( window.wcBlocksStoreCartListeners.count === 0 ) {
const removeJQueryAddedToCartEvent = translateJQueryEventToNative(
'added_to_cart',
`wc-blocks_added_to_cart`
) as () => () => void;
const removeJQueryRemovedFromCartEvent = translateJQueryEventToNative(
'removed_from_cart',
`wc-blocks_removed_from_cart`
) as () => () => void;
document.body.addEventListener(
`wc-blocks_added_to_cart`,
refreshData
);
document.body.addEventListener(
`wc-blocks_removed_from_cart`,
refreshData
);
window.wcBlocksStoreCartListeners.count = 0;
window.wcBlocksStoreCartListeners.remove = () => {
removeJQueryAddedToCartEvent();
removeJQueryRemovedFromCartEvent();
document.body.removeEventListener(
`wc-blocks_added_to_cart`,
refreshData
);
document.body.removeEventListener(
`wc-blocks_removed_from_cart`,
refreshData
);
};
}
window.wcBlocksStoreCartListeners.count++;
};
const removeListeners = (): void => {
if ( window.wcBlocksStoreCartListeners.count === 1 ) {
window.wcBlocksStoreCartListeners.remove();
}
window.wcBlocksStoreCartListeners.count--;
};
export const useStoreCartEventListeners = (): void => {
useEffect( () => {
addListeners();
return removeListeners;
}, [] );
};

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 );
// Update local state when server updates.
useEffect( () => setQuantity( cartItemQuantity ), [ cartItemQuantity ] );
// 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,245 @@
/** @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 } from '@woocommerce/base-utils';
/**
* Internal dependencies
*/
import { useEditorContext } from '../../providers/editor-context';
import { useStoreCartEventListeners } from './use-store-cart-event-listeners';
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 > =>
Object.fromEntries(
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,
isLoadingRates: 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();
// This will keep track of jQuery and DOM events triggered by other blocks
// or components and will invalidate the store resolution accordingly.
useStoreCartEventListeners();
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,
billingData: defaultBillingAddress,
billingAddress: defaultBillingAddress,
shippingAddress: defaultShippingAddress,
extensions: EMPTY_EXTENSIONS,
shippingRates: previewCart.shipping_rates,
isLoadingRates: 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 isLoadingRates = 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,
billingData: emptyHiddenAddressFields( billingAddress ),
billingAddress: emptyHiddenAddressFields( billingAddress ),
shippingAddress: emptyHiddenAddressFields( shippingAddress ),
extensions: cartData.extensions,
shippingRates: cartData.shippingRates,
isLoadingRates,
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,301 @@
/**
* 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; //eslint-disable-line testing-library/await-async-query
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' },
},
} )
);
} );
//eslint-disable-next-line testing-library/await-async-query
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' },
},
} )
);
} );
//eslint-disable-next-line testing-library/await-async-query
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,152 @@
/**
* External dependencies
*/
import { useState, useEffect, useMemo } from '@wordpress/element';
import { useDebounce } from 'use-debounce';
import { sortBy } from 'lodash';
import { useShallowEqual } from '@woocommerce/base-hooks';
import { objectHasProp } from '@woocommerce/types';
/**
* Internal dependencies
*/
import { useQueryStateByContext, useQueryStateByKey } from '../use-query-state';
import { useCollection } from './use-collection';
import { useQueryStateContext } from '../../providers/query-state-context';
const buildCollectionDataQuery = (
collectionDataQueryState: Record< string, unknown >
) => {
const query = collectionDataQueryState;
if (
Array.isArray( 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;
};
interface UseCollectionDataProps {
queryAttribute?: {
taxonomy: string;
queryType: string;
};
queryPrices?: boolean;
queryStock?: boolean;
queryState: Record< string, unknown >;
}
export const useCollectionData = ( {
queryAttribute,
queryPrices,
queryStock,
queryState,
}: UseCollectionDataProps ) => {
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 (
objectHasProp( currentQueryAttribute, 'taxonomy' ) &&
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/v1',
resourceName: 'products/collection-data',
query: {
...queryState,
page: undefined,
per_page: undefined,
orderby: undefined,
order: undefined,
...collectionDataQueryVars,
},
shouldSelect: debouncedShouldSelect,
} );
};

View File

@@ -0,0 +1,98 @@
/**
* External dependencies
*/
import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { useSelect } from '@wordpress/data';
import { useShallowEqual } from '@woocommerce/base-hooks';
/**
* Internal dependencies
*/
import { useCollectionOptions } from '.';
/**
* 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: string,
options: Omit< useCollectionOptions, 'shouldSelect' >
): {
value: unknown;
isLoading: boolean;
} => {
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,124 @@
/**
* 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';
import { isError } from '@woocommerce/types';
/**
* 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 interface useCollectionOptions {
namespace: string;
resourceName: string;
resourceValues?: number[];
query?: Record< string, unknown >;
shouldSelect?: boolean;
}
export const useCollection = (
options: useCollectionOptions
): {
results: unknown;
isLoading: boolean;
} => {
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: unknown; isLoading: boolean } >( {
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 ) {
if ( isError( error ) ) {
throwError( error );
} else {
throw new Error(
'TypeError: `error` object is not an instance of Error constructor'
);
}
}
return {
results: store.getCollection< T >( ...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,15 @@
export * from './cart';
export * from './collections';
export * from './shipping';
export * from './payment-methods';
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,172 @@
/**
* 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';
import LoadingMask from '@woocommerce/base-components/loading-mask';
import type { PaymentMethodInterface } from '@woocommerce/types';
/**
* 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';
import { useShippingData } from '../shipping/use-shipping-data';
/**
* Returns am interface to use as payment method props.
*/
export const usePaymentMethodInterface = (): PaymentMethodInterface => {
const {
isCalculating,
isComplete,
isIdle,
isProcessing,
onCheckoutBeforeProcessing,
onCheckoutValidationBeforeProcessing,
onCheckoutAfterProcessingWithSuccess,
onCheckoutAfterProcessingWithError,
onSubmit,
customerId,
} = useCheckoutContext();
const {
currentStatus,
activePaymentMethod,
onPaymentProcessing,
setExpressPaymentError,
shouldSavePayment,
} = usePaymentMethodDataContext();
const {
shippingErrorStatus,
shippingErrorTypes,
onShippingRateSuccess,
onShippingRateFail,
onShippingRateSelectSuccess,
onShippingRateSelectFail,
} = useShippingDataContext();
const {
shippingRates,
isLoadingRates,
selectedRates,
isSelectingRate,
selectShippingRate,
needsShipping,
} = useShippingData();
const { billingAddress, shippingAddress, setShippingAddress } =
useCustomerDataContext();
const { cartItems, cartFees, cartTotals, extensions } = 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: {
appliedCoupons,
billingAddress,
billingData: billingAddress,
cartTotal: currentCartTotal.current,
cartTotalItems: currentCartTotals.current,
currency: getCurrencyFromPriceResponse( cartTotals ),
customerId,
displayPricesIncludingTax: getSetting(
'displayCartPricesIncludingTax',
false
) as boolean,
},
cartData: {
cartItems,
cartFees,
extensions,
},
checkoutStatus: {
isCalculating,
isComplete,
isIdle,
isProcessing,
},
components: {
LoadingMask,
PaymentMethodIcons,
PaymentMethodLabel,
ValidationInputError,
},
emitResponse: {
noticeContexts,
responseTypes,
},
eventRegistration: {
onCheckoutAfterProcessingWithError,
onCheckoutAfterProcessingWithSuccess,
onCheckoutBeforeProcessing,
onCheckoutValidationBeforeProcessing,
onPaymentProcessing,
onShippingRateFail,
onShippingRateSelectFail,
onShippingRateSelectSuccess,
onShippingRateSuccess,
},
onSubmit,
paymentStatus: currentStatus,
setExpressPaymentError: deprecatedSetExpressPaymentError,
shippingData: {
isSelectingRate,
needsShipping,
selectedRates,
setSelectedRates: selectShippingRate,
setShippingAddress,
shippingAddress,
shippingRates,
shippingRatesLoading: isLoadingRates,
},
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-shipping-data';

View File

@@ -0,0 +1,63 @@
/**
* 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';
import { SelectShippingRateType } from '@woocommerce/type-defs/shipping';
/**
* Internal dependencies
*/
import { useStoreEvents } from '../use-store-events';
/**
* This is a custom hook for selecting shipping rates for a shipping package.
*
* @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 useSelectShippingRate = (): SelectShippingRateType => {
const throwError = useThrowError();
const { dispatchCheckoutEvent } = useStoreEvents();
const { selectShippingRate: dispatchSelectShippingRate } = useDispatch(
storeKey
) as {
selectShippingRate: unknown;
} as {
selectShippingRate: (
newShippingRateId: string,
packageId: string | number
) => Promise< unknown >;
};
// Selects a shipping rate, fires an event, and catch any errors.
const selectShippingRate = useCallback(
( newShippingRateId, packageId ) => {
dispatchSelectShippingRate( newShippingRateId, packageId )
.then( () => {
dispatchCheckoutEvent( 'set-selected-shipping-rate', {
shippingRateId: newShippingRateId,
} );
} )
.catch( ( error ) => {
// Throw an error because an error when selecting a rate is problematic.
throwError( error );
} );
},
[ dispatchSelectShippingRate, dispatchCheckoutEvent, throwError ]
);
// See if rates are being selected.
const isSelectingRate = useSelect< boolean >( ( select ) => {
return select( storeKey ).isShippingRateBeingSelected();
}, [] );
return {
selectShippingRate,
isSelectingRate,
};
};

View File

@@ -0,0 +1,65 @@
/**
* External dependencies
*/
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { useSelect } from '@wordpress/data';
import { Cart } from '@woocommerce/type-defs/cart';
import { SelectShippingRateType } from '@woocommerce/type-defs/shipping';
import { useEffect, useRef } from '@wordpress/element';
import { deriveSelectedShippingRates } from '@woocommerce/base-utils';
import isShallowEqual from '@wordpress/is-shallow-equal';
import { isObject } from '@woocommerce/types';
/**
* Internal dependencies
*/
import { useSelectShippingRate } from './use-select-shipping-rate';
interface ShippingData extends SelectShippingRateType {
needsShipping: Cart[ 'needsShipping' ];
hasCalculatedShipping: Cart[ 'hasCalculatedShipping' ];
shippingRates: Cart[ 'shippingRates' ];
isLoadingRates: boolean;
selectedRates: Record< string, string | unknown >;
}
export const useShippingData = (): ShippingData => {
const {
shippingRates,
needsShipping,
hasCalculatedShipping,
isLoadingRates,
} = useSelect( ( select ) => {
const store = select( storeKey );
return {
shippingRates: store.getShippingRates(),
needsShipping: store.getNeedsShipping(),
hasCalculatedShipping: store.getHasCalculatedShipping(),
isLoadingRates: store.isCustomerDataUpdating(),
};
} );
const { isSelectingRate, selectShippingRate } = useSelectShippingRate();
// set selected rates on ref so it's always current.
const selectedRates = useRef< Record< string, unknown > >( {} );
useEffect( () => {
const derivedSelectedRates =
deriveSelectedShippingRates( shippingRates );
if (
isObject( derivedSelectedRates ) &&
! isShallowEqual( selectedRates.current, derivedSelectedRates )
) {
selectedRates.current = derivedSelectedRates;
}
}, [ shippingRates ] );
return {
isSelectingRate,
selectedRates: selectedRates.current,
selectShippingRate,
shippingRates,
needsShipping,
hasCalculatedShipping,
isLoadingRates,
};
};

View File

@@ -0,0 +1,65 @@
/**
* 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 )
);
} );
//eslint-disable-next-line testing-library/await-async-query
const { onSubmit } = renderer.root.findByType( 'div' ).props;
onSubmit();
expect( mockUseCheckoutContext.onSubmit ).toHaveBeenCalledTimes( 1 );
} );
} );

View File

@@ -0,0 +1,260 @@
/**
* 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 ) => {
//eslint-disable-next-line testing-library/await-async-query
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,109 @@
/**
* 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; //eslint-disable-line testing-library/await-async-query
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,87 @@
/**
* External dependencies
*/
import {
defaultAddressFields,
AddressFields,
EnteredAddress,
ShippingAddress,
BillingAddress,
} from '@woocommerce/settings';
import { useCallback } from '@wordpress/element';
/**
* Internal dependencies
*/
import { useCheckoutContext } from '../providers/cart-checkout';
import { useCustomerData } from './use-customer-data';
import { useShippingData } from './shipping/use-shipping-data';
interface CheckoutAddress {
shippingAddress: ShippingAddress;
billingAddress: BillingAddress;
setShippingAddress: ( data: Partial< EnteredAddress > ) => void;
setBillingAddress: ( data: Partial< EnteredAddress > ) => void;
setEmail: ( value: string ) => void;
setBillingPhone: ( value: string ) => void;
setShippingPhone: ( value: string ) => void;
useShippingAsBilling: boolean;
setUseShippingAsBilling: ( useShippingAsBilling: boolean ) => void;
defaultAddressFields: AddressFields;
showShippingFields: boolean;
showBillingFields: boolean;
}
/**
* Custom hook for exposing address related functionality for the checkout address form.
*/
export const useCheckoutAddress = (): CheckoutAddress => {
const { needsShipping } = useShippingData();
const { useShippingAsBilling, setUseShippingAsBilling } =
useCheckoutContext();
const {
billingAddress,
setBillingAddress,
shippingAddress,
setShippingAddress,
} = useCustomerData();
const setEmail = useCallback(
( value ) =>
void setBillingAddress( {
email: value,
} ),
[ setBillingAddress ]
);
const setBillingPhone = useCallback(
( value ) =>
void setBillingAddress( {
phone: value,
} ),
[ setBillingAddress ]
);
const setShippingPhone = useCallback(
( value ) =>
void setShippingAddress( {
phone: value,
} ),
[ setShippingAddress ]
);
return {
shippingAddress,
billingAddress,
setShippingAddress,
setBillingAddress,
setEmail,
setBillingPhone,
setShippingPhone,
defaultAddressFields,
useShippingAsBilling,
setUseShippingAsBilling,
showShippingFields: needsShipping,
showBillingFields: ! needsShipping || ! useShippingAsBilling,
};
};

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,45 @@
/**
* 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,36 @@
/**
* External dependencies
*/
import { useDispatch, useSelect } from '@wordpress/data';
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
import type { BillingAddress, ShippingAddress } from '@woocommerce/settings';
export interface CustomerDataType {
isInitialized: boolean;
billingAddress: BillingAddress;
shippingAddress: ShippingAddress;
setBillingAddress: ( data: Partial< BillingAddress > ) => void;
setShippingAddress: ( data: Partial< ShippingAddress > ) => void;
}
/**
* This is a custom hook for syncing customer address data (billing and shipping) with the server.
*/
export const useCustomerData = (): CustomerDataType => {
const { customerData, isInitialized } = useSelect( ( select ) => {
const store = select( storeKey );
return {
customerData: store.getCustomerData(),
isInitialized: store.hasFinishedResolution( 'getCartData' ),
};
} );
const { setShippingAddress, setBillingAddress } = useDispatch( storeKey );
return {
isInitialized,
billingAddress: customerData.billingAddress,
shippingAddress: customerData.shippingAddress,
setBillingAddress,
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,95 @@
/**
* 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';
/**
* @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 { createErrorNotice, removeNotice } = useDispatch( 'core/notices' );
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 ) => {
createErrorNotice( decodeEntities( error.message ), {
id: 'add-to-cart',
context: 'wc/all-products',
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,51 @@
/**
* External dependencies
*/
import { Query, ProductResponseItem } from '@woocommerce/types';
/**
* 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/v1/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: Query
): {
products: ProductResponseItem[];
totalProducts: number;
productsLoading: boolean;
} => {
const collectionOptions = {
namespace: '/wc/store/v1',
resourceName: 'products',
};
const { results: products, isLoading: productsLoading } = useCollection( {
...collectionOptions,
query,
} );
const { value: totalProducts } = useCollectionHeader( 'x-wp-total', {
...collectionOptions,
query,
} );
return {
products: products as ProductResponseItem[], // TODO: Remove this once getCollection selector and resolver is converted to TS.
totalProducts: parseInt( totalProducts as string, 10 ),
productsLoading,
};
};

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: 0,
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,328 @@
/**
* 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';
import { useDispatch } from '@wordpress/data';
/**
* 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 { useEmitResponse } from '../../../hooks/use-emit-response';
import { removeNoticesByStatus } from '../../../../../utils/notices';
/**
* @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 { createErrorNotice } = useDispatch( 'core/notices' );
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 ) {
removeNoticesByStatus( 'error', 'wc/add-to-cart' );
emitEvent(
currentObservers,
EMIT_TYPES.ADD_TO_CART_BEFORE_PROCESSING,
{}
).then( ( response ) => {
if ( response !== true ) {
if ( Array.isArray( response ) ) {
response.forEach(
( { errorMessage, validationErrors } ) => {
if ( errorMessage ) {
createErrorNotice( errorMessage, {
context: 'wc/add-to-cart',
} );
}
if ( validationErrors ) {
setValidationErrors( validationErrors );
}
}
);
}
dispatch( actions.setIdle() );
} else {
dispatch( actions.setProcessing() );
}
} );
}
}, [
addToCartFormState.status,
setValidationErrors,
createErrorNotice,
dispatch,
currentObservers,
product?.id,
] );
/**
* 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;
createErrorNotice( 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'
);
createErrorNotice( message, {
id: 'add-to-cart',
context: `woocommerce/single-product/${
product?.id || 0
}`,
} );
}
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,
createErrorNotice,
isErrorResponse,
isFailResponse,
isSuccessResponse,
currentObservers,
product?.id,
] );
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 || product?.add_to_cart?.minimum || 1,
minQuantity: product?.add_to_cart?.minimum || 1,
maxQuantity: product?.add_to_cart?.maximum || 99,
multipleOf: product?.add_to_cart?.multiple_of || 1,
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,154 @@
/**
* 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';
import { triggerAddedToCartEvent } from '@woocommerce/base-utils';
import { useDispatch } from '@wordpress/data';
/**
* Internal dependencies
*/
import { useAddToCartFormContext } from '../../form-state';
import { useValidationContext } from '../../../validation';
import { useStoreCart } from '../../../../hooks/cart/use-store-cart';
/**
* 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 { createErrorNotice, removeNotice } = useDispatch( 'core/notices' );
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',
`woocommerce/single-product/${ product?.id || 0 }`
);
const fetchData = {
id: product.id || 0,
quantity,
...requestParams,
};
triggerFetch( {
path: '/wc/store/v1/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 ) {
createErrorNotice(
decodeEntities( response.body.message ),
{
id: 'add-to-cart',
context: `woocommerce/single-product/${
product?.id || 0
}`,
}
);
} else {
createErrorNotice(
__(
'Something went wrong. Please contact us to get assistance.',
'woocommerce'
),
{
id: 'add-to-cart',
context: `woocommerce/single-product/${
product?.id || 0
}`,
}
);
}
dispatchActions.setHasError();
} else {
receiveCart( response );
}
triggerAddedToCartEvent( { preserveCartData: true } );
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,
createErrorNotice,
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,297 @@
/**
* External dependencies
*/
import { __, sprintf } 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';
import { useDispatch } from '@wordpress/data';
/**
* 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 { useStoreNoticesContext } from '../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 { billingAddress, shippingAddress } = useCustomerDataContext();
const { cartNeedsPayment, cartNeedsShipping, receiveCart } = useStoreCart();
const {
activePaymentMethod,
isExpressPaymentMethodActive,
currentStatus: currentPaymentStatus,
paymentMethodData,
expressPaymentMethods,
paymentMethods,
shouldSavePayment,
} = usePaymentMethodDataContext();
const { setIsSuppressed } = useStoreNoticesContext();
const { createErrorNotice, removeNotice } = useDispatch( 'core/notices' );
const currentBillingAddress = useRef( billingAddress );
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( () => {
currentBillingAddress.current = billingAddress;
currentShippingAddress.current = shippingAddress;
currentRedirectUrl.current = redirectUrl;
}, [ billingAddress, 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(
currentBillingAddress.current
),
customer_note: orderNotes,
create_account: shouldCreateAccount,
...paymentData,
extensions: { ...extensionData },
};
if ( cartNeedsShipping ) {
data.shipping_address = emptyHiddenAddressFields(
currentShippingAddress.current
);
}
triggerFetch( {
path: '/wc/store/v1/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( ( responseJson ) => {
dispatchActions.setAfterProcessing( responseJson );
setIsProcessingOrder( false );
} )
.catch( ( errorResponse ) => {
try {
if ( errorResponse?.headers ) {
processCheckoutResponseHeaders(
errorResponse.headers,
dispatchActions
);
}
// This attempts to parse a JSON error response where the status code was 4xx/5xx.
errorResponse.json().then( ( response ) => {
// If updated cart state was returned, update the store.
if ( response.data?.cart ) {
receiveCart( response.data.cart );
}
createErrorNotice(
formatStoreApiErrorMessage( response ),
{ id: 'checkout', context: 'wc/checkout' }
);
response?.additional_errors?.forEach?.(
( additionalError ) => {
createErrorNotice( additionalError.message, {
id: additionalError.error_code,
context: 'wc/checkout',
} );
}
);
dispatchActions.setAfterProcessing( response );
} );
} catch {
createErrorNotice(
sprintf(
// Translators: %s Error text.
__(
'%s Please try placing your order again.',
'woocommerce'
),
errorResponse?.message ??
__(
'Something went wrong.',
'woocommerce'
)
),
{ id: 'checkout', context: 'wc/checkout' }
);
}
dispatchActions.setHasError( true );
setIsProcessingOrder( false );
} );
}, [
isProcessingOrder,
removeNotice,
cartNeedsPayment,
paymentMethodId,
paymentMethodData,
shouldSavePayment,
activePaymentMethod,
orderNotes,
shouldCreateAccount,
extensionData,
cartNeedsShipping,
dispatchActions,
createErrorNotice,
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,118 @@
/**
* 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_SHIPPING_ADDRESS_AS_BILLING_ADDRESS = 'set_shipping_address_as_billing_address',
SET_SHOULD_CREATE_ACCOUNT = 'set_should_create_account',
SET_EXTENSION_DATA = 'set_extension_data',
}
export interface ActionType extends Partial< CheckoutStateContextState > {
type: ACTION;
data?:
| Record< string, unknown >
| Record< string, never >
| 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 ),
setUseShippingAsBilling: ( useShippingAsBilling: boolean ) =>
( {
type: ACTION.SET_SHIPPING_ADDRESS_AS_BILLING_ADDRESS,
useShippingAsBilling,
} as const ),
setShouldCreateAccount: ( shouldCreateAccount: boolean ) =>
( {
type: ACTION.SET_SHOULD_CREATE_ACCOUNT,
shouldCreateAccount,
} as const ),
setOrderNotes: ( orderNotes: string ) =>
( {
type: ACTION.SET_ORDER_NOTES,
orderNotes,
} as const ),
setExtensionData: (
extensionData: Record< string, Record< string, unknown > >
) =>
( {
type: ACTION.SET_EXTENSION_DATA,
extensionData,
} as const ),
};

View File

@@ -0,0 +1,96 @@
/**
* External dependencies
*/
import { getSetting, EnteredAddress } from '@woocommerce/settings';
import { isSameAddress } from '@woocommerce/base-utils';
/**
* Internal dependencies
*/
import type {
CheckoutStateContextType,
CheckoutStateContextState,
} from './types';
export enum STATUS {
// Checkout is in it's initialized state.
PRISTINE = 'pristine',
// When checkout state has changed but there is no activity happening.
IDLE = 'idle',
// After BEFORE_PROCESSING status emitters have finished successfully. Payment processing is started on this checkout status.
PROCESSING = 'processing',
// After the AFTER_PROCESSING event emitters have completed. This status triggers the checkout redirect.
COMPLETE = 'complete',
// This is the state before checkout processing begins after the checkout button has been pressed/submitted.
BEFORE_PROCESSING = 'before_processing',
// After server side checkout processing is completed this status is set
AFTER_PROCESSING = 'after_processing',
}
const preloadedCheckoutData = getSetting( 'checkoutData', {} ) as Record<
string,
unknown
>;
const checkoutData = {
order_id: 0,
customer_id: 0,
billing_address: {} as EnteredAddress,
shipping_address: {} as EnteredAddress,
...( preloadedCheckoutData || {} ),
};
export const DEFAULT_CHECKOUT_STATE_DATA: CheckoutStateContextType = {
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,
useShippingAsBilling: false,
setUseShippingAsBilling: ( value ) => void value,
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,
useShippingAsBilling: isSameAddress(
checkoutData.billing_address,
checkoutData.shipping_address
),
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,400 @@
/**
* External dependencies
*/
import {
createContext,
useContext,
useReducer,
useRef,
useMemo,
useEffect,
useCallback,
} from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { usePrevious } from '@woocommerce/base-hooks';
import deprecated from '@wordpress/deprecated';
import { isObject, isString } from '@woocommerce/types';
import { useDispatch } from '@wordpress/data';
/**
* 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 { useStoreEvents } from '../../../hooks/use-store-events';
import { useCheckoutNotices } from '../../../hooks/use-checkout-notices';
import { useEmitResponse } from '../../../hooks/use-emit-response';
import { removeNoticesByStatus } from '../../../../../utils/notices';
/**
* @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 { createErrorNotice } = useDispatch( 'core/notices' );
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 );
dispatch(
actions.setRedirectUrl( paymentResult?.redirectUrl || '' )
);
dispatch( actions.setProcessingResponse( paymentResult ) );
dispatch( actions.setAfterProcessing() );
},
} ),
[]
);
// emit events.
useEffect( () => {
const status = checkoutState.status;
if ( status === STATUS.BEFORE_PROCESSING ) {
removeNoticesByStatus( 'error' );
emitEvent(
currentObservers.current,
EMIT_TYPES.CHECKOUT_VALIDATION_BEFORE_PROCESSING,
{}
).then( ( response ) => {
if ( response !== true ) {
if ( Array.isArray( response ) ) {
response.forEach(
( { errorMessage, validationErrors } ) => {
createErrorNotice( errorMessage, {
context: 'wc/checkout',
} );
setValidationErrors( validationErrors );
}
);
}
dispatch( actions.setIdle() );
dispatch( actions.setHasError() );
} else {
dispatch( actions.setProcessing() );
}
} );
}
}, [
checkoutState.status,
setValidationErrors,
createErrorNotice,
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 && isString( response.message ) ) {
const errorOptions =
response.messageContext &&
isString( response.messageContent )
? // The `as string` is OK here because of the type guard above.
{ context: response.messageContext as string }
: undefined;
errorResponse = response;
createErrorNotice( response.message, errorOptions );
}
}
} );
return errorResponse;
};
if ( checkoutState.status === STATUS.AFTER_PROCESSING ) {
const data = {
redirectUrl: checkoutState.redirectUrl,
orderId: checkoutState.orderId,
customerId: checkoutState.customerId,
orderNotes: checkoutState.orderNotes,
processingResponse: checkoutState.processingResponse,
};
if ( checkoutState.hasError ) {
// allow payment methods or other things to customize the error
// with a fallback if nothing customizes it.
emitEventWithAbort(
currentObservers.current,
EMIT_TYPES.CHECKOUT_AFTER_PROCESSING_WITH_ERROR,
data
).then( ( observerResponses ) => {
const errorResponse =
handleErrorResponse( observerResponses );
if ( errorResponse !== null ) {
// irrecoverable error so set complete
if ( ! shouldRetry( errorResponse ) ) {
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'
);
createErrorNotice( message, {
id: 'checkout',
context: 'wc/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 &&
isString( errorResponse.message )
) {
const errorOptions =
errorResponse.messageContext &&
isString( errorResponse.messageContext )
? { context: errorResponse.messageContext }
: undefined;
createErrorNotice(
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,
createErrorNotice,
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,
useShippingAsBilling: checkoutState.useShippingAsBilling,
setUseShippingAsBilling: ( value ) =>
dispatch( actions.setUseShippingAsBilling( value ) ),
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,208 @@
/**
* 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,
useShippingAsBilling,
shouldCreateAccount,
data,
}: ActionType
): CheckoutStateContextState => {
let newState = state;
switch ( type ) {
case ACTION.SET_PRISTINE:
newState = DEFAULT_STATE;
break;
case ACTION.SET_IDLE:
newState =
state.status !== STATUS.IDLE
? {
...state,
status: STATUS.IDLE,
}
: state;
break;
case ACTION.SET_REDIRECT_URL:
newState =
redirectUrl !== undefined && redirectUrl !== state.redirectUrl
? {
...state,
redirectUrl,
}
: state;
break;
case ACTION.SET_PROCESSING_RESPONSE:
newState = {
...state,
processingResponse: data as PaymentResultDataType,
};
break;
case ACTION.SET_COMPLETE:
newState =
state.status !== STATUS.COMPLETE
? {
...state,
status: STATUS.COMPLETE,
redirectUrl:
typeof data?.redirectUrl === 'string'
? data.redirectUrl
: state.redirectUrl,
}
: state;
break;
case ACTION.SET_PROCESSING:
newState =
state.status !== STATUS.PROCESSING
? {
...state,
status: STATUS.PROCESSING,
hasError: false,
}
: state;
// clear any error state.
newState =
newState.hasError === false
? newState
: { ...newState, hasError: false };
break;
case ACTION.SET_BEFORE_PROCESSING:
newState =
state.status !== STATUS.BEFORE_PROCESSING
? {
...state,
status: STATUS.BEFORE_PROCESSING,
hasError: false,
}
: state;
break;
case ACTION.SET_AFTER_PROCESSING:
newState =
state.status !== STATUS.AFTER_PROCESSING
? {
...state,
status: STATUS.AFTER_PROCESSING,
}
: state;
break;
case ACTION.SET_HAS_ERROR:
newState = state.hasError
? state
: {
...state,
hasError: true,
};
newState =
state.status === STATUS.PROCESSING ||
state.status === STATUS.BEFORE_PROCESSING
? {
...newState,
status: STATUS.IDLE,
}
: newState;
break;
case ACTION.SET_NO_ERROR:
newState = state.hasError
? {
...state,
hasError: false,
}
: state;
break;
case ACTION.INCREMENT_CALCULATING:
newState = {
...state,
calculatingCount: state.calculatingCount + 1,
};
break;
case ACTION.DECREMENT_CALCULATING:
newState = {
...state,
calculatingCount: Math.max( 0, state.calculatingCount - 1 ),
};
break;
case ACTION.SET_CUSTOMER_ID:
newState =
customerId !== undefined
? {
...state,
customerId,
}
: state;
break;
case ACTION.SET_ORDER_ID:
newState =
orderId !== undefined
? {
...state,
orderId,
}
: state;
break;
case ACTION.SET_SHIPPING_ADDRESS_AS_BILLING_ADDRESS:
if (
useShippingAsBilling !== undefined &&
useShippingAsBilling !== state.useShippingAsBilling
) {
newState = {
...state,
useShippingAsBilling,
};
}
break;
case ACTION.SET_SHOULD_CREATE_ACCOUNT:
if (
shouldCreateAccount !== undefined &&
shouldCreateAccount !== state.shouldCreateAccount
) {
newState = {
...state,
shouldCreateAccount,
};
}
break;
case ACTION.SET_ORDER_NOTES:
if ( orderNotes !== undefined && state.orderNotes !== orderNotes ) {
newState = {
...state,
orderNotes,
};
}
break;
case ACTION.SET_EXTENSION_DATA:
if (
extensionData !== undefined &&
state.extensionData !== extensionData
) {
newState = {
...state,
extensionData,
};
}
break;
}
// automatically update state to idle from pristine as soon as it
// initially changes.
if (
newState !== state &&
type !== ACTION.SET_PRISTINE &&
newState.status === STATUS.PRISTINE
) {
newState.status = STATUS.IDLE;
}
return newState;
};

View File

@@ -0,0 +1,116 @@
/**
* 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;
useShippingAsBilling: boolean;
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 >;
// Toggle using shipping address as billing address.
setUseShippingAsBilling: ( useShippingAsBilling: boolean ) => void;
// 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 the billing form be hidden and inherit the shipping address?
useShippingAsBilling: CheckoutStateContextState[ 'useShippingAsBilling' ];
// 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,62 @@
/**
* 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,31 @@
/**
* Internal dependencies
*/
import type { CustomerDataType } from '../../../hooks/use-customer-data';
export const defaultBillingAddress: CustomerDataType[ 'billingAddress' ] = {
first_name: '',
last_name: '',
company: '',
address_1: '',
address_2: '',
city: '',
state: '',
postcode: '',
country: '',
email: '',
phone: '',
};
export const defaultShippingAddress: CustomerDataType[ 'shippingAddress' ] = {
first_name: '',
last_name: '',
company: '',
address_1: '',
address_2: '',
city: '',
state: '',
postcode: '',
country: '',
phone: '',
};

View File

@@ -0,0 +1,42 @@
/**
* External dependencies
*/
import { createContext, useContext } from '@wordpress/element';
/**
* Internal dependencies
*/
import { defaultBillingAddress, defaultShippingAddress } from './constants';
import {
useCustomerData,
CustomerDataType,
} from '../../../hooks/use-customer-data';
const CustomerDataContext = createContext< CustomerDataType >( {
isInitialized: false,
billingAddress: defaultBillingAddress,
shippingAddress: defaultShippingAddress,
setBillingAddress: () => void 0,
setShippingAddress: () => void 0,
} );
export const useCustomerDataContext = (): CustomerDataType => {
return useContext( CustomerDataContext );
};
/**
* Customer Data context provider.
*/
export const CustomerDataProvider = ( {
children,
}: {
children: JSX.Element | JSX.Element[];
} ): JSX.Element => {
const contextValue = useCustomerData();
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,81 @@
/**
* 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 > | undefined;
paymentMethods?: PaymentMethods | ExpressPaymentMethods;
paymentMethod?: string;
shouldSavePaymentMethod?: boolean;
}
/**
* All the actions that can be dispatched for payment methods.
*/
export const actions = {
statusOnly: ( type: STATUS ): ActionType => ( {
type,
} ),
error: ( errorMessage: string ): ActionType => ( {
type: STATUS.ERROR,
errorMessage,
} ),
failed: ( {
errorMessage,
paymentMethodData,
}: {
errorMessage: string;
paymentMethodData: Record< string, unknown >;
} ): ActionType => ( {
type: STATUS.FAILED,
errorMessage,
paymentMethodData,
} ),
success: ( {
paymentMethodData,
}: {
paymentMethodData?: Record< string, unknown >;
} ): ActionType => ( {
type: STATUS.SUCCESS,
paymentMethodData,
} ),
setRegisteredPaymentMethods: (
paymentMethods: PaymentMethods
): ActionType => ( {
type: ACTION.SET_REGISTERED_PAYMENT_METHODS,
paymentMethods,
} ),
setRegisteredExpressPaymentMethods: (
paymentMethods: ExpressPaymentMethods
): ActionType => ( {
type: ACTION.SET_REGISTERED_EXPRESS_PAYMENT_METHODS,
paymentMethods,
} ),
setShouldSavePaymentMethod: (
shouldSavePaymentMethod: boolean
): ActionType => ( {
type: ACTION.SET_SHOULD_SAVE_PAYMENT_METHOD,
shouldSavePaymentMethod,
} ),
setActivePaymentMethod: (
paymentMethod: string,
paymentMethodData: Record< string, unknown >
): ActionType => ( {
type: ACTION.SET_ACTIVE_PAYMENT_METHOD,
paymentMethod,
paymentMethodData,
} ),
};
export default actions;

View File

@@ -0,0 +1,78 @@
/**
* 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',
SET_ACTIVE_PAYMENT_METHOD = 'set_active_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,
activePaymentMethod: '',
paymentMethodData: {
payment_method: '',
},
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, billingAddress ) =>
void [ paymentMethodData, billingAddress ],
} ),
currentStatus: {
isPristine: true,
isStarted: false,
isProcessing: false,
isFinished: false,
hasError: false,
hasFailed: false,
isSuccessful: false,
isDoingExpressPayment: false,
},
paymentStatuses: STATUS,
paymentMethodData: {},
errorMessage: '',
activePaymentMethod: '',
activeSavedToken: '',
setActivePaymentMethod: () => 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,370 @@
/**
* External dependencies
*/
import {
createContext,
useContext,
useReducer,
useCallback,
useRef,
useEffect,
useMemo,
} from '@wordpress/element';
import { objectHasProp } from '@woocommerce/types';
import { useDispatch } from '@wordpress/data';
/**
* 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 { 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 { 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.ReactNode;
} ): JSX.Element => {
const {
isProcessing: checkoutIsProcessing,
isIdle: checkoutIsIdle,
isCalculating: checkoutIsCalculating,
hasError: checkoutHasError,
} = useCheckoutContext();
const { isEditor, getPreviewData } = useEditorContext();
const { setValidationErrors } = useValidationContext();
const { createErrorNotice: addErrorNotice, removeNotice } =
useDispatch( 'core/notices' );
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 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( paymentData.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 ]
);
/**
* Active Gateway Selection
*
* Updates the active (selected) payment method when it is empty, or invalid. This uses the first saved payment
* method found (if applicable), or the first standard gateway.
*/
useEffect( () => {
const paymentMethodKeys = Object.keys( paymentData.paymentMethods );
if ( ! paymentMethodsInitialized || ! paymentMethodKeys.length ) {
return;
}
const allPaymentMethodKeys = [
...paymentMethodKeys,
...Object.keys( paymentData.expressPaymentMethods ),
];
// Return if current method is valid.
if (
paymentData.activePaymentMethod &&
allPaymentMethodKeys.includes( paymentData.activePaymentMethod )
) {
return;
}
setPaymentStatus().pristine();
const customerPaymentMethod =
Object.keys( customerPaymentMethods ).flatMap(
( type ) => customerPaymentMethods[ type ]
)[ 0 ] || undefined;
if ( customerPaymentMethod ) {
const token = customerPaymentMethod.tokenId.toString();
const paymentMethodSlug = customerPaymentMethod.method.gateway;
const savedTokenKey = `wc-${ paymentMethodSlug }-payment-token`;
dispatchActions.setActivePaymentMethod( paymentMethodSlug, {
token,
payment_method: paymentMethodSlug,
[ savedTokenKey ]: token,
isSavedToken: true,
} );
return;
}
dispatchActions.setActivePaymentMethod(
Object.keys( paymentData.paymentMethods )[ 0 ]
);
}, [
paymentMethodsInitialized,
paymentData.paymentMethods,
paymentData.expressPaymentMethods,
dispatchActions,
setPaymentStatus,
paymentData.activePaymentMethod,
customerPaymentMethods,
] );
// 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 sync payment status back to pristine.
useEffect( () => {
if ( checkoutHasError && currentStatus.isSuccessful ) {
setPaymentStatus().pristine();
}
}, [ checkoutHasError, currentStatus.isSuccessful, 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?.billingAddress,
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?.billingAddress
);
} 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 activeSavedToken =
typeof paymentData.paymentMethodData === 'object' &&
objectHasProp( paymentData.paymentMethodData, 'token' )
? paymentData.paymentMethodData.token + ''
: '';
const paymentContextData: PaymentMethodDataContextType = {
setPaymentStatus,
currentStatus,
paymentStatuses: STATUS,
paymentMethodData: paymentData.paymentMethodData,
errorMessage: paymentData.errorMessage,
activePaymentMethod: paymentData.activePaymentMethod,
activeSavedToken,
setActivePaymentMethod: dispatchActions.setActivePaymentMethod,
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,112 @@
/**
* External dependencies
*/
import { PaymentMethods } from '@woocommerce/type-defs/payments';
/**
* Internal dependencies
*/
import {
ACTION,
STATUS,
DEFAULT_PAYMENT_DATA_CONTEXT_STATE,
} from './constants';
import type { PaymentMethodDataContextState } from './types';
import type { ActionType } from './actions';
/**
* Reducer for payment data state
*/
const reducer = (
state = DEFAULT_PAYMENT_DATA_CONTEXT_STATE,
{
type,
paymentMethodData,
shouldSavePaymentMethod = false,
errorMessage = '',
paymentMethods = {},
paymentMethod = '',
}: ActionType
): PaymentMethodDataContextState => {
switch ( type ) {
case STATUS.PRISTINE:
return {
// This keeps payment method registration state and any set data. This effectively just resets the
// status and any error messages.
...DEFAULT_PAYMENT_DATA_CONTEXT_STATE,
...state,
errorMessage: '',
currentStatus: STATUS.PRISTINE,
};
case STATUS.STARTED:
return {
...state,
currentStatus: STATUS.STARTED,
};
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,
}
: 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 ACTION.SET_REGISTERED_PAYMENT_METHODS:
return {
...state,
paymentMethods: paymentMethods as PaymentMethods,
};
case ACTION.SET_REGISTERED_EXPRESS_PAYMENT_METHODS:
return {
...state,
expressPaymentMethods: paymentMethods,
};
case ACTION.SET_SHOULD_SAVE_PAYMENT_METHOD:
return {
...state,
shouldSavePaymentMethod,
};
case ACTION.SET_ACTIVE_PAYMENT_METHOD:
return {
...state,
activePaymentMethod: paymentMethod,
paymentMethodData: paymentMethodData || state.paymentMethodData,
};
}
};
export default reducer;

View File

@@ -0,0 +1,303 @@
/**
* External dependencies
*/
import { render, screen, waitFor, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
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-shared/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: 'credit-card',
last4: '4242',
brand: 'Visa',
},
expires: '12/22',
is_default: true,
tokenId: 1,
},
],
};
}
return originalModule.getSetting( setting, ...rest );
},
};
} );
const registerMockPaymentMethods = ( savedCards = true ) => {
[ '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,
} );
} );
[ 'credit-card' ].forEach( ( name ) => {
registerPaymentMethod( {
name,
label: name,
content: <div>A payment method</div>,
edit: <div>A payment method</div>,
icons: null,
canMakePayment: () => true,
supports: {
showSavedCards: savedCards,
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', 'credit-card' ].forEach( ( name ) => {
__experimentalDeRegisterPaymentMethod( name );
} );
[ 'express-payment' ].forEach( ( name ) => {
__experimentalDeRegisterExpressPaymentMethod( name );
} );
};
describe( 'Testing Payment Method Data Context Provider', () => {
beforeEach( () => {
act( () => {
registerMockPaymentMethods( false );
fetchMock.mockResponse( ( req ) => {
if ( req.url.match( /wc\/store\/v1\/cart/ ) ) {
return Promise.resolve( JSON.stringify( previewCart ) );
}
return Promise.resolve( '' );
} );
// need to clear the store resolution state between tests.
dispatch( storeKey ).invalidateResolutionForStore();
dispatch( storeKey ).receiveCart( defaultCartState.cartData );
} );
} );
afterEach( async () => {
act( () => {
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.
userEvent.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.
userEvent.click(
screen.getByText( 'express-payment express payment method close' )
);
await waitFor( () => {
const activePaymentMethod = screen.queryByText(
/Active Payment Method: cheque/
);
expect( activePaymentMethod ).not.toBeNull();
} );
} );
} );
describe( 'Testing Payment Method Data Context Provider with saved cards turned on', () => {
beforeEach( () => {
act( () => {
registerMockPaymentMethods( true );
fetchMock.mockResponse( ( req ) => {
if ( req.url.match( /wc\/store\/v1\/cart/ ) ) {
return Promise.resolve( JSON.stringify( previewCart ) );
}
return Promise.resolve( '' );
} );
// need to clear the store resolution state between tests.
dispatch( storeKey ).invalidateResolutionForStore();
dispatch( storeKey ).receiveCart( defaultCartState.cartData );
} );
} );
afterEach( async () => {
act( () => {
resetMockPaymentMethods();
fetchMock.resetMocks();
} );
} );
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-credit-card-payment-token' ] && (
<span>credit-card 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: credit-card/
);
expect( activePaymentMethod ).not.toBeNull();
} );
await waitFor( () => {
const creditCardToken = screen.queryByText( /credit-card token/ );
expect( creditCardToken ).not.toBeNull();
} );
// Express payment method clicked.
userEvent.click(
screen.getByText( 'express-payment express payment method' )
);
await waitFor( () => {
const activePaymentMethod = screen.queryByText(
/Active Payment Method: express-payment/
);
expect( activePaymentMethod ).not.toBeNull();
} );
await waitFor( () => {
const creditCardToken = screen.queryByText( /credit-card token/ );
expect( creditCardToken ).toBeNull();
} );
// Express payment method closed.
userEvent.click(
screen.getByText( 'express-payment express payment method close' )
);
await waitFor( () => {
const activePaymentMethod = screen.queryByText(
/Active Payment Method: credit-card/
);
expect( activePaymentMethod ).not.toBeNull();
} );
await waitFor( () => {
const creditCardToken = screen.queryByText( /credit-card token/ );
expect( creditCardToken ).not.toBeNull();
} );
} );
} );

View File

@@ -0,0 +1,132 @@
/**
* 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 interface PaymentMethodDispatchers {
setRegisteredPaymentMethods: ( paymentMethods: PaymentMethods ) => void;
setRegisteredExpressPaymentMethods: (
paymentMethods: ExpressPaymentMethods
) => void;
setShouldSavePayment: ( shouldSave: boolean ) => void;
setActivePaymentMethod: (
paymentMethod: string,
paymentMethodData?: ObjectType | EmptyObjectType
) => void;
}
export interface PaymentStatusDispatchers {
pristine: () => void;
started: () => void;
processing: () => void;
completed: () => void;
error: ( error: string ) => void;
failed: (
error?: string,
paymentMethodData?: ObjectType | EmptyObjectType,
billingAddress?: ObjectType | EmptyObjectType
) => void;
success: (
paymentMethodData?: ObjectType | EmptyObjectType,
billingAddress?: ObjectType | EmptyObjectType,
shippingData?: ObjectType | EmptyObjectType
) => void;
}
export interface PaymentMethodDataContextState {
currentStatus: STATUS;
shouldSavePaymentMethod: boolean;
activePaymentMethod: string;
paymentMethodData: ObjectType | EmptyObjectType;
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;
// Current active token.
activeSavedToken: string;
// A function for setting the active payment method.
setActivePaymentMethod: PaymentMethodDispatchers[ 'setActivePaymentMethod' ];
// 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: PaymentMethodDispatchers[ 'setShouldSavePayment' ];
// 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,104 @@
/**
* External dependencies
*/
import { useCallback, useMemo } from '@wordpress/element';
/**
* Internal dependencies
*/
import { actions, ActionType } from './actions';
import { STATUS } from './constants';
import type {
PaymentStatusDispatchers,
PaymentMethodDispatchers,
} from './types';
import { useCustomerData } from '../../../hooks/use-customer-data';
export const usePaymentMethodDataDispatchers = (
dispatch: React.Dispatch< ActionType >
): {
dispatchActions: PaymentMethodDispatchers;
setPaymentStatus: () => PaymentStatusDispatchers;
} => {
const { setBillingAddress, setShippingAddress } = useCustomerData();
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 )
),
setActivePaymentMethod: ( paymentMethod, paymentMethodData = {} ) =>
void dispatch(
actions.setActivePaymentMethod(
paymentMethod,
paymentMethodData
)
),
} ),
[ dispatch ]
);
const setPaymentStatus = useCallback(
(): PaymentStatusDispatchers => ( {
pristine: () => dispatch( actions.statusOnly( STATUS.PRISTINE ) ),
started: () => dispatch( actions.statusOnly( STATUS.STARTED ) ),
processing: () =>
dispatch( actions.statusOnly( STATUS.PROCESSING ) ),
completed: () => dispatch( actions.statusOnly( STATUS.COMPLETE ) ),
error: ( errorMessage ) =>
dispatch( actions.error( errorMessage ) ),
failed: (
errorMessage,
paymentMethodData,
billingAddress = undefined
) => {
if ( billingAddress ) {
setBillingAddress( billingAddress );
}
dispatch(
actions.failed( {
errorMessage: errorMessage || '',
paymentMethodData: paymentMethodData || {},
} )
);
},
success: (
paymentMethodData,
billingAddress = undefined,
shippingData = undefined
) => {
if ( billingAddress ) {
setBillingAddress( billingAddress );
}
if (
typeof shippingData !== undefined &&
shippingData?.address
) {
setShippingAddress(
shippingData.address as Record< string, unknown >
);
}
dispatch(
actions.success( {
paymentMethodData,
} )
);
},
} ),
[ dispatch, setBillingAddress, setShippingAddress ]
);
return {
dispatchActions,
setPaymentStatus,
};
};

View File

@@ -0,0 +1,256 @@
/**
* 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';
import { useDispatch } from '@wordpress/data';
import deprecated from '@wordpress/deprecated';
/**
* Internal dependencies
*/
import { useEditorContext } from '../../editor-context';
import { useCustomerDataContext } from '../customer';
import { useStoreCart } from '../../../hooks/cart/use-store-cart';
import { useEmitResponse } from '../../../hooks/use-emit-response';
import type { PaymentMethodsDispatcherType } from './types';
import { useShippingData } from '../../../hooks/shipping/use-shipping-data';
/**
* 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 } = useShippingData();
const { billingAddress, shippingAddress } = useCustomerDataContext();
const selectedShippingMethods = useShallowEqual( selectedRates );
const paymentMethodsOrder = useShallowEqual( paymentMethodsSortOrder );
const cart = useStoreCart();
const {
cartTotals,
cartIsLoading,
cartNeedsShipping,
paymentRequirements,
} = cart;
const canPayArgument = useRef( {
cart,
cartTotals,
cartNeedsShipping,
billingData: billingAddress,
billingAddress,
shippingAddress,
selectedShippingMethods,
paymentRequirements,
} );
const { createErrorNotice } = useDispatch( 'core/notices' );
useEffect( () => {
canPayArgument.current = {
cart,
cartTotals,
cartNeedsShipping,
get billingData() {
// prettier-ignore
deprecated(
'billingData',
{
alternative: 'billingAddress',
plugin: 'woocommerce-gutenberg-products-block',
link:
'https://github.com/woocommerce/woocommerce-blocks/pull/6369',
}
);
return this.billingAddress;
},
billingAddress,
shippingAddress,
selectedShippingMethods,
paymentRequirements,
};
}, [
cart,
cartTotals,
cartNeedsShipping,
billingAddress,
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, cheque...) */
__(
`There was an error registering the payment method with id '%s': `,
'woo-gutenberg-products-block'
),
paymentMethod.paymentMethodId
);
createErrorNotice( `${ errorText } ${ e }`, {
context: noticeContext,
id: `wc-${ paymentMethod.paymentMethodId }-registration-error`,
} );
}
}
}
// Re-dispatch available payment methods to store.
dispatcher( availablePaymentMethods );
// Note: Some 4rd party payment methods use the `canMakePayment` callback to initialize / setup.
// That's why we track "is initialized" state here.
setIsInitialized( true );
}, [
createErrorNotice,
dispatcher,
isEditor,
noticeContext,
paymentMethodsOrder,
registeredPaymentMethods,
] );
const debouncedRefreshCanMakePayments = useDebouncedCallback(
refreshCanMakePayments,
500,
{
leading: true,
}
);
// 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( () => {
if ( ! cartIsLoading ) {
debouncedRefreshCanMakePayments();
}
}, [
debouncedRefreshCanMakePayments,
cart,
selectedShippingMethods,
billingAddress,
cartIsLoading,
] );
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: [],
isLoadingRates: 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,203 @@
/**
* External dependencies
*/
import {
createContext,
useContext,
useReducer,
useEffect,
useMemo,
useRef,
} from '@wordpress/element';
/**
* 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 { useStoreCart } from '../../../hooks/cart/use-store-cart';
import { useSelectShippingRate } from '../../../hooks/shipping/use-select-shipping-rate';
import { useShippingData } from '../../../hooks/shipping/use-shipping-data';
/**
* @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 { shippingRates, isLoadingRates, cartErrors } = useStoreCart();
const { isSelectingRate } = useSelectShippingRate();
const { selectedRates } = useShippingData();
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 ] );
// increment/decrement checkout calculating counts when shipping is loading.
useEffect( () => {
if ( isLoadingRates ) {
dispatchActions.incrementCalculating();
} else {
dispatchActions.decrementCalculating();
}
}, [ isLoadingRates, 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 (
! isLoadingRates &&
( shippingRates.length === 0 || currentErrorStatus.hasError )
) {
emitEvent(
currentObservers.current,
EMIT_TYPES.SHIPPING_RATES_FAIL,
{
hasInvalidAddress: currentErrorStatus.hasInvalidAddress,
hasError: currentErrorStatus.hasError,
}
);
}
}, [
shippingRates,
isLoadingRates,
currentErrorStatus.hasError,
currentErrorStatus.hasInvalidAddress,
] );
useEffect( () => {
if (
! isLoadingRates &&
shippingRates.length > 0 &&
! currentErrorStatus.hasError
) {
emitEvent(
currentObservers.current,
EMIT_TYPES.SHIPPING_RATES_SUCCESS,
shippingRates
);
}
}, [ shippingRates, isLoadingRates, 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
);
}
}, [
selectedRates,
isSelectingRate,
currentErrorStatus.hasError,
currentErrorStatus.hasInvalidAddress,
] );
/**
* @type {ShippingDataContext}
*/
const ShippingData = {
shippingErrorStatus: currentErrorStatus,
dispatchErrorStatus,
shippingErrorTypes: ERROR_TYPES,
...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( 'User-ID' ) ) {
dispatchActions.setCustomerId(
parseInt( headers.get( 'User-ID' ) || '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,82 @@
/**
* 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,
currentView: '',
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.
* @param {string} [props.currentView] Current view, if using a view switcher.
*/
export const EditorProvider = ( {
children,
currentPostId = 0,
currentView = '',
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,
currentView,
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,86 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { Notice } from 'wordpress-components';
import { useDispatch, useSelect } from '@wordpress/data';
/**
* Internal dependencies
*/
import './style.scss';
import { useStoreNoticesContext } from '../context';
const getWooClassName = ( { status = 'default' } ) => {
switch ( status ) {
case 'error':
return 'woocommerce-error';
case 'success':
return 'woocommerce-message';
case 'info':
case 'warning':
return 'woocommerce-info';
}
return '';
};
export const StoreNoticesContainer = ( {
className,
context = 'default',
additionalNotices = [],
} ) => {
const { isSuppressed } = useStoreNoticesContext();
const { notices } = useSelect( ( select ) => {
const store = select( 'core/notices' );
return {
notices: store.getNotices( context ),
};
} );
const { removeNotice } = useDispatch( 'core/notices' );
const regularNotices = notices
.filter( ( notice ) => notice.type !== 'snackbar' )
.concat( additionalNotices );
if ( ! regularNotices.length ) {
return null;
}
const wrapperClass = classnames( className, 'wc-block-components-notices' );
return isSuppressed ? null : (
<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, context );
}
} }
>
{ 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' ] ),
} )
),
};

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,56 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import { createContext, useContext, useState } from '@wordpress/element';
/**
* @typedef {import('@woocommerce/type-defs/contexts').NoticeContext} NoticeContext
* @typedef {import('react')} React
*/
const StoreNoticesContext = createContext( {
setIsSuppressed: ( val ) => void { val },
isSuppressed: false,
} );
/**
* 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.
*/
export const StoreNoticesProvider = ( { children } ) => {
const [ isSuppressed, setIsSuppressed ] = useState( false );
const contextValue = {
setIsSuppressed,
isSuppressed,
};
return (
<StoreNoticesContext.Provider value={ contextValue }>
{ children }
</StoreNoticesContext.Provider>
);
};
StoreNoticesProvider.propTypes = {
children: PropTypes.node,
};

View File

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

View File

@@ -0,0 +1,85 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import { SnackbarList } from 'wordpress-components';
import classnames from 'classnames';
import { __experimentalApplyCheckoutFilter } from '@woocommerce/blocks-checkout';
import { useDispatch, useSelect } from '@wordpress/data';
/**
* Internal dependencies
*/
import { useEditorContext } from '../../editor-context';
const EMPTY_SNACKBAR_NOTICES = {};
export const SnackbarNoticesContainer = ( {
className,
context = 'default',
} ) => {
const { isEditor } = useEditorContext();
const { notices } = useSelect( ( select ) => {
const store = select( 'core/notices' );
return {
notices: store.getNotices( context ),
};
} );
const { removeNotice } = useDispatch( 'core/notices' );
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={ () => {
visibleNotices.forEach( ( notice ) =>
removeNotice( notice.id, context )
);
} }
/>
);
};
SnackbarNoticesContainer.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' ] ),
} )
),
};

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 @@
export * from './components/snackbar-notices-container';

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: $gap-smallest 0 0 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>
);
};

View File

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

View File

@@ -0,0 +1,53 @@
/**
* Internal dependencies
*/
import { assertValidContextValue } from '../utils';
describe( 'assertValidContextValue', () => {
const contextName = 'testContext';
const validationMap = {
cheeseburger: {
required: false,
type: 'string',
},
amountKetchup: {
required: true,
type: 'number',
},
};
it.each`
testValue
${ {} }
${ 10 }
${ { amountKetchup: '10' } }
`(
'The value of $testValue is expected to trigger an Error',
( { testValue } ) => {
const invokeTest = () => {
assertValidContextValue(
contextName,
validationMap,
testValue
);
};
expect( invokeTest ).toThrow();
}
);
it.each`
testValue
${ { amountKetchup: 20 } }
${ { cheeseburger: 'fries', amountKetchup: 20 } }
`(
'The value of $testValue is not expected to trigger an Error',
( { testValue } ) => {
const invokeTest = () => {
assertValidContextValue(
contextName,
validationMap,
testValue
);
};
expect( invokeTest ).not.toThrow();
}
);
} );

View File

@@ -0,0 +1,61 @@
/**
* This is an assertion utility for validating that the incoming value prop
* value on a given context provider is valid and throws an error if it isn't.
*
* Note: this asserts values that are expected to be an object.
*
* The validationMap is expected to be an object in the following shape.
*
* {
* [expectedPropertyName<String>]: {
* required: [expectedRequired<Boolean>]
* type: [expectedType<String>]
* }
* }
*
* @param {string} contextName The name of the context provider being
* validated.
* @param {Object} validationMap A map for validating the incoming value against.
* @param {Object} value The value being validated.
*
* @throws {Error}
*/
export const assertValidContextValue = (
contextName,
validationMap,
value
) => {
if ( typeof value !== 'object' ) {
throw new Error(
`${ contextName } expects an object for its context value`
);
}
const errors = [];
for ( const expectedProperty in validationMap ) {
if (
validationMap[ expectedProperty ].required &&
typeof value[ expectedProperty ] === 'undefined'
) {
errors.push(
`The ${ expectedProperty } is required and is not present.`
);
} else if (
typeof value[ expectedProperty ] !== 'undefined' &&
typeof value[ expectedProperty ] !==
validationMap[ expectedProperty ].type
) {
errors.push(
`The ${ expectedProperty } must be of ${
validationMap[ expectedProperty ].type
} and instead was ${ typeof value[ expectedProperty ] }`
);
}
}
if ( errors.length > 0 ) {
throw new Error(
`There was a problem with the value passed in on ${ contextName }:\n ${ errors.join(
'\n'
) }`
);
}
};