initial commit

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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