rebase from live enviornment

This commit is contained in:
Rachit Bhargava
2024-01-09 22:14:20 -05:00
parent ff0b49a046
commit 3a22fcaa4a
15968 changed files with 2344674 additions and 45234 deletions

View File

@@ -0,0 +1,17 @@
export const ACTION_TYPES = {
SET_CART_DATA: 'SET_CART_DATA',
SET_FULL_SHIPPING_ADDRESS_PUSHED: 'SET_FULL_SHIPPING_ADDRESS_PUSHED',
SET_ERROR_DATA: 'SET_ERROR_DATA',
APPLYING_COUPON: 'APPLYING_COUPON',
REMOVING_COUPON: 'REMOVING_COUPON',
RECEIVE_CART_ITEM: 'RECEIVE_CART_ITEM',
ITEM_PENDING_QUANTITY: 'ITEM_PENDING_QUANTITY',
SET_IS_CART_DATA_STALE: 'SET_IS_CART_DATA_STALE',
RECEIVE_REMOVED_ITEM: 'RECEIVE_REMOVED_ITEM',
UPDATING_CUSTOMER_DATA: 'UPDATING_CUSTOMER_DATA',
SET_BILLING_ADDRESS: 'SET_BILLING_ADDRESS',
SET_SHIPPING_ADDRESS: 'SET_SHIPPING_ADDRESS',
UPDATING_SELECTED_SHIPPING_RATE: 'UPDATING_SELECTED_SHIPPING_RATE',
TRIGGER_ADDING_TO_CART_EVENT: 'TRIGGER_ADDING_TO_CART_EVENT',
TRIGGER_ADDED_TO_CART_EVENT: 'TRIGGER_ADDED_TO_CART_EVENT',
} as const;

View File

@@ -0,0 +1,504 @@
/**
* External dependencies
*/
import type {
Cart,
CartResponse,
CartResponseItem,
ExtensionCartUpdateArgs,
BillingAddressShippingAddress,
ApiErrorResponse,
CartShippingPackageShippingRate,
CartShippingRate,
} from '@woocommerce/types';
import { BillingAddress, ShippingAddress } from '@woocommerce/settings';
import {
triggerAddedToCartEvent,
triggerAddingToCartEvent,
camelCaseKeys,
} from '@woocommerce/base-utils';
/**
* Internal dependencies
*/
import { ACTION_TYPES as types } from './action-types';
import { apiFetchWithHeaders } from '../shared-controls';
import { ReturnOrGeneratorYieldUnion } from '../mapped-types';
import { CartDispatchFromMap, CartSelectFromMap } from './index';
import type { Thunks } from './thunks';
// Thunks are functions that can be dispatched, similar to actions creators
// @todo Many of the functions that return promises in this file need to be moved to thunks.ts.
export * from './thunks';
/**
* An action creator that dispatches the plain action responsible for setting the cart data in the store.
*
* @param cart the parsed cart object. (Parsed into camelCase).
*/
export const setCartData = ( cart: Cart ): { type: string; response: Cart } => {
return {
type: types.SET_CART_DATA,
response: cart,
};
};
/**
* An action creator that dispatches the plain action responsible for setting the cart error data in the store.
*
* @param error the parsed error object (Parsed into camelCase).
*/
export const setErrorData = (
error: ApiErrorResponse | null
): { type: string; response: ApiErrorResponse | null } => {
return {
type: types.SET_ERROR_DATA,
error,
};
};
/**
* Returns an action object used in updating the store with the provided cart.
*
* This omits the customer addresses so that only updates to cart items and totals are received. This is useful when
* currently editing address information to prevent it being overwritten from the server.
*
* This is a generic response action.
*
* @param {CartResponse} response
*/
export const receiveCartContents = (
response: CartResponse
): { type: string; response: Partial< Cart > } => {
const cart = camelCaseKeys( response ) as unknown as Cart;
const { shippingAddress, billingAddress, ...cartWithoutAddress } = cart;
return {
type: types.SET_CART_DATA,
response: cartWithoutAddress,
};
};
/**
* Returns an action object used to track when a coupon is applying.
*
* @param {string} [couponCode] Coupon being added.
*/
export const receiveApplyingCoupon = ( couponCode: string ) =>
( {
type: types.APPLYING_COUPON,
couponCode,
} as const );
/**
* Returns an action object used to track when a coupon is removing.
*
* @param {string} [couponCode] Coupon being removed..
*/
export const receiveRemovingCoupon = ( couponCode: string ) =>
( {
type: types.REMOVING_COUPON,
couponCode,
} as const );
/**
* Returns an action object for updating a single cart item in the store.
*
* @param {CartResponseItem} [response=null] A cart item API response.
*/
export const receiveCartItem = ( response: CartResponseItem | null = null ) =>
( {
type: types.RECEIVE_CART_ITEM,
cartItem: response,
} as const );
/**
* Returns an action object to indicate if the specified cart item quantity is
* being updated.
*
* @param {string} cartItemKey Cart item being updated.
* @param {boolean} [isPendingQuantity=true] Flag for update state; true if API
* request is pending.
*/
export const itemIsPendingQuantity = (
cartItemKey: string,
isPendingQuantity = true
) =>
( {
type: types.ITEM_PENDING_QUANTITY,
cartItemKey,
isPendingQuantity,
} as const );
/**
* Returns an action object to remove a cart item from the store.
*
* @param {string} cartItemKey Cart item to remove.
* @param {boolean} [isPendingDelete=true] Flag for update state; true if API
* request is pending.
*/
export const itemIsPendingDelete = (
cartItemKey: string,
isPendingDelete = true
) =>
( {
type: types.RECEIVE_REMOVED_ITEM,
cartItemKey,
isPendingDelete,
} as const );
/**
* Returns an action object to mark the cart data in the store as stale.
*
* @param {boolean} [isCartDataStale=true] Flag to mark cart data as stale; true if
* lastCartUpdate timestamp is newer than the
* one in wcSettings.
*/
export const setIsCartDataStale = ( isCartDataStale = true ) =>
( {
type: types.SET_IS_CART_DATA_STALE,
isCartDataStale,
} as const );
/**
* Returns an action object used to track when customer data is being updated
* (billing and/or shipping).
*/
export const updatingCustomerData = ( isResolving: boolean ) =>
( {
type: types.UPDATING_CUSTOMER_DATA,
isResolving,
} as const );
/**
* Returns an action object used to track whether the shipping rate is being
* selected or not.
*
* @param {boolean} isResolving True if shipping rate is being selected.
*/
export const shippingRatesBeingSelected = ( isResolving: boolean ) =>
( {
type: types.UPDATING_SELECTED_SHIPPING_RATE,
isResolving,
} as const );
/**
* POSTs to the /cart/extensions endpoint with the data supplied by the extension.
*
* @param {Object} args The data to be posted to the endpoint
*/
export const applyExtensionCartUpdate =
( args: ExtensionCartUpdateArgs ) =>
async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => {
try {
const { response } = await apiFetchWithHeaders( {
path: '/wc/store/v1/cart/extensions',
method: 'POST',
data: { namespace: args.namespace, data: args.data },
cache: 'no-store',
} );
dispatch.receiveCart( response );
return response;
} catch ( error ) {
dispatch.receiveError( error );
return Promise.reject( error );
}
};
/**
* Applies a coupon code and either invalidates caches, or receives an error if
* the coupon cannot be applied.
*
* @param {string} couponCode The coupon code to apply to the cart.
* @throws Will throw an error if there is an API problem.
*/
export const applyCoupon =
( couponCode: string ) =>
async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => {
try {
dispatch.receiveApplyingCoupon( couponCode );
const { response } = await apiFetchWithHeaders( {
path: '/wc/store/v1/cart/apply-coupon',
method: 'POST',
data: {
code: couponCode,
},
cache: 'no-store',
} );
dispatch.receiveCart( response );
return response;
} catch ( error ) {
dispatch.receiveError( error );
return Promise.reject( error );
} finally {
dispatch.receiveApplyingCoupon( '' );
}
};
/**
* Removes a coupon code and either invalidates caches, or receives an error if
* the coupon cannot be removed.
*
* @param {string} couponCode The coupon code to remove from the cart.
* @throws Will throw an error if there is an API problem.
*/
export const removeCoupon =
( couponCode: string ) =>
async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => {
try {
dispatch.receiveRemovingCoupon( couponCode );
const { response } = await apiFetchWithHeaders( {
path: '/wc/store/v1/cart/remove-coupon',
method: 'POST',
data: {
code: couponCode,
},
cache: 'no-store',
} );
dispatch.receiveCart( response );
return response;
} catch ( error ) {
dispatch.receiveError( error );
return Promise.reject( error );
} finally {
dispatch.receiveRemovingCoupon( '' );
}
};
/**
* Adds an item to the cart:
* - Calls API to add item.
* - If successful, yields action to add item from store.
* - If error, yields action to store error.
*
* @param {number} productId Product ID to add to cart.
* @param {number} [quantity=1] Number of product ID being added to cart.
* @throws Will throw an error if there is an API problem.
*/
export const addItemToCart =
( productId: number, quantity = 1 ) =>
async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => {
try {
triggerAddingToCartEvent();
const { response } = await apiFetchWithHeaders( {
path: `/wc/store/v1/cart/add-item`,
method: 'POST',
data: {
id: productId,
quantity,
},
cache: 'no-store',
} );
dispatch.receiveCart( response );
triggerAddedToCartEvent( { preserveCartData: true } );
return response;
} catch ( error ) {
dispatch.receiveError( error );
return Promise.reject( error );
}
};
/**
* Removes specified item from the cart:
* - Calls API to remove item.
* - If successful, yields action to remove item from store.
* - If error, yields action to store error.
* - Sets cart item as pending while API request is in progress.
*
* @param {string} cartItemKey Cart item being updated.
*/
export const removeItemFromCart =
( cartItemKey: string ) =>
async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => {
try {
dispatch.itemIsPendingDelete( cartItemKey );
const { response } = await apiFetchWithHeaders( {
path: `/wc/store/v1/cart/remove-item`,
data: {
key: cartItemKey,
},
method: 'POST',
cache: 'no-store',
} );
dispatch.receiveCart( response );
return response;
} catch ( error ) {
dispatch.receiveError( error );
return Promise.reject( error );
} finally {
dispatch.itemIsPendingDelete( cartItemKey, false );
}
};
/**
* Persists a quantity change the for specified cart item:
* - Calls API to set quantity.
* - If successful, yields action to update store.
* - If error, yields action to store error.
*
* @param {string} cartItemKey Cart item being updated.
* @param {number} quantity Specified (new) quantity.
*/
export const changeCartItemQuantity =
(
cartItemKey: string,
quantity: number
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- unclear how to represent multiple different yields as type
) =>
async ( {
dispatch,
select,
}: {
dispatch: CartDispatchFromMap;
select: CartSelectFromMap;
} ) => {
const cartItem = select.getCartItem( cartItemKey );
if ( cartItem?.quantity === quantity ) {
return;
}
try {
dispatch.itemIsPendingQuantity( cartItemKey );
const { response } = await apiFetchWithHeaders( {
path: '/wc/store/v1/cart/update-item',
method: 'POST',
data: {
key: cartItemKey,
quantity,
},
cache: 'no-store',
} );
dispatch.receiveCart( response );
return response;
} catch ( error ) {
dispatch.receiveError( error );
return Promise.reject( error );
} finally {
dispatch.itemIsPendingQuantity( cartItemKey, false );
}
};
/**
* Selects a shipping rate.
*
* @param {string} rateId The id of the rate being selected.
* @param {number | string} [packageId] The key of the packages that we will select within.
*/
export const selectShippingRate =
( rateId: string, packageId: number | null = null ) =>
async ( {
dispatch,
select,
}: {
dispatch: CartDispatchFromMap;
select: CartSelectFromMap;
} ) => {
const selectedShippingRate = select
.getShippingRates()
.find(
( shippingPackage: CartShippingRate ) =>
shippingPackage.package_id === packageId
)
?.shipping_rates.find(
( rate: CartShippingPackageShippingRate ) =>
rate.selected === true
);
if ( selectedShippingRate?.rate_id === rateId ) {
return;
}
try {
dispatch.shippingRatesBeingSelected( true );
const { response } = await apiFetchWithHeaders( {
path: `/wc/store/v1/cart/select-shipping-rate`,
method: 'POST',
data: {
package_id: packageId,
rate_id: rateId,
},
cache: 'no-store',
} );
// Remove shipping and billing address from the response, so we don't overwrite what the shopper is
// entering in the form if rates suddenly appear mid-edit.
const {
shipping_address: shippingAddress,
billing_address: billingAddress,
...rest
} = response;
dispatch.receiveCart( rest );
return response as CartResponse;
} catch ( error ) {
dispatch.receiveError( error );
return Promise.reject( error );
} finally {
dispatch.shippingRatesBeingSelected( false );
}
};
/**
* Sets billing address locally, as opposed to updateCustomerData which sends it to the server.
*/
export const setBillingAddress = (
billingAddress: Partial< BillingAddress >
) => ( { type: types.SET_BILLING_ADDRESS, billingAddress } as const );
/**
* Sets shipping address locally, as opposed to updateCustomerData which sends it to the server.
*/
export const setShippingAddress = (
shippingAddress: Partial< ShippingAddress >
) => ( { type: types.SET_SHIPPING_ADDRESS, shippingAddress } as const );
/**
* Updates the shipping and/or billing address for the customer and returns an updated cart.
*/
export const updateCustomerData =
(
// Address data to be updated; can contain both billing_address and shipping_address.
customerData: Partial< BillingAddressShippingAddress >,
// If the address is being edited, we don't update the customer data in the store from the response.
editing = true
) =>
async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => {
try {
dispatch.updatingCustomerData( true );
const { response } = await apiFetchWithHeaders( {
path: '/wc/store/v1/cart/update-customer',
method: 'POST',
data: customerData,
cache: 'no-store',
} );
if ( editing ) {
dispatch.receiveCartContents( response );
} else {
dispatch.receiveCart( response );
}
return response;
} catch ( error ) {
dispatch.receiveError( error );
return Promise.reject( error );
} finally {
dispatch.updatingCustomerData( false );
}
};
type Actions =
| typeof addItemToCart
| typeof applyCoupon
| typeof changeCartItemQuantity
| typeof itemIsPendingDelete
| typeof itemIsPendingQuantity
| typeof receiveApplyingCoupon
| typeof receiveCartContents
| typeof receiveCartItem
| typeof receiveRemovingCoupon
| typeof removeCoupon
| typeof removeItemFromCart
| typeof selectShippingRate
| typeof setBillingAddress
| typeof setCartData
| typeof setErrorData
| typeof setIsCartDataStale
| typeof setShippingAddress
| typeof shippingRatesBeingSelected
| typeof updateCustomerData
| typeof updatingCustomerData;
export type CartAction = ReturnOrGeneratorYieldUnion< Actions | Thunks >;

View File

@@ -0,0 +1,16 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
export const STORE_KEY = 'wc/store/cart';
export const CART_API_ERROR = {
code: 'cart_api_error',
message: __(
'Unable to get cart data from the API.',
'woo-gutenberg-products-block'
),
data: {
status: 500,
},
};

View File

@@ -0,0 +1,105 @@
/**
* External dependencies
*/
import type { Cart, CartMeta, ApiErrorResponse } from '@woocommerce/types';
/**
* Internal dependencies
*/
import {
EMPTY_CART_COUPONS,
EMPTY_CART_ITEMS,
EMPTY_CART_CROSS_SELLS,
EMPTY_CART_FEES,
EMPTY_CART_ITEM_ERRORS,
EMPTY_CART_ERRORS,
EMPTY_SHIPPING_RATES,
EMPTY_TAX_LINES,
EMPTY_PAYMENT_METHODS,
EMPTY_PAYMENT_REQUIREMENTS,
EMPTY_EXTENSIONS,
} from '../constants';
const EMPTY_PENDING_QUANTITY: [] = [];
const EMPTY_PENDING_DELETE: [] = [];
export interface CartState {
cartItemsPendingQuantity: string[];
cartItemsPendingDelete: string[];
cartData: Cart;
metaData: CartMeta;
errors: ApiErrorResponse[];
}
export const defaultCartState: CartState = {
cartItemsPendingQuantity: EMPTY_PENDING_QUANTITY,
cartItemsPendingDelete: EMPTY_PENDING_DELETE,
cartData: {
coupons: EMPTY_CART_COUPONS,
shippingRates: EMPTY_SHIPPING_RATES,
shippingAddress: {
first_name: '',
last_name: '',
company: '',
address_1: '',
address_2: '',
city: '',
state: '',
postcode: '',
country: '',
phone: '',
},
billingAddress: {
first_name: '',
last_name: '',
company: '',
address_1: '',
address_2: '',
city: '',
state: '',
postcode: '',
country: '',
phone: '',
email: '',
},
items: EMPTY_CART_ITEMS,
itemsCount: 0,
itemsWeight: 0,
crossSells: EMPTY_CART_CROSS_SELLS,
needsShipping: true,
needsPayment: false,
hasCalculatedShipping: true,
fees: EMPTY_CART_FEES,
totals: {
currency_code: '',
currency_symbol: '',
currency_minor_unit: 2,
currency_decimal_separator: '.',
currency_thousand_separator: ',',
currency_prefix: '',
currency_suffix: '',
total_items: '0',
total_items_tax: '0',
total_fees: '0',
total_fees_tax: '0',
total_discount: '0',
total_discount_tax: '0',
total_shipping: '0',
total_shipping_tax: '0',
total_price: '0',
total_tax: '0',
tax_lines: EMPTY_TAX_LINES,
},
errors: EMPTY_CART_ITEM_ERRORS,
paymentMethods: EMPTY_PAYMENT_METHODS,
paymentRequirements: EMPTY_PAYMENT_REQUIREMENTS,
extensions: EMPTY_EXTENSIONS,
},
metaData: {
updatingCustomerData: false,
updatingSelectedRate: false,
applyingCoupon: '',
removingCoupon: '',
isCartDataStale: false,
},
errors: EMPTY_CART_ERRORS,
};

View File

@@ -0,0 +1,92 @@
/**
* External dependencies
*/
import { registerStore } from '@wordpress/data';
import { controls as dataControls } from '@wordpress/data-controls';
/**
* Internal dependencies
*/
import { STORE_KEY } from './constants';
import * as selectors from './selectors';
import * as actions from './actions';
import * as resolvers from './resolvers';
import reducer, { State } from './reducers';
import type { SelectFromMap, DispatchFromMap } from '../mapped-types';
import { pushChanges, flushChanges } from './push-changes';
import {
updatePaymentMethods,
debouncedUpdatePaymentMethods,
} from './update-payment-methods';
import { ResolveSelectFromMap } from '../mapped-types';
// Please update from deprecated "registerStore" to "createReduxStore" when this PR is merged:
// https://github.com/WordPress/gutenberg/pull/45513
const registeredStore = registerStore< State >( STORE_KEY, {
reducer,
actions,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
controls: dataControls,
selectors,
resolvers,
__experimentalUseThunks: true,
} );
// Pushes changes whenever the store is updated.
registeredStore.subscribe( pushChanges );
// This will skip the debounce and immediately push changes to the server when a field is blurred.
document.body.addEventListener( 'focusout', ( event: FocusEvent ) => {
if (
event.target &&
event.target instanceof Element &&
event.target.tagName.toLowerCase() === 'input'
) {
flushChanges();
}
} );
// First we will run the updatePaymentMethods function without any debounce to ensure payment methods are ready as soon
// as the cart is loaded. After that, we will unsubscribe this function and instead run the
// debouncedUpdatePaymentMethods function on subsequent cart updates.
const unsubscribeUpdatePaymentMethods = registeredStore.subscribe( async () => {
const didActionDispatch = await updatePaymentMethods();
if ( didActionDispatch ) {
// The function we're currently in will unsubscribe itself. When we reach this line, this will be the last time
// this function is called.
unsubscribeUpdatePaymentMethods();
// Resubscribe, but with the debounced version of updatePaymentMethods.
registeredStore.subscribe( debouncedUpdatePaymentMethods );
}
} );
export const CART_STORE_KEY = STORE_KEY;
declare module '@wordpress/data' {
function dispatch(
key: typeof CART_STORE_KEY
): DispatchFromMap< typeof actions >;
function select( key: typeof CART_STORE_KEY ): SelectFromMap<
typeof selectors
> & {
hasFinishedResolution: ( selector: string ) => boolean;
};
}
/**
* CartDispatchFromMap is a type that maps the cart store's action creators to the dispatch function passed to thunks.
*/
export type CartDispatchFromMap = DispatchFromMap< typeof actions >;
/**
* CartResolveSelectFromMap is a type that maps the cart store's resolvers and selectors to the resolveSelect function
* passed to thunks.
*/
export type CartResolveSelectFromMap = ResolveSelectFromMap<
typeof resolvers & typeof selectors
>;
/**
* CartSelectFromMap is a type that maps the cart store's selectors to the select function passed to thunks.
*/
export type CartSelectFromMap = SelectFromMap< typeof selectors >;

View File

@@ -0,0 +1,33 @@
/**
* External dependencies
*/
import { ApiErrorResponse, isApiErrorResponse } from '@woocommerce/types';
import { createNotice } from '@woocommerce/base-utils';
import { decodeEntities } from '@wordpress/html-entities';
import { dispatch } from '@wordpress/data';
/**
* This function is used to notify the user of cart item errors/conflicts
*/
export const notifyCartErrors = (
errors: ApiErrorResponse[] | null = null,
oldErrors: ApiErrorResponse[] | null = null
) => {
if ( oldErrors ) {
oldErrors.forEach( ( error ) => {
dispatch( 'core/notices' ).removeNotice( error.code, 'wc/cart' );
} );
}
if ( errors !== null ) {
errors.forEach( ( error ) => {
if ( isApiErrorResponse( error ) ) {
createNotice( 'error', decodeEntities( error.message ), {
id: error.code,
context: 'wc/cart',
isDismissible: false,
} );
}
} );
}
};

View File

@@ -0,0 +1,232 @@
/**
* External dependencies
*/
import { Cart, CartItem } from '@woocommerce/types';
import { dispatch, select } from '@wordpress/data';
import { __, sprintf } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { STORE_KEY as CART_STORE_KEY } from './constants';
interface NotifyQuantityChangesArgs {
oldCart: Cart;
newCart: Cart;
cartItemsPendingQuantity?: string[] | undefined;
cartItemsPendingDelete?: string[] | undefined;
}
const isWithinQuantityLimits = ( cartItem: CartItem ) => {
return (
cartItem.quantity >= cartItem.quantity_limits.minimum &&
cartItem.quantity <= cartItem.quantity_limits.maximum &&
cartItem.quantity % cartItem.quantity_limits.multiple_of === 0
);
};
const notifyIfQuantityLimitsChanged = ( oldCart: Cart, newCart: Cart ) => {
newCart.items.forEach( ( cartItem ) => {
const oldCartItem = oldCart.items.find( ( item ) => {
return item && item.key === cartItem.key;
} );
// If getCartData has not finished resolving, then this is the first load.
const isFirstLoad = oldCart.items.length === 0;
// Item has been removed, we don't need to do any more checks.
if ( ! oldCartItem && ! isFirstLoad ) {
return;
}
if ( isWithinQuantityLimits( cartItem ) ) {
return;
}
const quantityAboveMax =
cartItem.quantity > cartItem.quantity_limits.maximum;
const quantityBelowMin =
cartItem.quantity < cartItem.quantity_limits.minimum;
const quantityOutOfStep =
cartItem.quantity % cartItem.quantity_limits.multiple_of !== 0;
// If the quantity is still within the constraints, then we don't need to show any notice, this is because
// QuantitySelector will not automatically update the value.
if ( ! quantityAboveMax && ! quantityBelowMin && ! quantityOutOfStep ) {
return;
}
if ( quantityOutOfStep ) {
dispatch( 'core/notices' ).createInfoNotice(
sprintf(
/* translators: %1$s is the name of the item, %2$d is the quantity of the item. %3$d is a number that the quantity must be a multiple of. */
__(
'The quantity of "%1$s" was changed to %2$d. You must purchase this product in groups of %3$d.',
'woo-gutenberg-products-block'
),
cartItem.name,
// We round down to the nearest step value here. We need to do it this way because at this point we
// don't know the next quantity. That only gets set once the HTML Input field applies its min/max
// constraints.
Math.floor(
cartItem.quantity / cartItem.quantity_limits.multiple_of
) * cartItem.quantity_limits.multiple_of,
cartItem.quantity_limits.multiple_of
),
{
context: 'wc/cart',
speak: true,
type: 'snackbar',
id: `${ cartItem.key }-quantity-update`,
}
);
return;
}
if ( quantityBelowMin ) {
dispatch( 'core/notices' ).createInfoNotice(
sprintf(
/* translators: %1$s is the name of the item, %2$d is the quantity of the item. */
__(
'The quantity of "%1$s" was increased to %2$d. This is the minimum required quantity.',
'woo-gutenberg-products-block'
),
cartItem.name,
cartItem.quantity_limits.minimum
),
{
context: 'wc/cart',
speak: true,
type: 'snackbar',
id: `${ cartItem.key }-quantity-update`,
}
);
return;
}
// Quantity is above max, so has been reduced.
dispatch( 'core/notices' ).createInfoNotice(
sprintf(
/* translators: %1$s is the name of the item, %2$d is the quantity of the item. */
__(
'The quantity of "%1$s" was decreased to %2$d. This is the maximum allowed quantity.',
'woo-gutenberg-products-block'
),
cartItem.name,
cartItem.quantity_limits.maximum
),
{
context: 'wc/cart',
speak: true,
type: 'snackbar',
id: `${ cartItem.key }-quantity-update`,
}
);
} );
};
const notifyIfQuantityChanged = (
oldCart: Cart,
newCart: Cart,
cartItemsPendingQuantity: string[]
) => {
newCart.items.forEach( ( cartItem ) => {
if ( cartItemsPendingQuantity.includes( cartItem.key ) ) {
return;
}
const oldCartItem = oldCart.items.find( ( item ) => {
return item && item.key === cartItem.key;
} );
if ( ! oldCartItem ) {
return;
}
if ( cartItem.key === oldCartItem.key ) {
if (
cartItem.quantity !== oldCartItem.quantity &&
isWithinQuantityLimits( cartItem )
) {
dispatch( 'core/notices' ).createInfoNotice(
sprintf(
/* translators: %1$s is the name of the item, %2$d is the quantity of the item. */
__(
'The quantity of "%1$s" was changed to %2$d.',
'woo-gutenberg-products-block'
),
cartItem.name,
cartItem.quantity
),
{
context: 'wc/cart',
speak: true,
type: 'snackbar',
id: `${ cartItem.key }-quantity-update`,
}
);
}
return cartItem;
}
} );
};
/**
* Checks whether the old cart contains an item that the new cart doesn't, and that the item was not slated for removal.
*
* @param oldCart The old cart.
* @param newCart The new cart.
* @param cartItemsPendingDelete The cart items that are pending deletion.
*/
const notifyIfRemoved = (
oldCart: Cart,
newCart: Cart,
cartItemsPendingDelete: string[]
) => {
oldCart.items.forEach( ( oldCartItem ) => {
if ( cartItemsPendingDelete.includes( oldCartItem.key ) ) {
return;
}
const newCartItem = newCart.items.find( ( item: CartItem ) => {
return item && item.key === oldCartItem.key;
} );
if ( ! newCartItem ) {
dispatch( 'core/notices' ).createInfoNotice(
sprintf(
/* translators: %s is the name of the item. */
__(
'"%s" was removed from your cart.',
'woo-gutenberg-products-block'
),
oldCartItem.name
),
{
context: 'wc/cart',
speak: true,
type: 'snackbar',
id: `${ oldCartItem.key }-removed`,
}
);
}
} );
};
/**
* This function is used to notify the user when the quantity of an item in the cart has changed. It checks both the
* item's quantity and quantity limits.
*/
export const notifyQuantityChanges = ( {
oldCart,
newCart,
cartItemsPendingQuantity = [],
cartItemsPendingDelete = [],
}: NotifyQuantityChangesArgs ) => {
const isResolutionFinished =
select( CART_STORE_KEY ).hasFinishedResolution( 'getCartData' );
if ( ! isResolutionFinished ) {
return;
}
notifyIfRemoved( oldCart, newCart, cartItemsPendingDelete );
notifyIfQuantityLimitsChanged( oldCart, newCart );
notifyIfQuantityChanged( oldCart, newCart, cartItemsPendingQuantity );
};

View File

@@ -0,0 +1,181 @@
/**
* External dependencies
*/
import { removeAllNotices, debounce, pick } from '@woocommerce/base-utils';
import {
CartBillingAddress,
CartShippingAddress,
BillingAddressShippingAddress,
} from '@woocommerce/types';
import { select, dispatch } from '@wordpress/data';
import isShallowEqual from '@wordpress/is-shallow-equal';
/**
* Internal dependencies
*/
import { STORE_KEY } from './constants';
import { processErrorResponse } from '../utils';
import { getDirtyKeys, validateDirtyProps, BaseAddressKey } from './utils';
// This is used to track and cache the local state of push changes.
const localState = {
// True when the customer data has been initialized.
customerDataIsInitialized: false,
// True when a push is currently happening to avoid simultaneous pushes.
doingPush: false,
// Local cache of the last pushed customerData used for comparisons.
customerData: {
billingAddress: {} as CartBillingAddress,
shippingAddress: {} as CartShippingAddress,
},
// Tracks which props have changed so the correct data gets pushed to the server.
dirtyProps: {
billingAddress: [] as BaseAddressKey[],
shippingAddress: [] as BaseAddressKey[],
},
};
/**
* Initializes the customer data cache on the first run.
*/
const initialize = () => {
localState.customerData = select( STORE_KEY ).getCustomerData();
localState.customerDataIsInitialized = true;
};
/**
* Checks customer data against new customer data to get a list of dirty props.
*/
const updateDirtyProps = () => {
// Returns all current customer data from the store.
const newCustomerData = select( STORE_KEY ).getCustomerData();
localState.dirtyProps.billingAddress = [
...localState.dirtyProps.billingAddress,
...getDirtyKeys(
localState.customerData.billingAddress,
newCustomerData.billingAddress
),
];
localState.dirtyProps.shippingAddress = [
...localState.dirtyProps.shippingAddress,
...getDirtyKeys(
localState.customerData.shippingAddress,
newCustomerData.shippingAddress
),
];
// Update local cache of customer data so the next time this runs, it can compare against the latest data.
localState.customerData = newCustomerData;
};
/**
* Function to dispatch an update to the server.
*/
const updateCustomerData = (): void => {
if ( localState.doingPush ) {
return;
}
// Prevent multiple pushes from happening at the same time.
localState.doingPush = true;
// Get updated list of dirty props by comparing customer data.
updateDirtyProps();
// Do we need to push anything?
const needsPush =
localState.dirtyProps.billingAddress.length > 0 ||
localState.dirtyProps.shippingAddress.length > 0;
if ( ! needsPush ) {
localState.doingPush = false;
return;
}
// Check props are valid, or abort.
if ( ! validateDirtyProps( localState.dirtyProps ) ) {
localState.doingPush = false;
return;
}
// Find valid data from the list of dirtyProps and prepare to push to the server.
const customerDataToUpdate = {} as Partial< BillingAddressShippingAddress >;
if ( localState.dirtyProps.billingAddress.length ) {
customerDataToUpdate.billing_address = pick(
localState.customerData.billingAddress,
localState.dirtyProps.billingAddress
);
}
if ( localState.dirtyProps.shippingAddress.length ) {
customerDataToUpdate.shipping_address = pick(
localState.customerData.shippingAddress,
localState.dirtyProps.shippingAddress
);
}
dispatch( STORE_KEY )
.updateCustomerData( customerDataToUpdate )
.then( () => {
localState.dirtyProps.billingAddress = [];
localState.dirtyProps.shippingAddress = [];
localState.doingPush = false;
removeAllNotices();
} )
.catch( ( response ) => {
localState.doingPush = false;
processErrorResponse( response );
} );
};
/**
* Function to dispatch an update to the server. This is debounced.
*/
const debouncedUpdateCustomerData = debounce( () => {
if ( localState.doingPush ) {
debouncedUpdateCustomerData();
return;
}
updateCustomerData();
}, 1500 );
/**
* After cart has fully initialized, pushes changes to the server when data in the store is changed. Updates to the
* server are debounced to prevent excessive requests.
*
* Any update to the store triggers this, so we do a shallow compare on the important data to know if we really need to
* schedule a push.
*/
export const pushChanges = ( debounced = true ): void => {
if ( ! select( STORE_KEY ).hasFinishedResolution( 'getCartData' ) ) {
return;
}
if ( ! localState.customerDataIsInitialized ) {
initialize();
return;
}
if (
isShallowEqual(
localState.customerData,
select( STORE_KEY ).getCustomerData()
)
) {
return;
}
if ( debounced ) {
debouncedUpdateCustomerData();
} else {
updateCustomerData();
}
};
// Cancel the debounced updateCustomerData function and trigger it immediately.
export const flushChanges = (): void => {
debouncedUpdateCustomerData.flush();
};

