rebase from live enviornment

This commit is contained in:
Rachit Bhargava
2024-01-09 22:14:20 -05:00
parent ff0b49a046
commit 3a22fcaa4a
15968 changed files with 2344674 additions and 45234 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,101 @@
/**
* Internal dependencies
*/
import {
getObserversByPriority,
isErrorResponse,
isFailResponse,
ObserverResponse,
responseTypes,
} from './utils';
import type { EventObserversType } from './types';
import { isObserverResponse } from '../../../types/type-guards/observers';
/**
* 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< ObserverResponse[] > => {
const observerResponses: ObserverResponse[] = [];
const observersByType = getObserversByPriority( observers, eventType );
for ( const observer of observersByType ) {
try {
const response = await Promise.resolve( observer.callback( data ) );
if ( ! isObserverResponse( response ) ) {
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: responseTypes.ERROR } );
return observerResponses;
}
}
return observerResponses;
};

View File

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

View File

@@ -0,0 +1,64 @@
/**
* Internal dependencies
*/
import {
ACTION,
ActionType,
ActionCallbackType,
EventObserversType,
} from './types';
export function generateUniqueId() {
return Math.floor( Math.random() * Date.now() ).toString();
}
export const actions = {
addEventCallback: (
eventType: string,
callback: ActionCallbackType,
priority = 10
): ActionType => {
return {
id: generateUniqueId(),
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,94 @@
/**
* External dependencies
*/
import { FieldValidationStatus, isObject } from '@woocommerce/types';
/**
* 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;
} )
: [];
};
export enum responseTypes {
SUCCESS = 'success',
FAIL = 'failure',
ERROR = 'error',
}
export enum noticeContexts {
CART = 'wc/cart',
CHECKOUT = 'wc/checkout',
PAYMENTS = 'wc/checkout/payments',
EXPRESS_PAYMENTS = 'wc/checkout/express-payments',
CONTACT_INFORMATION = 'wc/checkout/contact-information',
SHIPPING_ADDRESS = 'wc/checkout/shipping-address',
BILLING_ADDRESS = 'wc/checkout/billing-address',
SHIPPING_METHODS = 'wc/checkout/shipping-methods',
CHECKOUT_ACTIONS = 'wc/checkout/checkout-actions',
}
export interface ResponseType extends Record< string, unknown > {
type: responseTypes;
retry?: boolean;
}
/**
* Observers of checkout/cart events can return a response object to indicate success/error/failure. They may also
* optionally pass metadata.
*/
export interface ObserverResponse {
type: responseTypes;
meta?: Record< string, unknown > | undefined;
validationErrors?: Record< string, FieldValidationStatus > | undefined;
}
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 ObserverFailResponse => {
return isResponseOf( response, responseTypes.SUCCESS );
};
interface ObserverSuccessResponse extends ObserverResponse {
type: responseTypes.SUCCESS;
}
export const isErrorResponse = (
response: unknown
): response is ObserverSuccessResponse => {
return isResponseOf( response, responseTypes.ERROR );
};
interface ObserverErrorResponse extends ObserverResponse {
type: responseTypes.ERROR;
}
interface ObserverFailResponse extends ObserverResponse {
type: responseTypes.FAIL;
}
export const isFailResponse = (
response: unknown
): response is ObserverErrorResponse => {
return isResponseOf( response, responseTypes.FAIL );
};
export const shouldRetry = ( response: unknown ): boolean => {
return (
! isObject( response ) ||
typeof response.retry === 'undefined' ||
response.retry === true
);
};

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,216 @@
/**
* External dependencies
*/
import TestRenderer, { act } from 'react-test-renderer';
import { createRegistry, RegistryProvider } from '@wordpress/data';
import { CART_STORE_KEY, CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import * as mockUseStoreCart from '../use-store-cart';
import { useStoreCartItemQuantity } from '../use-store-cart-item-quantity';
import { config as checkoutStoreConfig } from '../../../../../data/checkout';
jest.mock( '../use-store-cart', () => ( {
useStoreCart: jest.fn(),
} ) );
jest.mock( '@woocommerce/block-data', () => ( {
__esModule: true,
CART_STORE_KEY: 'test/cart/store',
CHECKOUT_STORE_KEY: 'test/checkout/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 } ) => {
// Register mock cart store
mockRemoveItemFromCart = jest
.fn()
.mockReturnValue( { type: 'removeItemFromCartAction' } );
mockChangeCartItemQuantity = jest
.fn()
.mockReturnValue( { type: 'changeCartItemQuantityAction' } );
registry.registerStore( CART_STORE_KEY, {
reducer: () => ( {} ),
actions: {
removeItemFromCart: mockRemoveItemFromCart,
changeCartItemQuantity: mockChangeCartItemQuantity,
},
selectors: {
isItemPendingDelete: jest
.fn()
.mockReturnValue( isPendingDelete ),
isItemPendingQuantity: jest
.fn()
.mockReturnValue( isPendingQuantity ),
},
} );
// Register actual checkout store
registry.registerStore( CHECKOUT_STORE_KEY, checkoutStoreConfig );
};
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,273 @@
/**
* 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 receiveCartContentsMock = () => {};
const previewCartData = {
cartCoupons: previewCart.coupons,
cartItems: previewCart.items,
crossSellsProducts: previewCart.cross_sells,
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: [],
paymentRequirements: [],
receiveCart: undefined,
receiveCartContents: undefined,
};
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,
paymentRequirements: [],
receiveCart: undefined,
receiveCartContents: undefined,
};
const getWrappedComponents = ( Component ) => (
<RegistryProvider value={ registry }>
<Component />
</RegistryProvider>
);
const getTestComponent = ( options ) => () => {
const { receiveCart, receiveCartContents, ...results } =
useStoreCart( options );
return (
<div
data-results={ results }
data-receiveCart={ receiveCart }
data-receiveCartContents={ receiveCartContents }
/>
);
};
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 props = renderer.root.findByType( 'div' ).props; //eslint-disable-line testing-library/await-async-query
const results = props[ 'data-results' ];
const receiveCart = props[ 'data-receiveCart' ];
const receiveCartContents = props[ 'data-receiveCartContents' ];
const {
receiveCart: defaultReceiveCart,
receiveCartContents: defaultReceiveCartContents,
...remaining
} = defaultCartData;
expect( results ).toEqual( remaining );
expect( receiveCart ).toEqual( defaultReceiveCart );
expect( receiveCartContents ).toEqual( defaultReceiveCartContents );
} );
it( 'return store data when shouldSelect is true', () => {
const TestComponent = getTestComponent( {
shouldSelect: true,
} );
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent )
);
} );
const props = renderer.root.findByType( 'div' ).props; //eslint-disable-line testing-library/await-async-query
const results = props[ 'data-results' ];
const receiveCart = props[ 'data-receiveCart' ];
const receiveCartContents = props[ 'data-receiveCartContents' ];
expect( results ).toEqual( mockStoreCartData );
expect( receiveCart ).toBeUndefined();
expect( receiveCartContents ).toBeUndefined();
} );
} );
describe( 'in editor', () => {
beforeEach( () => {
useEditorContext.mockReturnValue( {
isEditor: true,
previewData: {
previewCart: {
...previewCart,
receiveCart: receiveCartMock,
receiveCartContents: receiveCartContentsMock,
},
},
} );
} );
it( 'return preview data in editor', () => {
const TestComponent = getTestComponent();
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent )
);
} );
const props = renderer.root.findByType( 'div' ).props; //eslint-disable-line testing-library/await-async-query
const results = props[ 'data-results' ];
const receiveCart = props[ 'data-receiveCart' ];
const receiveCartContents = props[ 'data-receiveCartContents' ];
expect( results ).toEqual( previewCartData );
expect( receiveCart ).toEqual( receiveCartMock );
expect( receiveCartContents ).toEqual( receiveCartContentsMock );
} );
} );
} );

View File

@@ -0,0 +1,131 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { useDispatch, useSelect } from '@wordpress/data';
import { CART_STORE_KEY, VALIDATION_STORE_KEY } from '@woocommerce/block-data';
import { decodeEntities } from '@wordpress/html-entities';
import type { StoreCartCoupon } from '@woocommerce/types';
import { applyCheckoutFilter } from '@woocommerce/blocks-checkout';
/**
* Internal dependencies
*/
import { useStoreCart } from './use-store-cart';
/**
* 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
*/
export const useStoreCartCoupons = ( context = '' ): StoreCartCoupon => {
const { cartCoupons, cartIsLoading } = useStoreCart();
const { createErrorNotice } = useDispatch( 'core/notices' );
const { createNotice } = useDispatch( 'core/notices' );
const { setValidationErrors } = useDispatch( VALIDATION_STORE_KEY );
const {
isApplyingCoupon,
isRemovingCoupon,
}: Pick< StoreCartCoupon, 'isApplyingCoupon' | 'isRemovingCoupon' > =
useSelect(
( select ) => {
const store = select( CART_STORE_KEY );
return {
isApplyingCoupon: store.isApplyingCoupon(),
isRemovingCoupon: store.isRemovingCoupon(),
};
},
[ createErrorNotice, createNotice ]
);
const { applyCoupon, removeCoupon } = useDispatch( CART_STORE_KEY );
const applyCouponWithNotices = ( couponCode: string ) => {
return applyCoupon( couponCode )
.then( () => {
if (
applyCheckoutFilter( {
filterName: 'showApplyCouponNotice',
defaultValue: true,
arg: { couponCode, context },
} )
) {
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,
}
);
}
return Promise.resolve( true );
} )
.catch( ( error ) => {
setValidationErrors( {
coupon: {
message: decodeEntities( error.message ),
hidden: false,
},
} );
return Promise.resolve( false );
} );
};
const removeCouponWithNotices = ( couponCode: string ) => {
return removeCoupon( couponCode )
.then( () => {
if (
applyCheckoutFilter( {
filterName: 'showRemoveCouponNotice',
defaultValue: true,
arg: { couponCode, context },
} )
) {
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,
}
);
}
return Promise.resolve( true );
} )
.catch( ( error ) => {
createErrorNotice( error.message, {
id: 'coupon-form',
context,
} );
return Promise.resolve( false );
} );
};
return {
appliedCoupons: cartCoupons,
isLoading: cartIsLoading,
applyCoupon: applyCouponWithNotices,
removeCoupon: removeCouponWithNotices,
isApplyingCoupon,
isRemovingCoupon,
};
};

View File

@@ -0,0 +1,121 @@
/**
* External dependencies
*/
import { useEffect } from '@wordpress/element';
import { CART_STORE_KEY } from '@woocommerce/block-data';
import { dispatch } from '@wordpress/data';
import {
translateJQueryEventToNative,
getNavigationType,
} from '@woocommerce/base-utils';
interface StoreCartListenersType {
// Counts the number of consumers of this hook so we can remove listeners when no longer needed.
count: number;
// Function to remove all registered listeners.
remove: () => void;
}
interface CartDataCustomEvent extends Event {
detail?:
| {
preserveCartData?: boolean | undefined;
}
| undefined;
}
declare global {
interface Window {
wcBlocksStoreCartListeners: StoreCartListenersType;
}
}
const refreshData = ( event: CartDataCustomEvent ): void => {
const eventDetail = event?.detail;
if ( ! eventDetail || ! eventDetail.preserveCartData ) {
dispatch( CART_STORE_KEY ).invalidateResolutionForStore();
}
};
/**
* Refreshes data if the pageshow event is triggered by the browser history.
*
* - In Chrome, `back_forward` will be returned by getNavigationType() when the browser history is used.
* - In safari we instead need to use `event.persisted` which is true when page cache is used.
*/
const refreshCachedCartData = ( event: PageTransitionEvent ): void => {
if ( event?.persisted || getNavigationType() === 'back_forward' ) {
dispatch( CART_STORE_KEY ).invalidateResolutionForStore();
}
};
const setUp = (): void => {
if ( ! window.wcBlocksStoreCartListeners ) {
window.wcBlocksStoreCartListeners = {
count: 0,
remove: () => void null,
};
}
};
// Checks if there are any listeners registered.
const hasListeners = (): boolean => {
return window.wcBlocksStoreCartListeners?.count > 0;
};
// Add listeners if there are none, otherwise just increment the count.
const addListeners = (): void => {
setUp();
if ( hasListeners() ) {
window.wcBlocksStoreCartListeners.count++;
return;
}
document.body.addEventListener( 'wc-blocks_added_to_cart', refreshData );
document.body.addEventListener(
'wc-blocks_removed_from_cart',
refreshData
);
window.addEventListener( 'pageshow', refreshCachedCartData );
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;
window.wcBlocksStoreCartListeners.count = 1;
window.wcBlocksStoreCartListeners.remove = () => {
document.body.removeEventListener(
'wc-blocks_added_to_cart',
refreshData
);
document.body.removeEventListener(
'wc-blocks_removed_from_cart',
refreshData
);
window.removeEventListener( 'pageshow', refreshCachedCartData );
removeJQueryAddedToCartEvent();
removeJQueryRemovedFromCartEvent();
};
};
const removeListeners = (): void => {
if ( window.wcBlocksStoreCartListeners.count === 1 ) {
window.wcBlocksStoreCartListeners.remove();
}
window.wcBlocksStoreCartListeners.count--;
};
/**
* This will keep track of jQuery and DOM events that invalidate the store resolution.
*/
export const useStoreCartEventListeners = (): void => {
useEffect( () => {
addListeners();
return removeListeners;
}, [] );
};

View File

@@ -0,0 +1,163 @@
/**
* External dependencies
*/
import { useSelect, useDispatch } from '@wordpress/data';
import { useCallback, useState, useEffect } from '@wordpress/element';
import {
CART_STORE_KEY,
CHECKOUT_STORE_KEY,
processErrorResponse,
} from '@woocommerce/block-data';
import { useDebounce } from 'use-debounce';
import { usePrevious } from '@woocommerce/base-hooks';
import {
CartItem,
StoreCartItemQuantity,
isNumber,
isObject,
isString,
objectHasProp,
} from '@woocommerce/types';
/**
* Internal dependencies
*/
import { useStoreCart } from './use-store-cart';
/**
* 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 { __internalIncrementCalculating, __internalDecrementCalculating } =
useDispatch( CHECKOUT_STORE_KEY );
// 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( CART_STORE_KEY );
// 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( CART_STORE_KEY );
return {
quantity: store.isItemPendingQuantity( cartItemKey ),
delete: store.isItemPendingDelete( cartItemKey ),
};
},
[ cartItemKey ]
);
const removeItem = useCallback( () => {
if ( cartItemKey ) {
return removeItemFromCart( cartItemKey ).catch( ( error ) => {
processErrorResponse( error );
} );
}
return 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 ).catch(
( error ) => {
processErrorResponse( error );
}
);
}
}, [
cartItemKey,
changeCartItemQuantity,
debouncedQuantity,
previousDebouncedQuantity,
] );
useEffect( () => {
if ( isPending.delete ) {
__internalIncrementCalculating();
} else {
__internalDecrementCalculating();
}
return () => {
if ( isPending.delete ) {
__internalDecrementCalculating();
}
};
}, [
__internalDecrementCalculating,
__internalIncrementCalculating,
isPending.delete,
] );
useEffect( () => {
if ( isPending.quantity || debouncedQuantity !== quantity ) {
__internalIncrementCalculating();
} else {
__internalDecrementCalculating();
}
return () => {
if ( isPending.quantity || debouncedQuantity !== quantity ) {
__internalDecrementCalculating();
}
};
}, [
__internalIncrementCalculating,
__internalDecrementCalculating,
isPending.quantity,
debouncedQuantity,
quantity,
] );
return {
isPendingDelete: isPending.delete,
quantity,
setItemQuantity: setQuantity,
removeItem,
cartItemQuantityErrors: cartErrors,
};
};

View File

@@ -0,0 +1,256 @@
/** @typedef { import('@woocommerce/type-defs/hooks').StoreCart } StoreCart */
/**
* External dependencies
*/
import fastDeepEqual from 'fast-deep-equal/es6';
import { useRef } from '@wordpress/element';
import {
CART_STORE_KEY as storeKey,
EMPTY_CART_COUPONS,
EMPTY_CART_ITEMS,
EMPTY_CART_CROSS_SELLS,
EMPTY_CART_FEES,
EMPTY_CART_ITEM_ERRORS,
EMPTY_CART_ERRORS,
EMPTY_SHIPPING_RATES,
EMPTY_TAX_LINES,
EMPTY_PAYMENT_METHODS,
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,
crossSellsProducts: EMPTY_CART_CROSS_SELLS,
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,
paymentMethods: EMPTY_PAYMENT_METHODS,
paymentRequirements: EMPTY_PAYMENT_REQUIREMENTS,
receiveCart: () => undefined,
receiveCartContents: () => 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 that invalidate the store resolution.
useStoreCartEventListeners();
const results: StoreCart = useSelect(
( select, { dispatch } ) => {
if ( ! shouldSelect ) {
return defaultCartData;
}
if ( isEditor ) {
return {
cartCoupons: previewCart.coupons,
cartItems: previewCart.items,
crossSellsProducts: previewCart.cross_sells,
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,
receiveCartContents:
typeof previewCart?.receiveCartContents === 'function'
? previewCart.receiveCartContents
: () => 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, receiveCartContents } = 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,
crossSellsProducts: cartData.crossSells,
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,
receiveCartContents,
};
},
[ shouldSelect ]
);
if (
! currentResults.current ||
! fastDeepEqual( 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 data-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[ 'data-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[ 'data-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,171 @@
/**
* External dependencies
*/
import { useState, useEffect, useMemo } from '@wordpress/element';
import { useDebounce } from 'use-debounce';
import { objectHasProp } from '@woocommerce/types';
import { sort } from 'fast-sort';
import { useShallowEqual } from '@woocommerce/base-hooks';
/**
* Internal dependencies
*/
import { useQueryStateByContext, useQueryStateByKey } from '../use-query-state';
import { useCollection } from './use-collection';
import { useQueryStateContext } from '../../providers/query-state-context';
const buildCollectionDataQuery = (
collectionDataQueryState: Record< string, unknown >
) => {
const query = collectionDataQueryState;
if (
Array.isArray( collectionDataQueryState.calculate_attribute_counts )
) {
query.calculate_attribute_counts = sort(
collectionDataQueryState.calculate_attribute_counts.map(
( { taxonomy, queryType } ) => {
return {
taxonomy,
query_type: queryType,
};
}
)
).asc( [ 'taxonomy', 'query_type' ] );
}
return query;
};
interface UseCollectionDataProps {
queryAttribute?: {
taxonomy: string;
queryType: string;
};
queryPrices?: boolean;
queryStock?: boolean;
queryRating?: boolean;
queryState: Record< string, unknown >;
isEditor?: boolean;
}
export const useCollectionData = ( {
queryAttribute,
queryPrices,
queryStock,
queryRating,
queryState,
isEditor = false,
}: 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 [ calculateRatingQueryState, setCalculateRatingQueryState ] =
useQueryStateByKey( 'calculate_rating_counts', null, context );
const currentQueryAttribute = useShallowEqual( queryAttribute || {} );
const currentQueryPrices = useShallowEqual( queryPrices );
const currentQueryStock = useShallowEqual( queryStock );
const currentQueryRating = useShallowEqual( queryRating );
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,
] );
useEffect( () => {
if (
calculateRatingQueryState !== currentQueryRating &&
currentQueryRating !== undefined
) {
setCalculateRatingQueryState( currentQueryRating );
}
}, [
currentQueryRating,
setCalculateRatingQueryState,
calculateRatingQueryState,
] );
// Defer the select query so all collection-data query vars can be gathered.
const [ shouldSelect, setShouldSelect ] = useState( isEditor );
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,126 @@
/**
* 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;
isEditor?: boolean;
}
export const useCollection = < T >(
options: useCollectionOptions
): {
results: T[];
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: T[]; 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,14 @@
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-submit';
export * from './use-checkout-extension-data';
export * from './use-show-shipping-total-warning';
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,245 @@
/**
* 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';
import { useSelect, useDispatch } from '@wordpress/data';
import {
CHECKOUT_STORE_KEY,
PAYMENT_STORE_KEY,
CART_STORE_KEY,
} from '@woocommerce/block-data';
import { ValidationInputError } from '@woocommerce/blocks-components';
/**
* Internal dependencies
*/
import { useStoreCart } from '../cart/use-store-cart';
import { useStoreCartCoupons } from '../cart/use-store-cart-coupons';
import { noticeContexts, responseTypes } from '../../event-emit';
import { useCheckoutEventsContext } from '../../providers/cart-checkout/checkout-events';
import { usePaymentEventsContext } from '../../providers/cart-checkout/payment-events';
import { useShippingDataContext } from '../../providers/cart-checkout/shipping';
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 {
onCheckoutBeforeProcessing,
onCheckoutValidationBeforeProcessing,
onCheckoutAfterProcessingWithSuccess,
onCheckoutAfterProcessingWithError,
onSubmit,
onCheckoutSuccess,
onCheckoutFail,
onCheckoutValidation,
} = useCheckoutEventsContext();
const { isCalculating, isComplete, isIdle, isProcessing, customerId } =
useSelect( ( select ) => {
const store = select( CHECKOUT_STORE_KEY );
return {
isComplete: store.isComplete(),
isIdle: store.isIdle(),
isProcessing: store.isProcessing(),
customerId: store.getCustomerId(),
isCalculating: store.isCalculating(),
};
} );
const { paymentStatus, activePaymentMethod, shouldSavePayment } = useSelect(
( select ) => {
const store = select( PAYMENT_STORE_KEY );
return {
// The paymentStatus is exposed to third parties via the payment method interface so the API must not be changed
paymentStatus: {
get isPristine() {
deprecated( 'isPristine', {
since: '9.6.0',
alternative: 'isIdle',
plugin: 'WooCommerce Blocks',
link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8110',
} );
return store.isPaymentIdle();
}, // isPristine is the same as isIdle
isIdle: store.isPaymentIdle(),
isStarted: store.isExpressPaymentStarted(),
isProcessing: store.isPaymentProcessing(),
get isFinished() {
deprecated( 'isFinished', {
since: '9.6.0',
plugin: 'WooCommerce Blocks',
link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8110',
} );
return (
store.hasPaymentError() || store.isPaymentReady()
);
},
hasError: store.hasPaymentError(),
get hasFailed() {
deprecated( 'hasFailed', {
since: '9.6.0',
plugin: 'WooCommerce Blocks',
link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8110',
} );
return store.hasPaymentError();
},
get isSuccessful() {
deprecated( 'isSuccessful', {
since: '9.6.0',
plugin: 'WooCommerce Blocks',
link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8110',
} );
return store.isPaymentReady();
},
isReady: store.isPaymentReady(),
isDoingExpressPayment: store.isExpressPaymentMethodActive(),
},
activePaymentMethod: store.getActivePaymentMethod(),
shouldSavePayment: store.getShouldSavePaymentMethod(),
};
}
);
const { __internalSetExpressPaymentError } =
useDispatch( PAYMENT_STORE_KEY );
const { onPaymentProcessing, onPaymentSetup } = usePaymentEventsContext();
const {
shippingErrorStatus,
shippingErrorTypes,
onShippingRateSuccess,
onShippingRateFail,
onShippingRateSelectSuccess,
onShippingRateSelectFail,
} = useShippingDataContext();
const {
shippingRates,
isLoadingRates,
selectedRates,
isSelectingRate,
selectShippingRate,
needsShipping,
} = useShippingData();
const { billingAddress, shippingAddress } = useSelect( ( select ) =>
select( CART_STORE_KEY ).getCustomerData()
);
const { setShippingAddress } = useDispatch( CART_STORE_KEY );
const { cartItems, cartFees, cartTotals, extensions } = useStoreCart();
const { appliedCoupons } = useStoreCartCoupons();
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',
}
);
__internalSetExpressPaymentError( errorMessage );
},
[ __internalSetExpressPaymentError ]
);
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,
onCheckoutSuccess,
onCheckoutFail,
onCheckoutValidation,
onPaymentProcessing,
onPaymentSetup,
onShippingRateFail,
onShippingRateSelectFail,
onShippingRateSelectSuccess,
onShippingRateSuccess,
},
onSubmit,
paymentStatus,
setExpressPaymentError: deprecatedSetExpressPaymentError,
shippingData: {
isSelectingRate,
needsShipping,
selectedRates,
setSelectedRates: selectShippingRate,
setShippingAddress,
shippingAddress,
shippingRates,
shippingRatesLoading: isLoadingRates,
},
shippingStatus: {
shippingErrorStatus,
shippingErrorTypes,
},
shouldSavePayment,
};
};

