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;
};