View File

@@ -0,0 +1,189 @@
/**
* External dependencies
*/
import type { CartItem } from '@woocommerce/types';
import type { Reducer } from 'redux';
/**
* Internal dependencies
*/
import { ACTION_TYPES as types } from './action-types';
import { defaultCartState, CartState } from './default-state';
import { EMPTY_CART_ERRORS } from '../constants';
import type { CartAction } from './actions';
/**
* Sub-reducer for cart items array.
*
* @param {Array<CartItem>} state cartData.items state slice.
* @param {CartAction} action Action object.
*/
const cartItemsReducer = (
state: Array< CartItem > = [],
action: Partial< CartAction >
) => {
switch ( action.type ) {
case types.RECEIVE_CART_ITEM:
// Replace specified cart element with the new data from server.
return state.map( ( cartItem ) => {
if ( cartItem.key === action.cartItem?.key ) {
return action.cartItem;
}
return cartItem;
} );
}
return state;
};
/**
* Reducer for receiving items related to the cart.
*
* @param {CartState} state The current state in the store.
* @param {CartAction} action Action object.
*
* @return {CartState} New or existing state.
*/
const reducer: Reducer< CartState > = (
state = defaultCartState,
action: Partial< CartAction >
) => {
switch ( action.type ) {
case types.SET_ERROR_DATA:
if ( action.error ) {
state = {
...state,
errors: [ action.error ],
};
}
break;
case types.SET_CART_DATA:
if ( action.response ) {
state = {
...state,
errors: EMPTY_CART_ERRORS,
cartData: {
...state.cartData,
...action.response,
},
};
}
break;
case types.APPLYING_COUPON:
if ( action.couponCode || action.couponCode === '' ) {
state = {
...state,
metaData: {
...state.metaData,
applyingCoupon: action.couponCode,
},
};
}
break;
case types.SET_BILLING_ADDRESS:
state = {
...state,
cartData: {
...state.cartData,
billingAddress: {
...state.cartData.billingAddress,
...action.billingAddress,
},
},
};
break;
case types.SET_SHIPPING_ADDRESS:
state = {
...state,
cartData: {
...state.cartData,
shippingAddress: {
...state.cartData.shippingAddress,
...action.shippingAddress,
},
},
};
break;
case types.REMOVING_COUPON:
if ( action.couponCode || action.couponCode === '' ) {
state = {
...state,
metaData: {
...state.metaData,
removingCoupon: action.couponCode,
},
};
}
break;
case types.ITEM_PENDING_QUANTITY:
// Remove key by default - handles isQuantityPending==false
// and prevents duplicates when isQuantityPending===true.
const keysPendingQuantity = state.cartItemsPendingQuantity.filter(
( key ) => key !== action.cartItemKey
);
if ( action.isPendingQuantity && action.cartItemKey ) {
keysPendingQuantity.push( action.cartItemKey );
}
state = {
...state,
cartItemsPendingQuantity: keysPendingQuantity,
};
break;
case types.RECEIVE_REMOVED_ITEM:
const keysPendingDelete = state.cartItemsPendingDelete.filter(
( key ) => key !== action.cartItemKey
);
if ( action.isPendingDelete && action.cartItemKey ) {
keysPendingDelete.push( action.cartItemKey );
}
state = {
...state,
cartItemsPendingDelete: keysPendingDelete,
};
break;
// Delegate to cartItemsReducer.
case types.RECEIVE_CART_ITEM:
state = {
...state,
errors: EMPTY_CART_ERRORS,
cartData: {
...state.cartData,
items: cartItemsReducer( state.cartData.items, action ),
},
};
break;
case types.UPDATING_CUSTOMER_DATA:
state = {
...state,
metaData: {
...state.metaData,
updatingCustomerData: !! action.isResolving,
},
};
break;
case types.UPDATING_SELECTED_SHIPPING_RATE:
state = {
...state,
metaData: {
...state.metaData,
updatingSelectedRate: !! action.isResolving,
},
};
break;
case types.SET_IS_CART_DATA_STALE:
state = {
...state,
metaData: {
...state.metaData,
isCartDataStale: action.isCartDataStale,
},
};
break;
}
return state;
};
export type State = ReturnType< typeof reducer >;
export default reducer;

View File

@@ -0,0 +1,44 @@
/**
* External dependencies
*/
import apiFetch from '@wordpress/api-fetch';
import { CartResponse } from '@woocommerce/types';
/**
* Internal dependencies
*/
import { CART_API_ERROR } from './constants';
import type { CartDispatchFromMap, CartResolveSelectFromMap } from './index';
/**
* Resolver for retrieving all cart data.
*/
export const getCartData =
() =>
async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => {
const cartData = await apiFetch< CartResponse >( {
path: '/wc/store/v1/cart',
method: 'GET',
cache: 'no-store',
} );
const { receiveCart, receiveError } = dispatch;
if ( ! cartData ) {
receiveError( CART_API_ERROR );
return;
}
receiveCart( cartData );
};
/**
* Resolver for retrieving cart totals.
*/
export const getCartTotals =
() =>
async ( {
resolveSelect,
}: {
resolveSelect: CartResolveSelectFromMap;
} ) => {
await resolveSelect.getCartData();
};

View File

@@ -0,0 +1,224 @@
/**
* External dependencies
*/
import type {
Cart,
CartTotals,
CartMeta,
CartItem,
CartShippingRate,
ApiErrorResponse,
} from '@woocommerce/types';
import { BillingAddress, ShippingAddress } from '@woocommerce/settings';
/**
* Internal dependencies
*/
import { CartState, defaultCartState } from './default-state';
/**
* Retrieves cart data from state.
*
* @param {CartState} state The current state.
* @return {Cart} The data to return.
*/
export const getCartData = ( state: CartState ): Cart => {
return state.cartData;
};
export const getCustomerData = (
state: CartState
): {
shippingAddress: ShippingAddress;
billingAddress: BillingAddress;
} => {
return {
shippingAddress: state.cartData.shippingAddress,
billingAddress: state.cartData.billingAddress,
};
};
/**
* Retrieves shipping rates from state.
*
* @param { CartState } state The current state.
* @return { CartShippingRate[] } The shipping rates on the cart.
*/
export const getShippingRates = ( state: CartState ): CartShippingRate[] => {
return state.cartData.shippingRates;
};
/**
* Retrieves whether the cart needs shipping.
*
* @param { CartState } state The current state.
* @return { boolean } True if the cart needs shipping.
*/
export const getNeedsShipping = ( state: CartState ): boolean => {
return state.cartData.needsShipping;
};
/**
* Retrieves whether the cart shipping has been calculated.
*
* @param { CartState } state The current state.
* @return { boolean } True if the shipping has been calculated.
*/
export const getHasCalculatedShipping = ( state: CartState ): boolean => {
return state.cartData.hasCalculatedShipping;
};
/**
* Retrieves cart totals from state.
*
* @param {CartState} state The current state.
* @return {CartTotals} The data to return.
*/
export const getCartTotals = ( state: CartState ): CartTotals => {
return state.cartData.totals || defaultCartState.cartData.totals;
};
/**
* Retrieves cart meta from state.
*
* @param {CartState} state The current state.
* @return {CartMeta} The data to return.
*/
export const getCartMeta = ( state: CartState ): CartMeta => {
return state.metaData || defaultCartState.metaData;
};
/**
* Retrieves cart errors from state.
*/
export const getCartErrors = ( state: CartState ): ApiErrorResponse[] => {
return state.errors;
};
/**
* Returns true if any coupon is being applied.
*
* @param {CartState} state The current state.
* @return {boolean} True if a coupon is being applied.
*/
export const isApplyingCoupon = ( state: CartState ): boolean => {
return !! state.metaData.applyingCoupon;
};
/**
* Returns true if cart is stale, false if it is not.
*
* @param {CartState} state The current state.
* @return {boolean} True if the cart data is stale.
*/
export const isCartDataStale = ( state: CartState ): boolean => {
return state.metaData.isCartDataStale;
};
/**
* Retrieves the coupon code currently being applied.
*
* @param {CartState} state The current state.
* @return {string} The data to return.
*/
export const getCouponBeingApplied = ( state: CartState ): string => {
return state.metaData.applyingCoupon || '';
};
/**
* Returns true if any coupon is being removed.
*
* @param {CartState} state The current state.
* @return {boolean} True if a coupon is being removed.
*/
export const isRemovingCoupon = ( state: CartState ): boolean => {
return !! state.metaData.removingCoupon;
};
/**
* Retrieves the coupon code currently being removed.
*
* @param {CartState} state The current state.
* @return {string} The data to return.
*/
export const getCouponBeingRemoved = ( state: CartState ): string => {
return state.metaData.removingCoupon || '';
};
/**
* Returns cart item matching specified key.
*
* @param {CartState} state The current state.
* @param {string} cartItemKey Key for a cart item.
* @return {CartItem | void} Cart item object, or undefined if not found.
*/
export const getCartItem = (
state: CartState,
cartItemKey: string
): CartItem | void => {
return state.cartData.items.find(
( cartItem ) => cartItem.key === cartItemKey
);
};
/**
* Returns true if the specified cart item quantity is being updated.
*
* @param {CartState} state The current state.
* @param {string} cartItemKey Key for a cart item.
* @return {boolean} True if a item has a pending request to be updated.
*/
export const isItemPendingQuantity = (
state: CartState,
cartItemKey: string
): boolean => {
return state.cartItemsPendingQuantity.includes( cartItemKey );
};
/**
* Returns true if the specified cart item quantity is being updated.
*
* @param {CartState} state The current state.
* @param {string} cartItemKey Key for a cart item.
* @return {boolean} True if a item has a pending request to be updated.
*/
export const isItemPendingDelete = (
state: CartState,
cartItemKey: string
): boolean => {
return state.cartItemsPendingDelete.includes( cartItemKey );
};
/**
* Retrieves if the address is being applied for shipping.
*
* @param {CartState} state The current state.
* @return {boolean} are shipping rates loading.
*/
export const isCustomerDataUpdating = ( state: CartState ): boolean => {
return !! state.metaData.updatingCustomerData;
};
/**
* Retrieves if the shipping rate selection is being persisted.
*
* @param {CartState} state The current state.
*
* @return {boolean} True if the shipping rate selection is being persisted to
* the server.
*/
export const isShippingRateBeingSelected = ( state: CartState ): boolean => {
return !! state.metaData.updatingSelectedRate;
};
/**
* Retrieves the item keys for items whose quantity is currently being updated.
*/
export const getItemsPendingQuantityUpdate = ( state: CartState ): string[] => {
return state.cartItemsPendingQuantity;
};
/**
* Retrieves the item keys for items that are currently being deleted.
*/
export const getItemsPendingDelete = ( state: CartState ): string[] => {
return state.cartItemsPendingDelete;
};

View File

@@ -0,0 +1,196 @@
/**
* External dependencies
*/
import { dispatch, select } from '@wordpress/data';
import { previewCart } from '@woocommerce/resource-previews';
import { Cart } from '@woocommerce/types';
import { camelCaseKeys } from '@woocommerce/base-utils';
/**
* Internal dependencies
*/
import { notifyQuantityChanges } from '../notify-quantity-changes';
// Deep clone an object to avoid mutating it later.
const cloneObject = ( obj ) => JSON.parse( JSON.stringify( obj ) );
jest.mock( '@wordpress/data' );
const mockedCreateInfoNotice = jest.fn();
dispatch.mockImplementation( ( store ) => {
if ( store === 'core/notices' ) {
return {
createInfoNotice: mockedCreateInfoNotice,
};
}
} );
select.mockImplementation( () => {
return {
hasFinishedResolution() {
return true;
},
};
} );
/**
* Clones the preview cart and turns it into a `Cart`.
*/
const getFreshCarts = (): { oldCart: Cart; newCart: Cart } => {
const oldCart = camelCaseKeys( cloneObject( previewCart ) ) as Cart;
const newCart = camelCaseKeys( cloneObject( previewCart ) ) as Cart;
return { oldCart, newCart };
};
describe( 'notifyQuantityChanges', () => {
afterEach( () => {
jest.clearAllMocks();
} );
it( 'shows notices when the quantity limits of an item change', () => {
const { oldCart, newCart } = getFreshCarts();
newCart.items[ 0 ].quantity_limits.minimum = 50;
notifyQuantityChanges( {
oldCart,
newCart,
cartItemsPendingQuantity: [],
} );
expect( mockedCreateInfoNotice ).toHaveBeenLastCalledWith(
'The quantity of "Beanie" was increased to 50. This is the minimum required quantity.',
{
context: 'wc/cart',
speak: true,
type: 'snackbar',
id: '1-quantity-update',
}
);
newCart.items[ 0 ].quantity_limits.minimum = 1;
newCart.items[ 0 ].quantity_limits.maximum = 10;
// Quantity needs to be outside the limits for the notice to show.
newCart.items[ 0 ].quantity = 11;
notifyQuantityChanges( {
oldCart,
newCart,
cartItemsPendingQuantity: [],
} );
expect( mockedCreateInfoNotice ).toHaveBeenLastCalledWith(
'The quantity of "Beanie" was decreased to 10. This is the maximum allowed quantity.',
{
context: 'wc/cart',
speak: true,
type: 'snackbar',
id: '1-quantity-update',
}
);
newCart.items[ 0 ].quantity = 10;
oldCart.items[ 0 ].quantity = 10;
newCart.items[ 0 ].quantity_limits.multiple_of = 6;
notifyQuantityChanges( {
oldCart,
newCart,
cartItemsPendingQuantity: [],
} );
expect( mockedCreateInfoNotice ).toHaveBeenLastCalledWith(
'The quantity of "Beanie" was changed to 6. You must purchase this product in groups of 6.',
{
context: 'wc/cart',
speak: true,
type: 'snackbar',
id: '1-quantity-update',
}
);
} );
it( 'does not show notices if the quantity limit changes, and the quantity is within limits', () => {
const { oldCart, newCart } = getFreshCarts();
newCart.items[ 0 ].quantity = 5;
oldCart.items[ 0 ].quantity = 5;
newCart.items[ 0 ].quantity_limits.maximum = 10;
notifyQuantityChanges( {
oldCart,
newCart,
cartItemsPendingQuantity: [],
} );
expect( mockedCreateInfoNotice ).not.toHaveBeenCalled();
newCart.items[ 0 ].quantity_limits.minimum = 4;
notifyQuantityChanges( {
oldCart,
newCart,
cartItemsPendingQuantity: [],
} );
expect( mockedCreateInfoNotice ).not.toHaveBeenCalled();
} );
it( 'shows notices when the quantity of an item changes', () => {
const { oldCart, newCart } = getFreshCarts();
newCart.items[ 0 ].quantity = 50;
notifyQuantityChanges( {
oldCart,
newCart,
cartItemsPendingQuantity: [],
} );
expect( mockedCreateInfoNotice ).toHaveBeenLastCalledWith(
'The quantity of "Beanie" was changed to 50.',
{
context: 'wc/cart',
speak: true,
type: 'snackbar',
id: '1-quantity-update',
}
);
} );
it( 'does not show notices when the the item is the one being updated', () => {
const { oldCart, newCart } = getFreshCarts();
newCart.items[ 0 ].quantity = 5;
newCart.items[ 0 ].quantity_limits.maximum = 10;
notifyQuantityChanges( {
oldCart,
newCart,
cartItemsPendingQuantity: [ '1' ],
} );
expect( mockedCreateInfoNotice ).not.toHaveBeenCalled();
} );
it( 'does not show notices when a deleted item is the one being removed', () => {
const { oldCart, newCart } = getFreshCarts();
// Remove both items from the new cart.
delete newCart.items[ 0 ];
delete newCart.items[ 1 ];
notifyQuantityChanges( {
oldCart,
newCart,
// This means the user is only actively removing item with key '1'. The second item is "unexpected" so we
// expect exactly one notification to be shown.
cartItemsPendingDelete: [ '1' ],
} );
// Check it was called for item 2, but not item 1.
expect( mockedCreateInfoNotice ).toHaveBeenCalledTimes( 1 );
} );
it( 'shows a notice when an item is unexpectedly removed', () => {
const { oldCart, newCart } = getFreshCarts();
delete newCart.items[ 0 ];
notifyQuantityChanges( {
oldCart,
newCart,
} );
expect( mockedCreateInfoNotice ).toHaveBeenLastCalledWith(
'"Beanie" was removed from your cart.',
{
context: 'wc/cart',
speak: true,
type: 'snackbar',
id: '1-removed',
}
);
} );
it( 'does not show notices if the cart has not finished resolving', () => {
select.mockImplementation( () => {
return {
hasFinishedResolution() {
return false;
},
};
} );
expect( mockedCreateInfoNotice ).not.toHaveBeenCalled();
} );
} );

View File

@@ -0,0 +1,187 @@
/**
* External dependencies
*/
import * as wpDataFunctions from '@wordpress/data';
import { CART_STORE_KEY, VALIDATION_STORE_KEY } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import { pushChanges } from '../push-changes';
// When first updating the customer data, we want to simulate a rejected update.
const updateCustomerDataMock = jest.fn().mockRejectedValue( 'error' );
const getCustomerDataMock = jest.fn().mockReturnValue( {
billingAddress: {
first_name: 'John',
last_name: 'Doe',
address_1: '123 Main St',
address_2: '',
city: 'New York',
state: 'NY',
postcode: '10001',
country: 'US',
email: 'john.doe@mail.com',
phone: '555-555-5555',
},
shippingAddress: {
first_name: 'John',
last_name: 'Doe',
address_1: '123 Main St',
address_2: '',
city: 'New York',
state: 'NY',
postcode: '10001',
country: 'US',
phone: '555-555-5555',
},
} );
// Mocking select and dispatch here so we can control the actions/selectors used in pushChanges.
jest.mock( '@wordpress/data', () => ( {
...jest.requireActual( '@wordpress/data' ),
__esModule: true,
select: jest.fn(),
dispatch: jest.fn(),
} ) );
// Mocking processErrorResponse because we don't actually care about processing the error response, we just don't want
// pushChanges to throw an error.
jest.mock( '../../utils', () => ( {
...jest.requireActual( '../../utils' ),
__esModule: true,
processErrorResponse: jest.fn(),
} ) );
// Mocking updatePaymentMethods because this uses the mocked debounce earlier, and causes an error. Moreover, we don't
// need to update payment methods, they are not relevant to the tests in this file.
jest.mock( '../update-payment-methods', () => ( {
debouncedUpdatePaymentMethods: jest.fn(),
updatePaymentMethods: jest.fn(),
} ) );
describe( 'pushChanges', () => {
beforeEach( () => {
wpDataFunctions.select.mockImplementation( ( storeName: string ) => {
if ( storeName === CART_STORE_KEY ) {
return {
...jest
.requireActual( '@wordpress/data' )
.select( storeName ),
hasFinishedResolution: () => true,
getCustomerData: getCustomerDataMock,
};
}
if ( storeName === VALIDATION_STORE_KEY ) {
return {
...jest
.requireActual( '@wordpress/data' )
.select( storeName ),
getValidationError: jest.fn().mockReturnValue( undefined ),
};
}
return jest.requireActual( '@wordpress/data' ).select( storeName );
} );
wpDataFunctions.dispatch.mockImplementation( ( storeName: string ) => {
if ( storeName === CART_STORE_KEY ) {
return {
...jest
.requireActual( '@wordpress/data' )
.dispatch( storeName ),
updateCustomerData: updateCustomerDataMock,
};
}
return jest
.requireActual( '@wordpress/data' )
.dispatch( storeName );
} );
} );
it( 'Keeps props dirty if data did not persist due to an error', async () => {
// Run this without changing anything because the first run does not push data (the first run is populating what was received on page load).
pushChanges( false );
// Mock the returned value of `getCustomerData` to simulate a change in the shipping address.
getCustomerDataMock.mockReturnValue( {
billingAddress: {
first_name: 'John',
last_name: 'Doe',
address_1: '123 Main St',
address_2: '',
city: 'New York',
state: 'NY',
postcode: '10001',
country: 'US',
email: 'john.doe@mail.com',
phone: '555-555-5555',
},
shippingAddress: {
first_name: 'John',
last_name: 'Doe',
address_1: '123 Main St',
address_2: '',
city: 'Houston',
state: 'TX',
postcode: 'ABCDEF',
country: 'US',
phone: '555-555-5555',
},
} );
// Push these changes to the server, the `updateCustomerData` mock is set to reject (in the original mock at the top of the file), to simulate a server error.
pushChanges( false );
// Check that the mock was called with only the updated data.
await expect( updateCustomerDataMock ).toHaveBeenCalledWith( {
shipping_address: {
city: 'Houston',
state: 'TX',
postcode: 'ABCDEF',
},
} );
// This assertion is required to ensure the async `catch` block in `pushChanges` is done executing and all side effects finish.
await expect( updateCustomerDataMock ).toHaveReturned();
// Reset the mock so that it no longer rejects.
updateCustomerDataMock.mockReset();
updateCustomerDataMock.mockResolvedValue( jest.fn() );
// Simulate the user updating the postcode only.
getCustomerDataMock.mockReturnValue( {
billingAddress: {
first_name: 'John',
last_name: 'Doe',
address_1: '123 Main St',
address_2: '',
city: 'New York',
state: 'NY',
postcode: '10001',
country: 'US',
email: 'john.doe@mail.com',
phone: '555-555-5555',
},
shippingAddress: {
first_name: 'John',
last_name: 'Doe',
address_1: '123 Main St',
address_2: '',
city: 'Houston',
state: 'TX',
postcode: '77058',
country: 'US',
phone: '555-555-5555',
},
} );
// Although only one property was updated between calls, we should expect City, State, and Postcode to be pushed
// to the server because the previous push failed when they were originally changed.
pushChanges( false );
await expect( updateCustomerDataMock ).toHaveBeenLastCalledWith( {
shipping_address: {
city: 'Houston',
state: 'TX',
postcode: '77058',
},
} );
} );
} );

View File

@@ -0,0 +1,94 @@
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
import cartReducer from '../reducers';
import { ACTION_TYPES as types } from '../action-types';
describe( 'cartReducer', () => {
const originalState = deepFreeze( {
cartData: {
coupons: [],
items: [],
fees: [],
itemsCount: 0,
itemsWeight: 0,
needsShipping: true,
totals: {},
},
metaData: {},
errors: [
{
code: '100',
message: 'Test Error',
data: {},
},
],
} );
it( 'sets expected state when a cart is received', () => {
const testAction = {
type: types.SET_CART_DATA,
response: {
coupons: [],
items: [],
fees: [],
itemsCount: 0,
itemsWeight: 0,
needsShipping: true,
totals: {},
},
};
const newState = cartReducer( originalState, testAction );
expect( newState ).not.toBe( originalState );
expect( newState.cartData ).toEqual( {
coupons: [],
items: [],
fees: [],
itemsCount: 0,
itemsWeight: 0,
needsShipping: true,
totals: {},
} );
} );
it( 'sets expected state when errors are set', () => {
const testAction = {
type: types.SET_ERROR_DATA,
error: {
code: '101',
message: 'Test Error',
data: {},
},
};
const newState = cartReducer( originalState, testAction );
expect( newState ).not.toBe( originalState );
expect( newState.errors ).toEqual( [
{
code: '101',
message: 'Test Error',
data: {},
},
] );
} );
it( 'sets expected state when a coupon is applied', () => {
const testAction = {
type: types.APPLYING_COUPON,
couponCode: 'APPLYME',
};
const newState = cartReducer( originalState, testAction );
expect( newState ).not.toBe( originalState );
expect( newState.metaData.applyingCoupon ).toEqual( 'APPLYME' );
} );
it( 'sets expected state when a coupon is removed', () => {
const testAction = {
type: types.REMOVING_COUPON,
couponCode: 'REMOVEME',
};
const newState = cartReducer( originalState, testAction );
expect( newState ).not.toBe( originalState );
expect( newState.metaData.removingCoupon ).toEqual( 'REMOVEME' );
} );
} );

View File

@@ -0,0 +1,54 @@
/**
* External dependencies
*/
import { dispatch } from '@wordpress/data';
import apiFetch from '@wordpress/api-fetch';
/**
* Internal dependencies
*/
import { getCartData } from '../resolvers';
import { CART_STORE_KEY } from '..';
jest.mock( '@wordpress/data-controls' );
jest.mock( '@wordpress/api-fetch' );
describe( 'getCartData', () => {
it( 'when apiFetch returns a valid response, receives the cart correctly', async () => {
const mockDispatch = {
...dispatch( CART_STORE_KEY ),
receiveCart: jest.fn(),
receiveError: jest.fn(),
};
apiFetch.mockReturnValue( {
coupons: [],
items: [],
fees: [],
itemsCount: 0,
itemsWeight: 0,
needsShipping: true,
totals: {},
} );
await getCartData()( { dispatch: mockDispatch } );
expect( mockDispatch.receiveCart ).toHaveBeenCalledWith( {
coupons: [],
items: [],
fees: [],
itemsCount: 0,
itemsWeight: 0,
needsShipping: true,
totals: {},
} );
expect( mockDispatch.receiveError ).not.toHaveBeenCalled();
} );
it( 'when apiFetch returns an invalid response, dispatches the correct error action', async () => {
const mockDispatch = {
...dispatch( CART_STORE_KEY ),
receiveCart: jest.fn(),
receiveError: jest.fn(),
};
apiFetch.mockReturnValue( undefined );
await getCartData()( { dispatch: mockDispatch } );
expect( mockDispatch.receiveCart ).not.toHaveBeenCalled();
expect( mockDispatch.receiveError ).toHaveBeenCalled();
} );
} );

View File

@@ -0,0 +1,204 @@
/**
* Internal dependencies
*/
import {
getCartData,
getCartTotals,
getCartMeta,
getCartErrors,
isApplyingCoupon,
getCouponBeingApplied,
isRemovingCoupon,
getCouponBeingRemoved,
} from '../selectors';
const state = {
cartData: {
coupons: [
{
code: 'test',
totals: {
currency_code: 'GBP',
currency_symbol: '£',
currency_minor_unit: 2,
currency_decimal_separator: '.',
currency_thousand_separator: ',',
currency_prefix: '£',
currency_suffix: '',
total_discount: '583',
total_discount_tax: '117',
},
},
],
items: [
{
key: '1f0e3dad99908345f7439f8ffabdffc4',
id: 19,
quantity: 1,
name: 'Album',
short_description: '<p>This is a simple, virtual product.</p>',
description:
'<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum sagittis orci ac odio dictum tincidunt. Donec ut metus leo. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Sed luctus, dui eu sagittis sodales, nulla nibh sagittis augue, vel porttitor diam enim non metus. Vestibulum aliquam augue neque. Phasellus tincidunt odio eget ullamcorper efficitur. Cras placerat ut turpis pellentesque vulputate. Nam sed consequat tortor. Curabitur finibus sapien dolor. Ut eleifend tellus nec erat pulvinar dignissim. Nam non arcu purus. Vivamus et massa massa.</p>',
sku: 'woo-album',
low_stock_remaining: null,
permalink: 'http://local.wordpress.test/product/album/',
images: [
{
id: 48,
src: 'http://local.wordpress.test/wp-content/uploads/2019/12/album-1.jpg',
thumbnail:
'http://local.wordpress.test/wp-content/uploads/2019/12/album-1-324x324.jpg',
srcset: 'http://local.wordpress.test/wp-content/uploads/2019/12/album-1.jpg 800w, http://local.wordpress.test/wp-content/uploads/2019/12/album-1-324x324.jpg 324w, http://local.wordpress.test/wp-content/uploads/2019/12/album-1-100x100.jpg 100w, http://local.wordpress.test/wp-content/uploads/2019/12/album-1-416x416.jpg 416w, http://local.wordpress.test/wp-content/uploads/2019/12/album-1-300x300.jpg 300w, http://local.wordpress.test/wp-content/uploads/2019/12/album-1-150x150.jpg 150w, http://local.wordpress.test/wp-content/uploads/2019/12/album-1-768x768.jpg 768w',
sizes: '(max-width: 800px) 100vw, 800px',
name: 'album-1.jpg',
alt: '',
},
],
variation: [],
totals: {
currency_code: 'GBP',
currency_symbol: '£',
currency_minor_unit: 2,
currency_decimal_separator: '.',
currency_thousand_separator: ',',
currency_prefix: '£',
currency_suffix: '',
line_subtotal: '1250',
line_subtotal_tax: '250',
line_total: '1000',
line_total_tax: '200',
},
},
{
key: '6512bd43d9caa6e02c990b0a82652dca',
id: 11,
quantity: 1,
name: 'Beanie',
short_description: '<p>This is a simple product.</p>',
description:
'<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>',
sku: 'woo-beanie',
low_stock_remaining: null,
permalink: 'http://local.wordpress.test/product/beanie/',
images: [
{
id: 40,
src: 'http://local.wordpress.test/wp-content/uploads/2019/12/beanie-2.jpg',
thumbnail:
'http://local.wordpress.test/wp-content/uploads/2019/12/beanie-2-324x324.jpg',
srcset: 'http://local.wordpress.test/wp-content/uploads/2019/12/beanie-2.jpg 801w, http://local.wordpress.test/wp-content/uploads/2019/12/beanie-2-324x324.jpg 324w, http://local.wordpress.test/wp-content/uploads/2019/12/beanie-2-100x100.jpg 100w, http://local.wordpress.test/wp-content/uploads/2019/12/beanie-2-416x416.jpg 416w, http://local.wordpress.test/wp-content/uploads/2019/12/beanie-2-300x300.jpg 300w, http://local.wordpress.test/wp-content/uploads/2019/12/beanie-2-150x150.jpg 150w, http://local.wordpress.test/wp-content/uploads/2019/12/beanie-2-768x768.jpg 768w',
sizes: '(max-width: 801px) 100vw, 801px',
name: 'beanie-2.jpg',
alt: '',
},
],
variation: [],
totals: {
currency_code: 'GBP',
currency_symbol: '£',
currency_minor_unit: 2,
currency_decimal_separator: '.',
currency_thousand_separator: ',',
currency_prefix: '£',
currency_suffix: '',
line_subtotal: '1667',
line_subtotal_tax: '333',
line_total: '1333',
line_total_tax: '267',
},
},
],
items_count: 2,
items_weight: 0,
needs_payment: true,
needs_shipping: true,
totals: {
currency_code: 'GBP',
currency_symbol: '£',
currency_minor_unit: 2,
currency_decimal_separator: '.',
currency_thousand_separator: ',',
currency_prefix: '£',
currency_suffix: '',
total_items: '2917',
total_items_tax: '583',
total_fees: '0',
total_fees_tax: '0',
total_discount: '583',
total_discount_tax: '117',
total_shipping: '2000',
total_shipping_tax: '400',
total_price: '5200',
total_tax: '867',
tax_lines: [
{
name: 'Tax',
price: '867',
},
],
},
},
metaData: {
applyingCoupon: 'test-coupon',
removingCoupon: 'test-coupon2',
},
errors: [
{
code: '100',
message: 'Test Error',
data: {},
},
],
};
describe( 'getCartData', () => {
it( 'returns expected values for items existing in state', () => {
expect( getCartData( state ) ).toEqual( state.cartData );
} );
} );
describe( 'getCartTotals', () => {
it( 'returns expected values for items existing in state', () => {
expect( getCartTotals( state ) ).toEqual( state.cartData.totals );
} );
} );
describe( 'getCartMeta', () => {
it( 'returns expected values for items existing in state', () => {
expect( getCartMeta( state ) ).toEqual( state.metaData );
} );
} );
describe( 'getCartErrors', () => {
it( 'returns expected values for items existing in state', () => {
expect( getCartErrors( state ) ).toEqual( state.errors );
} );
} );
describe( 'isApplyingCoupon', () => {
it( 'returns expected values for items existing in state', () => {
expect( isApplyingCoupon( state ) ).toEqual( true );
} );
} );
describe( 'getCouponBeingApplied', () => {
it( 'returns expected values for items existing in state', () => {
expect( getCouponBeingApplied( state ) ).toEqual(
state.metaData.applyingCoupon
);
} );
} );
describe( 'isRemovingCoupon', () => {
it( 'returns expected values for items existing in state', () => {
expect( isRemovingCoupon( state ) ).toEqual( true );
} );
} );
describe( 'getCouponBeingRemoved', () => {
it( 'returns expected values for items existing in state', () => {
expect( getCouponBeingRemoved( state ) ).toEqual(
state.metaData.removingCoupon
);
} );
} );