View File

@@ -0,0 +1,100 @@
/**
* External dependencies
*/
import { useShallowEqual } from '@woocommerce/base-hooks';
import type {
PaymentMethods,
ExpressPaymentMethods,
PaymentMethodConfigInstance,
ExpressPaymentMethodConfigInstance,
} from '@woocommerce/types';
import {
getPaymentMethods,
getExpressPaymentMethods,
} from '@woocommerce/blocks-registry';
import { useSelect } from '@wordpress/data';
import { PAYMENT_STORE_KEY } from '@woocommerce/block-data';
interface PaymentMethodState {
paymentMethods: PaymentMethods;
isInitialized: boolean;
}
interface ExpressPaymentMethodState {
paymentMethods: ExpressPaymentMethods;
isInitialized: boolean;
}
const usePaymentMethodState = (
express = false
): PaymentMethodState | ExpressPaymentMethodState => {
const {
paymentMethodsInitialized,
expressPaymentMethodsInitialized,
availablePaymentMethods,
availableExpressPaymentMethods,
} = useSelect( ( select ) => {
const store = select( PAYMENT_STORE_KEY );
return {
paymentMethodsInitialized: store.paymentMethodsInitialized(),
expressPaymentMethodsInitialized:
store.expressPaymentMethodsInitialized(),
availableExpressPaymentMethods:
store.getAvailableExpressPaymentMethods(),
availablePaymentMethods: store.getAvailablePaymentMethods(),
};
} );
const availablePaymentMethodNames = Object.values(
availablePaymentMethods
).map( ( { name } ) => name );
const availableExpressPaymentMethodNames = Object.values(
availableExpressPaymentMethods
).map( ( { name } ) => name );
const registeredPaymentMethods = getPaymentMethods();
const registeredExpressPaymentMethods = getExpressPaymentMethods();
// Remove everything from registeredPaymentMethods that is not in availablePaymentMethodNames.
const paymentMethods = Object.keys( registeredPaymentMethods ).reduce(
( acc: Record< string, PaymentMethodConfigInstance >, key ) => {
if ( availablePaymentMethodNames.includes( key ) ) {
acc[ key ] = registeredPaymentMethods[ key ];
}
return acc;
},
{}
);
// Remove everything from registeredExpressPaymentMethods that is not in availableExpressPaymentMethodNames.
const expressPaymentMethods = Object.keys(
registeredExpressPaymentMethods
).reduce(
( acc: Record< string, ExpressPaymentMethodConfigInstance >, key ) => {
if ( availableExpressPaymentMethodNames.includes( key ) ) {
acc[ key ] = registeredExpressPaymentMethods[ key ];
}
return acc;
},
{}
);
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-shipping-data';
export * from './types';

View File

@@ -0,0 +1,23 @@
/**
* External dependencies
*/
import { Cart } from '@woocommerce/type-defs/cart';
export interface ShippingData {
needsShipping: Cart[ 'needsShipping' ];
hasCalculatedShipping: Cart[ 'hasCalculatedShipping' ];
shippingRates: Cart[ 'shippingRates' ];
isLoadingRates: boolean;
selectedRates: Record< string, string | unknown >;
// Returns a function that accepts a shipping rate ID and a package ID.
selectShippingRate: (
newShippingRateId: string,
packageId?: string | number | undefined
) => void;
// Only true when ALL packages support local pickup. If true, we can show the collection/delivery toggle
isCollectable: boolean;
// True when a rate is currently being selected and persisted to the server.
isSelectingRate: boolean;
// True when the user has chosen a local pickup method.
hasSelectedLocalPickup: boolean;
}

View File

@@ -0,0 +1,141 @@
/**
* External dependencies
*/
import {
CART_STORE_KEY as storeKey,
processErrorResponse,
} from '@woocommerce/block-data';
import { useSelect, useDispatch } from '@wordpress/data';
import { isObject } from '@woocommerce/types';
import { useEffect, useRef, useCallback } from '@wordpress/element';
import {
hasCollectableRate,
deriveSelectedShippingRates,
} from '@woocommerce/base-utils';
import isShallowEqual from '@wordpress/is-shallow-equal';
import { previewCart } from '@woocommerce/resource-previews';
/**
* Internal dependencies
*/
import { useStoreEvents } from '../use-store-events';
import type { ShippingData } from './types';
export const useShippingData = (): ShippingData => {
const {
shippingRates,
needsShipping,
hasCalculatedShipping,
isLoadingRates,
isCollectable,
isSelectingRate,
} = useSelect( ( select ) => {
const isEditor = !! select( 'core/editor' );
const store = select( storeKey );
const rates = isEditor
? previewCart.shipping_rates
: store.getShippingRates();
return {
shippingRates: rates,
needsShipping: isEditor
? previewCart.needs_shipping
: store.getNeedsShipping(),
hasCalculatedShipping: isEditor
? previewCart.has_calculated_shipping
: store.getHasCalculatedShipping(),
isLoadingRates: isEditor ? false : store.isCustomerDataUpdating(),
isCollectable: rates.every(
( { shipping_rates: packageShippingRates } ) =>
packageShippingRates.find( ( { method_id: methodId } ) =>
hasCollectableRate( methodId )
)
),
isSelectingRate: isEditor
? false
: store.isShippingRateBeingSelected(),
};
} );
// set selected rates on ref so it's always current.
const selectedRates = useRef< Record< string, string > >( {} );
useEffect( () => {
const derivedSelectedRates =
deriveSelectedShippingRates( shippingRates );
if (
isObject( derivedSelectedRates ) &&
! isShallowEqual( selectedRates.current, derivedSelectedRates )
) {
selectedRates.current = derivedSelectedRates;
}
}, [ shippingRates ] );
const { selectShippingRate: dispatchSelectShippingRate } = useDispatch(
storeKey
) as {
selectShippingRate: unknown;
} as {
selectShippingRate: (
newShippingRateId: string,
packageId?: string | number | null
) => Promise< unknown >;
};
const hasSelectedLocalPickup = hasCollectableRate(
Object.values( selectedRates.current ).map(
( rate ) => rate.split( ':' )[ 0 ]
)
);
// Selects a shipping rate, fires an event, and catch any errors.
const { dispatchCheckoutEvent } = useStoreEvents();
const selectShippingRate = useCallback(
(
newShippingRateId: string,
packageId?: string | number | undefined
): void => {
let selectPromise;
if ( typeof newShippingRateId === 'undefined' ) {
return;
}
/**
* Picking location handling
*
* Forces pickup location to be selected for all packages since we don't allow a mix of shipping and pickup.
*/
if ( hasCollectableRate( newShippingRateId.split( ':' )[ 0 ] ) ) {
selectPromise = dispatchSelectShippingRate(
newShippingRateId,
null
);
} else {
selectPromise = dispatchSelectShippingRate(
newShippingRateId,
packageId
);
}
selectPromise
.then( () => {
dispatchCheckoutEvent( 'set-selected-shipping-rate', {
shippingRateId: newShippingRateId,
} );
} )
.catch( ( error ) => {
processErrorResponse( error );
} );
},
[ dispatchSelectShippingRate, dispatchCheckoutEvent ]
);
return {
isSelectingRate,
selectedRates: selectedRates.current,
selectShippingRate,
shippingRates,
needsShipping,
hasCalculatedShipping,
isLoadingRates,
isCollectable,
hasSelectedLocalPickup,
};
};

View File

@@ -0,0 +1,70 @@
/**
* External dependencies
*/
import TestRenderer, { act } from 'react-test-renderer';
import { createRegistry, RegistryProvider } from '@wordpress/data';
/**
* Internal dependencies
*/
import { useCheckoutSubmit } from '../use-checkout-submit';
import {
CHECKOUT_STORE_KEY,
config as checkoutStoreConfig,
} from '../../../../data/checkout';
import {
PAYMENT_STORE_KEY,
config as paymentDataStoreConfig,
} from '../../../../data/payment';
jest.mock( '../../providers/cart-checkout/checkout-events', () => {
const original = jest.requireActual(
'../../providers/cart-checkout/checkout-events'
);
return {
...original,
useCheckoutEventsContext: () => {
return { onSubmit: jest.fn() };
},
};
} );
describe( 'useCheckoutSubmit', () => {
let registry, renderer;
const getWrappedComponents = ( Component ) => (
<RegistryProvider value={ registry }>
<Component />
</RegistryProvider>
);
const getTestComponent = () => () => {
const data = useCheckoutSubmit();
return <div { ...data } />;
};
beforeEach( () => {
registry = createRegistry( {
[ CHECKOUT_STORE_KEY ]: checkoutStoreConfig,
[ PAYMENT_STORE_KEY ]: paymentDataStoreConfig,
} );
renderer = null;
} );
it( 'onSubmit calls the correct action in the checkout events 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( onSubmit ).toHaveBeenCalledTimes( 1 );
} );
} );

View File

@@ -0,0 +1,263 @@
/**
* 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[ 'data-queryState' ], props[ 'data-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
data-queryState={ queryValue }
data-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,89 @@
/**
* External dependencies
*/
import {
defaultAddressFields,
AddressFields,
ShippingAddress,
BillingAddress,
getSetting,
} from '@woocommerce/settings';
import { useCallback } from '@wordpress/element';
import { useDispatch, useSelect } from '@wordpress/data';
import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import { useCustomerData } from './use-customer-data';
import { useShippingData } from './shipping/use-shipping-data';
interface CheckoutAddress {
shippingAddress: ShippingAddress;
billingAddress: BillingAddress;
setShippingAddress: ( data: Partial< ShippingAddress > ) => void;
setBillingAddress: ( data: Partial< BillingAddress > ) => void;
setEmail: ( value: string ) => void;
useShippingAsBilling: boolean;
setUseShippingAsBilling: ( useShippingAsBilling: boolean ) => void;
defaultAddressFields: AddressFields;
showShippingFields: boolean;
showBillingFields: boolean;
forcedBillingAddress: boolean;
useBillingAsShipping: boolean;
needsShipping: boolean;
showShippingMethods: boolean;
}
/**
* Custom hook for exposing address related functionality for the checkout address form.
*/
export const useCheckoutAddress = (): CheckoutAddress => {
const { needsShipping } = useShippingData();
const { useShippingAsBilling, prefersCollection } = useSelect(
( select ) => ( {
useShippingAsBilling:
select( CHECKOUT_STORE_KEY ).getUseShippingAsBilling(),
prefersCollection: select( CHECKOUT_STORE_KEY ).prefersCollection(),
} )
);
const { __internalSetUseShippingAsBilling } =
useDispatch( CHECKOUT_STORE_KEY );
const {
billingAddress,
setBillingAddress,
shippingAddress,
setShippingAddress,
} = useCustomerData();
const setEmail = useCallback(
( value: string ) =>
void setBillingAddress( {
email: value,
} ),
[ setBillingAddress ]
);
const forcedBillingAddress: boolean = getSetting(
'forcedBillingAddress',
false
);
return {
shippingAddress,
billingAddress,
setShippingAddress,
setBillingAddress,
setEmail,
defaultAddressFields,
useShippingAsBilling,
setUseShippingAsBilling: __internalSetUseShippingAsBilling,
needsShipping,
showShippingFields:
! forcedBillingAddress && needsShipping && ! prefersCollection,
showShippingMethods: needsShipping && ! prefersCollection,
showBillingFields:
! needsShipping || ! useShippingAsBilling || !! prefersCollection,
forcedBillingAddress,
useBillingAsShipping: forcedBillingAddress || !! prefersCollection,
};
};