View File

@@ -0,0 +1,61 @@
/**
* External dependencies
*/
import {
Cart,
CartResponse,
ApiErrorResponse,
isApiErrorResponse,
} from '@woocommerce/types';
import { camelCaseKeys } from '@woocommerce/base-utils';
/**
* Internal dependencies
*/
import { notifyQuantityChanges } from './notify-quantity-changes';
import { notifyCartErrors } from './notify-errors';
import { CartDispatchFromMap, CartSelectFromMap } from './index';
/**
* A thunk used in updating the store with the cart items retrieved from a request. This also notifies the shopper
* of any unexpected quantity changes occurred.
*
* @param {CartResponse} response
*/
export const receiveCart =
( response: CartResponse ) =>
( {
dispatch,
select,
}: {
dispatch: CartDispatchFromMap;
select: CartSelectFromMap;
} ) => {
const newCart = camelCaseKeys( response ) as unknown as Cart;
const oldCart = select.getCartData();
notifyCartErrors( newCart.errors, oldCart.errors );
notifyQuantityChanges( {
oldCart,
newCart,
cartItemsPendingQuantity: select.getItemsPendingQuantityUpdate(),
cartItemsPendingDelete: select.getItemsPendingDelete(),
} );
dispatch.setCartData( newCart );
};
/**
* A thunk used in updating the store with cart errors retrieved from a request.
*/
export const receiveError =
( response: ApiErrorResponse | null = null ) =>
( { dispatch }: { dispatch: CartDispatchFromMap } ) => {
if ( isApiErrorResponse( response ) ) {
dispatch.setErrorData( response );
if ( response.data?.cart ) {
dispatch.receiveCart( response?.data?.cart );
}
}
};
export type Thunks = typeof receiveCart | typeof receiveError;

View File

@@ -0,0 +1,35 @@
/**
* External dependencies
*/
import { dispatch, select } from '@wordpress/data';
import { debounce } from '@woocommerce/base-utils';
/**
* Internal dependencies
*/
import { STORE_KEY as PAYMENT_STORE_KEY } from '../payment/constants';
import { STORE_KEY } from './constants';
/**
* This function is used to update payment methods when the cart changes, or on first load.
*
* @return {boolean} True if the __internalUpdateAvailablePaymentMethods action was dispatched, false if not.
*/
export const updatePaymentMethods = async () => {
const isInitialized =
select( STORE_KEY ).hasFinishedResolution( 'getCartData' );
if ( ! isInitialized ) {
return false;
}
await dispatch(
PAYMENT_STORE_KEY
).__internalUpdateAvailablePaymentMethods();
return true;
};
// We debounce this because it's possible for multiple cart updates to happen in quick succession, we don't want to run
// each payment method's canMakePayment function on every single change.
export const debouncedUpdatePaymentMethods = debounce(
updatePaymentMethods,
1000
);

View File

@@ -0,0 +1,118 @@
/**
* External dependencies
*/
import { select } from '@wordpress/data';
import { camelCaseKeys } from '@woocommerce/base-utils';
import { isEmail } from '@wordpress/url';
import {
CartBillingAddress,
CartShippingAddress,
Cart,
CartResponse,
} from '@woocommerce/types';
/**
* Internal dependencies
*/
import { STORE_KEY as VALIDATION_STORE_KEY } from '../validation/constants';
export const mapCartResponseToCart = ( responseCart: CartResponse ): Cart => {
return camelCaseKeys( responseCart ) as unknown as Cart;
};
export const shippingAddressHasValidationErrors = () => {
const validationStore = select( VALIDATION_STORE_KEY );
// Check if the shipping address form has validation errors - if not then we know the full required
// address has been pushed to the server.
const stateValidationErrors =
validationStore.getValidationError( 'shipping_state' );
const address1ValidationErrors =
validationStore.getValidationError( 'shipping_address_1' );
const countryValidationErrors =
validationStore.getValidationError( 'shipping_country' );
const postcodeValidationErrors =
validationStore.getValidationError( 'shipping_postcode' );
const cityValidationErrors =
validationStore.getValidationError( 'shipping_city' );
return [
cityValidationErrors,
stateValidationErrors,
address1ValidationErrors,
countryValidationErrors,
postcodeValidationErrors,
].some( ( entry ) => typeof entry !== 'undefined' );
};
export type BaseAddressKey =
| keyof CartBillingAddress
| keyof CartShippingAddress;
/**
* Normalizes address values before push.
*/
export const normalizeAddressProp = (
key: BaseAddressKey,
value?: string | undefined
) => {
// Skip normalizing for any non string field
if ( typeof value !== 'string' ) {
return value;
}
if ( key === 'email' ) {
return isEmail( value ) ? value.trim() : '';
}
if ( key === 'postcode' ) {
return value.replace( ' ', '' ).toUpperCase();
}
return value.trim();
};
/**
* Compares two address objects and returns an array of keys that have changed.
*/
export const getDirtyKeys = <
T extends CartBillingAddress & CartShippingAddress
>(
// An object containing all previous address information
previousAddress: Partial< T >,
// An object containing all address information.
address: Partial< T >
): BaseAddressKey[] => {
const previousAddressKeys = Object.keys(
previousAddress
) as BaseAddressKey[];
return previousAddressKeys.filter( ( key: BaseAddressKey ) => {
return (
normalizeAddressProp( key, previousAddress[ key ] ) !==
normalizeAddressProp( key, address[ key ] )
);
} );
};
/**
* Validates dirty props before push.
*/
export const validateDirtyProps = ( dirtyProps: {
billingAddress: BaseAddressKey[];
shippingAddress: BaseAddressKey[];
} ): boolean => {
const validationStore = select( VALIDATION_STORE_KEY );
const invalidProps = [
...dirtyProps.billingAddress.filter( ( key ) => {
return (
validationStore.getValidationError( 'billing_' + key ) !==
undefined
);
} ),
...dirtyProps.shippingAddress.filter( ( key ) => {
return (
validationStore.getValidationError( 'shipping_' + key ) !==
undefined
);
} ),
].filter( Boolean );
return invalidProps.length === 0;
};

View File

@@ -0,0 +1,18 @@
export const ACTION_TYPES = {
SET_IDLE: 'SET_IDLE',
SET_REDIRECT_URL: 'SET_REDIRECT_URL',
SET_COMPLETE: 'SET_CHECKOUT_COMPLETE',
SET_BEFORE_PROCESSING: 'SET_BEFORE_PROCESSING',
SET_AFTER_PROCESSING: 'SET_AFTER_PROCESSING',
SET_PROCESSING: 'SET_CHECKOUT_IS_PROCESSING',
SET_HAS_ERROR: 'SET_CHECKOUT_HAS_ERROR',
SET_CUSTOMER_ID: 'SET_CHECKOUT_CUSTOMER_ID',
SET_ORDER_NOTES: 'SET_CHECKOUT_ORDER_NOTES',
INCREMENT_CALCULATING: 'INCREMENT_CALCULATING',
DECREMENT_CALCULATING: 'DECREMENT_CALCULATING',
SET_USE_SHIPPING_AS_BILLING: 'SET_USE_SHIPPING_AS_BILLING',
SET_SHOULD_CREATE_ACCOUNT: 'SET_SHOULD_CREATE_ACCOUNT',
SET_PREFERS_COLLECTION: 'SET_PREFERS_COLLECTION',
SET_EXTENSION_DATA: 'SET_EXTENSION_DATA',
SET_IS_CART: 'SET_IS_CART',
} as const;

View File

@@ -0,0 +1,168 @@
/**
* Internal dependencies
*/
import { ACTION_TYPES as types } from './action-types';
import { ReturnOrGeneratorYieldUnion } from '../mapped-types';
// `Thunks are functions that can be dispatched, similar to actions creators
export * from './thunks';
/**
* Set the checkout status to `idle`
*/
export const __internalSetIdle = () => ( {
type: types.SET_IDLE,
} );
/**
* Set the checkout status to `before_processing`
*/
export const __internalSetBeforeProcessing = () => ( {
type: types.SET_BEFORE_PROCESSING,
} );
/**
* Set the checkout status to `processing`
*/
export const __internalSetProcessing = () => ( {
type: types.SET_PROCESSING,
} );
/**
* Set the checkout status to `after_processing`
*/
export const __internalSetAfterProcessing = () => ( {
type: types.SET_AFTER_PROCESSING,
} );
/**
* Set the checkout status to `complete`
*/
export const __internalSetComplete = (
data: Record< string, unknown > = {}
) => ( {
type: types.SET_COMPLETE,
data,
} );
/**
* Set the url to redirect to after checkout completes`
*
* @param redirectUrl the url to redirect to
*/
export const __internalSetRedirectUrl = ( redirectUrl: string ) => ( {
type: types.SET_REDIRECT_URL,
redirectUrl,
} );
/**
* Set whether the checkout has an error or not
*
* @param hasError Wether the checkout has an error or not
*/
export const __internalSetHasError = ( hasError = true ) => ( {
type: types.SET_HAS_ERROR,
hasError,
} );
/**
* Used when any of the totals, taxes, shipping, etc need to be calculated, the `calculatingCount` will be increased
* A `calculatingCount` of 0 means nothing is being updated.
*/
export const __internalIncrementCalculating = () => ( {
type: types.INCREMENT_CALCULATING,
} );
/**
* When any of the totals, taxes, shipping, etc are done beign calculated, the `calculatingCount` will be decreased
* A `calculatingCount` of 0 means nothing is being updated.
*/
export const __internalDecrementCalculating = () => ( {
type: types.DECREMENT_CALCULATING,
} );
/**
* Set the customer id
*
* @param customerId ID of the customer who is checking out.
*/
export const __internalSetCustomerId = ( customerId: number ) => ( {
type: types.SET_CUSTOMER_ID,
customerId,
} );
/**
* Whether to use the shipping address as the billing address
*
* @param useShippingAsBilling True if shipping address should be the same as billing, false otherwise
*/
export const __internalSetUseShippingAsBilling = (
useShippingAsBilling: boolean
) => ( {
type: types.SET_USE_SHIPPING_AS_BILLING,
useShippingAsBilling,
} );
/**
* Whether an account should be created for the user while checking out
*
* @param shouldCreateAccount True if an account should be created, false otherwise
*/
export const __internalSetShouldCreateAccount = (
shouldCreateAccount: boolean
) => ( {
type: types.SET_SHOULD_CREATE_ACCOUNT,
shouldCreateAccount,
} );
/**
* Set the notes for the order
*
* @param orderNotes String that represents a note for the order
*/
export const __internalSetOrderNotes = ( orderNotes: string ) => ( {
type: types.SET_ORDER_NOTES,
orderNotes,
} );
export const setPrefersCollection = ( prefersCollection: boolean ) => ( {
type: types.SET_PREFERS_COLLECTION,
prefersCollection,
} );
/**
* Registers additional data under an extension namespace.
*/
export const __internalSetExtensionData = (
// The namespace for the extension. Defaults to 'default'. Must be unique to prevent conflicts.
namespace: string,
// Data to register under the namespace.
extensionData: Record< string, unknown >,
// If true, all data under the current extension namespace is replaced. If false, data is appended.
replace = false
) => ( {
type: types.SET_EXTENSION_DATA,
extensionData,
namespace,
replace,
} );
export type CheckoutAction =
| ReturnOrGeneratorYieldUnion<
| typeof __internalSetIdle
| typeof __internalSetComplete
| typeof __internalSetProcessing
| typeof __internalSetBeforeProcessing
| typeof __internalSetAfterProcessing
| typeof __internalSetRedirectUrl
| typeof __internalSetHasError
| typeof __internalIncrementCalculating
| typeof __internalDecrementCalculating
| typeof __internalSetCustomerId
| typeof __internalSetUseShippingAsBilling
| typeof __internalSetShouldCreateAccount
| typeof __internalSetOrderNotes
| typeof setPrefersCollection
| typeof __internalSetExtensionData
>
| Record< string, never >;

View File

@@ -0,0 +1,38 @@
/**
* External dependencies
*/
import {
BillingAddress,
getSetting,
ShippingAddress,
} from '@woocommerce/settings';
import { CheckoutResponseSuccess } from '@woocommerce/types';
export const STORE_KEY = 'wc/store/checkout';
export enum STATUS {
// When checkout state has changed but there is no activity happening.
IDLE = 'idle',
// After the AFTER_PROCESSING event emitters have completed. This status triggers the checkout redirect.
COMPLETE = 'complete',
// This is the state before checkout processing begins after the checkout button has been pressed/submitted.
BEFORE_PROCESSING = 'before_processing',
// After BEFORE_PROCESSING status emitters have finished successfully. Payment processing is started on this checkout status.
PROCESSING = 'processing',
// After server side checkout processing is completed this status is set
AFTER_PROCESSING = 'after_processing',
}
const preloadedCheckoutData = getSetting(
'checkoutData',
{}
) as Partial< CheckoutResponseSuccess >;
export const checkoutData = {
order_id: 0,
customer_id: 0,
billing_address: {} as BillingAddress,
shipping_address: {} as ShippingAddress,
...( preloadedCheckoutData || {} ),
};

View File

@@ -0,0 +1,51 @@
/**
* External dependencies
*/
import { isSameAddress } from '@woocommerce/base-utils';
/**
* Internal dependencies
*/
import { STATUS, checkoutData } from './constants';
export type CheckoutState = {
// Status of the checkout
status: STATUS;
// If any of the totals, taxes, shipping, etc need to be calculated, the count will be increased here
calculatingCount: number;
// True when the checkout is in an error state. Whatever caused the error (validation/payment method) will likely have triggered a notice.
hasError: boolean;
// This is the url that checkout will redirect to when it's ready.
redirectUrl: string;
// This is the ID for the draft order if one exists.
orderId: number;
// Order notes introduced by the user in the checkout form.
orderNotes: string;
// This is the ID of the customer the draft order belongs to.
customerId: number;
// Should the billing form be hidden and inherit the shipping address?
useShippingAsBilling: boolean;
// Should a user account be created?
shouldCreateAccount: boolean;
// If customer wants to checkout with a local pickup option.
prefersCollection?: boolean | undefined;
// Custom checkout data passed to the store API on processing.
extensionData: Record< string, Record< string, unknown > >;
};
export const defaultState: CheckoutState = {
redirectUrl: '',
status: STATUS.PRISTINE,
hasError: false,
orderId: checkoutData.order_id,
customerId: checkoutData.customer_id,
calculatingCount: 0,
orderNotes: '',
useShippingAsBilling: isSameAddress(
checkoutData.billing_address,
checkoutData.shipping_address
),
shouldCreateAccount: false,
prefersCollection: undefined,
extensionData: {},
};

View File

@@ -0,0 +1,35 @@
/**
* External dependencies
*/
import { createReduxStore, register } from '@wordpress/data';
/**
* Internal dependencies
*/
import { STORE_KEY } from './constants';
import * as selectors from './selectors';
import * as actions from './actions';
import reducer from './reducers';
import { DispatchFromMap, SelectFromMap } from '../mapped-types';
export const config = {
reducer,
selectors,
actions,
__experimentalUseThunks: true,
};
const store = createReduxStore( STORE_KEY, config );
register( store );
export const CHECKOUT_STORE_KEY = STORE_KEY;
declare module '@wordpress/data' {
function dispatch(
key: typeof CHECKOUT_STORE_KEY
): DispatchFromMap< typeof actions >;
function select( key: typeof CHECKOUT_STORE_KEY ): SelectFromMap<
typeof selectors
> & {
hasFinishedResolution: ( selector: string ) => boolean;
};
}

View File

@@ -0,0 +1,172 @@
/**
* Internal dependencies
*/
import { ACTION_TYPES as types } from './action-types';
import { STATUS } from './constants';
import { defaultState } from './default-state';
import { CheckoutAction } from './actions';
const reducer = ( state = defaultState, action: CheckoutAction ) => {
let newState = state;
switch ( action.type ) {
case types.SET_IDLE:
newState =
state.status !== STATUS.IDLE
? {
...state,
status: STATUS.IDLE,
}
: state;
break;
case types.SET_REDIRECT_URL:
newState =
action.redirectUrl !== undefined &&
action.redirectUrl !== state.redirectUrl
? {
...state,
redirectUrl: action.redirectUrl,
}
: state;
break;
case types.SET_COMPLETE:
newState = {
...state,
status: STATUS.COMPLETE,
redirectUrl:
typeof action.data?.redirectUrl === 'string'
? action.data.redirectUrl
: state.redirectUrl,
};
break;
case types.SET_PROCESSING:
newState = {
...state,
status: STATUS.PROCESSING,
hasError: false,
};
break;
case types.SET_BEFORE_PROCESSING:
newState = {
...state,
status: STATUS.BEFORE_PROCESSING,
hasError: false,
};
break;
case types.SET_AFTER_PROCESSING:
newState = {
...state,
status: STATUS.AFTER_PROCESSING,
};
break;
case types.SET_HAS_ERROR:
newState = {
...state,
hasError: action.hasError,
status:
state.status === STATUS.PROCESSING ||
state.status === STATUS.BEFORE_PROCESSING
? STATUS.IDLE
: state.status,
};
break;
case types.INCREMENT_CALCULATING:
newState = {
...state,
calculatingCount: state.calculatingCount + 1,
};
break;
case types.DECREMENT_CALCULATING:
newState = {
...state,
calculatingCount: Math.max( 0, state.calculatingCount - 1 ),
};
break;
case types.SET_CUSTOMER_ID:
if ( action.customerId !== undefined ) {
newState = {
...state,
customerId: action.customerId,
};
}
break;
case types.SET_USE_SHIPPING_AS_BILLING:
if (
action.useShippingAsBilling !== undefined &&
action.useShippingAsBilling !== state.useShippingAsBilling
) {
newState = {
...state,
useShippingAsBilling: action.useShippingAsBilling,
};
}
break;
case types.SET_SHOULD_CREATE_ACCOUNT:
if (
action.shouldCreateAccount !== undefined &&
action.shouldCreateAccount !== state.shouldCreateAccount
) {
newState = {
...state,
shouldCreateAccount: action.shouldCreateAccount,
};
}
break;
case types.SET_PREFERS_COLLECTION:
if (
action.prefersCollection !== undefined &&
action.prefersCollection !== state.prefersCollection
) {
newState = {
...state,
prefersCollection: action.prefersCollection,
};
}
break;
case types.SET_ORDER_NOTES:
if (
action.orderNotes !== undefined &&
state.orderNotes !== action.orderNotes
) {
newState = {
...state,
orderNotes: action.orderNotes,
};
}
break;
case types.SET_EXTENSION_DATA:
if (
action.extensionData !== undefined &&
action.namespace !== undefined
) {
newState = {
...state,
extensionData: {
...state.extensionData,
[ action.namespace ]: action.replace
? action.extensionData
: {
...state.extensionData[ action.namespace ],
...action.extensionData,
},
},
};
}
break;
}
return newState;
};
export default reducer;

View File

@@ -0,0 +1,97 @@
/**
* External dependencies
*/
import { select } from '@wordpress/data';
import { hasCollectableRate } from '@woocommerce/base-utils';
import { isString, objectHasProp } from '@woocommerce/types';
/**
* Internal dependencies
*/
import { STATUS } from './constants';
import { CheckoutState } from './default-state';
import { STORE_KEY as cartStoreKey } from '../cart/constants';
export const getCustomerId = ( state: CheckoutState ) => {
return state.customerId;
};
export const getOrderId = ( state: CheckoutState ) => {
return state.orderId;
};
export const getOrderNotes = ( state: CheckoutState ) => {
return state.orderNotes;
};
export const getRedirectUrl = ( state: CheckoutState ) => {
return state.redirectUrl;
};
export const getUseShippingAsBilling = ( state: CheckoutState ) => {
return state.useShippingAsBilling;
};
export const getExtensionData = ( state: CheckoutState ) => {
return state.extensionData;
};
export const getShouldCreateAccount = ( state: CheckoutState ) => {
return state.shouldCreateAccount;
};
export const getCheckoutStatus = ( state: CheckoutState ) => {
return state.status;
};
export const hasError = ( state: CheckoutState ) => {
return state.hasError;
};
export const hasOrder = ( state: CheckoutState ) => {
return !! state.orderId;
};
export const isComplete = ( state: CheckoutState ) => {
return state.status === STATUS.COMPLETE;
};
export const isIdle = ( state: CheckoutState ) => {
return state.status === STATUS.IDLE;
};
export const isBeforeProcessing = ( state: CheckoutState ) => {
return state.status === STATUS.BEFORE_PROCESSING;
};
export const isAfterProcessing = ( state: CheckoutState ) => {
return state.status === STATUS.AFTER_PROCESSING;
};
export const isProcessing = ( state: CheckoutState ) => {
return state.status === STATUS.PROCESSING;
};
export const isCalculating = ( state: CheckoutState ) => {
return state.calculatingCount > 0;
};
export const prefersCollection = ( state: CheckoutState ) => {
if ( typeof state.prefersCollection === 'undefined' ) {
const shippingRates = select( cartStoreKey ).getShippingRates();
if ( ! shippingRates || ! shippingRates.length ) {
return false;
}
const selectedRate = shippingRates[ 0 ].shipping_rates.find(
( rate ) => rate.selected
);
if (
objectHasProp( selectedRate, 'method_id' ) &&
isString( selectedRate.method_id )
) {
return hasCollectableRate( selectedRate?.method_id );
}
}
return state.prefersCollection;
};

View File

@@ -0,0 +1,288 @@
/**
* Internal dependencies
*/
import reducer from '../reducers';
import { defaultState } from '../default-state';
import { STATUS } from '../constants';
import * as actions from '../actions';
describe.only( 'Checkout Store Reducer', () => {
it( 'should return the initial state', () => {
expect( reducer( undefined, {} ) ).toEqual( defaultState );
} );
it( 'should handle SET_IDLE', () => {
const expectedState = {
...defaultState,
status: STATUS.IDLE,
};
expect( reducer( defaultState, actions.__internalSetIdle() ) ).toEqual(
expectedState
);
} );
it( 'should handle SET_REDIRECT_URL', () => {
const expectedState = {
...defaultState,
redirectUrl: 'https://example.com',
};
expect(
reducer(
defaultState,
actions.__internalSetRedirectUrl( 'https://example.com' )
)
).toEqual( expectedState );
} );
it( 'should handle SET_COMPLETE', () => {
const expectedState = {
...defaultState,
status: STATUS.COMPLETE,
redirectUrl: 'https://example.com',
};
expect(
reducer(
defaultState,
actions.__internalSetComplete( {
redirectUrl: 'https://example.com',
} )
)
).toEqual( expectedState );
} );
it( 'should handle SET_PROCESSING', () => {
const expectedState = {
...defaultState,
status: STATUS.PROCESSING,
};
expect(
reducer( defaultState, actions.__internalSetProcessing() )
).toEqual( expectedState );
} );
it( 'should handle SET_HAS_ERROR when status is PROCESSING', () => {
const initialState = { ...defaultState, status: STATUS.PROCESSING };
const expectedState = {
...defaultState,
hasError: true,
status: STATUS.IDLE,
};
expect(
reducer( initialState, actions.__internalSetHasError( true ) )
).toEqual( expectedState );
} );
it( 'should handle SET_HAS_ERROR when status is BEFORE_PROCESSING', () => {
const initialState = {
...defaultState,
status: STATUS.BEFORE_PROCESSING,
};
const expectedState = {
...defaultState,
hasError: true,
status: STATUS.IDLE,
};
expect(
reducer( initialState, actions.__internalSetHasError( true ) )
).toEqual( expectedState );
} );
it( 'should handle SET_HAS_ERROR when status is anything else', () => {
const initialState = {
...defaultState,
status: STATUS.AFTER_PROCESSING,
};
const expectedState = {
...defaultState,
hasError: false,
status: STATUS.AFTER_PROCESSING,
};
expect(
reducer( initialState, actions.__internalSetHasError( false ) )
).toEqual( expectedState );
} );
it( 'should handle SET_BEFORE_PROCESSING', () => {
const expectedState = {
...defaultState,
status: STATUS.BEFORE_PROCESSING,
};
expect(
reducer( defaultState, actions.__internalSetBeforeProcessing() )
).toEqual( expectedState );
} );
it( 'should handle SET_AFTER_PROCESSING', () => {
const expectedState = {
...defaultState,
status: STATUS.AFTER_PROCESSING,
};
expect(
reducer( defaultState, actions.__internalSetAfterProcessing() )
).toEqual( expectedState );
} );
it( 'should handle INCREMENT_CALCULATING', () => {
const expectedState = {
...defaultState,
calculatingCount: 1,
};
expect(
reducer( defaultState, actions.__internalIncrementCalculating() )
).toEqual( expectedState );
} );
it( 'should handle DECREMENT_CALCULATING', () => {
const initialState = {
...defaultState,
calculatingCount: 1,
};
const expectedState = {
...defaultState,
calculatingCount: 0,
};
expect(
reducer( initialState, actions.__internalDecrementCalculating() )
).toEqual( expectedState );
} );
it( 'should handle SET_CUSTOMER_ID', () => {
const expectedState = {
...defaultState,
customerId: 1,
};
expect(
reducer( defaultState, actions.__internalSetCustomerId( 1 ) )
).toEqual( expectedState );
} );
it( 'should handle SET_USE_SHIPPING_AS_BILLING', () => {
const expectedState = {
...defaultState,
useShippingAsBilling: false,
};
expect(
reducer(
defaultState,
actions.__internalSetUseShippingAsBilling( false )
)
).toEqual( expectedState );
} );
it( 'should handle SET_SHOULD_CREATE_ACCOUNT', () => {
const expectedState = {
...defaultState,
shouldCreateAccount: true,
};
expect(
reducer(
defaultState,
actions.__internalSetShouldCreateAccount( true )
)
).toEqual( expectedState );
} );
it( 'should handle SET_ORDER_NOTES', () => {
const expectedState = {
...defaultState,
orderNotes: 'test',
};
expect(
reducer( defaultState, actions.__internalSetOrderNotes( 'test' ) )
).toEqual( expectedState );
} );
describe( 'should handle SET_EXTENSION_DATA', () => {
it( 'should set data under a namespace', () => {
const mockExtensionData = {
extensionNamespace: {
testKey: 'test-value',
testKey2: 'test-value-2',
},
};
const expectedState = {
...defaultState,
extensionData: mockExtensionData,
};
expect(
reducer(
defaultState,
actions.__internalSetExtensionData(
'extensionNamespace',
mockExtensionData.extensionNamespace
)
)
).toEqual( expectedState );
} );
it( 'should append data under a namespace', () => {
const mockExtensionData = {
extensionNamespace: {
testKey: 'test-value',
testKey2: 'test-value-2',
},
};
const expectedState = {
...defaultState,
extensionData: mockExtensionData,
};
const firstState = reducer(
defaultState,
actions.__internalSetExtensionData( 'extensionNamespace', {
testKey: 'test-value',
} )
);
const secondState = reducer(
firstState,
actions.__internalSetExtensionData( 'extensionNamespace', {
testKey2: 'test-value-2',
} )
);
expect( secondState ).toEqual( expectedState );
} );
it( 'support replacing data under a namespace', () => {
const mockExtensionData = {
extensionNamespace: {
testKey: 'test-value',
},
};
const expectedState = {
...defaultState,
extensionData: mockExtensionData,
};
const firstState = reducer(
defaultState,
actions.__internalSetExtensionData( 'extensionNamespace', {
testKeyOld: 'test-value',
} )
);
const secondState = reducer(
firstState,
actions.__internalSetExtensionData(
'extensionNamespace',
{ testKey: 'test-value' },
true
)
);
expect( secondState ).toEqual( expectedState );
} );
} );
} );

View File