View File

@@ -0,0 +1,43 @@
/**
* External dependencies
*/
import { useDispatch, useSelect } from '@wordpress/data';
import { useCallback, useRef } from '@wordpress/element';
import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import type { CheckoutState } from '../../../data/checkout/default-state';
/**
* Custom hook for setting custom checkout data which is passed to the wc/store/checkout endpoint when processing orders.
*/
export const useCheckoutExtensionData = (): {
extensionData: CheckoutState[ 'extensionData' ];
setExtensionData: (
namespace: string,
key: string,
value: unknown
) => void;
} => {
const { __internalSetExtensionData } = useDispatch( CHECKOUT_STORE_KEY );
const extensionData = useSelect( ( select ) =>
select( CHECKOUT_STORE_KEY ).getExtensionData()
);
const extensionDataRef = useRef( extensionData );
const setExtensionData = useCallback(
( namespace, key, value ) => {
__internalSetExtensionData( namespace, {
[ key ]: value,
} );
},
[ __internalSetExtensionData ]
);
return {
extensionData: extensionDataRef.current,
setExtensionData,
};
};

View File

@@ -0,0 +1,65 @@
/**
* External dependencies
*/
import { CHECKOUT_STORE_KEY, PAYMENT_STORE_KEY } from '@woocommerce/block-data';
import { useSelect } from '@wordpress/data';
/**
* Internal dependencies
*/
import { useCheckoutEventsContext } from '../providers';
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 {
isCalculating,
isBeforeProcessing,
isProcessing,
isAfterProcessing,
isComplete,
hasError,
} = useSelect( ( select ) => {
const store = select( CHECKOUT_STORE_KEY );
return {
isCalculating: store.isCalculating(),
isBeforeProcessing: store.isBeforeProcessing(),
isProcessing: store.isProcessing(),
isAfterProcessing: store.isAfterProcessing(),
isComplete: store.isComplete(),
hasError: store.hasError(),
};
} );
const { activePaymentMethod, isExpressPaymentMethodActive } = useSelect(
( select ) => {
const store = select( PAYMENT_STORE_KEY );
return {
activePaymentMethod: store.getActivePaymentMethod(),
isExpressPaymentMethodActive:
store.isExpressPaymentMethodActive(),
};
}
);
const { onSubmit } = useCheckoutEventsContext();
const { paymentMethods = {} } = usePaymentMethods();
const paymentMethod = paymentMethods[ activePaymentMethod ] || {};
const waitingForProcessing =
isProcessing || isAfterProcessing || isBeforeProcessing;
const waitingForRedirect = isComplete && ! hasError;
const paymentMethodButtonLabel = paymentMethod.placeOrderButtonLabel;
return {
paymentMethodButtonLabel,
onSubmit,
isCalculating,
isDisabled: isProcessing || isExpressPaymentMethodActive,
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,79 @@
/**
* External dependencies
*/
import { useEffect, useRef, useState } from '@wordpress/element';
import { getAttributes, getTerms } from '@woocommerce/editor-components/utils';
import {
AttributeObject,
AttributeTerm,
AttributeWithTerms,
} from '@woocommerce/types';
import { formatError } from '@woocommerce/base-utils';
export default function useProductAttributes( shouldLoadAttributes: boolean ) {
const [ errorLoadingAttributes, setErrorLoadingAttributes ] =
useState< Awaited< ReturnType< typeof formatError > > | null >( null );
const [ isLoadingAttributes, setIsLoadingAttributes ] = useState( false );
const [ productsAttributes, setProductsAttributes ] = useState<
AttributeWithTerms[]
>( [] );
const hasLoadedAttributes = useRef( false );
useEffect( () => {
if (
! shouldLoadAttributes ||
isLoadingAttributes ||
hasLoadedAttributes.current
)
return;
async function fetchAttributesWithTerms() {
setIsLoadingAttributes( true );
try {
const attributes: AttributeObject[] = await getAttributes();
const attributesWithTerms: AttributeWithTerms[] = [];
for ( const attribute of attributes ) {
const terms: AttributeTerm[] = await getTerms(
attribute.id
);
attributesWithTerms.push( {
...attribute,
// Manually adding the parent id because of a Rest API bug
// returning always `0` as parent.
// see https://github.com/woocommerce/woocommerce-blocks/issues/8501
parent: 0,
terms: terms.map( ( term ) => ( {
...term,
attr_slug: attribute.taxonomy,
parent: attribute.id,
} ) ),
} );
}
setProductsAttributes( attributesWithTerms );
hasLoadedAttributes.current = true;
} catch ( e ) {
if ( e instanceof Error ) {
setErrorLoadingAttributes( await formatError( e ) );
}
} finally {
setIsLoadingAttributes( false );
}
}
fetchAttributesWithTerms();
return () => {
hasLoadedAttributes.current = true;
};
}, [ isLoadingAttributes, shouldLoadAttributes ] );
return {
errorLoadingAttributes,
isLoadingAttributes,
productsAttributes,
};
}

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,110 @@
/**
* External dependencies
*/
import { useDispatch, useSelect } from '@wordpress/data';
import { CART_STORE_KEY, CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
import { useEffect } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { hasShippingRate } from '@woocommerce/base-components/cart-checkout/totals/shipping/utils';
import { hasCollectableRate } from '@woocommerce/base-utils';
import { isString } from '@woocommerce/types';
/**
* Internal dependencies
*/
import { useShippingData } from './shipping/use-shipping-data';
export const useShowShippingTotalWarning = () => {
const context = 'woocommerce/checkout-totals-block';
const errorNoticeId = 'wc-blocks-totals-shipping-warning';
const { shippingRates } = useShippingData();
const hasRates = hasShippingRate( shippingRates );
const {
prefersCollection,
isRateBeingSelected,
shippingNotices,
cartData,
} = useSelect( ( select ) => {
return {
cartData: select( CART_STORE_KEY ).getCartData(),
prefersCollection: select( CHECKOUT_STORE_KEY ).prefersCollection(),
isRateBeingSelected:
select( CART_STORE_KEY ).isShippingRateBeingSelected(),
shippingNotices: select( 'core/notices' ).getNotices( context ),
};
} );
const { createInfoNotice, removeNotice } = useDispatch( 'core/notices' );
useEffect( () => {
if ( ! hasRates || isRateBeingSelected ) {
// Early return because shipping rates were not yet loaded from the cart data store, or the user is changing
// rate, no need to alter the notice until we know what the actual rate is.
return;
}
const selectedRates = cartData?.shippingRates?.reduce(
( acc: string[], rate ) => {
const selectedRateForPackage = rate.shipping_rates.find(
( shippingRate ) => {
return shippingRate.selected;
}
);
if (
typeof selectedRateForPackage?.method_id !== 'undefined'
) {
acc.push( selectedRateForPackage?.method_id );
}
return acc;
},
[]
);
const isPickupRateSelected = Object.values( selectedRates ).some(
( rate: unknown ) => {
if ( isString( rate ) ) {
return hasCollectableRate( rate );
}
return false;
}
);
// There is a mismatch between the method the user chose (pickup or shipping) and the currently selected rate.
if (
hasRates &&
! prefersCollection &&
! isRateBeingSelected &&
isPickupRateSelected &&
shippingNotices.length === 0
) {
createInfoNotice(
__(
'Totals will be recalculated when a valid shipping method is selected.',
'woo-gutenberg-products-block'
),
{
id: 'wc-blocks-totals-shipping-warning',
isDismissible: false,
context,
}
);
return;
}
// Don't show the notice if they have selected local pickup, or if they have selected a valid regular shipping rate.
if (
( prefersCollection || ! isPickupRateSelected ) &&
shippingNotices.length > 0
) {
removeNotice( errorNoticeId, context );
}
}, [
cartData?.shippingRates,
createInfoNotice,
hasRates,
isRateBeingSelected,
prefersCollection,
removeNotice,
shippingNotices,
shippingRates,
] );
};

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,53 @@
/**
* External dependencies
*/
import { doAction } from '@wordpress/hooks';
import { select } from '@wordpress/data';
import { useCallback } from '@wordpress/element';
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 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: select( 'wc/store/cart' ).getCartData(),
}
);
} 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,52 @@
/**
* 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< ProductResponseItem >( {
...collectionOptions,
query,
} );
const { value: totalProducts } = useCollectionHeader( 'x-wp-total', {
...collectionOptions,
query,
} );
return {
products,
totalProducts: parseInt( totalProducts as string, 10 ),
productsLoading,
};
};

View File

@@ -0,0 +1,62 @@
/**
* External dependencies
*/
import { useCallback } from '@wordpress/element';
import type {
ValidationData,
ValidationContextError,
} from '@woocommerce/types';
import { useDispatch, useSelect } from '@wordpress/data';
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
/**
* Custom hook for setting for adding errors to the validation system.
*/
export const useValidation = (): ValidationData => {
const { clearValidationError, hideValidationError, setValidationErrors } =
useDispatch( VALIDATION_STORE_KEY );
const prefix = 'extensions-errors';
const { hasValidationErrors, getValidationError } = useSelect(
( mapSelect ) => {
const store = mapSelect( VALIDATION_STORE_KEY );
return {
hasValidationErrors: store.hasValidationErrors(),
getValidationError: ( validationErrorId: string ) =>
store.getValidationError(
`${ prefix }-${ validationErrorId }`
),
};
}
);
return {
hasValidationErrors,
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,324 @@
/**
* 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';
import { VALIDATION_STORE_KEY } from '@woocommerce/block-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 { isErrorResponse, isFailResponse } from '../../../event-emit';
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 } = useDispatch( VALIDATION_STORE_KEY );
/**
* @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 for 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,
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,31 @@
/**
* Internal dependencies
*/
import { AddToCartFormStateContextProvider } from '../form-state';
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 (
<AddToCartFormStateContextProvider
product={ product }
showFormElements={ showFormElements }
>
{ children }
<FormSubmit />
</AddToCartFormStateContextProvider>
);
};

View File

@@ -0,0 +1,157 @@
/**
* 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, useSelect } from '@wordpress/data';
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import { useAddToCartFormContext } from '../../form-state';
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 { showAllValidationErrors } = useDispatch( VALIDATION_STORE_KEY );
const hasValidationErrors = useSelect( ( select ) => {
const store = select( VALIDATION_STORE_KEY );
return store.hasValidationErrors;
} );
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 for 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,51 @@
/**
* External dependencies
*/
import { useMemo } from '@wordpress/element';
/**
* Internal dependencies
*/
import {
emitterCallback,
reducer,
emitEvent,
emitEventWithAbort,
ActionType,
} from '../../../event-emit';
// These events are emitted when the Cart status is BEFORE_PROCESSING and AFTER_PROCESSING
// to enable third parties to hook into the cart process
const EVENTS = {
PROCEED_TO_CHECKOUT: 'cart_proceed_to_checkout',
};
type EventEmittersType = Record< string, ReturnType< typeof emitterCallback > >;
/**
* Receives a reducer dispatcher and returns an object with the
* various event emitters for the payment processing events.
*
* Calling the event registration function with the callback will register it
* for the event emitter and will return a dispatcher for removing the
* registered callback (useful for implementation in `useEffect`).
*
* @param {Function} observerDispatch The emitter reducer dispatcher.
* @return {Object} An object with the various payment event emitter registration functions
*/
const useEventEmitters = (
observerDispatch: React.Dispatch< ActionType >
): EventEmittersType => {
const eventEmitters = useMemo(
() => ( {
onProceedToCheckout: emitterCallback(
EVENTS.PROCEED_TO_CHECKOUT,
observerDispatch
),
} ),
[ observerDispatch ]
);
return eventEmitters;
};
export { EVENTS, useEventEmitters, reducer, emitEvent, emitEventWithAbort };

View File

@@ -0,0 +1,78 @@
/**
* External dependencies
*/
import {
createContext,
useContext,
useReducer,
useRef,
useEffect,
} from '@wordpress/element';
/**
* Internal dependencies
*/
import {
useEventEmitters,
reducer as emitReducer,
emitEventWithAbort,
EVENTS,
} from './event-emit';
import type { emitterCallback } from '../../../event-emit';
type CartEventsContextType = {
// Used to register a callback that will fire when the cart has been processed and has an error.
onProceedToCheckout: ReturnType< typeof emitterCallback >;
// Used to register a callback that will fire when the cart has been processed and has an error.
dispatchOnProceedToCheckout: () => Promise< unknown[] >;
};
const CartEventsContext = createContext< CartEventsContextType >( {
onProceedToCheckout: () => () => void null,
dispatchOnProceedToCheckout: () => new Promise( () => void null ),
} );
export const useCartEventsContext = () => {
return useContext( CartEventsContext );
};
/**
* Checkout Events provider
* Emit Checkout events and provide access to Checkout event handlers
*
* @param {Object} props Incoming props for the provider.
* @param {Object} props.children The children being wrapped.
*/
export const CartEventsProvider = ( {
children,
}: {
children: React.ReactNode;
} ): JSX.Element => {
const [ observers, observerDispatch ] = useReducer( emitReducer, {} );
const currentObservers = useRef( observers );
const { onProceedToCheckout } = useEventEmitters( observerDispatch );
// set observers on ref so it's always current.
useEffect( () => {
currentObservers.current = observers;
}, [ observers ] );
const dispatchOnProceedToCheckout = async () => {
return await emitEventWithAbort(
currentObservers.current,
EVENTS.PROCEED_TO_CHECKOUT,
null
);
};
const cartEvents = {
onProceedToCheckout,
dispatchOnProceedToCheckout,
};
return (
<CartEventsContext.Provider value={ cartEvents }>
{ children }
</CartEventsContext.Provider>
);
};

View File

@@ -0,0 +1,49 @@
/**
* External dependencies
*/
import { useCartEventsContext } from '@woocommerce/base-context';
import { useEffect } from '@wordpress/element';
import { render, screen, waitFor } from '@testing-library/react';
/**
* Internal dependencies
*/
import { CartEventsProvider } from '../index';
import Block from '../../../../../../blocks/cart/inner-blocks/proceed-to-checkout-block/block';
describe( 'CartEventsProvider', () => {
it( 'allows observers to unsubscribe', async () => {
const mockObserver = jest.fn().mockReturnValue( { type: 'error' } );
const MockObserverComponent = () => {
const { onProceedToCheckout } = useCartEventsContext();
useEffect( () => {
const unsubscribe = onProceedToCheckout( () => {
mockObserver();
unsubscribe();
} );
}, [ onProceedToCheckout ] );
return <div>Mock observer</div>;
};
render(
<CartEventsProvider>
<div>
<MockObserverComponent />
<Block checkoutPageId={ 0 } className="test-block" />
</div>
</CartEventsProvider>
);
expect( screen.getByText( 'Mock observer' ) ).toBeInTheDocument();
const button = screen.getByText( 'Proceed to Checkout' );
// Forcibly set the button URL to # to prevent JSDOM error: `["Error: Not implemented: navigation (except hash changes)`
button.parentElement?.removeAttribute( 'href' );
// Click twice. The observer should unsubscribe after the first click.
button.click();
button.click();
await waitFor( () => {
expect( mockObserver ).toHaveBeenCalledTimes( 1 );
} );
} );
} );

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 redirectUrl={ redirectUrl }>
{ children }
</CheckoutProvider>
);
};