@@ -0,0 +1,137 @@
/**
* External dependencies
*/
import type { CheckoutResponse } from '@woocommerce/types';
import { store as noticesStore } from '@wordpress/notices';
import { dispatch as wpDispatch, select as wpSelect } from '@wordpress/data';
/**
* Internal dependencies
*/
import { STORE_KEY as PAYMENT_STORE_KEY } from '../payment/constants';
import { removeNoticesByStatus } from '../../utils/notices';
import {
getPaymentResultFromCheckoutResponse,
runCheckoutFailObservers,
runCheckoutSuccessObservers,
} from './utils';
import {
EVENTS,
emitEvent,
emitEventWithAbort,
} from '../../base/context/providers/cart-checkout/checkout-events/event-emit';
import type {
emitValidateEventType,
emitAfterProcessingEventsType,
} from './types';
import type { DispatchFromMap } from '../mapped-types';
import * as actions from './actions';
/**
* Based on the result of the payment, update the redirect url,
* set the payment processing response in the checkout data store
* and change the status to AFTER_PROCESSING
*/
export const __internalProcessCheckoutResponse = (
response: CheckoutResponse
) => {
return ( {
dispatch,
}: {
dispatch: DispatchFromMap< typeof actions >;
} ) => {
const paymentResult = getPaymentResultFromCheckoutResponse( response );
dispatch.__internalSetRedirectUrl( paymentResult?.redirectUrl || '' );
// The local `dispatch` here is bound to the actions of the data store. We need to use the global dispatch here
// to dispatch an action on a different store.
wpDispatch( PAYMENT_STORE_KEY ).__internalSetPaymentResult(
paymentResult
);
dispatch.__internalSetAfterProcessing();
};
};
/**
* Emit the CHECKOUT_VALIDATION event and process all
* registered observers
*/
export const __internalEmitValidateEvent: emitValidateEventType = ( {
observers,
setValidationErrors, // TODO: Fix this type after we move to validation store
} ) => {
return ( { dispatch, registry } ) => {
const { createErrorNotice } = registry.dispatch( noticesStore );
removeNoticesByStatus( 'error' );
emitEvent( observers, EVENTS.CHECKOUT_VALIDATION, {} ).then(
( response ) => {
if ( response !== true ) {
if ( Array.isArray( response ) ) {
response.forEach(
( {
errorMessage,
validationErrors,
context = 'wc/checkout',
} ) => {
createErrorNotice( errorMessage, { context } );
setValidationErrors( validationErrors );
}
);
}
dispatch.__internalSetIdle();
dispatch.__internalSetHasError();
} else {
dispatch.__internalSetProcessing();
}
}
);
};
};
/**
* Emit the CHECKOUT_FAIL if the checkout contains an error,
* or the CHECKOUT_SUCCESS if not. Set checkout errors according
* to the observer responses
*/
export const __internalEmitAfterProcessingEvents: emitAfterProcessingEventsType =
( { observers, notices } ) => {
return ( { select, dispatch, registry } ) => {
const { createErrorNotice } = registry.dispatch( noticesStore );
const data = {
redirectUrl: select.getRedirectUrl(),
orderId: select.getOrderId(),
customerId: select.getCustomerId(),
orderNotes: select.getOrderNotes(),
processingResponse:
wpSelect( PAYMENT_STORE_KEY ).getPaymentResult(),
};
if ( select.hasError() ) {
// allow payment methods or other things to customize the error
// with a fallback if nothing customizes it.
emitEventWithAbort(
observers,
EVENTS.CHECKOUT_FAIL,
data
).then( ( observerResponses ) => {
runCheckoutFailObservers( {
observerResponses,
notices,
dispatch,
createErrorNotice,
data,
} );
} );
} else {
emitEventWithAbort(
observers,
EVENTS.CHECKOUT_SUCCESS,
data
).then( ( observerResponses: unknown[] ) => {
runCheckoutSuccessObservers( {
observerResponses,
dispatch,
createErrorNotice,
} );
} );
}
};
};

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
export const ACTION_TYPES = {
RECEIVE_COLLECTION: 'RECEIVE_COLLECTION',
RESET_COLLECTION: 'RESET_COLLECTION',
ERROR: 'ERROR',
RECEIVE_LAST_MODIFIED: 'RECEIVE_LAST_MODIFIED',
INVALIDATE_RESOLUTION_FOR_STORE: 'INVALIDATE_RESOLUTION_FOR_STORE',
};

View File

@@ -0,0 +1,85 @@
/**
* Internal dependencies
*/
import { ACTION_TYPES as types } from './action-types';
let Headers = window.Headers || null;
Headers = Headers
? new Headers()
: { get: () => undefined, has: () => undefined };
/**
* Returns an action object used in updating the store with the provided items
* retrieved from a request using the given querystring.
*
* This is a generic response action.
*
* @param {string} namespace The namespace for the collection route.
* @param {string} resourceName The resource name for the collection route.
* @param {string} [queryString=''] The query string for the collection
* @param {Array} [ids=[]] An array of ids (in correct order) for the
* model.
* @param {Object} [response={}] An object containing the response from the
* collection request.
* @param {Array<*>} response.items An array of items for the given collection.
* @param {Headers} response.headers A Headers object from the response
* link https://developer.mozilla.org/en-US/docs/Web/API/Headers
* @param {boolean} [replace=false] If true, signals to replace the current
* items in the state with the provided
* items.
* @return {
* {
* type: string,
* namespace: string,
* resourceName: string,
* queryString: string,
* ids: Array<*>,
* items: Array<*>,
* }
* } Object for action.
*/
export function receiveCollection(
namespace,
resourceName,
queryString = '',
ids = [],
response = { items: [], headers: Headers },
replace = false
) {
return {
type: replace ? types.RESET_COLLECTION : types.RECEIVE_COLLECTION,
namespace,
resourceName,
queryString,
ids,
response,
};
}
export function receiveCollectionError(
namespace,
resourceName,
queryString,
ids,
error
) {
return {
type: 'ERROR',
namespace,
resourceName,
queryString,
ids,
response: {
items: [],
headers: Headers,
error,
},
};
}
export function receiveLastModified( timestamp ) {
return {
type: types.RECEIVE_LAST_MODIFIED,
timestamp,
};
}

View File

@@ -0,0 +1,2 @@
export const STORE_KEY = 'wc/store/collections';
export const DEFAULT_EMPTY_ARRAY = [];

View File

@@ -0,0 +1,27 @@
/**
* External dependencies
*/
import { register, createReduxStore } from '@wordpress/data';
import { controls as dataControls } from '@wordpress/data-controls';
/**
* Internal dependencies
*/
import { STORE_KEY } from './constants';
import * as selectors from './selectors';
import * as actions from './actions';
import * as resolvers from './resolvers';
import reducer from './reducers';
import { controls } from '../shared-controls';
const store = createReduxStore( STORE_KEY, {
reducer,
actions,
controls: { ...dataControls, ...controls },
selectors,
resolvers,
} );
register( store );
export const COLLECTIONS_STORE_KEY = STORE_KEY;

View File

@@ -0,0 +1,72 @@
/**
* Internal dependencies
*/
import { ACTION_TYPES as types } from './action-types';
import { hasInState, updateState } from '../utils';
/**
* Reducer for receiving items to a collection.
*
* @param {Object} state The current state in the store.
* @param {Object} action Action object.
*
* @return {Object} New or existing state depending on if there are
* any changes.
*/
const receiveCollection = ( state = {}, action ) => {
// Update last modified and previous last modified values.
if ( action.type === types.RECEIVE_LAST_MODIFIED ) {
if ( action.timestamp === state.lastModified ) {
return state;
}
return {
...state,
lastModified: action.timestamp,
};
}
// When invalidating data, remove stored values from state.
if ( action.type === types.INVALIDATE_RESOLUTION_FOR_STORE ) {
return {};
}
const { type, namespace, resourceName, queryString, response } = action;
// ids are stringified so they can be used as an index.
const ids = action.ids ? JSON.stringify( action.ids ) : '[]';
switch ( type ) {
case types.RECEIVE_COLLECTION:
if (
hasInState( state, [
namespace,
resourceName,
ids,
queryString,
] )
) {
return state;
}
state = updateState(
state,
[ namespace, resourceName, ids, queryString ],
response
);
break;
case types.RESET_COLLECTION:
state = updateState(
state,
[ namespace, resourceName, ids, queryString ],
response
);
break;
case types.ERROR:
state = updateState(
state,
[ namespace, resourceName, ids, queryString ],
response
);
break;
}
return state;
};
export default receiveCollection;

View File

@@ -0,0 +1,110 @@
/**
* External dependencies
*/
import { controls } from '@wordpress/data';
import { addQueryArgs } from '@wordpress/url';
/**
* Internal dependencies
*/
import { receiveCollection, receiveCollectionError } from './actions';
import { STORE_KEY as SCHEMA_STORE_KEY } from '../schema/constants';
import { STORE_KEY, DEFAULT_EMPTY_ARRAY } from './constants';
import { apiFetchWithHeadersControl } from '../shared-controls';
/**
* Check if the store needs invalidating due to a change in last modified headers.
*
* @param {number} timestamp Last update timestamp.
*/
function* invalidateModifiedCollection( timestamp ) {
const lastModified = yield controls.resolveSelect(
STORE_KEY,
'getCollectionLastModified'
);
if ( ! lastModified ) {
yield controls.dispatch( STORE_KEY, 'receiveLastModified', timestamp );
} else if ( timestamp > lastModified ) {
yield controls.dispatch( STORE_KEY, 'invalidateResolutionForStore' );
yield controls.dispatch( STORE_KEY, 'receiveLastModified', timestamp );
}
}
/**
* Resolver for retrieving a collection via a api route.
*
* @param {string} namespace
* @param {string} resourceName
* @param {Object} query
* @param {Array} ids
*/
export function* getCollection( namespace, resourceName, query, ids ) {
const route = yield controls.resolveSelect(
SCHEMA_STORE_KEY,
'getRoute',
namespace,
resourceName,
ids
);
const queryString = addQueryArgs( '', query );
if ( ! route ) {
yield receiveCollection( namespace, resourceName, queryString, ids );
return;
}
try {
const { response = DEFAULT_EMPTY_ARRAY, headers } =
yield apiFetchWithHeadersControl( { path: route + queryString } );
if ( headers && headers.get && headers.has( 'last-modified' ) ) {
// Do any invalidation before the collection is received to prevent
// this query running again.
yield invalidateModifiedCollection(
parseInt( headers.get( 'last-modified' ), 10 )
);
}
yield receiveCollection( namespace, resourceName, queryString, ids, {
items: response,
headers,
} );
} catch ( error ) {
yield receiveCollectionError(
namespace,
resourceName,
queryString,
ids,
error
);
}
}
/**
* Resolver for retrieving a specific collection header for the given arguments
*
* Note: This triggers the `getCollection` resolver if it hasn't been resolved
* yet.
*
* @param {string} header
* @param {string} namespace
* @param {string} resourceName
* @param {Object} query
* @param {Array} ids
*/
export function* getCollectionHeader(
header,
namespace,
resourceName,
query,
ids
) {
// feed the correct number of args in for the select so we don't resolve
// unnecessarily. Any undefined args will be excluded. This is important
// because resolver resolution is cached by both number and value of args.
const args = [ namespace, resourceName, query, ids ].filter(
( arg ) => typeof arg !== 'undefined'
);
// we call this simply to do any resolution of the collection if necessary.
yield controls.resolveSelect( STORE_KEY, 'getCollection', ...args );
}

View File

@@ -0,0 +1,144 @@
/**
* External dependencies
*/
import { addQueryArgs } from '@wordpress/url';
/**
* Internal dependencies
*/
import { hasInState } from '../utils';
import { DEFAULT_EMPTY_ARRAY } from './constants';
const getFromState = ( {
state,
namespace,
resourceName,
query,
ids,
type = 'items',
fallback = DEFAULT_EMPTY_ARRAY,
} ) => {
// prep ids and query for state retrieval
ids = JSON.stringify( ids );
query = query !== null ? addQueryArgs( '', query ) : '';
if ( hasInState( state, [ namespace, resourceName, ids, query, type ] ) ) {
return state[ namespace ][ resourceName ][ ids ][ query ][ type ];
}
return fallback;
};
const getCollectionHeaders = (
state,
namespace,
resourceName,
query = null,
ids = DEFAULT_EMPTY_ARRAY
) => {
return getFromState( {
state,
namespace,
resourceName,
query,
ids,
type: 'headers',
fallback: undefined,
} );
};
/**
* Retrieves the collection items from the state for the given arguments.
*
* @param {Object} state The current collections state.
* @param {string} namespace The namespace for the collection.
* @param {string} resourceName The resource name for the collection.
* @param {Object} [query=null] The query for the collection request.
* @param {Array} [ids=[]] Any ids for the collection request (these are
* values that would be added to the route for a
* route with id placeholders)
* @return {Array} an array of items stored in the collection.
*/
export const getCollection = (
state,
namespace,
resourceName,
query = null,
ids = DEFAULT_EMPTY_ARRAY
) => {
return getFromState( { state, namespace, resourceName, query, ids } );
};
export const getCollectionError = (
state,
namespace,
resourceName,
query = null,
ids = DEFAULT_EMPTY_ARRAY
) => {
return getFromState( {
state,
namespace,
resourceName,
query,
ids,
type: 'error',
fallback: null,
} );
};
/**
* This selector enables retrieving a specific header value from a given
* collection request.
*
* Example:
*
* ```js
* const totalProducts = wp.data.select( COLLECTION_STORE_KEY )
* .getCollectionHeader( '/wc/blocks', 'products', 'x-wp-total' )
* ```
*
* @param {string} state The current collection state.
* @param {string} header The header to retrieve.
* @param {string} namespace The namespace for the collection.
* @param {string} resourceName The model name for the collection.
* @param {Object} [query=null] The query object on the collection request.
* @param {Array} [ids=[]] Any ids for the collection request (these are
* values that would be added to the route for a
* route with id placeholders)
*
* @return {*|null} The value for the specified header, null if there are no
* headers available and undefined if the header does not exist for the
* collection.
*/
export const getCollectionHeader = (
state,
header,
namespace,
resourceName,
query = null,
ids = DEFAULT_EMPTY_ARRAY
) => {
const headers = getCollectionHeaders(
state,
namespace,
resourceName,
query,
ids
);
// Can't just do a truthy check because `getCollectionHeaders` resolver
// invokes the `getCollection` selector to trigger the resolution of the
// collection request. Its fallback is an empty array.
if ( headers && headers.get ) {
return headers.has( header ) ? headers.get( header ) : undefined;
}
return null;
};
/**
* Gets the last modified header for the collection.
*
* @param {string} state The current collection state.
* @return {number} Timestamp.
*/
export const getCollectionLastModified = ( state ) => {
return state.lastModified || 0;
};

View File

@@ -0,0 +1,99 @@
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
import receiveCollection from '../reducers';
import { ACTION_TYPES as types } from '../action-types';
describe( 'receiveCollection', () => {
const originalState = deepFreeze( {
'wc/blocks': {
products: {
'[]': {
'?someQuery=2': {
items: [ 'foo' ],
headers: { 'x-wp-total': 22 },
},
},
},
},
} );
it(
'returns original state when there is already an entry in the state ' +
'for the given arguments',
() => {
const testAction = {
type: types.RECEIVE_COLLECTION,
namespace: 'wc/blocks',
resourceName: 'products',
queryString: '?someQuery=2',
response: {
items: [ 'bar' ],
headers: { foo: 'bar' },
},
};
expect( receiveCollection( originalState, testAction ) ).toBe(
originalState
);
}
);
it(
'returns new state when items exist in collection but the type is ' +
'for a reset',
() => {
const testAction = {
type: types.RESET_COLLECTION,
namespace: 'wc/blocks',
resourceName: 'products',
queryString: '?someQuery=2',
response: {
items: [ 'cheeseburger' ],
headers: { foo: 'bar' },
},
};
const newState = receiveCollection( originalState, testAction );
expect( newState ).not.toBe( originalState );
expect(
newState[ 'wc/blocks' ].products[ '[]' ][ '?someQuery=2' ]
).toEqual( {
items: [ 'cheeseburger' ],
headers: { foo: 'bar' },
} );
}
);
it( 'returns new state when items do not exist in collection yet', () => {
const testAction = {
type: types.RECEIVE_COLLECTION,
namespace: 'wc/blocks',
resourceName: 'products',
queryString: '?someQuery=3',
response: { items: [ 'cheeseburger' ], headers: { foo: 'bar' } },
};
const newState = receiveCollection( originalState, testAction );
expect( newState ).not.toBe( originalState );
expect(
newState[ 'wc/blocks' ].products[ '[]' ][ '?someQuery=3' ]
).toEqual( { items: [ 'cheeseburger' ], headers: { foo: 'bar' } } );
} );
it( 'sets expected state when ids are passed in', () => {
const testAction = {
type: types.RECEIVE_COLLECTION,
namespace: 'wc/blocks',
resourceName: 'products/attributes',
queryString: '?something',
response: { items: [ 10, 20 ], headers: { foo: 'bar' } },
ids: [ 30, 42 ],
};
const newState = receiveCollection( originalState, testAction );
expect( newState ).not.toBe( originalState );
expect(
newState[ 'wc/blocks' ][ 'products/attributes' ][ '[30,42]' ][
'?something'
]
).toEqual( { items: [ 10, 20 ], headers: { foo: 'bar' } } );
} );
} );

View File

@@ -0,0 +1,166 @@
/**
* External dependencies
*/
import { controls } from '@wordpress/data';
/**
* Internal dependencies
*/
import { getCollection, getCollectionHeader } from '../resolvers';
import { receiveCollection } from '../actions';
import { STORE_KEY as SCHEMA_STORE_KEY } from '../../schema/constants';
import { STORE_KEY } from '../constants';
import { apiFetchWithHeadersControl } from '../../shared-controls';
jest.mock( '@wordpress/data' );
describe( 'getCollection', () => {
describe( 'yields with expected responses', () => {
let fulfillment;
const testArgs = [
'wc/blocks',
'products',
{ foo: 'bar' },
[ 20, 30 ],
];
const rewind = () => ( fulfillment = getCollection( ...testArgs ) );
test( 'with getRoute call invoked to retrieve route', () => {
rewind();
fulfillment.next();
expect( controls.resolveSelect ).toHaveBeenCalledWith(
SCHEMA_STORE_KEY,
'getRoute',
testArgs[ 0 ],
testArgs[ 1 ],
testArgs[ 3 ]
);
} );
test(
'when no route is retrieved, yields receiveCollection and ' +
'returns',
() => {
const { value } = fulfillment.next();
const expected = receiveCollection(
'wc/blocks',
'products',
'?foo=bar',
[ 20, 30 ],
{
items: [],
headers: {
get: () => undefined,
has: () => undefined,
},
}
);
expect( value.type ).toBe( expected.type );
expect( value.namespace ).toBe( expected.namespace );
expect( value.resourceName ).toBe( expected.resourceName );
expect( value.queryString ).toBe( expected.queryString );
expect( value.ids ).toEqual( expected.ids );
expect( Object.keys( value.response ) ).toEqual(
Object.keys( expected.response )
);
const { done } = fulfillment.next();
expect( done ).toBe( true );
}
);
test(
'when route is retrieved, yields apiFetchWithHeaders control action with ' +
'expected route',
() => {
rewind();
fulfillment.next();
const { value } = fulfillment.next( 'https://example.org' );
expect( value ).toEqual(
apiFetchWithHeadersControl( {
path: 'https://example.org?foo=bar',
} )
);
}
);
test(
'when apiFetchWithHeaders does not return a valid response, ' +
'yields expected action',
() => {
const { value } = fulfillment.next( {} );
expect( value ).toEqual(
receiveCollection(
'wc/blocks',
'products',
'?foo=bar',
[ 20, 30 ],
{ items: [], headers: undefined }
)
);
}
);
test(
'when apiFetch returns a valid response, yields expected ' +
'action',
() => {
rewind();
fulfillment.next();
fulfillment.next( 'https://example.org' );
const { value } = fulfillment.next( {
response: [ '42', 'cheeseburgers' ],
headers: { foo: 'bar' },
} );
expect( value ).toEqual(
receiveCollection(
'wc/blocks',
'products',
'?foo=bar',
[ 20, 30 ],
{
items: [ '42', 'cheeseburgers' ],
headers: { foo: 'bar' },
}
)
);
const { done } = fulfillment.next();
expect( done ).toBe( true );
}
);
} );
} );
describe( 'getCollectionHeader', () => {
let fulfillment;
const rewind = ( ...testArgs ) =>
( fulfillment = getCollectionHeader( ...testArgs ) );
it( 'yields expected select control when called with less args', () => {
rewind( 'x-wp-total', '/wc/blocks', 'products' );
const { value } = fulfillment.next();
expect( value ).toEqual(
controls.resolveSelect(
STORE_KEY,
'getCollection',
'/wc/blocks',
'products'
)
);
} );
it( 'yields expected select control when called with all args', () => {
const args = [
'x-wp-total',
'/wc/blocks',
'products/attributes',
{ sort: 'ASC' },
[ 10 ],
];
rewind( ...args );
const { value } = fulfillment.next();
expect( value ).toEqual(
controls.resolveSelect(
STORE_KEY,
'/wc/blocks',
'products/attributes',
{ sort: 'ASC' },
[ 10 ]
)
);
const { done } = fulfillment.next();
expect( done ).toBe( true );
} );
} );

View File

@@ -0,0 +1,117 @@
/**
* Internal dependencies
*/
import { getCollection, getCollectionHeader } from '../selectors';
const getHeaderMock = ( total ) => {
const headers = { total };
return {
get: ( key ) => headers[ key ] || null,
has: ( key ) => !! headers[ key ],
};
};
const state = {
'wc/blocks': {
products: {
'[]': {
'?someQuery=2': {
items: [ 'foo' ],
headers: getHeaderMock( 22 ),
},
},
},
'products/attributes': {
'[10]': {
'?someQuery=2': {
items: [ 'bar' ],
headers: getHeaderMock( 42 ),
},
},
},
'products/attributes/terms': {
'[10,20]': {
'?someQuery=10': {
items: [ 42 ],
headers: getHeaderMock( 12 ),
},
},
},
},
};
describe( 'getCollection', () => {
it( 'returns empty array when namespace does not exist in state', () => {
expect( getCollection( state, 'invalid', 'products' ) ).toEqual( [] );
} );
it( 'returns empty array when resourceName does not exist in state', () => {
expect( getCollection( state, 'wc/blocks', 'invalid' ) ).toEqual( [] );
} );
it( 'returns empty array when query does not exist in state', () => {
expect( getCollection( state, 'wc/blocks', 'products' ) ).toEqual( [] );
} );
it( 'returns empty array when ids do not exist in state', () => {
expect(
getCollection(
state,
'wc/blocks',
'products/attributes',
'?someQuery=2',
[ 20 ]
)
).toEqual( [] );
} );
describe( 'returns expected values for items existing in state', () => {
test.each`
resourceName | ids | query | expected
${ 'products' } | ${ [] } | ${ { someQuery: 2 } } | ${ [ 'foo' ] }
${ 'products/attributes' } | ${ [ 10 ] } | ${ { someQuery: 2 } } | ${ [ 'bar' ] }
${ 'products/attributes/terms' } | ${ [ 10, 20 ] } | ${ { someQuery: 10 } } | ${ [ 42 ] }
`(
'for "$resourceName", "$ids", and "$query"',
( { resourceName, ids, query, expected } ) => {
expect(
getCollection(
state,
'wc/blocks',
resourceName,
query,
ids
)
).toEqual( expected );
}
);
} );
} );
describe( 'getCollectionHeader', () => {
it(
'returns undefined when there are headers but the specific header ' +
'does not exist',
() => {
expect(
getCollectionHeader(
state,
'invalid',
'wc/blocks',
'products',
{
someQuery: 2,
}
)
).toBeUndefined();
}
);
it( 'returns null when there are no headers for the given arguments', () => {
expect( getCollectionHeader( state, 'wc/blocks', 'invalid' ) ).toBe(
null
);
} );
it( 'returns expected header when it exists', () => {
expect(
getCollectionHeader( state, 'total', 'wc/blocks', 'products', {
someQuery: 2,
} )
).toBe( 22 );
} );
} );

View File

@@ -0,0 +1,18 @@
/**
* REST API namespace for rest requests against blocks namespace.
*
* @member {string}
*/
export const API_BLOCK_NAMESPACE = 'wc/blocks';
export const EMPTY_CART_COUPONS: [] = [];
export const EMPTY_CART_ITEMS: [] = [];
export const EMPTY_CART_CROSS_SELLS: [] = [];
export const EMPTY_CART_FEES: [] = [];
export const EMPTY_CART_ITEM_ERRORS: [] = [];
export const EMPTY_CART_ERRORS: [] = [];
export const EMPTY_SHIPPING_RATES: [] = [];
export const EMPTY_PAYMENT_METHODS: [] = [];
export const EMPTY_PAYMENT_REQUIREMENTS: [] = [];
export const EMPTY_EXTENSIONS: Record< string, unknown > = {};
export const EMPTY_TAX_LINES: [] = [];

View File

@@ -0,0 +1,18 @@
/**
* External dependencies
*/
import '@wordpress/notices';
/**
* Internal dependencies
*/
export { CART_STORE_KEY } from './cart';
export { CHECKOUT_STORE_KEY } from './checkout';
export { COLLECTIONS_STORE_KEY } from './collections';
export { PAYMENT_STORE_KEY } from './payment';
export { QUERY_STATE_STORE_KEY } from './query-state';
export { SCHEMA_STORE_KEY } from './schema';
export { STORE_NOTICES_STORE_KEY } from './store-notices';
export { VALIDATION_STORE_KEY } from './validation';
export * from './constants';
export * from './utils';

View File

@@ -0,0 +1,107 @@
/**
* External dependencies
*/
import type { FunctionKeys } from 'utility-types';
/**
* Mapped types
*
* This module should only contain mapped types, operations useful in the type system
* that do not produce any runtime code.
*
* Mapped types can be thought of as functions in the type system, they accept some type
* argument and transform it to another type.
*
* @see https://www.typescriptlang.org/docs/handbook/advanced-types.html#mapped-types
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-shadow */
/**
* Maps a "raw" selector object to the selectors available when registered on the @wordpress/data store.
*
* @template S Selector map, usually from `import * as selectors from './my-store/selectors';`
*/
// eslint-disable-next-line @typescript-eslint/ban-types
export type SelectFromMap< S extends object > = {
[ selector in FunctionKeys< S > ]: (
...args: TailParameters< S[ selector ] >
) => ReturnType< S[ selector ] >;
};
/**
* Maps a "raw" resolver object to the resolvers available on a @wordpress/data store.
*
* @template R Resolver map, usually from `import * as resolvers from './my-store/resolvers';`
*/
export type ResolveSelectFromMap< R extends object > = {
[ resolver in FunctionKeys< R > ]: (
...args: ReturnType< R[ resolver ] > extends Promise< any >
? Parameters< R[ resolver ] >
: TailParameters< R[ resolver ] >
) => ReturnType< R[ resolver ] > extends Promise< any >
? Promise< ReturnType< R[ resolver ] > >
: void;
};
/**
* Maps a "raw" actionCreators object to the actions available when registered on the @wordpress/data store.
*
* @template A Selector map, usually from `import * as actions from './my-store/actions';`
*/
export type DispatchFromMap<
A extends Record< string, ( ...args: any[] ) => any >
> = {
[ actionCreator in keyof A ]: (
...args: Parameters< A[ actionCreator ] >
) => // If the action creator is a function that returns a generator return GeneratorReturnType, if not, then check
// if it's a function that returns a Promise, in other words: a thunk. https://developer.wordpress.org/block-editor/how-to-guides/thunks/
// If it is, then return the return type of the thunk (which in most cases will be void, but sometimes it won't be).
A[ actionCreator ] extends ( ...args: any[] ) => Generator
? Promise< GeneratorReturnType< A[ actionCreator ] > >
: A[ actionCreator ] extends Thunk
? ThunkReturnType< A[ actionCreator ] >
: void;
};
/**
* A thunk is a function (action creator) that returns a function.
*/
type Thunk = ( ...args: any[] ) => ( ...args: any[] ) => any;
/**
* The function returned by a thunk action creator can return a value, too.
*/
type ThunkReturnType< A extends Thunk > = ReturnType< ReturnType< A > >;
/**
* Parameters type of a function, excluding the first parameter.
*
* This is useful for typing some @wordpres/data functions that make a leading
* `state` argument implicit.
*/
// eslint-disable-next-line @typescript-eslint/ban-types
export type TailParameters< F extends Function > = F extends (
head: any,
...tail: infer T
) => any
? T
: never;
/**
* Obtain the type finally returned by the generator when it's done iterating.
*/
export type GeneratorReturnType< T extends ( ...args: any[] ) => Generator > =
T extends ( ...args: any ) => Generator< any, infer R, any > ? R : never;
/**
* Usually we use ReturnType of all the action creators to deduce all the actions.
* This works until one of the action creators is a generator and doesn't actually "Return" an action.
* This type helper allows for actions to be both functions and generators
*/
export type ReturnOrGeneratorYieldUnion< T extends ( ...args: any ) => any > =
T extends ( ...args: any ) => infer Return
? Return extends Generator< infer T, infer U, any >
? T | U
: Return
: never;

View File

@@ -0,0 +1,18 @@
export enum ACTION_TYPES {
SET_PAYMENT_IDLE = 'SET_PAYMENT_IDLE',
SET_EXPRESS_PAYMENT_STARTED = 'SET_EXPRESS_PAYMENT_STARTED',
SET_PAYMENT_READY = 'SET_PAYMENT_READY',
SET_PAYMENT_PROCESSING = 'SET_PAYMENT_PROCESSING',
SET_PAYMENT_ERROR = 'SET_PAYMENT_ERROR',
SET_PAYMENT_METHODS_INITIALIZED = 'SET_PAYMENT_METHODS_INITIALIZED',
SET_EXPRESS_PAYMENT_METHODS_INITIALIZED = 'SET_EXPRESS_PAYMENT_METHODS_INITIALIZED',
SET_ACTIVE_PAYMENT_METHOD = 'SET_ACTIVE_PAYMENT_METHOD',
SET_SHOULD_SAVE_PAYMENT_METHOD = 'SET_SHOULD_SAVE_PAYMENT_METHOD',
SET_AVAILABLE_PAYMENT_METHODS = 'SET_AVAILABLE_PAYMENT_METHODS',
SET_AVAILABLE_EXPRESS_PAYMENT_METHODS = 'SET_AVAILABLE_EXPRESS_PAYMENT_METHODS',
REMOVE_AVAILABLE_PAYMENT_METHOD = 'REMOVE_AVAILABLE_PAYMENT_METHOD',
REMOVE_AVAILABLE_EXPRESS_PAYMENT_METHOD = 'REMOVE_AVAILABLE_EXPRESS_PAYMENT_METHOD',
INITIALIZE_PAYMENT_METHODS = 'INITIALIZE_PAYMENT_METHODS',
SET_PAYMENT_METHOD_DATA = 'SET_PAYMENT_METHOD_DATA',
SET_PAYMENT_RESULT = 'SET_PAYMENT_RESULT',
}

View File

@@ -0,0 +1,189 @@
/**
* External dependencies
*/
import {
PlainPaymentMethods,
PlainExpressPaymentMethods,
} from '@woocommerce/types';
import type { PaymentResult } from '@woocommerce/types';
/**
* Internal dependencies
*/
import { ACTION_TYPES } from './action-types';
import { checkPaymentMethodsCanPay } from './utils/check-payment-methods';
import { setDefaultPaymentMethod } from './utils/set-default-payment-method';
// `Thunks are functions that can be dispatched, similar to actions creators
export * from './thunks';
export const __internalSetPaymentIdle = () => ( {
type: ACTION_TYPES.SET_PAYMENT_IDLE,
} );
export const __internalSetExpressPaymentStarted = () => ( {
type: ACTION_TYPES.SET_EXPRESS_PAYMENT_STARTED,
} );
export const __internalSetPaymentProcessing = () => ( {
type: ACTION_TYPES.SET_PAYMENT_PROCESSING,
} );
export const __internalSetPaymentError = () => ( {
type: ACTION_TYPES.SET_PAYMENT_ERROR,
} );
export const __internalSetPaymentReady = () => ( {
type: ACTION_TYPES.SET_PAYMENT_READY,
} );
/**
* Set whether the payment methods have been initialised or not
*
* @param initialized True if the `checkCanPay` methods have been run on all available payment methods
*/
export const __internalSetPaymentMethodsInitialized = (
initialized: boolean
) => {
return async ( { select, dispatch } ) => {
// If the currently selected method is not in this new list, then we need to select a new one, or select a default.
const methods = select.getAvailablePaymentMethods();
if ( initialized ) {
await setDefaultPaymentMethod( methods );
}
dispatch( {
type: ACTION_TYPES.SET_PAYMENT_METHODS_INITIALIZED,
initialized,
} );
};
};
/**
* Set whether the express payment methods have been initialised or not
*
* @param initialized True if the `checkCanPay` methods have been run on all express available payment methods
*/
export const __internalSetExpressPaymentMethodsInitialized = (
initialized: boolean
) => ( {
type: ACTION_TYPES.SET_EXPRESS_PAYMENT_METHODS_INITIALIZED,
initialized,
} );
/**
* Set a flag for whether to save the current payment method for next time
*
* @param shouldSavePaymentMethod Whether to save the current payment method for next time
*/
export const __internalSetShouldSavePaymentMethod = (
shouldSavePaymentMethod: boolean
) => ( {
type: ACTION_TYPES.SET_SHOULD_SAVE_PAYMENT_METHOD,
shouldSavePaymentMethod,
} );
/**
* Set the payment method the user has chosen. This should change every time the user selects a new payment method
*
* @param activePaymentMethod The name of the payment method selected by the user
* @param paymentMethodData The extra data associated with a payment
*/
export const __internalSetActivePaymentMethod = (
activePaymentMethod: string,
paymentMethodData: Record< string, unknown > = {}
) => ( {
type: ACTION_TYPES.SET_ACTIVE_PAYMENT_METHOD,
activePaymentMethod,
paymentMethodData,
} );
/**
* Set the extra data for the chosen payment method
*
* @param paymentMethodData The extra data associated with a payment
*/
export const __internalSetPaymentMethodData = (
paymentMethodData: Record< string, unknown > = {}
) => ( {
type: ACTION_TYPES.SET_PAYMENT_METHOD_DATA,
paymentMethodData,
} );
/**
* Store the result of the payment attempt from the /checkout StoreApi call
*
* @param data The result of the payment attempt through the StoreApi /checkout endpoints
*/
export const __internalSetPaymentResult = ( data: PaymentResult ) => ( {
type: ACTION_TYPES.SET_PAYMENT_RESULT,
data,
} );
/**
* Set the available payment methods.
* An available payment method is one that has been validated and can make a payment.
*/
export const __internalSetAvailablePaymentMethods = (
paymentMethods: PlainPaymentMethods
) => {
return async ( { dispatch, select } ) => {
// If the currently selected method is not in this new list, then we need to select a new one, or select a default.
const activePaymentMethod = select.getActivePaymentMethod();
if ( ! ( activePaymentMethod in paymentMethods ) ) {
await setDefaultPaymentMethod( paymentMethods );
}
dispatch( {
type: ACTION_TYPES.SET_AVAILABLE_PAYMENT_METHODS,
paymentMethods,
} );
};
};
/**
* Set the available express payment methods.
* An available payment method is one that has been validated and can make a payment.
*/
export const __internalSetAvailableExpressPaymentMethods = (
paymentMethods: PlainExpressPaymentMethods
) => ( {
type: ACTION_TYPES.SET_AVAILABLE_EXPRESS_PAYMENT_METHODS,
paymentMethods,
} );
/**
* Remove a payment method name from the available payment methods.
* This is called when a payment method is removed from the registry.
*/
export const __internalRemoveAvailablePaymentMethod = ( name: string ) => ( {
type: ACTION_TYPES.REMOVE_AVAILABLE_PAYMENT_METHOD,
name,
} );
/**
* Remove an express payment method name from the available payment methods.
* This is called when an express payment method is removed from the registry.
*/
export const __internalRemoveAvailableExpressPaymentMethod = (
name: string
) => ( {
type: ACTION_TYPES.REMOVE_AVAILABLE_EXPRESS_PAYMENT_METHOD,
name,
} );
/**
* The store is initialised once we have checked whether the payment methods registered can pay or not
*/
export function __internalUpdateAvailablePaymentMethods() {
return async ( { select, dispatch } ) => {
const expressRegistered = await checkPaymentMethodsCanPay( true );
const registered = await checkPaymentMethodsCanPay( false );
const { paymentMethodsInitialized, expressPaymentMethodsInitialized } =
select;
if ( registered && ! paymentMethodsInitialized() ) {
dispatch( __internalSetPaymentMethodsInitialized( true ) );
}
if ( expressRegistered && ! expressPaymentMethodsInitialized() ) {
dispatch( __internalSetExpressPaymentMethodsInitialized( true ) );
}
};
}

View File

@@ -0,0 +1,9 @@
export const STORE_KEY = 'wc/store/payment';
export enum STATUS {
IDLE = 'idle',
EXPRESS_STARTED = 'express_started',
PROCESSING = 'processing',
READY = 'ready',
ERROR = 'has_error',
}

View File

@@ -0,0 +1,48 @@
/**
* External dependencies
*/
import type { EmptyObjectType, PaymentResult } from '@woocommerce/types';
import { getSetting } from '@woocommerce/settings';
import {
PlainPaymentMethods,
PlainExpressPaymentMethods,
} from '@woocommerce/types';
/**
* Internal dependencies
*/
import { SavedPaymentMethod } from './types';
import { STATUS as PAYMENT_STATUS } from './constants';
export interface PaymentState {
status: string;
activePaymentMethod: string;
activeSavedToken: string;
// Available payment methods are payment methods which have been validated and can make payment.
availablePaymentMethods: PlainPaymentMethods;
availableExpressPaymentMethods: PlainExpressPaymentMethods;
savedPaymentMethods:
| Record< string, SavedPaymentMethod[] >
| EmptyObjectType;
paymentMethodData: Record< string, unknown >;
paymentResult: PaymentResult | null;
paymentMethodsInitialized: boolean;
expressPaymentMethodsInitialized: boolean;
shouldSavePaymentMethod: boolean;
}
export const defaultPaymentState: PaymentState = {
status: PAYMENT_STATUS.IDLE,
activePaymentMethod: '',
activeSavedToken: '',
availablePaymentMethods: {},
availableExpressPaymentMethods: {},
savedPaymentMethods: getSetting<
Record< string, SavedPaymentMethod[] > | EmptyObjectType
>( 'customerPaymentMethods', {} ),
paymentMethodData: {},
paymentResult: null,
paymentMethodsInitialized: false,
expressPaymentMethodsInitialized: false,
shouldSavePaymentMethod: false,
};

View File

@@ -0,0 +1,40 @@
/**
* External dependencies
*/
import { createReduxStore, register } from '@wordpress/data';
import { controls as dataControls } from '@wordpress/data-controls';
/**
* Internal dependencies
*/
import reducer from './reducers';
import { STORE_KEY } from './constants';
import * as actions from './actions';
import { controls as sharedControls } from '../shared-controls';
import * as selectors from './selectors';
import { DispatchFromMap, SelectFromMap } from '../mapped-types';
export const config = {
reducer,
selectors,
actions,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
controls: { ...dataControls, ...sharedControls } as any,
__experimentalUseThunks: true,
};
const store = createReduxStore( STORE_KEY, config );
register( store );
declare module '@wordpress/data' {
function dispatch(
key: typeof STORE_KEY
): DispatchFromMap< typeof actions >;
function select( key: typeof STORE_KEY ): SelectFromMap<
typeof selectors
> & {
hasFinishedResolution: ( selector: string ) => boolean;
};
}
export const PAYMENT_STORE_KEY = STORE_KEY;

View File

@@ -0,0 +1,152 @@
/**
* External dependencies
*/
import type { Reducer } from 'redux';
import { objectHasProp, PaymentResult } from '@woocommerce/types';
/**
* Internal dependencies
*/
import { defaultPaymentState, PaymentState } from './default-state';
import { ACTION_TYPES } from './action-types';
import { STATUS } from './constants';
const reducer: Reducer< PaymentState > = (
state = defaultPaymentState,
action
) => {
let newState = state;
switch ( action.type ) {
case ACTION_TYPES.SET_PAYMENT_IDLE:
newState = {
...state,
status: STATUS.IDLE,
};
break;
case ACTION_TYPES.SET_EXPRESS_PAYMENT_STARTED:
newState = {
...state,
status: STATUS.EXPRESS_STARTED,
};
break;
case ACTION_TYPES.SET_PAYMENT_PROCESSING:
newState = {
...state,
status: STATUS.PROCESSING,
};
break;
case ACTION_TYPES.SET_PAYMENT_READY:
newState = {
...state,
status: STATUS.READY,
};
break;
case ACTION_TYPES.SET_PAYMENT_ERROR:
newState = {
...state,
status: STATUS.ERROR,
};
break;
case ACTION_TYPES.SET_SHOULD_SAVE_PAYMENT_METHOD:
newState = {
...state,
shouldSavePaymentMethod: action.shouldSavePaymentMethod,
};
break;
case ACTION_TYPES.SET_PAYMENT_METHOD_DATA:
newState = {
...state,
paymentMethodData: action.paymentMethodData,
};
break;
case ACTION_TYPES.SET_PAYMENT_RESULT:
newState = {
...state,
paymentResult: action.data as PaymentResult,
};
break;
case ACTION_TYPES.REMOVE_AVAILABLE_PAYMENT_METHOD:
const previousAvailablePaymentMethods = {
...state.availablePaymentMethods,
};
delete previousAvailablePaymentMethods[ action.name ];
newState = {
...state,
availablePaymentMethods: {
...previousAvailablePaymentMethods,
},
};
break;
case ACTION_TYPES.REMOVE_AVAILABLE_EXPRESS_PAYMENT_METHOD:
const previousAvailableExpressPaymentMethods = {
...state.availablePaymentMethods,
};
delete previousAvailableExpressPaymentMethods[ action.name ];
newState = {
...state,
availableExpressPaymentMethods: {
...previousAvailableExpressPaymentMethods,
},
};
break;
case ACTION_TYPES.SET_PAYMENT_METHODS_INITIALIZED:
newState = {
...state,
paymentMethodsInitialized: action.initialized,
};
break;
case ACTION_TYPES.SET_EXPRESS_PAYMENT_METHODS_INITIALIZED:
newState = {
...state,
expressPaymentMethodsInitialized: action.initialized,
};
break;
case ACTION_TYPES.SET_AVAILABLE_PAYMENT_METHODS:
newState = {
...state,
availablePaymentMethods: action.paymentMethods,
};
break;
case ACTION_TYPES.SET_AVAILABLE_EXPRESS_PAYMENT_METHODS:
newState = {
...state,
availableExpressPaymentMethods: action.paymentMethods,
};
break;
case ACTION_TYPES.SET_ACTIVE_PAYMENT_METHOD:
const activeSavedToken =
typeof state.paymentMethodData === 'object' &&
objectHasProp( action.paymentMethodData, 'token' )
? action.paymentMethodData.token + ''
: '';
newState = {
...state,
activeSavedToken,
activePaymentMethod: action.activePaymentMethod,
paymentMethodData:
action.paymentMethodData || state.paymentMethodData,
};
break;
default:
return newState;
}
return newState;
};
export type State = ReturnType< typeof reducer >;
export default reducer;

View File

@@ -0,0 +1,231 @@
/**
* External dependencies
*/
import { objectHasProp } from '@woocommerce/types';
import deprecated from '@wordpress/deprecated';
import { getSetting } from '@woocommerce/settings';
import type { GlobalPaymentMethod } from '@woocommerce/types';
/**
* Internal dependencies
*/
import { PaymentState } from './default-state';
import { filterActiveSavedPaymentMethods } from './utils/filter-active-saved-payment-methods';
import { STATUS as PAYMENT_STATUS } from './constants';
const globalPaymentMethods: Record< string, string > = {};
if ( getSetting( 'globalPaymentMethods' ) ) {
getSetting< GlobalPaymentMethod[] >( 'globalPaymentMethods' ).forEach(
( method ) => {
globalPaymentMethods[ method.id ] = method.title;
}
);
}
export const isPaymentPristine = ( state: PaymentState ) => {
deprecated( 'isPaymentPristine', {
since: '9.6.0',
alternative: 'isPaymentIdle',
plugin: 'WooCommerce Blocks',
link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8110',
} );
return state.status === PAYMENT_STATUS.IDLE;
};
export const isPaymentIdle = ( state: PaymentState ) =>
state.status === PAYMENT_STATUS.IDLE;
export const isPaymentStarted = ( state: PaymentState ) => {
deprecated( 'isPaymentStarted', {
since: '9.6.0',
alternative: 'isExpressPaymentStarted',
plugin: 'WooCommerce Blocks',
link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8110',
} );
return state.status === PAYMENT_STATUS.EXPRESS_STARTED;
};
export const isExpressPaymentStarted = ( state: PaymentState ) => {
return state.status === PAYMENT_STATUS.EXPRESS_STARTED;
};
export const isPaymentProcessing = ( state: PaymentState ) =>
state.status === PAYMENT_STATUS.PROCESSING;
export const isPaymentReady = ( state: PaymentState ) =>
state.status === PAYMENT_STATUS.READY;
export const isPaymentSuccess = ( state: PaymentState ) => {
deprecated( 'isPaymentSuccess', {
since: '9.6.0',
alternative: 'isPaymentReady',
plugin: 'WooCommerce Blocks',
link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8110',
} );
return state.status === PAYMENT_STATUS.READY;
};
export const hasPaymentError = ( state: PaymentState ) =>
state.status === PAYMENT_STATUS.ERROR;
export const isPaymentFailed = ( state: PaymentState ) => {
deprecated( 'isPaymentFailed', {
since: '9.6.0',
plugin: 'WooCommerce Blocks',
link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8110',
} );
return state.status === PAYMENT_STATUS.ERROR;
};
export const isExpressPaymentMethodActive = ( state: PaymentState ) => {
return Object.keys( state.availableExpressPaymentMethods ).includes(
state.activePaymentMethod
);
};
export const getActiveSavedToken = ( state: PaymentState ) => {
return typeof state.paymentMethodData === 'object' &&
objectHasProp( state.paymentMethodData, 'token' )
? state.paymentMethodData.token + ''
: '';
};
export const getActivePaymentMethod = ( state: PaymentState ) => {
return state.activePaymentMethod;
};
export const getAvailablePaymentMethods = ( state: PaymentState ) => {
return state.availablePaymentMethods;
};
export const getAvailableExpressPaymentMethods = ( state: PaymentState ) => {
return state.availableExpressPaymentMethods;
};
export const getPaymentMethodData = ( state: PaymentState ) => {
return state.paymentMethodData;
};
export const getIncompatiblePaymentMethods = ( state: PaymentState ) => {
const {
availablePaymentMethods,
availableExpressPaymentMethods,
paymentMethodsInitialized,
expressPaymentMethodsInitialized,
} = state;
if ( ! paymentMethodsInitialized || ! expressPaymentMethodsInitialized ) {
return {};
}
return Object.fromEntries(
Object.entries( globalPaymentMethods ).filter( ( [ k ] ) => {
return ! (
k in
{
...availablePaymentMethods,
...availableExpressPaymentMethods,
}
);
} )
);
};
export const getSavedPaymentMethods = ( state: PaymentState ) => {
return state.savedPaymentMethods;
};
/**
* Filters the list of saved payment methods and returns only the ones which
* are active and supported by the payment gateway
*/
export const getActiveSavedPaymentMethods = ( state: PaymentState ) => {
const availablePaymentMethodKeys = Object.keys(
state.availablePaymentMethods
);
return filterActiveSavedPaymentMethods(
availablePaymentMethodKeys,
state.savedPaymentMethods
);
};
export const paymentMethodsInitialized = ( state: PaymentState ) => {
return state.paymentMethodsInitialized;
};
export const expressPaymentMethodsInitialized = ( state: PaymentState ) => {
return state.expressPaymentMethodsInitialized;
};
/**
* @deprecated - Use these selectors instead: isPaymentIdle, isPaymentProcessing,
* hasPaymentError
*/
export const getCurrentStatus = ( state: PaymentState ) => {
deprecated( 'getCurrentStatus', {
since: '8.9.0',
alternative: 'isPaymentIdle, isPaymentProcessing, hasPaymentError',
plugin: 'WooCommerce Blocks',
link: 'https://github.com/woocommerce/woocommerce-blocks/pull/7666',
} );
return {
get isPristine() {
deprecated( 'isPristine', {
since: '9.6.0',
alternative: 'isIdle',
plugin: 'WooCommerce Blocks',
} );
return isPaymentIdle( state );
}, // isPristine is the same as isIdle.
isIdle: isPaymentIdle( state ),
isStarted: isExpressPaymentStarted( state ),
isProcessing: isPaymentProcessing( state ),
get isFinished() {
deprecated( 'isFinished', {
since: '9.6.0',
plugin: 'WooCommerce Blocks',
link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8110',
} );
return hasPaymentError( state ) || isPaymentReady( state );
},
hasError: hasPaymentError( state ),
get hasFailed() {
deprecated( 'hasFailed', {
since: '9.6.0',
plugin: 'WooCommerce Blocks',
link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8110',
} );
return hasPaymentError( state );
},
get isSuccessful() {
deprecated( 'isSuccessful', {
since: '9.6.0',
plugin: 'WooCommerce Blocks',
link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8110',
} );
return isPaymentReady( state );
},
isDoingExpressPayment: isExpressPaymentMethodActive( state ),
};
};
export const getShouldSavePaymentMethod = ( state: PaymentState ) => {
return state.shouldSavePaymentMethod;
};
export const getPaymentResult = ( state: PaymentState ) => {
return state.paymentResult;
};
// We should avoid using this selector and instead use the focused selectors
// We're keeping it because it's used in our unit test: assets/js/blocks/cart-checkout-shared/payment-methods/test/payment-methods.js
// to mock the selectors.
export const getState = ( state: PaymentState ) => {
return state;
};

View File

@@ -0,0 +1,45 @@
/**
* Internal dependencies
*/
import { setDefaultPaymentMethod as setDefaultPaymentMethodOriginal } from '../utils/set-default-payment-method';
import { PAYMENT_STORE_KEY } from '..';
import { PlainPaymentMethods } from '../../../types';
const originalDispatch = jest.requireActual( '@wordpress/data' ).dispatch;
jest.mock( '../utils/set-default-payment-method', () => ( {
setDefaultPaymentMethod: jest.fn(),
} ) );
describe( 'payment data store actions', () => {
const paymentMethods: PlainPaymentMethods = {
'wc-payment-gateway-1': {
name: 'wc-payment-gateway-1',
},
'wc-payment-gateway-2': {
name: 'wc-payment-gateway-2',
},
};
describe( 'setAvailablePaymentMethods', () => {
it( 'Does not call setDefaultPaymentGateway if the current method is still available', () => {
const actions = originalDispatch( PAYMENT_STORE_KEY );
actions.__internalSetActivePaymentMethod(
Object.keys( paymentMethods )[ 0 ]
);
actions.__internalSetAvailablePaymentMethods( paymentMethods );
expect( setDefaultPaymentMethodOriginal ).not.toBeCalled();
} );
it( 'Resets the default gateway if the current method is no longer available', () => {
const actions = originalDispatch( PAYMENT_STORE_KEY );
actions.__internalSetActivePaymentMethod(
Object.keys( paymentMethods )[ 0 ]
);
actions.__internalSetAvailablePaymentMethods( [
paymentMethods[ Object.keys( paymentMethods )[ 0 ] ],
] );
expect( setDefaultPaymentMethodOriginal ).toBeCalled();
} );
} );
} );

View File

@@ -0,0 +1,166 @@
/**
* External dependencies
*/
import * as wpDataFunctions from '@wordpress/data';
import { previewCart } from '@woocommerce/resource-previews';
import { PAYMENT_STORE_KEY, CART_STORE_KEY } from '@woocommerce/block-data';
import {
registerPaymentMethod,
registerExpressPaymentMethod,
__experimentalDeRegisterPaymentMethod,
__experimentalDeRegisterExpressPaymentMethod,
} from '@woocommerce/blocks-registry';
import { CanMakePaymentArgument } from '@woocommerce/types';
/**
* Internal dependencies
*/
import { checkPaymentMethodsCanPay } from '../utils/check-payment-methods';
const requiredKeyCheck = ( args: CanMakePaymentArgument ) => {
const requiredKeys = [
'billingData',
'billingAddress',
'cart',
'cartNeedsShipping',
'cartTotals',
'paymentMethods',
'paymentRequirements',
'selectedShippingMethods',
'shippingAddress',
];
const argKeys = Object.keys( args );
const requiredCartKeys = [
'cartCoupons',
'cartItems',
'crossSellsProducts',
'cartFees',
'cartItemsCount',
'cartItemsWeight',
'cartNeedsPayment',
'cartNeedsShipping',
'cartItemErrors',
'cartTotals',
'cartIsLoading',
'cartErrors',
'billingData',
'billingAddress',
'shippingAddress',
'extensions',
'shippingRates',
'isLoadingRates',
'cartHasCalculatedShipping',
'paymentRequirements',
'receiveCart',
];
const cartKeys = Object.keys( args.cart );
const requiredTotalsKeys = [
'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',
'currency_code',
'currency_symbol',
'currency_minor_unit',
'currency_decimal_separator',
'currency_thousand_separator',
'currency_prefix',
'currency_suffix',
];
const totalsKeys = Object.keys( args.cartTotals );
return (
requiredKeys.every( ( key ) => argKeys.includes( key ) ) &&
requiredTotalsKeys.every( ( key ) => totalsKeys.includes( key ) ) &&
requiredCartKeys.every( ( key ) => cartKeys.includes( key ) )
);
};
const mockedCanMakePayment = jest.fn().mockImplementation( requiredKeyCheck );
const mockedExpressCanMakePayment = jest
.fn()
.mockImplementation( requiredKeyCheck );
const registerMockPaymentMethods = ( savedCards = true ) => {
[ 'credit-card' ].forEach( ( name ) => {
registerPaymentMethod( {
name,
label: name,
content: <div>A payment method</div>,
edit: <div>A payment method</div>,
icons: null,
canMakePayment: mockedCanMakePayment,
supports: {
showSavedCards: savedCards,
showSaveOption: true,
features: [ 'products' ],
},
ariaLabel: name,
} );
} );
[ 'express-payment' ].forEach( ( name ) => {
const Content = ( {
onClose = () => void null,
onClick = () => void null,
} ) => {
return (
<>
<button onClick={ onClick }>
{ name + ' express payment method' }
</button>
<button onClick={ onClose }>
{ name + ' express payment method close' }
</button>
</>
);
};
registerExpressPaymentMethod( {
name,
content: <Content />,
edit: <div>An express payment method</div>,
canMakePayment: mockedExpressCanMakePayment,
paymentMethodId: name,
supports: {
features: [ 'products' ],
},
} );
} );
wpDataFunctions
.dispatch( PAYMENT_STORE_KEY )
.__internalUpdateAvailablePaymentMethods();
wpDataFunctions.dispatch( CART_STORE_KEY ).receiveCart( {
...previewCart,
payment_methods: [ 'cheque', 'bacs', 'credit-card' ],
} );
};
const resetMockPaymentMethods = () => {
[ 'cheque', 'bacs', 'credit-card' ].forEach( ( name ) => {
__experimentalDeRegisterPaymentMethod( name );
} );
[ 'express-payment' ].forEach( ( name ) => {
__experimentalDeRegisterExpressPaymentMethod( name );
} );
};
describe( 'checkPaymentMethods', () => {
beforeEach( registerMockPaymentMethods );
afterEach( resetMockPaymentMethods );
it( `Sends correct arguments to regular payment methods' canMakePayment functions`, async () => {
await checkPaymentMethodsCanPay();
expect( mockedCanMakePayment ).toHaveReturnedWith( true );
} );
it( `Sends correct arguments to express payment methods' canMakePayment functions`, async () => {
await checkPaymentMethodsCanPay( true );
expect( mockedExpressCanMakePayment ).toHaveReturnedWith( true );
} );
} );

View File

@@ -0,0 +1,211 @@
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
import reducer from '../reducers';
import { ACTION_TYPES } from '../action-types';
describe( 'paymentMethodDataReducer', () => {
const originalState = deepFreeze( {
currentStatus: {
isPristine: true,
isStarted: false,
isProcessing: false,
isFinished: false,
hasError: false,
hasFailed: false,
isSuccessful: false,
},
availablePaymentMethods: {},
availableExpressPaymentMethods: {},
paymentMethodData: {},
paymentMethodsInitialized: false,
expressPaymentMethodsInitialized: false,
shouldSavePaymentMethod: false,
errorMessage: '',
activePaymentMethod: '',
activeSavedToken: '',
incompatiblePaymentMethods: {},
} );
it( 'sets state as expected when adding a payment method', () => {
const nextState = reducer( originalState, {
type: ACTION_TYPES.SET_AVAILABLE_PAYMENT_METHODS,
paymentMethods: { 'my-new-method': { express: false } },
} );
expect( nextState ).toEqual( {
currentStatus: {
isPristine: true,
isStarted: false,
isProcessing: false,
isFinished: false,
hasError: false,
hasFailed: false,
isSuccessful: false,
},
availablePaymentMethods: { 'my-new-method': { express: false } },
availableExpressPaymentMethods: {},
paymentMethodData: {},
paymentMethodsInitialized: false,
expressPaymentMethodsInitialized: false,
shouldSavePaymentMethod: false,
errorMessage: '',
activePaymentMethod: '',
activeSavedToken: '',
incompatiblePaymentMethods: {},
} );
} );
it( 'sets state as expected when removing a payment method', () => {
const stateWithRegisteredMethod = deepFreeze( {
currentStatus: {
isPristine: true,
isStarted: false,
isProcessing: false,
isFinished: false,
hasError: false,
hasFailed: false,
isSuccessful: false,
},
availablePaymentMethods: { 'my-new-method': { express: false } },
availableExpressPaymentMethods: {},
paymentMethodData: {},
paymentMethodsInitialized: false,
expressPaymentMethodsInitialized: false,
shouldSavePaymentMethod: false,
errorMessage: '',
activePaymentMethod: '',
activeSavedToken: '',
incompatiblePaymentMethods: {},
} );
const nextState = reducer( stateWithRegisteredMethod, {
type: ACTION_TYPES.REMOVE_AVAILABLE_PAYMENT_METHOD,
name: 'my-new-method',
} );
expect( nextState ).toEqual( {
currentStatus: {
isPristine: true,
isStarted: false,
isProcessing: false,
isFinished: false,
hasError: false,
hasFailed: false,
isSuccessful: false,
},
availablePaymentMethods: {},
availableExpressPaymentMethods: {},
paymentMethodData: {},
paymentMethodsInitialized: false,
expressPaymentMethodsInitialized: false,
shouldSavePaymentMethod: false,
errorMessage: '',
activePaymentMethod: '',
activeSavedToken: '',
incompatiblePaymentMethods: {},
} );
} );
it( 'sets state as expected when adding an express payment method', () => {
const nextState = reducer( originalState, {
type: ACTION_TYPES.SET_AVAILABLE_EXPRESS_PAYMENT_METHODS,
paymentMethods: { 'my-new-method': { express: true } },
} );
expect( nextState ).toEqual( {
currentStatus: {
isPristine: true,
isStarted: false,
isProcessing: false,
isFinished: false,
hasError: false,
hasFailed: false,
isSuccessful: false,
},
availablePaymentMethods: {},
availableExpressPaymentMethods: {
'my-new-method': { express: true },
},
paymentMethodData: {},
paymentMethodsInitialized: false,
expressPaymentMethodsInitialized: false,
shouldSavePaymentMethod: false,
errorMessage: '',
activePaymentMethod: '',
activeSavedToken: '',
incompatiblePaymentMethods: {},
} );
} );
it( 'sets state as expected when removing an express payment method', () => {
const stateWithRegisteredMethod = deepFreeze( {
currentStatus: {
isPristine: true,
isStarted: false,
isProcessing: false,
isFinished: false,
hasError: false,
hasFailed: false,
isSuccessful: false,
},
availablePaymentMethods: {},
availableExpressPaymentMethods: [ 'my-new-method' ],
paymentMethodData: {},
paymentMethodsInitialized: false,
expressPaymentMethodsInitialized: false,
shouldSavePaymentMethod: false,
errorMessage: '',
activePaymentMethod: '',
activeSavedToken: '',
incompatiblePaymentMethods: {},
} );
const nextState = reducer( stateWithRegisteredMethod, {
type: ACTION_TYPES.REMOVE_AVAILABLE_EXPRESS_PAYMENT_METHOD,
name: 'my-new-method',
} );
expect( nextState ).toEqual( {
currentStatus: {
isPristine: true,
isStarted: false,
isProcessing: false,
isFinished: false,
hasError: false,
hasFailed: false,
isSuccessful: false,
},
availablePaymentMethods: {},
availableExpressPaymentMethods: {},
paymentMethodData: {},
paymentMethodsInitialized: false,
expressPaymentMethodsInitialized: false,
shouldSavePaymentMethod: false,
errorMessage: '',
activePaymentMethod: '',
activeSavedToken: '',
incompatiblePaymentMethods: {},
} );
} );
it( 'should handle SET_PAYMENT_RESULT', () => {
const mockResponse = {
message: 'success',
redirectUrl: 'https://example.com',
paymentStatus: 'not set',
paymentDetails: {},
};
const expectedState = {
...originalState,
paymentResult: mockResponse,
};
expect(
reducer( originalState, {
type: ACTION_TYPES.SET_PAYMENT_RESULT,
data: mockResponse,
} )
).toEqual( expectedState );
} );
} );

View File