View File

@@ -0,0 +1,61 @@
/**
* External dependencies
*/
import { useMemo } from '@wordpress/element';
/**
* Internal dependencies
*/
import {
emitterCallback,
reducer,
emitEvent,
emitEventWithAbort,
ActionType,
} from '../../../event-emit';
// These events are emitted when the Checkout status is BEFORE_PROCESSING and AFTER_PROCESSING
// to enable third parties to hook into the checkout process
const EVENTS = {
CHECKOUT_SUCCESS: 'checkout_success',
CHECKOUT_FAIL: 'checkout_fail',
CHECKOUT_VALIDATION: 'checkout_validation',
};
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(
() => ( {
onCheckoutSuccess: emitterCallback(
EVENTS.CHECKOUT_SUCCESS,
observerDispatch
),
onCheckoutFail: emitterCallback(
EVENTS.CHECKOUT_FAIL,
observerDispatch
),
onCheckoutValidation: emitterCallback(
EVENTS.CHECKOUT_VALIDATION,
observerDispatch
),
} ),
[ observerDispatch ]
);
return eventEmitters;
};
export { EVENTS, useEventEmitters, reducer, emitEvent, emitEventWithAbort };

View File

@@ -0,0 +1,319 @@
/**
* External dependencies
*/
import {
createContext,
useContext,
useReducer,
useRef,
useMemo,
useEffect,
useCallback,
} from '@wordpress/element';
import { usePrevious } from '@woocommerce/base-hooks';
import deprecated from '@wordpress/deprecated';
import { useDispatch, useSelect } from '@wordpress/data';
import {
CHECKOUT_STORE_KEY,
PAYMENT_STORE_KEY,
VALIDATION_STORE_KEY,
} from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import { useEventEmitters, reducer as emitReducer } from './event-emit';
import { emitterCallback, noticeContexts } from '../../../event-emit';
import { useStoreEvents } from '../../../hooks/use-store-events';
import {
getExpressPaymentMethods,
getPaymentMethods,
} from '../../../../../blocks-registry/payment-methods/registry';
import { useEditorContext } from '../../editor-context';
type CheckoutEventsContextType = {
// Submits the checkout and begins processing.
onSubmit: () => void;
// Deprecated in favour of onCheckoutSuccess.
onCheckoutAfterProcessingWithSuccess: ReturnType< typeof emitterCallback >;
// Deprecated in favour of onCheckoutFail.
onCheckoutAfterProcessingWithError: ReturnType< typeof emitterCallback >;
// Deprecated in favour of onCheckoutValidationBeforeProcessing.
onCheckoutBeforeProcessing: ReturnType< typeof emitterCallback >;
// Deprecated in favour of onCheckoutValidation.
onCheckoutValidationBeforeProcessing: ReturnType< typeof emitterCallback >;
// Used to register a callback that will fire if the api call to /checkout is successful
onCheckoutSuccess: ReturnType< typeof emitterCallback >;
// Used to register a callback that will fire if the api call to /checkout fails
onCheckoutFail: ReturnType< typeof emitterCallback >;
// Used to register a callback that will fire when the checkout performs validation on the form
onCheckoutValidation: ReturnType< typeof emitterCallback >;
};
const CheckoutEventsContext = createContext< CheckoutEventsContextType >( {
onSubmit: () => void null,
onCheckoutAfterProcessingWithSuccess: () => () => void null, // deprecated for onCheckoutSuccess
onCheckoutAfterProcessingWithError: () => () => void null, // deprecated for onCheckoutFail
onCheckoutBeforeProcessing: () => () => void null, // deprecated for onCheckoutValidationBeforeProcessing
onCheckoutValidationBeforeProcessing: () => () => void null, // deprecated for onCheckoutValidation
onCheckoutSuccess: () => () => void null,
onCheckoutFail: () => () => void null,
onCheckoutValidation: () => () => void null,
} );
export const useCheckoutEventsContext = () => {
return useContext( CheckoutEventsContext );
};
/**
* Checkout Events provider
* Emit Checkout events and provide access to Checkout event handlers
*
* @param {Object} props Incoming props for the provider.
* @param {Object} props.children The children being wrapped.
* @param {string} props.redirectUrl Initialize what the checkout will redirect to after successful submit.
*/
export const CheckoutEventsProvider = ( {
children,
redirectUrl,
}: {
children: React.ReactChildren;
redirectUrl: string;
} ): JSX.Element => {
const paymentMethods = getPaymentMethods();
const expressPaymentMethods = getExpressPaymentMethods();
const { isEditor } = useEditorContext();
const { __internalUpdateAvailablePaymentMethods } =
useDispatch( PAYMENT_STORE_KEY );
// Update the payment method store when paymentMethods or expressPaymentMethods changes.
// Ensure this happens in the editor even if paymentMethods is empty. This won't happen instantly when the objects
// are updated, but on the next re-render.
useEffect( () => {
if (
! isEditor &&
Object.keys( paymentMethods ).length === 0 &&
Object.keys( expressPaymentMethods ).length === 0
) {
return;
}
__internalUpdateAvailablePaymentMethods();
}, [
isEditor,
paymentMethods,
expressPaymentMethods,
__internalUpdateAvailablePaymentMethods,
] );
const {
__internalSetRedirectUrl,
__internalEmitValidateEvent,
__internalEmitAfterProcessingEvents,
__internalSetBeforeProcessing,
} = useDispatch( CHECKOUT_STORE_KEY );
const {
checkoutRedirectUrl,
checkoutStatus,
isCheckoutBeforeProcessing,
isCheckoutAfterProcessing,
checkoutHasError,
checkoutOrderId,
checkoutOrderNotes,
checkoutCustomerId,
} = useSelect( ( select ) => {
const store = select( CHECKOUT_STORE_KEY );
return {
checkoutRedirectUrl: store.getRedirectUrl(),
checkoutStatus: store.getCheckoutStatus(),
isCheckoutBeforeProcessing: store.isBeforeProcessing(),
isCheckoutAfterProcessing: store.isAfterProcessing(),
checkoutHasError: store.hasError(),
checkoutOrderId: store.getOrderId(),
checkoutOrderNotes: store.getOrderNotes(),
checkoutCustomerId: store.getCustomerId(),
};
} );
if ( redirectUrl && redirectUrl !== checkoutRedirectUrl ) {
__internalSetRedirectUrl( redirectUrl );
}
const { setValidationErrors } = useDispatch( VALIDATION_STORE_KEY );
const { dispatchCheckoutEvent } = useStoreEvents();
const { checkoutNotices, paymentNotices, expressPaymentNotices } =
useSelect( ( select ) => {
const { getNotices } = select( 'core/notices' );
const checkoutContexts = Object.values( noticeContexts ).filter(
( context ) =>
context !== noticeContexts.PAYMENTS &&
context !== noticeContexts.EXPRESS_PAYMENTS
);
const allCheckoutNotices = checkoutContexts.reduce(
( acc, context ) => {
return [ ...acc, ...getNotices( context ) ];
},
[]
);
return {
checkoutNotices: allCheckoutNotices,
paymentNotices: getNotices( noticeContexts.PAYMENTS ),
expressPaymentNotices: getNotices(
noticeContexts.EXPRESS_PAYMENTS
),
};
}, [] );
const [ observers, observerDispatch ] = useReducer( emitReducer, {} );
const currentObservers = useRef( observers );
const { onCheckoutValidation, onCheckoutSuccess, onCheckoutFail } =
useEventEmitters( observerDispatch );
// set observers on ref so it's always current.
useEffect( () => {
currentObservers.current = observers;
}, [ observers ] );
/**
* @deprecated use onCheckoutValidation 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 onCheckoutValidation > ) {
deprecated( 'onCheckoutBeforeProcessing', {
alternative: 'onCheckoutValidation',
plugin: 'WooCommerce Blocks',
} );
return onCheckoutValidation( ...args );
};
}, [ onCheckoutValidation ] );
/**
* @deprecated use onCheckoutValidation instead
*/
const onCheckoutValidationBeforeProcessing = useMemo( () => {
return function ( ...args: Parameters< typeof onCheckoutValidation > ) {
deprecated( 'onCheckoutValidationBeforeProcessing', {
since: '9.7.0',
alternative: 'onCheckoutValidation',
plugin: 'WooCommerce Blocks',
link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8381',
} );
return onCheckoutValidation( ...args );
};
}, [ onCheckoutValidation ] );
/**
* @deprecated use onCheckoutSuccess instead
*/
const onCheckoutAfterProcessingWithSuccess = useMemo( () => {
return function ( ...args: Parameters< typeof onCheckoutSuccess > ) {
deprecated( 'onCheckoutAfterProcessingWithSuccess', {
since: '9.7.0',
alternative: 'onCheckoutSuccess',
plugin: 'WooCommerce Blocks',
link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8381',
} );
return onCheckoutSuccess( ...args );
};
}, [ onCheckoutSuccess ] );
/**
* @deprecated use onCheckoutFail instead
*/
const onCheckoutAfterProcessingWithError = useMemo( () => {
return function ( ...args: Parameters< typeof onCheckoutFail > ) {
deprecated( 'onCheckoutAfterProcessingWithError', {
since: '9.7.0',
alternative: 'onCheckoutFail',
plugin: 'WooCommerce Blocks',
link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8381',
} );
return onCheckoutFail( ...args );
};
}, [ onCheckoutFail ] );
// Emit CHECKOUT_VALIDATE event and set the error state based on the response of
// the registered callbacks
useEffect( () => {
if ( isCheckoutBeforeProcessing ) {
__internalEmitValidateEvent( {
observers: currentObservers.current,
setValidationErrors,
} );
}
}, [
isCheckoutBeforeProcessing,
setValidationErrors,
__internalEmitValidateEvent,
] );
const previousStatus = usePrevious( checkoutStatus );
const previousHasError = usePrevious( checkoutHasError );
// Emit CHECKOUT_SUCCESS and CHECKOUT_FAIL events
// and set checkout errors according to the callback responses
useEffect( () => {
if (
checkoutStatus === previousStatus &&
checkoutHasError === previousHasError
) {
return;
}
if ( isCheckoutAfterProcessing ) {
__internalEmitAfterProcessingEvents( {
observers: currentObservers.current,
notices: {
checkoutNotices,
paymentNotices,
expressPaymentNotices,
},
} );
}
}, [
checkoutStatus,
checkoutHasError,
checkoutRedirectUrl,
checkoutOrderId,
checkoutCustomerId,
checkoutOrderNotes,
isCheckoutAfterProcessing,
isCheckoutBeforeProcessing,
previousStatus,
previousHasError,
checkoutNotices,
expressPaymentNotices,
paymentNotices,
__internalEmitValidateEvent,
__internalEmitAfterProcessingEvents,
] );
const onSubmit = useCallback( () => {
dispatchCheckoutEvent( 'submit' );
__internalSetBeforeProcessing();
}, [ dispatchCheckoutEvent, __internalSetBeforeProcessing ] );
const checkoutEventHandlers = {
onSubmit,
onCheckoutBeforeProcessing,
onCheckoutValidationBeforeProcessing,
onCheckoutAfterProcessingWithSuccess,
onCheckoutAfterProcessingWithError,
onCheckoutSuccess,
onCheckoutFail,
onCheckoutValidation,
};
return (
<CheckoutEventsContext.Provider value={ checkoutEventHandlers }>
{ children }
</CheckoutEventsContext.Provider>
);
};