@@ -0,0 +1,331 @@
/**
* External dependencies
*/
import { render, screen, waitFor, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { previewCart } from '@woocommerce/resource-previews';
import * as wpDataFunctions from '@wordpress/data';
import {
CART_STORE_KEY as storeKey,
PAYMENT_STORE_KEY,
} from '@woocommerce/block-data';
import {
registerPaymentMethod,
registerExpressPaymentMethod,
__experimentalDeRegisterPaymentMethod,
__experimentalDeRegisterExpressPaymentMethod,
} from '@woocommerce/blocks-registry';
import { default as fetchMock } from 'jest-fetch-mock';
/**
* Internal dependencies
*/
import {
CheckoutExpressPayment,
SavedPaymentMethodOptions,
} from '../../../blocks/cart-checkout-shared/payment-methods';
import { defaultCartState } from '../../cart/default-state';
const originalSelect = jest.requireActual( '@wordpress/data' ).select;
jest.spyOn( wpDataFunctions, 'select' ).mockImplementation( ( storeName ) => {
const originalStore = originalSelect( storeName );
if ( storeName === storeKey ) {
return {
...originalStore,
hasFinishedResolution: jest
.fn()
.mockImplementation( ( selectorName ) => {
if ( selectorName === 'getCartTotals' ) {
return true;
}
return originalStore.hasFinishedResolution( selectorName );
} ),
};
}
return originalStore;
} );
jest.mock( '@woocommerce/settings', () => {
const originalModule = jest.requireActual( '@woocommerce/settings' );
return {
// @ts-ignore We know @woocommerce/settings is an object.
...originalModule,
getSetting: ( setting, ...rest ) => {
if ( setting === 'customerPaymentMethods' ) {
return {
cc: [
{
method: {
gateway: 'credit-card',
last4: '4242',
brand: 'Visa',
},
expires: '12/22',
is_default: true,
tokenId: 1,
},
],
};
}
return originalModule.getSetting( setting, ...rest );
},
};
} );
const registerMockPaymentMethods = ( savedCards = true ) => {
[ 'cheque', 'bacs' ].forEach( ( name ) => {
registerPaymentMethod( {
name,
label: name,
content: <div>A payment method</div>,
edit: <div>A payment method</div>,
icons: null,
canMakePayment: () => true,
supports: {
features: [ 'products' ],
},
ariaLabel: name,
} );
} );
[ 'credit-card' ].forEach( ( name ) => {
registerPaymentMethod( {
name,
label: name,
content: <div>A payment method</div>,
edit: <div>A payment method</div>,
icons: null,
canMakePayment: () => true,
supports: {
showSavedCards: savedCards,
showSaveOption: true,
features: [ 'products' ],
},
ariaLabel: name,
} );
} );
[ 'express-payment' ].forEach( ( name ) => {
const Content = ( {
onClose = () => void null,
onClick = () => void null,
} ) => {
return (
<>
<button onClick={ onClick }>
{ name + ' express payment method' }
</button>
<button onClick={ onClose }>
{ name + ' express payment method close' }
</button>
</>
);
};
registerExpressPaymentMethod( {
name,
content: <Content />,
edit: <div>An express payment method</div>,
canMakePayment: () => true,
paymentMethodId: name,
supports: {
features: [ 'products' ],
},
} );
} );
wpDataFunctions
.dispatch( PAYMENT_STORE_KEY )
.__internalUpdateAvailablePaymentMethods();
};
const resetMockPaymentMethods = () => {
[ 'cheque', 'bacs', 'credit-card' ].forEach( ( name ) => {
__experimentalDeRegisterPaymentMethod( name );
} );
[ 'express-payment' ].forEach( ( name ) => {
__experimentalDeRegisterExpressPaymentMethod( name );
} );
};
describe( 'Payment method data store selectors/thunks', () => {
beforeEach( () => {
act( () => {
registerMockPaymentMethods( false );
fetchMock.mockResponse( ( req ) => {
if ( req.url.match( /wc\/store\/v1\/cart/ ) ) {
return Promise.resolve( JSON.stringify( previewCart ) );
}
return Promise.resolve( '' );
} );
// need to clear the store resolution state between tests.
wpDataFunctions.dispatch( storeKey ).invalidateResolutionForStore();
wpDataFunctions
.dispatch( storeKey )
.receiveCart( defaultCartState.cartData );
} );
} );
afterEach( async () => {
act( () => {
resetMockPaymentMethods();
fetchMock.resetMocks();
} );
} );
it( 'toggles active payment method correctly for express payment activation and close', async () => {
const TriggerActiveExpressPaymentMethod = () => {
const activePaymentMethod = wpDataFunctions.useSelect(
( select ) => {
return select( PAYMENT_STORE_KEY ).getActivePaymentMethod();
}
);
return (
<>
<CheckoutExpressPayment />
{ 'Active Payment Method: ' + activePaymentMethod }
</>
);
};
const TestComponent = () => {
return <TriggerActiveExpressPaymentMethod />;
};
render( <TestComponent /> );
// should initialize by default the first payment method.
await waitFor( () => {
const activePaymentMethod = screen.queryByText(
/Active Payment Method: credit-card/
);
expect( activePaymentMethod ).not.toBeNull();
} );
// Express payment method clicked.
userEvent.click(
screen.getByText( 'express-payment express payment method' )
);
await waitFor( () => {
const activePaymentMethod = screen.queryByText(
/Active Payment Method: express-payment/
);
expect( activePaymentMethod ).not.toBeNull();
} );
// Express payment method closed.
userEvent.click(
screen.getByText( 'express-payment express payment method close' )
);
await waitFor( () => {
const activePaymentMethod = screen.queryByText(
/Active Payment Method: credit-card/
);
expect( activePaymentMethod ).not.toBeNull();
} );
} );
} );
describe( 'Testing Payment Methods work correctly with saved cards turned on', () => {
beforeEach( () => {
act( () => {
registerMockPaymentMethods( true );
fetchMock.mockResponse( ( req ) => {
if ( req.url.match( /wc\/store\/v1\/cart/ ) ) {
return Promise.resolve( JSON.stringify( previewCart ) );
}
return Promise.resolve( '' );
} );
// need to clear the store resolution state between tests.
wpDataFunctions.dispatch( storeKey ).invalidateResolutionForStore();
wpDataFunctions
.dispatch( storeKey )
.receiveCart( defaultCartState.cartData );
} );
} );
afterEach( async () => {
act( () => {
resetMockPaymentMethods();
fetchMock.resetMocks();
} );
} );
it( 'resets saved payment method data after starting and closing an express payment method', async () => {
const TriggerActiveExpressPaymentMethod = () => {
const { activePaymentMethod, paymentMethodData } =
wpDataFunctions.useSelect( ( select ) => {
const store = select( PAYMENT_STORE_KEY );
return {
activePaymentMethod: store.getActivePaymentMethod(),
paymentMethodData: store.getPaymentMethodData(),
};
} );
return (
<>
<CheckoutExpressPayment />
<SavedPaymentMethodOptions onChange={ () => void null } />
{ 'Active Payment Method: ' + activePaymentMethod }
{ paymentMethodData[ 'wc-credit-card-payment-token' ] && (
<span>credit-card token</span>
) }
</>
);
};
const TestComponent = () => {
return <TriggerActiveExpressPaymentMethod />;
};
render( <TestComponent /> );
// Should initialize by default the default saved payment method.
await waitFor( () => {
const activePaymentMethod = screen.queryByText(
/Active Payment Method: credit-card/
);
expect( activePaymentMethod ).not.toBeNull();
} );
await waitFor( () => {
const creditCardToken = screen.queryByText( /credit-card token/ );
expect( creditCardToken ).not.toBeNull();
} );
// Express payment method clicked.
userEvent.click(
screen.getByText( 'express-payment express payment method' )
);
await waitFor( () => {
const activePaymentMethod = screen.queryByText(
/Active Payment Method: express-payment/
);
expect( activePaymentMethod ).not.toBeNull();
} );
await waitFor( () => {
const creditCardToken = screen.queryByText( /credit-card token/ );
expect( creditCardToken ).toBeNull();
} );
// Express payment method closed.
userEvent.click(
screen.getByText( 'express-payment express payment method close' )
);
await waitFor( () => {
const activePaymentMethod = screen.queryByText(
/Active Payment Method: credit-card/
);
expect( activePaymentMethod ).not.toBeNull();
} );
await waitFor( () => {
const creditCardToken = screen.queryByText( /credit-card token/ );
expect( creditCardToken ).not.toBeNull();
} );
} );
} );

View File

@@ -0,0 +1,149 @@
/* eslint-disable no-unused-expressions */
/**
* External dependencies
*/
import * as wpDataFunctions from '@wordpress/data';
/**
* Internal dependencies
*/
import { setDefaultPaymentMethod } from '../utils/set-default-payment-method';
import { PlainPaymentMethods } from '../../../types';
import { PAYMENT_STORE_KEY } from '..';
const originalSelect = jest.requireActual( '@wordpress/data' ).select;
describe( 'setDefaultPaymentMethod', () => {
afterEach( () => {
jest.resetAllMocks();
jest.resetModules();
} );
const paymentMethods: PlainPaymentMethods = {
'wc-payment-gateway-1': {
name: 'wc-payment-gateway-1',
},
'wc-payment-gateway-2': {
name: 'wc-payment-gateway-2',
},
};
it( 'correctly sets the first payment method in the list of available payment methods', async () => {
jest.spyOn( wpDataFunctions, 'select' ).mockImplementation(
( storeName ) => {
const originalStore = originalSelect( storeName );
if ( storeName === PAYMENT_STORE_KEY ) {
return {
...originalStore,
getAvailableExpressPaymentMethods: () => {
return {
express_payment_1: {
name: 'express_payment_1',
},
};
},
getSavedPaymentMethods: () => {
return {};
},
};
}
return originalStore;
}
);
const originalDispatch =
jest.requireActual( '@wordpress/data' ).dispatch;
const setActivePaymentMethodMock = jest.fn();
jest.spyOn( wpDataFunctions, 'dispatch' ).mockImplementation(
( storeName ) => {
const originalStore = originalDispatch( storeName );
if ( storeName === PAYMENT_STORE_KEY ) {
return {
...originalStore,
__internalSetActivePaymentMethod:
setActivePaymentMethodMock,
};
}
return originalStore;
}
);
await setDefaultPaymentMethod( paymentMethods );
expect( setActivePaymentMethodMock ).toHaveBeenCalledWith(
'wc-payment-gateway-1'
);
} );
it( 'correctly sets the saved payment method if one is available', async () => {
jest.spyOn( wpDataFunctions, 'select' ).mockImplementation(
( storeName ) => {
const originalStore = originalSelect( storeName );
if ( storeName === PAYMENT_STORE_KEY ) {
return {
...originalStore,
getAvailableExpressPaymentMethods: () => {
return {
express_payment_1: {
name: 'express_payment_1',
},
};
},
getSavedPaymentMethods: () => {
return {
cc: [
{
method: {
gateway: 'saved-method',
last4: '4242',
brand: 'Visa',
},
expires: '04/44',
is_default: true,
actions: {
delete: {
url: 'https://example.com/delete',
name: 'Delete',
},
},
tokenId: 2,
},
],
};
},
};
}
return originalStore;
}
);
const originalDispatch =
jest.requireActual( '@wordpress/data' ).dispatch;
const setActivePaymentMethodMock = jest.fn();
jest.spyOn( wpDataFunctions, 'dispatch' ).mockImplementation(
( storeName ) => {
const originalStore = originalDispatch( storeName );
if ( storeName === PAYMENT_STORE_KEY ) {
return {
...originalStore,
__internalSetActivePaymentMethod:
setActivePaymentMethodMock,
__internalSetPaymentError: () => void 0,
__internalSetPaymentIdle: () => void 0,
__internalSetExpressPaymentStarted: () => void 0,
__internalSetPaymentProcessing: () => void 0,
__internalSetPaymentReady: () => void 0,
};
}
return originalStore;
}
);
await setDefaultPaymentMethod( paymentMethods );
expect( setActivePaymentMethodMock ).toHaveBeenCalledWith(
'saved-method',
{
isSavedToken: true,
payment_method: 'saved-method',
token: '2',
'wc-saved-method-payment-token': '2',
}
);
} );
} );

View File

@@ -0,0 +1,224 @@
/**
* External dependencies
*/
import * as wpDataFunctions from '@wordpress/data';
import { EventObserversType } from '@woocommerce/base-context';
/**
* Internal dependencies
*/
import { PAYMENT_STORE_KEY } from '../index';
import { __internalEmitPaymentProcessingEvent } from '../thunks';
/**
* If an observer returns billingAddress, shippingAddress, or paymentData, then the values of these
* should be updated in the data stores.
*/
const testShippingAddress = {
first_name: 'test',
last_name: 'test',
company: 'test',
address_1: 'test',
address_2: 'test',
city: 'test',
state: 'test',
postcode: 'test',
country: 'test',
phone: 'test',
};
const testBillingAddress = {
...testShippingAddress,
email: 'test@test.com',
};
const testPaymentMethodData = {
payment_method: 'test',
};
describe( 'wc/store/payment thunks', () => {
const testPaymentProcessingCallback = jest.fn();
const testPaymentProcessingCallback2 = jest.fn();
const currentObservers: EventObserversType = {
payment_setup: new Map(),
};
currentObservers.payment_setup.set( 'test', {
callback: testPaymentProcessingCallback,
priority: 10,
} );
currentObservers.payment_setup.set( 'test2', {
callback: testPaymentProcessingCallback2,
priority: 10,
} );
describe( '__internalEmitPaymentProcessingEvent', () => {
beforeEach( () => {
jest.resetAllMocks();
} );
it( 'calls all registered observers', async () => {
const {
__internalEmitPaymentProcessingEvent:
__internalEmitPaymentProcessingEventFromStore,
} = wpDataFunctions.dispatch( PAYMENT_STORE_KEY );
await __internalEmitPaymentProcessingEventFromStore(
currentObservers,
jest.fn()
);
expect( testPaymentProcessingCallback ).toHaveBeenCalled();
expect( testPaymentProcessingCallback2 ).toHaveBeenCalled();
} );
it( 'sets metadata if successful observers return it', async () => {
const testSuccessCallbackWithMetadata = jest.fn().mockReturnValue( {
type: 'success',
meta: {
billingAddress: testBillingAddress,
shippingAddress: testShippingAddress,
paymentMethodData: testPaymentMethodData,
},
} );
currentObservers.payment_setup.set( 'test3', {
callback: testSuccessCallbackWithMetadata,
priority: 10,
} );
const setBillingAddressMock = jest.fn();
const setShippingAddressMock = jest.fn();
const setPaymentMethodDataMock = jest.fn();
const registryMock = {
dispatch: jest.fn().mockImplementation( ( store: string ) => {
return {
...wpDataFunctions.dispatch( store ),
setBillingAddress: setBillingAddressMock,
setShippingAddress: setShippingAddressMock,
};
} ),
};
// Await here because the function returned by the __internalEmitPaymentProcessingEvent action creator
// (a thunk) returns a Promise.
await __internalEmitPaymentProcessingEvent(
currentObservers,
jest.fn()
)( {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - it would be too much work to mock the entire registry, so we only mock dispatch on it,
// which is all we need to test this thunk.
registry: registryMock,
dispatch: {
...wpDataFunctions.dispatch( PAYMENT_STORE_KEY ),
__internalSetPaymentMethodData: setPaymentMethodDataMock,
},
} );
expect( setBillingAddressMock ).toHaveBeenCalledWith(
testBillingAddress
);
expect( setShippingAddressMock ).toHaveBeenCalledWith(
testShippingAddress
);
expect( setPaymentMethodDataMock ).toHaveBeenCalledWith(
testPaymentMethodData
);
} );
it( 'sets metadata if failed observers return it', async () => {
const testFailingCallbackWithMetadata = jest.fn().mockReturnValue( {
type: 'failure',
meta: {
billingAddress: testBillingAddress,
paymentMethodData: testPaymentMethodData,
},
} );
currentObservers.payment_setup.set( 'test4', {
callback: testFailingCallbackWithMetadata,
priority: 10,
} );
const setBillingAddressMock = jest.fn();
const setPaymentMethodDataMock = jest.fn();
const registryMock = {
dispatch: jest.fn().mockImplementation( ( store: string ) => {
return {
...wpDataFunctions.dispatch( store ),
setBillingAddress: setBillingAddressMock,
};
} ),
};
// Await here because the function returned by the __internalEmitPaymentProcessingEvent action creator
// (a thunk) returns a Promise.
await __internalEmitPaymentProcessingEvent(
currentObservers,
jest.fn()
)( {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - it would be too much work to mock the entire registry, so we only mock dispatch on it,
// which is all we need to test this thunk.
registry: registryMock,
dispatch: {
...wpDataFunctions.dispatch( PAYMENT_STORE_KEY ),
__internalSetPaymentMethodData: setPaymentMethodDataMock,
},
} );
expect( setBillingAddressMock ).toHaveBeenCalledWith(
testBillingAddress
);
expect( setPaymentMethodDataMock ).toHaveBeenCalledWith(
testPaymentMethodData
);
} );
it( 'sets payment status to error if one observer is successful, but another errors', async () => {
const testErrorCallbackWithMetadata = jest
.fn()
.mockImplementation( () => {
return {
type: 'error',
};
} );
const testSuccessCallback = jest.fn().mockReturnValue( {
type: 'success',
} );
currentObservers.payment_setup.set( 'test5', {
callback: testErrorCallbackWithMetadata,
priority: 10,
} );
currentObservers.payment_setup.set( 'test6', {
callback: testSuccessCallback,
priority: 9,
} );
const setPaymentErrorMock = jest.fn();
const setPaymentReadyMock = jest.fn();
const registryMock = {
dispatch: jest
.fn()
.mockImplementation( wpDataFunctions.dispatch ),
};
// Await here because the function returned by the __internalEmitPaymentProcessingEvent action creator
// (a thunk) returns a Promise.
await __internalEmitPaymentProcessingEvent(
currentObservers,
jest.fn()
)( {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - it would be too much work to mock the entire registry, so we only mock dispatch on it,
// which is all we need to test this thunk.
registry: registryMock,
dispatch: {
...wpDataFunctions.dispatch( PAYMENT_STORE_KEY ),
__internalSetPaymentError: setPaymentErrorMock,
__internalSetPaymentReady: setPaymentReadyMock,
},
} );
// The observer throwing will cause this.
//expect( console ).toHaveErroredWith( new Error( 'test error' ) );
expect( setPaymentErrorMock ).toHaveBeenCalled();
expect( setPaymentReadyMock ).not.toHaveBeenCalled();
} );
} );
} );

View File

@@ -0,0 +1,215 @@
/**
* External dependencies
*/
import { store as noticesStore } from '@wordpress/notices';
import deprecated from '@wordpress/deprecated';
import type { BillingAddress, ShippingAddress } from '@woocommerce/settings';
import { isObject, isString, objectHasProp } from '@woocommerce/types';
/**
* Internal dependencies
*/
import {
emitEventWithAbort,
isErrorResponse,
isFailResponse,
isSuccessResponse,
noticeContexts,
ObserverResponse,
} from '../../base/context/event-emit';
import { EMIT_TYPES } from '../../base/context/providers/cart-checkout/payment-events/event-emit';
import type { emitProcessingEventType } from './types';
import { CART_STORE_KEY } from '../cart';
import {
isBillingAddress,
isShippingAddress,
} from '../../types/type-guards/address';
import { isObserverResponse } from '../../types/type-guards/observers';
import { isValidValidationErrorsObject } from '../../types/type-guards/validation';
export const __internalSetExpressPaymentError = ( message?: string ) => {
return ( { registry } ) => {
const { createErrorNotice, removeNotice } =
registry.dispatch( noticesStore );
if ( message ) {
createErrorNotice( message, {
id: 'wc-express-payment-error',
context: noticeContexts.EXPRESS_PAYMENTS,
} );
} else {
removeNotice(
'wc-express-payment-error',
noticeContexts.EXPRESS_PAYMENTS
);
}
};
};
/**
* Emit the payment_processing event
*/
export const __internalEmitPaymentProcessingEvent: emitProcessingEventType = (
currentObserver,
setValidationErrors
) => {
return ( { dispatch, registry } ) => {
const { createErrorNotice, removeNotice } =
registry.dispatch( 'core/notices' );
removeNotice( 'wc-payment-error', noticeContexts.PAYMENTS );
return emitEventWithAbort(
currentObserver,
EMIT_TYPES.PAYMENT_SETUP,
{}
).then( ( observerResponses ) => {
let successResponse: ObserverResponse | undefined,
errorResponse: ObserverResponse | undefined,
billingAddress: BillingAddress | undefined,
shippingAddress: ShippingAddress | undefined;
observerResponses.forEach( ( response ) => {
if ( isSuccessResponse( response ) ) {
// The last observer response always "wins" for success.
successResponse = response;
}
// We consider both failed and error responses as an error.
if (
isErrorResponse( response ) ||
isFailResponse( response )
) {
errorResponse = response;
}
// Extensions may return shippingData, shippingAddress, billingData, and billingAddress in the response,
// so we need to check for all. If we detect either shippingData or billingData we need to show a
// deprecated warning for it, but also apply the changes to the wc/store/cart store.
const {
billingAddress: billingAddressFromResponse,
// Deprecated, but keeping it for now, for compatibility with extensions returning it.
billingData: billingDataFromResponse,
shippingAddress: shippingAddressFromResponse,
// Deprecated, but keeping it for now, for compatibility with extensions returning it.
shippingData: shippingDataFromResponse,
} = response?.meta || {};
billingAddress = billingAddressFromResponse as BillingAddress;
shippingAddress =
shippingAddressFromResponse as ShippingAddress;
if ( billingDataFromResponse ) {
// Set this here so that old extensions still using billingData can set the billingAddress.
billingAddress = billingDataFromResponse as BillingAddress;
deprecated(
'returning billingData from an onPaymentProcessing observer in WooCommerce Blocks',
{
version: '9.5.0',
alternative: 'billingAddress',
link: 'https://github.com/woocommerce/woocommerce-blocks/pull/6369',
}
);
}
if (
objectHasProp( shippingDataFromResponse, 'address' ) &&
shippingDataFromResponse.address
) {
// Set this here so that old extensions still using shippingData can set the shippingAddress.
shippingAddress =
shippingDataFromResponse.address as ShippingAddress;
deprecated(
'returning shippingData from an onPaymentProcessing observer in WooCommerce Blocks',
{
version: '9.5.0',
alternative: 'shippingAddress',
link: 'https://github.com/woocommerce/woocommerce-blocks/pull/8163',
}
);
}
} );
const { setBillingAddress, setShippingAddress } =
registry.dispatch( CART_STORE_KEY );
// Observer returned success, we sync the payment method data and billing address.
if ( isObserverResponse( successResponse ) && ! errorResponse ) {
const { paymentMethodData } = successResponse?.meta || {};
if ( isBillingAddress( billingAddress ) ) {
setBillingAddress( billingAddress );
}
if ( isShippingAddress( shippingAddress ) ) {
setShippingAddress( shippingAddress );
}
dispatch.__internalSetPaymentMethodData(
isObject( paymentMethodData ) ? paymentMethodData : {}
);
dispatch.__internalSetPaymentReady();
} else if ( isFailResponse( errorResponse ) ) {
const { paymentMethodData } = errorResponse?.meta || {};
if (
objectHasProp( errorResponse, 'message' ) &&
isString( errorResponse.message ) &&
errorResponse.message.length
) {
let context: string = noticeContexts.PAYMENTS;
if (
objectHasProp( errorResponse, 'messageContext' ) &&
isString( errorResponse.messageContext ) &&
errorResponse.messageContext.length
) {
context = errorResponse.messageContext;
}
createErrorNotice( errorResponse.message, {
id: 'wc-payment-error',
isDismissible: false,
context,
} );
}
if ( isBillingAddress( billingAddress ) ) {
setBillingAddress( billingAddress );
}
dispatch.__internalSetPaymentMethodData(
isObject( paymentMethodData ) ? paymentMethodData : {}
);
dispatch.__internalSetPaymentError();
} else if ( isErrorResponse( errorResponse ) ) {
if (
objectHasProp( errorResponse, 'message' ) &&
isString( errorResponse.message ) &&
errorResponse.message.length
) {
let context: string = noticeContexts.PAYMENTS;
if (
objectHasProp( errorResponse, 'messageContext' ) &&
isString( errorResponse.messageContext ) &&
errorResponse.messageContext.length
) {
context = errorResponse.messageContext;
}
createErrorNotice( errorResponse.message, {
id: 'wc-payment-error',
isDismissible: false,
context,
} );
}
dispatch.__internalSetPaymentError();
if (
isValidValidationErrorsObject(
errorResponse.validationErrors
)
) {
setValidationErrors( errorResponse.validationErrors );
}
} else {
// Otherwise there are no payment methods doing anything so just assume payment method is ready.
dispatch.__internalSetPaymentReady();
}
} );
};
};

View File

@@ -0,0 +1,96 @@
/**
* External dependencies
*/
import {
PlainPaymentMethods,
PlainExpressPaymentMethods,
} from '@woocommerce/types';
import type {
EmptyObjectType,
ObjectType,
FieldValidationStatus,
} from '@woocommerce/types';
import { DataRegistry } from '@wordpress/data';
/**
* Internal dependencies
*/
import type { EventObserversType } from '../../base/context/event-emit';
import type { DispatchFromMap } from '../mapped-types';
import * as actions from './actions';
export interface CustomerPaymentMethodConfiguration {
gateway: string;
brand: string;
last4: string;
}
export interface SavedPaymentMethod {
method: CustomerPaymentMethodConfiguration;
expires: string;
is_default: boolean;
tokenId: number;
actions: ObjectType;
}
export type SavedPaymentMethods =
| Record< string, SavedPaymentMethod[] >
| EmptyObjectType;
export interface PaymentMethodDispatchers {
setRegisteredPaymentMethods: (
paymentMethods: PlainPaymentMethods
) => void;
setRegisteredExpressPaymentMethods: (
paymentMethods: PlainExpressPaymentMethods
) => void;
setActivePaymentMethod: (
paymentMethod: string,
paymentMethodData?: ObjectType | EmptyObjectType
) => void;
}
export interface PaymentStatusDispatchers {
pristine: () => void;
started: () => void;
processing: () => void;
error: ( error: string ) => void;
failed: (
error?: string,
paymentMethodData?: ObjectType | EmptyObjectType,
billingAddress?: ObjectType | EmptyObjectType
) => void;
success: (
paymentMethodData?: ObjectType | EmptyObjectType,
billingAddress?: ObjectType | EmptyObjectType,
shippingData?: ObjectType | EmptyObjectType
) => void;
}
export type PaymentMethodsDispatcherType = (
paymentMethods: PlainPaymentMethods
) => undefined | void;
/**
* Type for emitProcessingEventType() thunk
*/
export type emitProcessingEventType = (
observers: EventObserversType,
setValidationErrors: (
errors: Record< string, FieldValidationStatus >
) => void
) => ( {
dispatch,
registry,
}: {
dispatch: DispatchFromMap< typeof actions >;
registry: DataRegistry;
} ) => void;
export interface PaymentStatus {
isPristine?: boolean;
isStarted?: boolean;
isProcessing?: boolean;
isFinished?: boolean;
hasError?: boolean;
hasFailed?: boolean;
isSuccessful?: boolean;
}

View File

@@ -0,0 +1,247 @@
/**
* External dependencies
*/
import {
CanMakePaymentArgument,
ExpressPaymentMethodConfigInstance,
PaymentMethodConfigInstance,
} from '@woocommerce/types';
import { CURRENT_USER_IS_ADMIN, getSetting } from '@woocommerce/settings';
import { dispatch, select } from '@wordpress/data';
import {
deriveSelectedShippingRates,
emptyHiddenAddressFields,
} from '@woocommerce/base-utils';
import { __, sprintf } from '@wordpress/i18n';
import {
getExpressPaymentMethods,
getPaymentMethods,
} from '@woocommerce/blocks-registry';
import { previewCart } from '@woocommerce/resource-previews';
/**
* Internal dependencies
*/
import { STORE_KEY as CART_STORE_KEY } from '../../cart/constants';
import { STORE_KEY as PAYMENT_STORE_KEY } from '../constants';
import { noticeContexts } from '../../../base/context/event-emit';
import {
EMPTY_CART_ERRORS,
EMPTY_CART_ITEM_ERRORS,
EMPTY_EXTENSIONS,
} from '../../../data/constants';
import { defaultCartState } from '../../../data/cart/default-state';
/**
* Get the argument that will be passed to a payment method's `canMakePayment` method.
*/
export const getCanMakePaymentArg = (): CanMakePaymentArgument => {
const isEditor = !! select( 'core/editor' );
let canPayArgument: CanMakePaymentArgument;
if ( ! isEditor ) {
const store = select( CART_STORE_KEY );
const cart = store.getCartData();
const cartErrors = store.getCartErrors();
const cartTotals = store.getCartTotals();
const cartIsLoading = ! store.hasFinishedResolution( 'getCartData' );
const isLoadingRates = store.isCustomerDataUpdating();
const selectedShippingMethods = deriveSelectedShippingRates(
cart.shippingRates
);
const cartForCanPayArgument = {
cartCoupons: cart.coupons,
cartItems: cart.items,
crossSellsProducts: cart.crossSells,
cartFees: cart.fees,
cartItemsCount: cart.itemsCount,
cartItemsWeight: cart.itemsWeight,
cartNeedsPayment: cart.needsPayment,
cartNeedsShipping: cart.needsShipping,
cartItemErrors: cart.errors,
cartTotals,
cartIsLoading,
cartErrors,
billingData: emptyHiddenAddressFields( cart.billingAddress ),
billingAddress: emptyHiddenAddressFields( cart.billingAddress ),
shippingAddress: emptyHiddenAddressFields( cart.shippingAddress ),
extensions: cart.extensions,
shippingRates: cart.shippingRates,
isLoadingRates,
cartHasCalculatedShipping: cart.hasCalculatedShipping,
paymentRequirements: cart.paymentRequirements,
receiveCart: dispatch( CART_STORE_KEY ).receiveCart,
};
canPayArgument = {
cart: cartForCanPayArgument,
cartTotals: cart.totals,
cartNeedsShipping: cart.needsShipping,
billingData: cart.billingAddress,
billingAddress: cart.billingAddress,
shippingAddress: cart.shippingAddress,
selectedShippingMethods,
paymentMethods: cart.paymentMethods,
paymentRequirements: cart.paymentRequirements,
};
} else {
const cartForCanPayArgument = {
cartCoupons: previewCart.coupons,
cartItems: previewCart.items,
crossSellsProducts: previewCart.cross_sells,
cartFees: previewCart.fees,
cartItemsCount: previewCart.items_count,
cartItemsWeight: previewCart.items_weight,
cartNeedsPayment: previewCart.needs_payment,
cartNeedsShipping: previewCart.needs_shipping,
cartItemErrors: EMPTY_CART_ITEM_ERRORS,
cartTotals: previewCart.totals,
cartIsLoading: false,
cartErrors: EMPTY_CART_ERRORS,
billingData: defaultCartState.cartData.billingAddress,
billingAddress: defaultCartState.cartData.billingAddress,
shippingAddress: defaultCartState.cartData.shippingAddress,
extensions: EMPTY_EXTENSIONS,
shippingRates: previewCart.shipping_rates,
isLoadingRates: false,
cartHasCalculatedShipping: previewCart.has_calculated_shipping,
paymentRequirements: previewCart.payment_requirements,
receiveCart: () => undefined,
};
canPayArgument = {
cart: cartForCanPayArgument,
cartTotals: cartForCanPayArgument.cartTotals,
cartNeedsShipping: cartForCanPayArgument.cartNeedsShipping,
billingData: cartForCanPayArgument.billingAddress,
billingAddress: cartForCanPayArgument.billingAddress,
shippingAddress: cartForCanPayArgument.shippingAddress,
selectedShippingMethods: deriveSelectedShippingRates(
cartForCanPayArgument.shippingRates
),
paymentMethods: previewCart.payment_methods,
paymentRequirements: cartForCanPayArgument.paymentRequirements,
};
}
return canPayArgument;
};
const registrationErrorNotice = (
paymentMethod:
| ExpressPaymentMethodConfigInstance
| PaymentMethodConfigInstance,
errorMessage: string,
express = false
) => {
const { createErrorNotice } = dispatch( 'core/notices' );
const noticeContext = express
? noticeContexts.EXPRESS_PAYMENTS
: noticeContexts.PAYMENTS;
const errorText = sprintf(
/* translators: %s the id of the payment method being registered (bank transfer, cheque...) */
__(
`There was an error registering the payment method with id '%s': `,
'woo-gutenberg-products-block'
),
paymentMethod.paymentMethodId
);
createErrorNotice( `${ errorText } ${ errorMessage }`, {
context: noticeContext,
id: `wc-${ paymentMethod.paymentMethodId }-registration-error`,
} );
};
export const checkPaymentMethodsCanPay = async ( express = false ) => {
let availablePaymentMethods = {};
const paymentMethods = express
? getExpressPaymentMethods()
: getPaymentMethods();
const addAvailablePaymentMethod = (
paymentMethod:
| PaymentMethodConfigInstance
| ExpressPaymentMethodConfigInstance
) => {
const { name } = paymentMethod;
availablePaymentMethods = {
...availablePaymentMethods,
[ paymentMethod.name ]: { name },
};
};
// Order payment methods.
const paymentMethodsOrder = express
? Object.keys( paymentMethods )
: Array.from(
new Set( [
...( getSetting( 'paymentMethodSortOrder', [] ) as [] ),
...Object.keys( paymentMethods ),
] )
);
const canPayArgument = getCanMakePaymentArg();
const cartPaymentMethods = canPayArgument.paymentMethods as string[];
const isEditor = !! select( 'core/editor' );
for ( let i = 0; i < paymentMethodsOrder.length; i++ ) {
const paymentMethodName = paymentMethodsOrder[ i ];
const paymentMethod = paymentMethods[ paymentMethodName ];
if ( ! paymentMethod ) {
continue;
}
// See if payment method should be available. This always evaluates to true in the editor context.
try {
const validForCart =
isEditor || express
? true
: cartPaymentMethods.includes( paymentMethodName );
const canPay = isEditor
? true
: validForCart &&
( await Promise.resolve(
paymentMethod.canMakePayment( canPayArgument )
) );
if ( canPay ) {
if ( typeof canPay === 'object' && canPay.error ) {
throw new Error( canPay.error.message );
}
addAvailablePaymentMethod( paymentMethod );
}
} catch ( e ) {
if ( CURRENT_USER_IS_ADMIN || isEditor ) {
registrationErrorNotice( paymentMethod, e as string, express );
}
}
}
const availablePaymentMethodNames = Object.keys( availablePaymentMethods );
const currentlyAvailablePaymentMethods = express
? select( PAYMENT_STORE_KEY ).getAvailableExpressPaymentMethods()
: select( PAYMENT_STORE_KEY ).getAvailablePaymentMethods();
if (
Object.keys( currentlyAvailablePaymentMethods ).length ===
availablePaymentMethodNames.length &&
Object.keys( currentlyAvailablePaymentMethods ).every( ( current ) =>
availablePaymentMethodNames.includes( current )
)
) {
// All the names are the same, no need to dispatch more actions.
return true;
}
const {
__internalSetAvailablePaymentMethods,
__internalSetAvailableExpressPaymentMethods,
} = dispatch( PAYMENT_STORE_KEY );
const setCallback = express
? __internalSetAvailableExpressPaymentMethods
: __internalSetAvailablePaymentMethods;
setCallback( availablePaymentMethods );
return true;
};

View File

@@ -0,0 +1,49 @@
/**
* External dependencies
*/
import { getPaymentMethods } from '@woocommerce/blocks-registry';
/**
* Internal dependencies
*/
import type { SavedPaymentMethods } from '../types';
/**
* Gets the payment methods saved for the current user after filtering out disabled ones.
*/
export const filterActiveSavedPaymentMethods = (
availablePaymentMethods: string[] = [],
savedPaymentMethods: SavedPaymentMethods
): SavedPaymentMethods => {
if ( availablePaymentMethods.length === 0 ) {
return {};
}
const registeredPaymentMethods = getPaymentMethods();
const availablePaymentMethodsWithConfig = Object.fromEntries(
availablePaymentMethods.map( ( name ) => [
name,
registeredPaymentMethods[ name ],
] )
);
const paymentMethodKeys = Object.keys( savedPaymentMethods );
const activeSavedPaymentMethods = {} as SavedPaymentMethods;
paymentMethodKeys.forEach( ( type ) => {
const methods = savedPaymentMethods[ type ].filter(
( {
method: { gateway },
}: {
method: {
gateway: string;
};
} ) =>
gateway in availablePaymentMethodsWithConfig &&
availablePaymentMethodsWithConfig[ gateway ].supports
?.showSavedCards
);
if ( methods.length ) {
activeSavedPaymentMethods[ type ] = methods;
}
} );
return activeSavedPaymentMethods;
};

View File

@@ -0,0 +1,68 @@
/**
* External dependencies
*/
import { select, dispatch } from '@wordpress/data';
import { PlainPaymentMethods } from '@woocommerce/types';
/**
* Internal dependencies
*/
import { STORE_KEY as PAYMENT_STORE_KEY } from '../constants';
export const setDefaultPaymentMethod = async (
paymentMethods: PlainPaymentMethods
) => {
const paymentMethodKeys = Object.keys( paymentMethods );
const expressPaymentMethodKeys = Object.keys(
select( PAYMENT_STORE_KEY ).getAvailableExpressPaymentMethods()
);
const allPaymentMethodKeys = [
...paymentMethodKeys,
...expressPaymentMethodKeys,
];
const savedPaymentMethods =
select( PAYMENT_STORE_KEY ).getSavedPaymentMethods();
const savedPaymentMethod =
Object.keys( savedPaymentMethods ).flatMap(
( type ) => savedPaymentMethods[ type ]
)[ 0 ] || undefined;
if ( savedPaymentMethod ) {
const token = savedPaymentMethod.tokenId.toString();
const paymentMethodSlug = savedPaymentMethod.method.gateway;
const savedTokenKey = `wc-${ paymentMethodSlug }-payment-token`;
dispatch( PAYMENT_STORE_KEY ).__internalSetActivePaymentMethod(
paymentMethodSlug,
{
token,
payment_method: paymentMethodSlug,
[ savedTokenKey ]: token,
isSavedToken: true,
}
);
return;
}
const activePaymentMethod =
select( PAYMENT_STORE_KEY ).getActivePaymentMethod();
// Return if current method is valid.
if (
activePaymentMethod &&
allPaymentMethodKeys.includes( activePaymentMethod )
) {
return;
}
dispatch( PAYMENT_STORE_KEY ).__internalSetPaymentIdle();
dispatch( PAYMENT_STORE_KEY ).__internalSetActivePaymentMethod(
paymentMethodKeys[ 0 ]
);
};

View File

@@ -0,0 +1,4 @@
export const ACTION_TYPES = {
SET_QUERY_KEY_VALUE: 'SET_QUERY_KEY_VALUE',
SET_QUERY_CONTEXT_VALUE: 'SET_QUERY_CONTEXT_VALUE',
};

View File

@@ -0,0 +1,38 @@
/**
* Internal dependencies
*/
import { ACTION_TYPES as types } from './action-types';
/**
* Action creator for setting a single query-state value for a given context.
*
* @param {string} context Context for query state being stored.
* @param {string} queryKey Key for query item.
* @param {*} value The value for the query item.
*
* @return {Object} The action object.
*/
export const setQueryValue = ( context, queryKey, value ) => {
return {
type: types.SET_QUERY_KEY_VALUE,
context,
queryKey,
value,
};
};
/**
* Action creator for setting query-state for a given context.
*
* @param {string} context Context for query state being stored.
* @param {*} value Query state being stored for the given context.
*
* @return {Object} The action object.
*/
export const setValueForQueryContext = ( context, value ) => {
return {
type: types.SET_QUERY_CONTEXT_VALUE,
context,
value,
};
};

View File

@@ -0,0 +1 @@
export const STORE_KEY = 'wc/store/query-state';

View File

@@ -0,0 +1,22 @@
/**
* External dependencies
*/
import { register, createReduxStore } from '@wordpress/data';
/**
* Internal dependencies
*/
import { STORE_KEY } from './constants';
import * as selectors from './selectors';
import * as actions from './actions';
import reducer from './reducers';
const store = createReduxStore( STORE_KEY, {
reducer,
actions,
selectors,
} );
register( store );
export const QUERY_STATE_STORE_KEY = STORE_KEY;

View File

@@ -0,0 +1,46 @@
/**
* Internal dependencies
*/
import { ACTION_TYPES as types } from './action-types';
import { getStateForContext } from './utils';
/**
* Reducer for processing actions related to the query state store.
*
* @param {Object} state Current state in store.
* @param {Object} action Action being processed.
*/
const queryStateReducer = ( state = {}, action ) => {
const { type, context, queryKey, value } = action;
const prevState = getStateForContext( state, context );
let newState;
switch ( type ) {
case types.SET_QUERY_KEY_VALUE:
const prevStateObject =
prevState !== null ? JSON.parse( prevState ) : {};
// mutate it and JSON.stringify to compare
prevStateObject[ queryKey ] = value;
newState = JSON.stringify( prevStateObject );
if ( prevState !== newState ) {
state = {
...state,
[ context ]: newState,
};
}
break;
case types.SET_QUERY_CONTEXT_VALUE:
newState = JSON.stringify( value );
if ( prevState !== newState ) {
state = {
...state,
[ context ]: newState,
};
}
break;
}
return state;
};
export default queryStateReducer;

View File

@@ -0,0 +1,51 @@
/**
* Internal dependencies
*/
import { getStateForContext } from './utils';
/**
* Selector for retrieving a specific query-state for the given context.
*
* @param {Object} state Current state.
* @param {string} context Context for the query-state being retrieved.
* @param {string} queryKey Key for the specific query-state item.
* @param {*} defaultValue Default value for the query-state key if it doesn't
* currently exist in state.
*
* @return {*} The currently stored value or the defaultValue if not present.
*/
export const getValueForQueryKey = (
state,
context,
queryKey,
defaultValue = {}
) => {
let stateContext = getStateForContext( state, context );
if ( stateContext === null ) {
return defaultValue;
}
stateContext = JSON.parse( stateContext );
return typeof stateContext[ queryKey ] !== 'undefined'
? stateContext[ queryKey ]
: defaultValue;
};
/**
* Selector for retrieving the query-state for the given context.
*
* @param {Object} state The current state.
* @param {string} context The context for the query-state being retrieved.
* @param {*} defaultValue The default value to return if there is no state for
* the given context.
*
* @return {*} The currently stored query-state for the given context or
* defaultValue if not present in state.
*/
export const getValueForQueryContext = (
state,
context,
defaultValue = {}
) => {
const stateContext = getStateForContext( state, context );
return stateContext === null ? defaultValue : JSON.parse( stateContext );
};

View File

@@ -0,0 +1,136 @@
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
import queryStateReducer from '../reducers';
import { setQueryValue, setValueForQueryContext } from '../actions';
describe( 'queryStateReducer', () => {
const originalState = deepFreeze( {
contexta: JSON.stringify( {
foo: 'bar',
cheese: 'pizza',
} ),
} );
it(
'returns original state when the action is not of the type being ' +
'processed',
() => {
expect(
queryStateReducer( originalState, { type: 'invalid' } )
).toBe( originalState );
}
);
describe( 'SET_QUERY_KEY_VALUE action', () => {
it(
'returns original state when incoming query-state key value ' +
'matches what is already in the state',
() => {
expect(
queryStateReducer(
originalState,
setQueryValue( 'contexta', 'foo', 'bar' )
)
).toBe( originalState );
}
);
it(
'returns new state when incoming query-state key exist ' +
'but the value is a new value',
() => {
const newState = queryStateReducer(
originalState,
setQueryValue( 'contexta', 'foo', 'zed' )
);
expect( newState ).not.toBe( originalState );
expect( newState ).toEqual( {
contexta: JSON.stringify( {
foo: 'zed',
cheese: 'pizza',
} ),
} );
}
);
it(
'returns new state when incoming query-state key does not ' +
'exist',
() => {
const newState = queryStateReducer(
originalState,
setQueryValue( 'contexta', 'burger', 'pizza' )
);
expect( newState ).not.toBe( originalState );
expect( newState ).toEqual( {
contexta: JSON.stringify( {
foo: 'bar',
cheese: 'pizza',
burger: 'pizza',
} ),
} );
}
);
} );
describe( 'SET_QUERY_CONTEXT_VALUE action', () => {
it(
'returns original state when incoming context value matches ' +
'what is already in the state',
() => {
expect(
queryStateReducer(
originalState,
setValueForQueryContext( 'contexta', {
foo: 'bar',
cheese: 'pizza',
} )
)
).toBe( originalState );
}
);
it(
'returns new state when incoming context value is different ' +
'than what is already in the state',
() => {
const newState = queryStateReducer(
originalState,
setValueForQueryContext( 'contexta', {
bar: 'foo',
pizza: 'cheese',
} )
);
expect( newState ).not.toBe( originalState );
expect( newState ).toEqual( {
contexta: JSON.stringify( {
bar: 'foo',
pizza: 'cheese',
} ),
} );
}
);
it(
'returns new state when incoming context does not exist in the ' +
'state',
() => {
const newState = queryStateReducer(
originalState,
setValueForQueryContext( 'contextb', {
foo: 'bar',
} )
);
expect( newState ).not.toBe( originalState );
expect( newState ).toEqual( {
contexta: JSON.stringify( {
foo: 'bar',
cheese: 'pizza',
} ),
contextb: JSON.stringify( {
foo: 'bar',
} ),
} );
}
);
} );
} );

View File

@@ -0,0 +1,63 @@
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
import { getValueForQueryKey, getValueForQueryContext } from '../selectors';
const testState = deepFreeze( {
contexta: JSON.stringify( {
foo: 'bar',
cheese: 'pizza',
} ),
} );
describe( 'getValueForQueryKey', () => {
it(
'returns provided default value when there is no state for the ' +
'given context',
() => {
expect(
getValueForQueryKey( testState, 'invalid', 'foo', 42 )
).toBe( 42 );
}
);
it(
'returns provided default value when there is no value for the ' +
'given context and queryKey',
() => {
expect(
getValueForQueryKey( testState, 'contexta', 'pizza', 42 )
).toBe( 42 );
}
);
it( 'returns expected value when context and queryKey exist', () => {
expect( getValueForQueryKey( testState, 'contexta', 'foo', 42 ) ).toBe(
'bar'
);
} );
} );
describe( 'getValueForQueryContext', () => {
it(
'returns provided default value when there is no state for the ' +
'given context',
() => {
expect( getValueForQueryContext( testState, 'invalid', 42 ) ).toBe(
42
);
}
);
it(
'returns expected value when selecting a context that exists in ' +
'state',
() => {
expect(
getValueForQueryContext( testState, 'contexta', 42 )
).toEqual( JSON.parse( testState.contexta ) );
}
);
} );

View File

@@ -0,0 +1,3 @@
export const getStateForContext = ( state, context ) => {
return typeof state[ context ] === 'undefined' ? null : state[ context ];
};

View File

@@ -0,0 +1,3 @@
export const ACTION_TYPES = {
RECEIVE_MODEL_ROUTES: 'RECEIVE_MODEL_ROUTES',
};

View File

@@ -0,0 +1,22 @@
/**
* Internal dependencies
*/
import { ACTION_TYPES as types } from './action-types.js';
import { API_BLOCK_NAMESPACE } from '../constants';
/**
* Returns an action object used to update the store with the provided list
* of model routes.
*
* @param {Object} routes An array of routes to add to the store state.
* @param {string} namespace
*
* @return {Object} The action object.
*/
export function receiveRoutes( routes, namespace = API_BLOCK_NAMESPACE ) {
return {
type: types.RECEIVE_MODEL_ROUTES,
routes,
namespace,
};
}

View File

@@ -0,0 +1,6 @@
/**
* Identifier key for this store reducer.
*
* @type {string}
*/
export const STORE_KEY = 'wc/store/schema';

View File

@@ -0,0 +1,26 @@
/**
* External dependencies
*/
import { register, createReduxStore } from '@wordpress/data';
import { controls } from '@wordpress/data-controls';
/**
* Internal dependencies
*/
import { STORE_KEY } from './constants';
import * as selectors from './selectors';
import * as actions from './actions';
import * as resolvers from './resolvers';
import reducer from './reducers';
const store = createReduxStore( STORE_KEY, {
reducer,
actions,
controls,
selectors,
resolvers,
} );
register( store );
export const SCHEMA_STORE_KEY = STORE_KEY;

View File

@@ -0,0 +1,57 @@
/**
* External dependencies
*/
import { combineReducers } from '@wordpress/data';
/**
* Internal dependencies
*/
import { ACTION_TYPES as types } from './action-types';
import {
extractResourceNameFromRoute,
getRouteIds,
simplifyRouteWithId,
} from './utils';
import { hasInState, updateState } from '../utils';
/**
* Reducer for routes
*
* @param {Object} state The current state.
* @param {Object} action The action object for parsing.
*
* @return {Object} The new (or original) state.
*/
export const receiveRoutes = ( state = {}, action ) => {
const { type, routes, namespace } = action;
if ( type === types.RECEIVE_MODEL_ROUTES ) {
routes.forEach( ( route ) => {
const resourceName = extractResourceNameFromRoute(
namespace,
route
);
if ( resourceName && resourceName !== namespace ) {
const routeIdNames = getRouteIds( route );
const savedRoute = simplifyRouteWithId( route, routeIdNames );
if (
! hasInState( state, [
namespace,
resourceName,
savedRoute,
] )
) {
state = updateState(
state,
[ namespace, resourceName, savedRoute ],
routeIdNames
);
}
}
} );
}
return state;
};
export default combineReducers( {
routes: receiveRoutes,
} );

View File

@@ -0,0 +1,39 @@
/**
* External dependencies
*/
import { apiFetch } from '@wordpress/data-controls';
import { controls } from '@wordpress/data';
/**
* Internal dependencies
*/
import { receiveRoutes } from './actions';
import { STORE_KEY } from './constants';
/**
* Resolver for the getRoute selector.
*
* Note: All this essentially does is ensure the routes for the given namespace
* have been resolved.
*
* @param {string} namespace The namespace of the route being resolved.
*/
export function* getRoute( namespace ) {
// we call this simply to do any resolution of all endpoints if necessary.
// allows for jit population of routes for a given namespace.
yield controls.resolveSelect( STORE_KEY, 'getRoutes', namespace );
}
/**
* Resolver for the getRoutes selector.
*
* @param {string} namespace The namespace of the routes being resolved.
*/
export function* getRoutes( namespace ) {
const routeResponse = yield apiFetch( { path: namespace } );
const routes =
routeResponse && routeResponse.routes
? Object.keys( routeResponse.routes )
: [];
yield receiveRoutes( routes, namespace );
}

View File

@@ -0,0 +1,161 @@
/**
* External dependencies
*/
import { sprintf } from '@wordpress/i18n';
import { createRegistrySelector } from '@wordpress/data';
/**
* Internal dependencies
*/
import { STORE_KEY } from './constants';
/**
* Returns the requested route for the given arguments.
*
* @param {Object} state The original state.
* @param {string} namespace The namespace for the route.
* @param {string} resourceName The resource being requested
* (eg. products/attributes)
* @param {Array} [ids] This is for any ids that might be implemented in
* the route request. It is not for any query
* parameters.
*
* Ids example:
* If you are looking for the route for a single product on the `wc/blocks`
* namespace, then you'd have `[ 20 ]` as the ids. This would produce something
* like `/wc/blocks/products/20`
*
*
* @throws {Error} If there is no route for the given arguments, then this will
* throw
*
* @return {string} The route if it is available.
*/
export const getRoute = createRegistrySelector(
( select ) =>
( state, namespace, resourceName, ids = [] ) => {
const hasResolved = select( STORE_KEY ).hasFinishedResolution(
'getRoutes',
[ namespace ]
);
state = state.routes;
let error = '';
if ( ! state[ namespace ] ) {
error = sprintf(
'There is no route for the given namespace (%s) in the store',
namespace
);
} else if ( ! state[ namespace ][ resourceName ] ) {
error = sprintf(
'There is no route for the given resource name (%s) in the store',
resourceName
);
}
if ( error !== '' ) {
if ( hasResolved ) {
throw new Error( error );
}
return '';
}
const route = getRouteFromResourceEntries(
state[ namespace ][ resourceName ],
ids
);
if ( route === '' ) {
if ( hasResolved ) {
throw new Error(
sprintf(
'While there is a route for the given namespace (%1$s) and resource name (%2$s), there is no route utilizing the number of ids you included in the select arguments. The available routes are: (%3$s)',
namespace,
resourceName,
JSON.stringify( state[ namespace ][ resourceName ] )
)
);
}
}
return route;
}
);
/**
* Return all the routes for a given namespace.
*
* @param {Object} state The current state.
* @param {string} namespace The namespace to return routes for.
*
* @return {Array} An array of all routes for the given namespace.
*/
export const getRoutes = createRegistrySelector(
( select ) => ( state, namespace ) => {
const hasResolved = select( STORE_KEY ).hasFinishedResolution(
'getRoutes',
[ namespace ]
);
const routes = state.routes[ namespace ];
if ( ! routes ) {
if ( hasResolved ) {
throw new Error(
sprintf(
'There is no route for the given namespace (%s) in the store',
namespace
)
);
}
return [];
}
let namespaceRoutes = [];
for ( const resourceName in routes ) {
namespaceRoutes = [
...namespaceRoutes,
...Object.keys( routes[ resourceName ] ),
];
}
return namespaceRoutes;
}
);
/**
* Returns the route from the given slice of the route state.
*
* @param {Object} stateSlice This will be a slice of the route state from a
* given namespace and resource name.
* @param {Array} [ids=[]] Any id references that are to be replaced in
* route placeholders.
*
* @return {string} The route or an empty string if nothing found.
*/
const getRouteFromResourceEntries = ( stateSlice, ids = [] ) => {
// convert to array for easier discovery
stateSlice = Object.entries( stateSlice );
const match = stateSlice.find( ( [ , idNames ] ) => {
return ids.length === idNames.length;
} );
const [ matchingRoute, routePlaceholders ] = match || [];
// if we have a matching route, let's return it.
if ( matchingRoute ) {
return ids.length === 0
? matchingRoute
: assembleRouteWithPlaceholders(
matchingRoute,
routePlaceholders,
ids
);
}
return '';
};
/**
* For a given route, route parts and ids,
*
* @param {string} route
* @param {Array} routePlaceholders
* @param {Array} ids
*
* @return {string} Assembled route.
*/
const assembleRouteWithPlaceholders = ( route, routePlaceholders, ids ) => {
routePlaceholders.forEach( ( part, index ) => {
route = route.replace( `{${ part }}`, ids[ index ] );
} );
return route;
};

View File

@@ -0,0 +1,73 @@
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
import { receiveRoutes } from '../reducers';
import { ACTION_TYPES as types } from '../action-types';
describe( 'receiveRoutes', () => {
it( 'returns original state when action type is not a match', () => {
expect( receiveRoutes( undefined, { type: 'invalid' } ) ).toEqual( {} );
} );
it( 'returns original state when the given endpoints already exists', () => {
const routes = [
'wc/blocks/products/attributes',
'wc/blocks/products/attributes/(?P<attribute_id>[d]+)/terms/(?P<id>[d]+)',
];
const originalState = deepFreeze( {
'wc/blocks': {
'products/attributes': {
'wc/blocks/products/attributes': [],
},
'products/attributes/terms': {
'wc/blocks/products/attributes/{attribute_id}/terms/{id}': [
'attribute_id',
'id',
],
},
},
} );
const newState = receiveRoutes( originalState, {
type: types.RECEIVE_MODEL_ROUTES,
namespace: 'wc/blocks',
routes,
} );
expect( newState ).toBe( originalState );
} );
it( 'returns expected state when new route added', () => {
const action = {
type: types.RECEIVE_MODEL_ROUTES,
namespace: 'wc/blocks',
routes: [ 'wc/blocks/products/attributes' ],
};
const originalState = deepFreeze( {
'wc/blocks': {
'products/attributes/terms': {
'wc/blocks/products/attributes/{attribute_id}/terms/{id}': [
'attribute_id',
'id',
],
},
},
} );
const newState = receiveRoutes( originalState, action );
expect( newState ).not.toBe( originalState );
expect( newState ).toEqual( {
'wc/blocks': {
'products/attributes': {
'wc/blocks/products/attributes': [],
},
'products/attributes/terms': {
'wc/blocks/products/attributes/{attribute_id}/terms/{id}': [
'attribute_id',
'id',
],
},
},
} );
} );
} );

View File

@@ -0,0 +1,57 @@
/**
* External dependencies
*/
import { apiFetch } from '@wordpress/data-controls';
import { controls } from '@wordpress/data';
/**
* Internal dependencies
*/
import { getRoute, getRoutes } from '../resolvers';
import { receiveRoutes } from '../actions';
import { STORE_KEY } from '../constants';
jest.mock( '@wordpress/data-controls' );
jest.mock( '@wordpress/data' );
describe( 'getRoute', () => {
it( 'yields select control response', () => {
const fulfillment = getRoute( 'wc/blocks' );
fulfillment.next();
expect( controls.resolveSelect ).toHaveBeenCalledWith(
STORE_KEY,
'getRoutes',
'wc/blocks'
);
const { done } = fulfillment.next();
expect( done ).toBe( true );
} );
} );
describe( 'getRoutes', () => {
describe( 'yields with expected responses', () => {
let fulfillment;
const rewind = () => ( fulfillment = getRoutes( 'wc/blocks' ) );
test( 'with apiFetch control invoked', () => {
rewind();
fulfillment.next();
expect( apiFetch ).toHaveBeenCalledWith( { path: 'wc/blocks' } );
} );
test( 'with receiveRoutes action with valid response', () => {
const testResponse = {
routes: {
'/wc/blocks/products/attributes': [],
},
};
const { value } = fulfillment.next( testResponse );
expect( value ).toEqual(
receiveRoutes( Object.keys( testResponse.routes ), 'wc/blocks' )
);
} );
test( 'with receiveRoutesAction with invalid response', () => {
rewind();
fulfillment.next();
const { value } = fulfillment.next( {} );
expect( value ).toEqual( receiveRoutes( [], 'wc/blocks' ) );
} );
} );
} );

View File

@@ -0,0 +1,107 @@
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
import { getRoute, getRoutes } from '../selectors';
const mockHasFinishedResolution = jest.fn().mockReturnValue( false );
jest.mock( '@wordpress/data', () => ( {
__esModule: true,
createRegistrySelector: ( callback ) =>
callback( () => ( {
hasFinishedResolution: mockHasFinishedResolution,
} ) ),
} ) );
const testState = deepFreeze( {
routes: {
'wc/blocks': {
'products/attributes': {
'wc/blocks/products/attributes': [],
},
'products/attributes/terms': {
'wc/blocks/products/attributes/{attribute_id}/terms/{id}': [
'attribute_id',
'id',
],
},
},
},
} );
describe( 'getRoute', () => {
const invokeTest =
( namespace, resourceName, ids = [] ) =>
() => {
return getRoute( testState, namespace, resourceName, ids );
};
describe( 'with throwing errors', () => {
beforeEach( () => mockHasFinishedResolution.mockReturnValue( true ) );
it( 'throws an error if there is no route for the given namespace', () => {
expect( invokeTest( 'invalid' ) ).toThrowError( /given namespace/ );
} );
it(
'throws an error if there are routes for the given namespace, but no ' +
'route for the given resource',
() => {
expect( invokeTest( 'wc/blocks', 'invalid' ) ).toThrowError();
}
);
it(
'throws an error if there are routes for the given namespace and ' +
'resource name, but no routes for the given ids',
() => {
expect(
invokeTest( 'wc/blocks', 'products/attributes', [ 10 ] )
).toThrowError( /number of ids you included/ );
}
);
} );
describe( 'with no throwing of errors if resolution has not finished', () => {
beforeEach( () => mockHasFinishedResolution.mockReturnValue( false ) );
it.each`
description | args
${ 'is no route for the given namespace' } | ${ [ 'invalid' ] }
${ 'are no routes for the given namespace, but no route for the given resource' } | ${ [ 'wc/blocks', 'invalid' ] }
${ 'are routes for the given namespace and resource name, but no routes for the given ids' } | ${ [ 'wc/blocks', 'products/attributes', [ 10 ] ] }
`( 'does not throw an error if there $description', ( { args } ) => {
expect( invokeTest( ...args ) ).not.toThrowError();
} );
} );
describe( 'returns expected value for given valid arguments', () => {
test( 'when there is a route with no placeholders', () => {
expect( invokeTest( 'wc/blocks', 'products/attributes' )() ).toBe(
'wc/blocks/products/attributes'
);
} );
test( 'when there is a route with placeholders', () => {
expect(
invokeTest(
'wc/blocks',
'products/attributes/terms',
[ 10, 20 ]
)()
).toBe( 'wc/blocks/products/attributes/10/terms/20' );
} );
} );
} );
describe( 'getRoutes', () => {
const invokeTest = ( namespace ) => () => {
return getRoutes( testState, namespace );
};
it( 'throws an error if there is no route for the given namespace', () => {
mockHasFinishedResolution.mockReturnValue( true );
expect( invokeTest( 'invalid' ) ).toThrowError( /given namespace/ );
} );
it( 'returns expected routes for given namespace', () => {
expect( invokeTest( 'wc/blocks' )() ).toEqual( [
'wc/blocks/products/attributes',
'wc/blocks/products/attributes/{attribute_id}/terms/{id}',
] );
} );
} );

View File

@@ -0,0 +1,56 @@
/**
* Internal dependencies
*/
import {
extractResourceNameFromRoute,
getRouteIds,
simplifyRouteWithId,
} from '../utils';
describe( 'extractResourceNameFromRoute', () => {
it.each`
namespace | route | expected
${ 'wc/blocks' } | ${ 'wc/blocks/products' } | ${ 'products' }
${ 'wc/other' } | ${ 'wc/blocks/product' } | ${ 'wc/blocks/product' }
${ 'wc/blocks' } | ${ 'wc/blocks/products/attributes/(?P<attribute_id>[\\d]+)' } | ${ 'products/attributes' }
${ 'wc/blocks' } | ${ 'wc/blocks/products/attributes/(?P<attribute_id>[\\d]+)/terms' } | ${ 'products/attributes/terms' }
${ 'wc/blocks' } | ${ 'wc/blocks/products/attributes/(?P<attribute_id>[\\d]+)/terms/(?P<id>[d]+)' } | ${ 'products/attributes/terms' }
`(
'returns "$expected" when namespace is "$namespace" and route is "$route"',
( { namespace, route, expected } ) => {
expect( extractResourceNameFromRoute( namespace, route ) ).toBe(
expected
);
}
);
} );
describe( 'getRouteIds', () => {
it.each`
route | expected
${ 'wc/blocks/products' } | ${ [] }
${ 'wc/blocks/products/(?P<id>[\\d]+)' } | ${ [ 'id' ] }
${ 'wc/blocks/products/attributes/(?P<attribute_id>[\\d]+)/terms/(?P<id>[\\d]+)' } | ${ [ 'attribute_id', 'id' ] }
`(
'returns "$expected" when route is "$route"',
( { route, expected } ) => {
expect( getRouteIds( route ) ).toEqual( expected );
}
);
} );
describe( 'simplifyRouteWithId', () => {
it.each`
route | matchIds | expected
${ 'wc/blocks/products' } | ${ [] } | ${ 'wc/blocks/products' }
${ 'wc/blocks/products/attributes/(?P<attribute_id>[\\d]+)' } | ${ [ 'attribute_id' ] } | ${ 'wc/blocks/products/attributes/{attribute_id}' }
${ 'wc/blocks/products/attributes/(?P<attribute_id>[\\d]+)/terms' } | ${ [ 'attribute_id' ] } | ${ 'wc/blocks/products/attributes/{attribute_id}/terms' }
${ 'wc/blocks/products/attributes/(?P<attribute_id>[\\d]+)/terms/(?P<id>[\\d]+)' } | ${ [ 'attribute_id', 'id' ] } | ${ 'wc/blocks/products/attributes/{attribute_id}/terms/{id}' }
${ 'wc/blocks/products/attributes/(?P<attribute_id>[\\d]+)/terms/(?P<id>[\\d]+)' } | ${ [ 'id', 'attribute_id' ] } | ${ 'wc/blocks/products/attributes/{attribute_id}/terms/{id}' }
`(
'returns "$expected" when route is "$route" and matchIds is "$matchIds"',
( { route, matchIds, expected } ) => {
expect( simplifyRouteWithId( route, matchIds ) ).toBe( expected );
}
);
} );

View File

@@ -0,0 +1,65 @@
/**
* This returns a resource name string as an index for a given route.
*
* For example:
* /wc/blocks/products/attributes/(?P<id>[\d]+)/terms
* returns
* /products/attributes/terms
*
* @param {string} namespace
* @param {string} route
*
* @return {string} The resource name extracted from the route.
*/
export const extractResourceNameFromRoute = ( namespace, route ) => {
route = route.replace( `${ namespace }/`, '' );
return route.replace( /\/\(\?P\<[a-z_]*\>\[\\*[a-z]\]\+\)/g, '' );
};
/**
* Returns an array of the identifier for the named capture groups in a given
* route.
*
* For example, if the route was this:
* /wc/blocks/products/attributes/(?P<attribute_id>[\d]+)/terms/(?P<id>[\d]+)
*
* ...then the following would get returned
* [ 'attribute_id', 'id' ]
*
* @param {string} route - The route to extract identifier names from.
*
* @return {Array} An array of named route identifier names.
*/
export const getRouteIds = ( route ) => {
const matches = route.match( /\<[a-z_]*\>/g );
if ( ! Array.isArray( matches ) || matches.length === 0 ) {
return [];
}
return matches.map( ( match ) => match.replace( /<|>/g, '' ) );
};
/**
* This replaces regex placeholders in routes with the relevant named string
* found in the matchIds.
*
* Something like:
* /wc/blocks/products/attributes/(?P<attribute_id>[\d]+)/terms/(?P<id>[\d]+)
*
* ..ends up as:
* /wc/blocks/products/attributes/{attribute_id}/terms/{id}
*
* @param {string} route The route to manipulate
* @param {Array} matchIds An array of named ids ( [ attribute_id, id ] )
*
* @return {string} The route with new id placeholders
*/
export const simplifyRouteWithId = ( route, matchIds ) => {
if ( ! Array.isArray( matchIds ) || matchIds.length === 0 ) {
return route;
}
matchIds.forEach( ( matchId ) => {
const expression = `\\(\\?P<${ matchId }>.*?\\)`;
route = route.replace( new RegExp( expression ), `{${ matchId }}` );
} );
return route;
};

View File

@@ -0,0 +1,207 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import triggerFetch, { APIFetchOptions } from '@wordpress/api-fetch';
import DataLoader from 'dataloader';
import {
ApiResponse,
assertBatchResponseIsValid,
assertResponseIsValid,
} from '@woocommerce/types';
const EMPTY_OBJECT = {};
/**
* Error thrown when JSON cannot be parsed.
*/
const invalidJsonError = {
code: 'invalid_json',
message: __(
'The response is not a valid JSON response.',
'woo-gutenberg-products-block'
),
};
const setNonceOnFetch = ( headers: Headers ): void => {
if (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore -- this does exist because it's monkey patched in
// middleware/store-api-nonce.
triggerFetch.setNonce &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore -- this does exist because it's monkey patched in
// middleware/store-api-nonce.
typeof triggerFetch.setNonce === 'function'
) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore -- this does exist because it's monkey patched in
// middleware/store-api-nonce.
triggerFetch.setNonce( headers );
} else {
// eslint-disable-next-line no-console
console.error(
'The monkey patched function on APIFetch, "setNonce", is not present, likely another plugin or some other code has removed this augmentation'
);
}
};
/**
* Trigger a fetch from the API using the batch endpoint.
*/
const triggerBatchFetch = ( keys: readonly APIFetchOptions[] ) => {
return triggerFetch( {
path: `/wc/store/v1/batch`,
method: 'POST',
data: {
requests: keys.map( ( request: APIFetchOptions ) => {
return {
...request,
body: request?.data,
};
} ),
},
} ).then( ( response: unknown ) => {
assertBatchResponseIsValid( response );
return keys.map(
( key, index: number ) =>
response.responses[ index ] || EMPTY_OBJECT
);
} );
};
/**
* In ms, how long we should wait for requests to batch.
*
* DataLoader collects all requests over this window of time (and as a consequence, adds this amount of latency).
*/
const triggerBatchFetchDelay = 300;
/**
* DataLoader instance for triggerBatchFetch.
*/
const triggerBatchFetchLoader = new DataLoader( triggerBatchFetch, {
batchScheduleFn: ( callback: () => void ) =>
setTimeout( callback, triggerBatchFetchDelay ),
cache: false,
maxBatchSize: 25,
} );
/**
* Trigger a fetch from the API using the batch endpoint.
*
* @param {APIFetchOptions} request Request object containing API request.
*/
const batchFetch = async ( request: APIFetchOptions ) => {
return await triggerBatchFetchLoader.load( request );
};
/**
* Dispatched a control action for triggering an api fetch call with no parsing.
* Typically this would be used in scenarios where headers are needed.
*
* @param {APIFetchOptions} options The options for the API request.
*/
export const apiFetchWithHeadersControl = ( options: APIFetchOptions ) =>
( {
type: 'API_FETCH_WITH_HEADERS',
options,
} as const );
/**
* The underlying function that actually does the fetch. This is used by both the generator (control) version of
* apiFetchWithHeadersControl and the async function apiFetchWithHeaders.
*/
const doApiFetchWithHeaders = ( options: APIFetchOptions ) =>
new Promise( ( resolve, reject ) => {
// GET Requests cannot be batched.
if ( ! options.method || options.method === 'GET' ) {
// Parse is disabled here to avoid returning just the body--we also need headers.
triggerFetch( {
...options,
parse: false,
} )
.then( ( fetchResponse ) => {
fetchResponse
.json()
.then( ( response ) => {
resolve( {
response,
headers: fetchResponse.headers,
} );
setNonceOnFetch( fetchResponse.headers );
} )
.catch( () => {
reject( invalidJsonError );
} );
} )
.catch( ( errorResponse ) => {
setNonceOnFetch( errorResponse.headers );
if ( typeof errorResponse.json === 'function' ) {
// Parse error response before rejecting it.
errorResponse
.json()
.then( ( error: unknown ) => {
reject( error );
} )
.catch( () => {
reject( invalidJsonError );
} );
} else {
reject( errorResponse.message );
}
} );
} else {
batchFetch( options )
.then( ( response: ApiResponse ) => {
assertResponseIsValid( response );
if ( response.status >= 200 && response.status < 300 ) {
resolve( {
response: response.body,
headers: response.headers,
} );
setNonceOnFetch( response.headers );
}
// Status code indicates error.
throw response;
} )
.catch( ( errorResponse: ApiResponse ) => {
if ( errorResponse.headers ) {
setNonceOnFetch( errorResponse.headers );
}
if ( errorResponse.body ) {
reject( errorResponse.body );
} else {
reject( errorResponse );
}
} );
}
} );
/**
* Triggers an api fetch call with no parsing.
* Typically this would be used in scenarios where headers are needed.
*
* @param {APIFetchOptions} options The options for the API request.
*/
export const apiFetchWithHeaders = ( options: APIFetchOptions ) => {
return doApiFetchWithHeaders( options );
};
/**
* Default export for registering the controls with the store.
*
* @return {Object} An object with the controls to register with the store on
* the controls property of the registration object.
*/
export const controls = {
API_FETCH_WITH_HEADERS: ( {
options,
}: ReturnType<
typeof apiFetchWithHeadersControl
> ): Promise< unknown > => {
return doApiFetchWithHeaders( options );
},
};