View File

@@ -0,0 +1,344 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import triggerFetch from '@wordpress/api-fetch';
import {
useEffect,
useRef,
useCallback,
useState,
useMemo,
} from '@wordpress/element';
import {
emptyHiddenAddressFields,
removeAllNotices,
} from '@woocommerce/base-utils';
import { useDispatch, useSelect, select as selectStore } from '@wordpress/data';
import {
CHECKOUT_STORE_KEY,
PAYMENT_STORE_KEY,
VALIDATION_STORE_KEY,
CART_STORE_KEY,
processErrorResponse,
} from '@woocommerce/block-data';
import {
getPaymentMethods,
getExpressPaymentMethods,
} from '@woocommerce/blocks-registry';
import {
ApiResponse,
CheckoutResponseSuccess,
CheckoutResponseError,
assertResponseIsValid,
} from '@woocommerce/types';
/**
* Internal dependencies
*/
import { preparePaymentData, processCheckoutResponseHeaders } from './utils';
import { useCheckoutEventsContext } from './checkout-events';
import { useShippingDataContext } from './shipping';
import { useStoreCart } from '../../hooks/cart/use-store-cart';
/**
* CheckoutProcessor component.
*
* Subscribes to checkout context and triggers processing via the API.
*/
const CheckoutProcessor = () => {
const { onCheckoutValidation } = useCheckoutEventsContext();
const {
hasError: checkoutHasError,
redirectUrl,
isProcessing: checkoutIsProcessing,
isBeforeProcessing: checkoutIsBeforeProcessing,
isComplete: checkoutIsComplete,
orderNotes,
shouldCreateAccount,
extensionData,
customerId,
} = useSelect( ( select ) => {
const store = select( CHECKOUT_STORE_KEY );
return {
hasError: store.hasError(),
redirectUrl: store.getRedirectUrl(),
isProcessing: store.isProcessing(),
isBeforeProcessing: store.isBeforeProcessing(),
isComplete: store.isComplete(),
orderNotes: store.getOrderNotes(),
shouldCreateAccount: store.getShouldCreateAccount(),
extensionData: store.getExtensionData(),
customerId: store.getCustomerId(),
};
} );
const { __internalSetHasError, __internalProcessCheckoutResponse } =
useDispatch( CHECKOUT_STORE_KEY );
const hasValidationErrors = useSelect(
( select ) => select( VALIDATION_STORE_KEY ).hasValidationErrors
);
const { shippingErrorStatus } = useShippingDataContext();
const { billingAddress, shippingAddress } = useSelect( ( select ) =>
select( CART_STORE_KEY ).getCustomerData()
);
const { cartNeedsPayment, cartNeedsShipping, receiveCartContents } =
useStoreCart();
const {
activePaymentMethod,
paymentMethodData,
isExpressPaymentMethodActive,
hasPaymentError,
isPaymentReady,
shouldSavePayment,
} = useSelect( ( select ) => {
const store = select( PAYMENT_STORE_KEY );
return {
activePaymentMethod: store.getActivePaymentMethod(),
paymentMethodData: store.getPaymentMethodData(),
isExpressPaymentMethodActive: store.isExpressPaymentMethodActive(),
hasPaymentError: store.hasPaymentError(),
isPaymentReady: store.isPaymentReady(),
shouldSavePayment: store.getShouldSavePaymentMethod(),
};
}, [] );
const paymentMethods = getPaymentMethods();
const expressPaymentMethods = getExpressPaymentMethods();
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 ) ||
hasPaymentError ||
shippingErrorStatus.hasError;
const paidAndWithoutErrors =
! checkoutHasError &&
! checkoutWillHaveError &&
( isPaymentReady || ! cartNeedsPayment ) &&
checkoutIsProcessing;
// Determine if checkout has an error.
useEffect( () => {
if (
checkoutWillHaveError !== checkoutHasError &&
( checkoutIsProcessing || checkoutIsBeforeProcessing ) &&
! isExpressPaymentMethodActive
) {
__internalSetHasError( checkoutWillHaveError );
}
}, [
checkoutWillHaveError,
checkoutHasError,
checkoutIsProcessing,
checkoutIsBeforeProcessing,
isExpressPaymentMethodActive,
__internalSetHasError,
] );
// Keep the billing, shipping and redirectUrl current
useEffect( () => {
currentBillingAddress.current = billingAddress;
currentShippingAddress.current = shippingAddress;
currentRedirectUrl.current = redirectUrl;
}, [ billingAddress, shippingAddress, redirectUrl ] );
const checkValidation = useCallback( () => {
if ( hasValidationErrors() ) {
// If there is a shipping rates validation error, return the error message to be displayed.
if (
selectStore( VALIDATION_STORE_KEY ).getValidationError(
'shipping-rates-error'
) !== undefined
) {
return {
errorMessage: __(
'Sorry, this order requires a shipping option.',
'woo-gutenberg-products-block'
),
};
}
return false;
}
if ( hasPaymentError ) {
return {
errorMessage: __(
'There was a problem with your payment option.',
'woo-gutenberg-products-block'
),
context: 'wc/checkout/payments',
};
}
if ( shippingErrorStatus.hasError ) {
return {
errorMessage: __(
'There was a problem with your shipping option.',
'woo-gutenberg-products-block'
),
context: 'wc/checkout/shipping-methods',
};
}
return true;
}, [ hasValidationErrors, hasPaymentError, shippingErrorStatus.hasError ] );
// Validate the checkout using the CHECKOUT_VALIDATION_BEFORE_PROCESSING event
useEffect( () => {
let unsubscribeProcessing: () => void;
if ( ! isExpressPaymentMethodActive ) {
unsubscribeProcessing = onCheckoutValidation( checkValidation, 0 );
}
return () => {
if (
! isExpressPaymentMethodActive &&
typeof unsubscribeProcessing === 'function'
) {
unsubscribeProcessing();
}
};
}, [
onCheckoutValidation,
checkValidation,
isExpressPaymentMethodActive,
] );
// Redirect when checkout is complete and there is a redirect url.
useEffect( () => {
if ( currentRedirectUrl.current ) {
window.location.href = currentRedirectUrl.current;
}
}, [ checkoutIsComplete ] );
// POST to the Store API and process and display any errors, or set order complete
const processOrder = useCallback( async () => {
if ( isProcessingOrder ) {
return;
}
setIsProcessingOrder( true );
removeAllNotices();
const paymentData = cartNeedsPayment
? {
payment_method: paymentMethodId,
payment_data: preparePaymentData(
paymentMethodData,
shouldSavePayment,
activePaymentMethod
),
}
: {};
const data = {
shipping_address: cartNeedsShipping
? emptyHiddenAddressFields( currentShippingAddress.current )
: undefined,
billing_address: emptyHiddenAddressFields(
currentBillingAddress.current
),
customer_note: orderNotes,
create_account: shouldCreateAccount,
...paymentData,
extensions: { ...extensionData },
};
triggerFetch( {
path: '/wc/store/v1/checkout',
method: 'POST',
data,
cache: 'no-store',
parse: false,
} )
.then( ( response: unknown ) => {
assertResponseIsValid< CheckoutResponseSuccess >( response );
processCheckoutResponseHeaders( response.headers );
if ( ! response.ok ) {
throw response;
}
return response.json();
} )
.then( ( responseJson: CheckoutResponseSuccess ) => {
__internalProcessCheckoutResponse( responseJson );
setIsProcessingOrder( false );
} )
.catch( ( errorResponse: ApiResponse< CheckoutResponseError > ) => {
processCheckoutResponseHeaders( errorResponse?.headers );
try {
// This attempts to parse a JSON error response where the status code was 4xx/5xx.
errorResponse
.json()
.then(
( response ) => response as CheckoutResponseError
)
.then( ( response: CheckoutResponseError ) => {
if ( response.data?.cart ) {
// We don't want to receive the address here because it will overwrite fields.
receiveCartContents( response.data.cart );
}
processErrorResponse( response );
__internalProcessCheckoutResponse( response );
} );
} catch {
let errorMessage = __(
'Something went wrong when placing the order. Check your email for order updates before retrying.',
'woo-gutenberg-products-block'
);
if ( customerId !== 0 ) {
errorMessage = __(
"Something went wrong when placing the order. Check your account's order history or your email for order updates before retrying.",
'woo-gutenberg-products-block'
);
}
processErrorResponse( {
code: 'unknown_error',
message: errorMessage,
data: null,
} );
}
__internalSetHasError( true );
setIsProcessingOrder( false );
} );
}, [
isProcessingOrder,
cartNeedsPayment,
paymentMethodId,
paymentMethodData,
shouldSavePayment,
activePaymentMethod,
orderNotes,
shouldCreateAccount,
extensionData,
cartNeedsShipping,
receiveCartContents,
__internalSetHasError,
__internalProcessCheckoutResponse,
] );
// Process order if conditions are good.
useEffect( () => {
if ( paidAndWithoutErrors && ! isProcessingOrder ) {
processOrder();
}
}, [ processOrder, paidAndWithoutErrors, isProcessingOrder ] );
return null;
};
export default CheckoutProcessor;