View File

@@ -0,0 +1,4 @@
export enum ACTION_TYPES {
REGISTER_CONTAINER = 'REGISTER_CONTAINER',
UNREGISTER_CONTAINER = 'UNREGISTER_CONTAINER',
}

View File

@@ -0,0 +1,18 @@
/**
* Internal dependencies
*/
import { ACTION_TYPES } from './action-types';
export const registerContainer = ( containerContext: string ) => {
return {
type: ACTION_TYPES.REGISTER_CONTAINER,
containerContext,
};
};
export const unregisterContainer = ( containerContext: string ) => {
return {
type: ACTION_TYPES.UNREGISTER_CONTAINER,
containerContext,
};
};

View File

@@ -0,0 +1,7 @@
export interface StoreNoticesState {
containers: string[];
}
export const defaultStoreNoticesState: StoreNoticesState = {
containers: [],
};

View File

@@ -0,0 +1,34 @@
/**
* External dependencies
*/
import { createReduxStore, register } from '@wordpress/data';
/**
* Internal dependencies
*/
import * as actions from './actions';
import * as selectors from './selectors';
import reducer from './reducers';
import { DispatchFromMap, SelectFromMap } from '../mapped-types';
const STORE_KEY = 'wc/store/store-notices';
const config = {
reducer,
actions,
selectors,
};
const store = createReduxStore( STORE_KEY, config );
register( store );
export const STORE_NOTICES_STORE_KEY = STORE_KEY;
declare module '@wordpress/data' {
function dispatch(
key: typeof STORE_KEY
): DispatchFromMap< typeof actions >;
function select( key: typeof STORE_KEY ): SelectFromMap<
typeof selectors
> & {
hasFinishedResolution: ( selector: string ) => boolean;
};
}