View File

@@ -0,0 +1,47 @@
/**
* 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 { PaymentEventsProvider } from './payment-events';
import { ShippingDataProvider } from './shipping';
import { CheckoutEventsProvider } from './checkout-events';
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.
* component.
* @param {string} [props.redirectUrl] Initialize what the checkout will
* redirect to after successful
* submit.
*/
export const CheckoutProvider = ( { children, redirectUrl } ) => {
return (
<CheckoutEventsProvider redirectUrl={ redirectUrl }>
<ShippingDataProvider>
<PaymentEventsProvider>
{ 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 />
</PaymentEventsProvider>
</ShippingDataProvider>
</CheckoutEventsProvider>
);
};

View File

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

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_SETUP: 'payment_setup',
};
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(
() => ( {
onPaymentSetup: emitterCallback(
EMIT_TYPES.PAYMENT_SETUP,
observerDispatch
),
} ),
[ observerDispatch ]
);
return eventEmitters;
};
export { EMIT_TYPES, useEventEmitters, reducer, emitEvent, emitEventWithAbort };

View File

@@ -0,0 +1,161 @@
/**
* External dependencies
*/
import {
createContext,
useContext,
useReducer,
useRef,
useEffect,
useMemo,
} from '@wordpress/element';
import { useDispatch, useSelect } from '@wordpress/data';
import {
CHECKOUT_STORE_KEY,
PAYMENT_STORE_KEY,
VALIDATION_STORE_KEY,
} from '@woocommerce/block-data';
import deprecated from '@wordpress/deprecated';
/**
* Internal dependencies
*/
import { useEventEmitters, reducer as emitReducer } from './event-emit';
import { emitterCallback } from '../../../event-emit';
type PaymentEventsContextType = {
// Event registration callback for registering observers for the payment processing event.
onPaymentProcessing: ReturnType< typeof emitterCallback >;
onPaymentSetup: ReturnType< typeof emitterCallback >;
};
const PaymentEventsContext = createContext< PaymentEventsContextType >( {
onPaymentProcessing: () => () => () => void null,
onPaymentSetup: () => () => () => void null,
} );
export const usePaymentEventsContext = () => {
return useContext( PaymentEventsContext );
};
/**
* PaymentEventsProvider is automatically included in the CheckoutProvider.
*
* This provides the api interface (via the context hook) for payment status and data.
*
* @param {Object} props Incoming props for provider
* @param {Object} props.children The wrapped components in this provider.
*/
export const PaymentEventsProvider = ( {
children,
}: {
children: React.ReactNode;
} ): JSX.Element => {
const {
isProcessing: checkoutIsProcessing,
isIdle: checkoutIsIdle,
isCalculating: checkoutIsCalculating,
hasError: checkoutHasError,
} = useSelect( ( select ) => {
const store = select( CHECKOUT_STORE_KEY );
return {
isProcessing: store.isProcessing(),
isIdle: store.isIdle(),
hasError: store.hasError(),
isCalculating: store.isCalculating(),
};
} );
const { isPaymentReady } = useSelect( ( select ) => {
const store = select( PAYMENT_STORE_KEY );
return {
// The PROCESSING status represents before the checkout runs the observers
// registered for the payment_setup event.
isPaymentProcessing: store.isPaymentProcessing(),
// the READY status represents when the observers have finished processing and payment data
// synced with the payment store, ready to be sent to the StoreApi
isPaymentReady: store.isPaymentReady(),
};
} );
const { setValidationErrors } = useDispatch( VALIDATION_STORE_KEY );
const [ observers, observerDispatch ] = useReducer( emitReducer, {} );
const { onPaymentSetup } = useEventEmitters( observerDispatch );
const currentObservers = useRef( observers );
// ensure observers are always current.
useEffect( () => {
currentObservers.current = observers;
}, [ observers ] );
const {
__internalSetPaymentProcessing,
__internalSetPaymentIdle,
__internalEmitPaymentProcessingEvent,
} = useDispatch( PAYMENT_STORE_KEY );
// flip payment to processing if checkout processing is complete and there are no errors
useEffect( () => {
if (
checkoutIsProcessing &&
! checkoutHasError &&
! checkoutIsCalculating
) {
__internalSetPaymentProcessing();
// 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).
__internalEmitPaymentProcessingEvent(
currentObservers.current,
setValidationErrors
);
}
}, [
checkoutIsProcessing,
checkoutHasError,
checkoutIsCalculating,
__internalSetPaymentProcessing,
__internalEmitPaymentProcessingEvent,
setValidationErrors,
] );
// When checkout is returned to idle, and the payment setup has not completed, set payment status to idle
useEffect( () => {
if ( checkoutIsIdle && ! isPaymentReady ) {
__internalSetPaymentIdle();
}
}, [ checkoutIsIdle, isPaymentReady, __internalSetPaymentIdle ] );
// if checkout has an error sync payment status back to idle.
useEffect( () => {
if ( checkoutHasError && isPaymentReady ) {
__internalSetPaymentIdle();
}
}, [ checkoutHasError, isPaymentReady, __internalSetPaymentIdle ] );
/**
* @deprecated use onPaymentSetup instead
*/
const onPaymentProcessing = useMemo( () => {
return function ( ...args: Parameters< typeof onPaymentSetup > ) {
deprecated( 'onPaymentProcessing', {
alternative: 'onPaymentSetup',
plugin: 'WooCommerce Blocks',
} );
return onPaymentSetup( ...args );
};
}, [ onPaymentSetup ] );
const paymentContextData = {
onPaymentProcessing,
onPaymentSetup,
};
return (
<PaymentEventsContext.Provider value={ paymentContextData }>
{ children }
</PaymentEventsContext.Provider>
);
};

View File

@@ -0,0 +1,48 @@
/**
* External dependencies
*/
import type { CartShippingAddress } from '@woocommerce/types';
/**
* Internal dependencies
*/
import type { ShippingDataContextType, ShippingErrorTypes } from './types';
export const ERROR_TYPES = {
NONE: 'none',
INVALID_ADDRESS: 'invalid_address',
UNKNOWN: 'unknown_error',
} as ShippingErrorTypes;
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',
};
export const DEFAULT_SHIPPING_ADDRESS = {
first_name: '',
last_name: '',
company: '',
address_1: '',
address_2: '',
city: '',
state: '',
postcode: '',
country: '',
} as CartShippingAddress;
export const DEFAULT_SHIPPING_CONTEXT_DATA = {
shippingErrorStatus: {
isPristine: true,
isValid: false,
hasInvalidAddress: false,
hasError: false,
},
dispatchErrorStatus: ( status ) => status,
shippingErrorTypes: ERROR_TYPES,
onShippingRateSuccess: () => () => void null,
onShippingRateFail: () => () => void null,
onShippingRateSelectSuccess: () => () => void null,
onShippingRateSelectFail: () => () => void null,
} as ShippingDataContextType;

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';
import { useDispatch } from '@wordpress/data';
import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import type {
ShippingDataContextType,
ShippingDataProviderProps,
} from './types';
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 { useStoreCart } from '../../../hooks/cart/use-store-cart';
import { useShippingData } from '../../../hooks/shipping/use-shipping-data';
const { NONE, INVALID_ADDRESS, UNKNOWN } = ERROR_TYPES;
const ShippingDataContext = createContext( DEFAULT_SHIPPING_CONTEXT_DATA );
export const useShippingDataContext = (): ShippingDataContextType => {
return useContext( ShippingDataContext );
};
/**
* The shipping data provider exposes the interface for shipping in the checkout/cart.
*/
export const ShippingDataProvider = ( {
children,
}: ShippingDataProviderProps ) => {
const { __internalIncrementCalculating, __internalDecrementCalculating } =
useDispatch( CHECKOUT_STORE_KEY );
const { shippingRates, isLoadingRates, cartErrors } = useStoreCart();
const { selectedRates, isSelectingRate } = 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 ) {
__internalIncrementCalculating();
} else {
__internalDecrementCalculating();
}
}, [
isLoadingRates,
__internalIncrementCalculating,
__internalDecrementCalculating,
] );
// increment/decrement checkout calculating counts when shipping rates are being selected.
useEffect( () => {
if ( isSelectingRate ) {
__internalIncrementCalculating();
} else {
__internalDecrementCalculating();
}
}, [
__internalIncrementCalculating,
__internalDecrementCalculating,
isSelectingRate,
] );
// 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,
] );
const ShippingData: ShippingDataContextType = {
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,42 @@
/**
* Internal dependencies
*/
import type { emitterCallback } from '../../../event-emit';
export type ShippingErrorStatus = {
isPristine: boolean;
isValid: boolean;
hasInvalidAddress: boolean;
hasError: boolean;
};
export type ShippingErrorTypes = {
// eslint-disable-next-line @typescript-eslint/naming-convention
NONE: 'none';
// eslint-disable-next-line @typescript-eslint/naming-convention
INVALID_ADDRESS: 'invalid_address';
// eslint-disable-next-line @typescript-eslint/naming-convention
UNKNOWN: 'unknown_error';
};
export type ShippingDataContextType = {
// A function for dispatching a shipping rate error status.
dispatchErrorStatus: React.Dispatch< {
type: string;
} >;
onShippingRateFail: ReturnType< typeof emitterCallback >;
// Used to register a callback to be invoked when shipping rate is selected unsuccessfully
onShippingRateSelectFail: ReturnType< typeof emitterCallback >;
// Used to register a callback to be invoked when shipping rate is selected.
onShippingRateSelectSuccess: ReturnType< typeof emitterCallback >;
// Used to register a callback to be invoked when shipping rates are retrieved.
onShippingRateSuccess: ReturnType< typeof emitterCallback >;
// The current shipping error status.
shippingErrorStatus: ShippingErrorStatus;
// The error type constants for the shipping rate error status.
shippingErrorTypes: ShippingErrorTypes;
};
export interface ShippingDataProviderProps {
children: JSX.Element | JSX.Element[];
}

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';
import { dispatch } from '@wordpress/data';
import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
/**
* 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 | undefined
): void => {
if ( ! headers ) {
return;
}
const { __internalSetCustomerId } = dispatch( CHECKOUT_STORE_KEY );
if (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore -- this does exist because it's monkey patched in
// 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' ) ) {
__internalSetCustomerId(
parseInt( headers.get( 'User-ID' ) || '0', 10 )
);
}
};

View File

@@ -0,0 +1,63 @@
/**
* External dependencies
*/
import { createContext, useContext } from '@wordpress/element';
import { useContainerQueries } from '@woocommerce/base-hooks';
import classNames from 'classnames';
export type ContainerWidthContextProps = {
hasContainerWidth: boolean;
containerClassName: string;
isMobile: boolean;
isSmall: boolean;
isMedium: boolean;
isLarge: boolean;
};
const ContainerWidthContext: React.Context< ContainerWidthContextProps > =
createContext< ContainerWidthContextProps >( {
hasContainerWidth: false,
containerClassName: '',
isMobile: false,
isSmall: false,
isMedium: false,
isLarge: false,
} );
export const useContainerWidthContext = (): ContainerWidthContextProps => {
return useContext( ContainerWidthContext );
};
interface ContainerWidthContextProviderProps {
children: JSX.Element | JSX.Element[];
className: string;
}
/**
* Provides an interface to useContainerQueries so children can see what size is being used by the
* container.
*/
export const ContainerWidthContextProvider = ( {
children,
className = '',
}: ContainerWidthContextProviderProps ): JSX.Element => {
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',
};
return (
<ContainerWidthContext.Provider value={ contextValue }>
<div className={ classNames( className, containerClassName ) }>
{ resizeListener }
{ children }
</div>
</ContainerWidthContext.Provider>
);
};

View File

@@ -0,0 +1,84 @@
/**
* External dependencies
*/
import { createContext, useContext, useCallback } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
interface EditorContextType {
// Indicates whether in the editor context.
isEditor: boolean;
// The post ID being edited.
currentPostId: number;
// The current view name, if using a view switcher.
currentView: string;
// Object containing preview data for the editor.
previewData: Record< string, unknown >;
// Get data by name.
getPreviewData: ( name: string ) => Record< string, unknown >;
// Indicates whether in the preview context.
isPreview?: boolean;
}
const EditorContext = createContext( {
isEditor: false,
currentPostId: 0,
currentView: '',
previewData: {},
getPreviewData: () => ( {} ),
} as EditorContextType );
export const useEditorContext = (): EditorContextType => {
return useContext( EditorContext );
};
export const EditorProvider = ( {
children,
currentPostId = 0,
previewData = {},
currentView = '',
isPreview = false,
}: {
children: React.ReactChildren;
currentPostId?: number | undefined;
previewData?: Record< string, unknown > | undefined;
currentView?: string | undefined;
isPreview?: boolean | undefined;
} ) => {
const editingPostId = useSelect(
( select ): number =>
currentPostId
? currentPostId
: select( 'core/editor' ).getCurrentPostId(),
[ currentPostId ]
);
const getPreviewData = useCallback(
( name: string ): Record< string, unknown > => {
if ( previewData && name in previewData ) {
return previewData[ name ] as Record< string, unknown >;
}
return {};
},
[ previewData ]
);
const editorData: EditorContextType = {
isEditor: true,
currentPostId: editingPostId,
currentView,
previewData,
getPreviewData,
isPreview,
};
return (
<EditorContext.Provider value={ editorData }>
{ children }
</EditorContext.Provider>
);
};

View File

@@ -0,0 +1,6 @@
export * from './editor-context';
export * from './add-to-cart-form';
export * from './cart-checkout';
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,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'
) }`
);
}
};