View File

@@ -0,0 +1,34 @@
/**
* External dependencies
*/
import type { Reducer } from 'redux';
/**
* Internal dependencies
*/
import { defaultStoreNoticesState, StoreNoticesState } from './default-state';
import { ACTION_TYPES } from './action-types';
const reducer: Reducer< StoreNoticesState > = (
state = defaultStoreNoticesState,
action
) => {
switch ( action.type ) {
case ACTION_TYPES.REGISTER_CONTAINER:
return {
...state,
containers: [ ...state.containers, action.containerContext ],
};
case ACTION_TYPES.UNREGISTER_CONTAINER:
const newContainers = state.containers.filter(
( container ) => container !== action.containerContext
);
return {
...state,
containers: newContainers,
};
}
return state;
};
export default reducer;

View File

@@ -0,0 +1,8 @@
/**
* Internal dependencies
*/
import { StoreNoticesState } from './default-state';
export const getRegisteredContainers = (
state: StoreNoticesState
): StoreNoticesState[ 'containers' ] => state.containers;

View File

@@ -0,0 +1,27 @@
const has = ( obj: Record< string, unknown >, path: string[] ): boolean => {
return (
!! path &&
!! path.reduce< unknown >(
( prevObj, key ) =>
typeof prevObj === 'object' && prevObj !== null
? ( prevObj as Record< string, unknown > )[ key ]
: undefined,
obj
)
);
};
/**
* Utility for returning whether the given path exists in the state.
*
* @param {Object} state The state being checked
* @param {Array} path The path to check
*
* @return {boolean} True means this exists in the state.
*/
export default function hasInState(
state: Record< string, unknown >,
path: string[]
): boolean {
return has( state, path );
}

View File

@@ -0,0 +1,3 @@
export { default as hasInState } from './has-in-state';
export { default as updateState } from './update-state';
export * from './process-error-response';

View File

@@ -0,0 +1,159 @@
/**
* External dependencies
*/
import { createNotice, DEFAULT_ERROR_MESSAGE } from '@woocommerce/base-utils';
import { decodeEntities } from '@wordpress/html-entities';
import {
objectHasProp,
ApiErrorResponse,
isApiErrorResponse,
} from '@woocommerce/types';
import { noticeContexts } from '@woocommerce/base-context/event-emit/utils';
type ApiParamError = {
param: string;
id: string;
code: string;
message: string;
};
/**
* Flattens error details which are returned from the API when multiple params are not valid.
*
* - Codes will be prefixed with the param. For example, `invalid_email` becomes `billing_address_invalid_email`.
* - Additional error messages will be flattened alongside the main error message.
* - Supports 1 level of nesting.
* - Decodes HTML entities in error messages.
*/
export const getErrorDetails = (
response: ApiErrorResponse
): ApiParamError[] => {
const errorDetails = objectHasProp( response.data, 'details' )
? Object.entries( response.data.details )
: null;
if ( ! errorDetails ) {
return [];
}
return errorDetails.reduce(
(
acc,
[
param,
{ code, message, additional_errors: additionalErrors = [] },
]
) => {
return [
...acc,
{
param,
id: `${ param }_${ code }`,
code,
message: decodeEntities( message ),
},
...( Array.isArray( additionalErrors )
? additionalErrors.flatMap( ( additionalError ) => {
if (
! objectHasProp( additionalError, 'code' ) ||
! objectHasProp( additionalError, 'message' )
) {
return [];
}
return [
{
param,
id: `${ param }_${ additionalError.code }`,
code: additionalError.code,
message: decodeEntities(
additionalError.message
),
},
];
} )
: [] ),
];
},
[] as ApiParamError[]
);
};
/**
* Gets appropriate error context from error code.
*/
const getErrorContextFromCode = ( code: string ): string => {
switch ( code ) {
case 'woocommerce_rest_missing_email_address':
case 'woocommerce_rest_invalid_email_address':
return noticeContexts.CONTACT_INFORMATION;
default:
return noticeContexts.CART;
}
};
/**
* Gets appropriate error context from error param name.
*/
const getErrorContextFromParam = ( param: string ): string | undefined => {
switch ( param ) {
case 'invalid_email':
return noticeContexts.CONTACT_INFORMATION;
case 'billing_address':
return noticeContexts.BILLING_ADDRESS;
case 'shipping_address':
return noticeContexts.SHIPPING_ADDRESS;
default:
return undefined;
}
};
/**
* Processes the response for an invalid param error, with response code rest_invalid_param.
*/
const processInvalidParamResponse = (
response: ApiErrorResponse,
context: string | undefined
) => {
const errorDetails = getErrorDetails( response );
errorDetails.forEach( ( { code, message, id, param } ) => {
createNotice( 'error', message, {
id,
context:
context ||
getErrorContextFromParam( param ) ||
getErrorContextFromCode( code ),
} );
} );
};
/**
* Takes an API response object and creates error notices to display to the customer.
*
* This is where we can handle specific error codes and display notices in specific contexts.
*/
export const processErrorResponse = (
response: ApiErrorResponse | null,
context?: string | undefined
) => {
if ( ! isApiErrorResponse( response ) ) {
return;
}
if ( response.code === 'rest_invalid_param' ) {
return processInvalidParamResponse( response, context );
}
let errorMessage =
decodeEntities( response.message ) || DEFAULT_ERROR_MESSAGE;
// Replace the generic invalid JSON message with something more user friendly.
if ( response.code === 'invalid_json' ) {
errorMessage = DEFAULT_ERROR_MESSAGE;
}
createNotice( 'error', errorMessage, {
id: response.code,
context: context || getErrorContextFromCode( response.code ),
} );
};

View File

@@ -0,0 +1,37 @@
/**
* Utility for updating nested state in the path that changed.
*/
function updateNested< T >( // The state being updated
state: T,
// The path being updated
path: string[],
// The value to update for the path
value: unknown,
// The current index in the path
index = 0
): T {
const key = path[ index ] as keyof T;
if ( index === path.length - 1 ) {
return { ...state, [ key ]: value };
}
const nextState = state[ key ] || {};
return {
...state,
[ key ]: updateNested( nextState, path, value, index + 1 ),
} as T;
}
/**
* Utility for updating state and only cloning objects in the path that changed.
*/
export default function updateState< T >(
// The state being updated
state: T,
// The path being updated
path: string[],
// The value to update for the path
value: unknown
): T {
return updateNested( state, path, value );
}

View File

@@ -0,0 +1,8 @@
export const ACTION_TYPES = {
SET_VALIDATION_ERRORS: 'SET_VALIDATION_ERRORS',
CLEAR_VALIDATION_ERROR: 'CLEAR_VALIDATION_ERROR',
CLEAR_VALIDATION_ERRORS: 'CLEAR_VALIDATION_ERRORS',
HIDE_VALIDATION_ERROR: 'HIDE_VALIDATION_ERROR',
SHOW_VALIDATION_ERROR: 'SHOW_VALIDATION_ERROR',
SHOW_ALL_VALIDATION_ERRORS: 'SHOW_ALL_VALIDATION_ERRORS',
} as const;

View File

@@ -0,0 +1,70 @@
/**
* External dependencies
*/
import deprecated from '@wordpress/deprecated';
import { FieldValidationStatus } from '@woocommerce/types';
/**
* Internal dependencies
*/
import { ACTION_TYPES as types } from './action-types';
import { ReturnOrGeneratorYieldUnion } from '../mapped-types';
export const setValidationErrors = (
errors: Record< string, FieldValidationStatus >
) => ( {
type: types.SET_VALIDATION_ERRORS,
errors,
} );
/**
* Clears validation errors for the given ids.
*
* @param errors Array of error ids to clear.
*/
export const clearValidationErrors = ( errors?: string[] | undefined ) => ( {
type: types.CLEAR_VALIDATION_ERRORS,
errors,
} );
export const clearAllValidationErrors = () => {
deprecated( 'clearAllValidationErrors', {
version: '9.0.0',
alternative: 'clearValidationErrors',
plugin: 'WooCommerce Blocks',
link: 'https://github.com/woocommerce/woocommerce-blocks/pull/7601',
hint: 'Calling `clearValidationErrors` with no arguments will clear all validation errors.',
} );
// Return clearValidationErrors which will clear all errors by defaults if no error ids are passed.
return clearValidationErrors();
};
export const clearValidationError = ( error: string ) => ( {
type: types.CLEAR_VALIDATION_ERROR,
error,
} );
export const hideValidationError = ( error: string ) => ( {
type: types.HIDE_VALIDATION_ERROR,
error,
} );
export const showValidationError = ( error: string ) => ( {
type: types.SHOW_VALIDATION_ERROR,
error,
} );
export const showAllValidationErrors = () => ( {
type: types.SHOW_ALL_VALIDATION_ERRORS,
} );
export type ValidationAction = ReturnOrGeneratorYieldUnion<
| typeof setValidationErrors
| typeof clearAllValidationErrors
| typeof clearValidationError
| typeof clearValidationErrors
| typeof hideValidationError
| typeof showValidationError
| typeof showAllValidationErrors
>;

View File

@@ -0,0 +1 @@
export const STORE_KEY = 'wc/store/validation';

View File

@@ -0,0 +1,35 @@
/**
* External dependencies
*/
import { createReduxStore, register } from '@wordpress/data';
/**
* Internal dependencies
*/
import reducer from './reducers';
import { STORE_KEY } from './constants';
import * as actions from './actions';
import * as selectors from './selectors';
import { DispatchFromMap, SelectFromMap } from '../mapped-types';
export const config = {
reducer,
selectors,
actions,
};
const store = createReduxStore( STORE_KEY, config );
register( store );
export const VALIDATION_STORE_KEY = STORE_KEY;
declare module '@wordpress/data' {
function dispatch(
key: typeof VALIDATION_STORE_KEY
): DispatchFromMap< typeof actions >;
function select( key: typeof VALIDATION_STORE_KEY ): SelectFromMap<
typeof selectors
> & {
hasFinishedResolution: ( selector: string ) => boolean;
};
}

View File

@@ -0,0 +1,98 @@
/**
* External dependencies
*/
import type { Reducer } from 'redux';
import isShallowEqual from '@wordpress/is-shallow-equal';
import { isString, FieldValidationStatus } from '@woocommerce/types';
/**
* Internal dependencies
*/
import { ValidationAction } from './actions';
import { ACTION_TYPES as types } from './action-types';
const reducer: Reducer< Record< string, FieldValidationStatus > > = (
state: Record< string, FieldValidationStatus > = {},
action: Partial< ValidationAction >
) => {
const newState = { ...state };
switch ( action.type ) {
case types.SET_VALIDATION_ERRORS:
if ( ! action.errors ) {
return state;
}
const hasNewError = Object.entries( action.errors ).some(
( [ property, error ] ) => {
if ( typeof error?.message !== 'string' ) {
return false;
}
if (
state.hasOwnProperty( property ) &&
isShallowEqual( state[ property ], error )
) {
return false;
}
return true;
}
);
if ( ! hasNewError ) {
return state;
}
return { ...state, ...action.errors };
case types.CLEAR_VALIDATION_ERROR:
if (
! isString( action.error ) ||
! newState.hasOwnProperty( action.error )
) {
return newState;
}
delete newState[ action.error ];
return newState;
case types.CLEAR_VALIDATION_ERRORS:
const { errors } = action;
if ( typeof errors === 'undefined' ) {
return {};
}
if ( ! Array.isArray( errors ) ) {
return newState;
}
errors.forEach( ( error ) => {
if ( newState.hasOwnProperty( error ) ) {
delete newState[ error ];
}
} );
return newState;
case types.HIDE_VALIDATION_ERROR:
if (
! isString( action.error ) ||
! newState.hasOwnProperty( action.error )
) {
return newState;
}
newState[ action.error ].hidden = true;
return newState;
case types.SHOW_VALIDATION_ERROR:
if (
! isString( action.error ) ||
! newState.hasOwnProperty( action.error )
) {
return newState;
}
newState[ action.error ].hidden = false;
return newState;
case types.SHOW_ALL_VALIDATION_ERRORS:
Object.keys( newState ).forEach( ( property ) => {
if ( newState[ property ].hidden ) {
newState[ property ].hidden = false;
}
} );
return { ...newState };
default:
return state;
}
};
export type State = ReturnType< typeof reducer >;
export default reducer;

View File

@@ -0,0 +1,38 @@
/**
* Internal dependencies
*/
import type { State } from './reducers';
/**
* Gets a validation error by ID.
*
* @param { State } state The current state.
* @param { string } errorId The error ID.
* @return { string } The validation error.
*/
export const getValidationError = ( state: State, errorId: string ) =>
state[ errorId ];
/**
* Gets a validation error ID for use in HTML which can be used as a CSS selector, or to reference an error message.
*
* @param { State } state The current state.
* @param { string } errorId The error ID.
* @return { string } The validation error ID.
*/
export const getValidationErrorId = ( state: State, errorId: string ) => {
if ( ! state.hasOwnProperty( errorId ) || state[ errorId ].hidden ) {
return;
}
return `validate-error-${ errorId }`;
};
/**
* Whether the store has validation errors.
*
* @param { State } state The current state.
* @return { boolean } Whether the store has validation errors or not.
*/
export const hasValidationErrors = ( state: State ) => {
return Object.keys( state ).length > 0;
};

View File

@@ -0,0 +1,273 @@
/**
* External dependencies
*/
import { FieldValidationStatus } from '@woocommerce/types';
/**
* Internal dependencies
*/
import reducer from '../reducers';
import { ACTION_TYPES as types } from '.././action-types';
import { ValidationAction } from '../actions';
describe( 'Validation reducer', () => {
it( 'Sets a single validation error', () => {
const singleValidationAction: ValidationAction = {
type: types.SET_VALIDATION_ERRORS,
errors: {
singleValidationError: {
message: 'This is a single validation error message',
hidden: false,
},
},
};
const nextState = reducer( {}, singleValidationAction );
expect( nextState ).toEqual( {
singleValidationError: {
message: 'This is a single validation error message',
hidden: false,
},
} );
} );
it( 'Does not add new errors if the same error already exists in state', () => {
const state: Record< string, FieldValidationStatus > = {
existingError: {
message: 'This is an existing error message',
hidden: false,
},
};
const existingErrorValidation: ValidationAction = {
type: types.SET_VALIDATION_ERRORS,
errors: {
existingError: {
message: 'This is an existing error message',
hidden: false,
},
},
};
const nextState = reducer( state, existingErrorValidation );
expect( nextState ).toEqual( {
existingError: {
message: 'This is an existing error message',
hidden: false,
},
} );
} );
it( 'Does not add new errors if error message is not string, but keeps existing errors', () => {
const integerErrorAction: ValidationAction = {
type: types.SET_VALIDATION_ERRORS,
errors: {
integerError: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore ignoring because we're testing runtime errors with integers.
message: 1234,
hidden: false,
},
},
};
const nextState = reducer( {}, integerErrorAction );
expect( nextState ).not.toHaveProperty( 'integerError' );
} );
it( 'Updates existing error if message or hidden property changes', () => {
const state: Record< string, FieldValidationStatus > = {
existingValidationError: {
message: 'This is an existing error message',
hidden: false,
},
};
const updateExistingErrorAction: ValidationAction = {
type: types.SET_VALIDATION_ERRORS,
errors: {
existingValidationError: {
message: 'This is an existing error message',
hidden: true,
},
},
};
const nextState = reducer( state, updateExistingErrorAction );
expect( nextState ).toEqual( {
existingValidationError: {
message: 'This is an existing error message',
hidden: true,
},
} );
} );
it( 'Appends new errors to list of existing errors', () => {
const state: Record< string, FieldValidationStatus > = {
existingError: {
message: 'This is an existing error message',
hidden: false,
},
};
const addNewError: ValidationAction = {
type: types.SET_VALIDATION_ERRORS,
errors: {
newError: {
message: 'This is a new error',
hidden: false,
},
},
};
const nextState = reducer( state, addNewError );
expect( nextState ).toEqual( {
existingError: {
message: 'This is an existing error message',
hidden: false,
},
newError: {
message: 'This is a new error',
hidden: false,
},
} );
} );
it( 'Clears all validation errors', () => {
const state: Record< string, FieldValidationStatus > = {
existingError: {
message: 'This is an existing error message',
hidden: false,
},
};
const clearAllErrors: ValidationAction = {
type: types.CLEAR_VALIDATION_ERRORS,
errors: undefined,
};
const nextState = reducer( state, clearAllErrors );
expect( nextState ).toEqual( {} );
} );
it( 'Clears a single validation error', () => {
const state: Record< string, FieldValidationStatus > = {
existingError: {
message: 'This is an existing error message',
hidden: false,
},
testError: {
message: 'This is error should not be removed',
hidden: false,
},
};
const clearError: ValidationAction = {
type: types.CLEAR_VALIDATION_ERROR,
error: 'existingError',
};
const nextState = reducer( state, clearError );
expect( nextState ).not.toHaveProperty( 'existingError' );
expect( nextState ).toHaveProperty( 'testError' );
} );
it( 'Clears multiple validation errors', () => {
const state: Record< string, FieldValidationStatus > = {
existingError: {
message: 'This is an existing error message',
hidden: false,
},
testError: {
message: 'This is error should also be removed',
hidden: false,
},
};
const clearError: ValidationAction = {
type: types.CLEAR_VALIDATION_ERRORS,
errors: [ 'existingError', 'testError' ],
};
const nextState = reducer( state, clearError );
expect( nextState ).not.toHaveProperty( 'existingError' );
expect( nextState ).not.toHaveProperty( 'testError' );
} );
it( 'Hides a single validation error', () => {
const state: Record< string, FieldValidationStatus > = {
existingError: {
message: 'This is an existing error message',
hidden: false,
},
testError: {
message: 'This is error should not be removed',
hidden: false,
},
};
const testAction: ValidationAction = {
type: types.HIDE_VALIDATION_ERROR,
error: 'existingError',
};
const nextState = reducer( state, testAction );
expect( nextState ).toEqual( {
existingError: {
message: 'This is an existing error message',
hidden: true,
},
testError: {
message: 'This is error should not be removed',
hidden: false,
},
} );
} );
it( 'Shows a single validation error', () => {
const state: Record< string, FieldValidationStatus > = {
existingError: {
message: 'This is an existing error message',
hidden: true,
},
testError: {
message: 'This is error should not be removed',
hidden: true,
},
visibleError: {
message: 'This is error should remain visible',
hidden: false,
},
};
const testAction: ValidationAction = {
type: types.SHOW_VALIDATION_ERROR,
error: 'existingError',
};
const nextState = reducer( state, testAction );
expect( nextState ).toEqual( {
existingError: {
message: 'This is an existing error message',
hidden: false,
},
testError: {
message: 'This is error should not be removed',
hidden: true,
},
visibleError: {
message: 'This is error should remain visible',
hidden: false,
},
} );
} );
it( 'Shows all validation errors', () => {
const state: Record< string, FieldValidationStatus > = {
firstExistingError: {
message: 'This is first existing error message',
hidden: true,
},
secondExistingError: {
message: 'This is the second existing error message',
hidden: true,
},
};
const showAllErrors: ValidationAction = {
type: types.SHOW_ALL_VALIDATION_ERRORS,
};
const nextState = reducer( state, showAllErrors );
expect( nextState ).toEqual( {
firstExistingError: {
message: 'This is first existing error message',
hidden: false,
},
secondExistingError: {
message: 'This is the second existing error message',
hidden: false,
},
} );
} );
} );

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