Add notice on quantity change and update `wc/store/cart` to use thunks (https://github.com/woocommerce/woocommerce-blocks/pull/7938)
* Add receiveCart thunk * Add mapCartResponseToCart helper * Add getItemsPendingQuantityUpdate selector * Update cart resolvers to be thunks * Remove RECEIVE_CART action and replace with SET_CART_DATA receiveCart will turn into a thunk. * Add notifyQuantityChanges functions * Remove receiveCart from action type definition, replace with setCartData * Move apiFetchWithHeaders out of controls This will just be a normal function since we'll be updating actions to thunks which will use this instead of a control. * Include thunks in actions file * Update receiveCart action to setCartData * Update applyCoupon action to a thunk * Update useStoreCartCoupons to get action from correct place * Update StoreCartCoupon types * Add types for Thunk and ThunkReturnType in mapped-types * Change applyCoupon to a thunk * Get applyCoupon, removeCoupon, receiveApplyingCoupon from useDispatch This is to separate the concerns of actions vs. selectors. Previously the actions were fetched during useSelect which is not a pattern we use anywhere else in the codebase. Since we updated the MapToDispatch type, we can now get correctly typed thunks from the data store. * Improve apiFetchWithHeaders typings * Convert removeCoupon from generator to thunk * Add applyCoupon and removeCoupon to CartAction type * Remove unused old-style type-def * Add receiveApplyingCoupon & receiveRemovingCoupon to StoreCartCoupon * Correct issues with StoreCartCoupon type These were not intended to reflect the actions in data store, rather the functions offered by the useStoreCartCoupons hook. * Update applyExtensionCartUpdate to a thunk * Update addItemToCart to thunk * Add ResolveSelectFromMap type that works with thunks * Add CartDispatchFromMap and CartResolveSelectFromMap types We can add this to all data stores to get them working with thunks properly. * Add docs and update generic name in ResolveSelectFromMap * Add correct types for thunk resolvers in cart data store * Update removeItemFromCart to thunk * Update apiFetchWithHeaders to use generic * Update selectShippingRate to thunk * Update resolver tests to test correct thunk functionality * Update updateCustomerData to thunk * Update reducer test to reflect new action name * Update comments on CartDispatchFromMap and CartResolveSelectFromMap * Add quantity_limits to preview cart * Make notices speak when shown * Remove copilot comment * Add isWithinQuantityLimits function This is because we shouldn't show a notice if the quantity limits change, but the item's quantity is still OK. * Add tests for notifyQuantityChanges * Show notice when multiple_of is updated * Update test to test for multiple_of changes * Remove empty export * Remove controls from cart data store Not needed anymore since the exported value from the shared-controls file was empty. * Export a control and async function for apiFetchWithHeaders This is required because async functions cannot be called from sync generators. * Use control version of apiFetchWithHeaders in the collections store * Improve comments and remove incorrect TypeScript * Update assets/js/data/cart/actions.ts Co-authored-by: Mike Jolley <mike.jolley@me.com> * Update ResolveSelectFromMap to include selectors too * Update TS in actions * Use finally to remove duplicate code * remove item pending delete/qty update after action runs in all cases This will also reset the state when the request to remove it/change quantity errors * Remove unnecessary type from param. Not needed because we have TS now. The description can stay though, it is useful. * Update snackbar wording to use active voice * Remove old WP version check * Set max quantity to high number instead of null This would only happen in a niche case, and would require several TS changes to fix, so it's better to set it as a number here. 9999 should be high enough, and is the default quantity limit set below in get_product_quantity_limit * Set code on woocommerce_rest_cart_invalid_key to 409 This is so the cart is returned in the response, so the client can update. * Fix typo in comment and add CartSelectFromMap * Remove unnecessary docblock * Add getItemsPendingDelete selector This is needed so we can show a notice for items that are unexpectedly removed from the cart. We need to know which ones are pending delete so we can skip showing the notice for them. * Add type for notifyQuantityChanges args and change args to object * Add notifyIfRemoved function This will check items that have been removed and show a notice for them. * Fix TS in receiveCart & pass itemsPendingDelete to notifyQuantiyChanges * Update wording on removal notice * Update types for notifyQuantityChanges args * Update tests to reflect new wording and args being an object * Check item is truth before running comparison of keys * Update tests for unexpectedly and expectedly removed items * Ignore print_r to satisfy phpcs * Update PHP tests to reflect correct response code when deleting items * Remove unnecessary controls and dispatch events directly from thunk Co-authored-by: Mike Jolley <mike.jolley@me.com>
This commit is contained in:
parent
6ea35ccc93
commit
ebe05700ab
|
@ -1,5 +1,3 @@
|
|||
/** @typedef { import('@woocommerce/type-defs/hooks').StoreCartCoupon } StoreCartCoupon */
|
||||
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
|
@ -32,32 +30,23 @@ export const useStoreCartCoupons = ( context = '' ): StoreCartCoupon => {
|
|||
const { setValidationErrors } = useDispatch( VALIDATION_STORE_KEY );
|
||||
|
||||
const {
|
||||
applyCoupon,
|
||||
removeCoupon,
|
||||
isApplyingCoupon,
|
||||
isRemovingCoupon,
|
||||
}: Pick<
|
||||
StoreCartCoupon,
|
||||
| 'applyCoupon'
|
||||
| 'removeCoupon'
|
||||
| 'isApplyingCoupon'
|
||||
| 'isRemovingCoupon'
|
||||
| 'receiveApplyingCoupon'
|
||||
> = useSelect(
|
||||
( select, { dispatch } ) => {
|
||||
const store = select( storeKey );
|
||||
const actions = dispatch( storeKey );
|
||||
}: Pick< StoreCartCoupon, 'isApplyingCoupon' | 'isRemovingCoupon' > =
|
||||
useSelect(
|
||||
( select ) => {
|
||||
const store = select( storeKey );
|
||||
|
||||
return {
|
||||
applyCoupon: actions.applyCoupon,
|
||||
removeCoupon: actions.removeCoupon,
|
||||
isApplyingCoupon: store.isApplyingCoupon(),
|
||||
isRemovingCoupon: store.isRemovingCoupon(),
|
||||
receiveApplyingCoupon: actions.receiveApplyingCoupon,
|
||||
};
|
||||
},
|
||||
[ createErrorNotice, createNotice ]
|
||||
);
|
||||
return {
|
||||
isApplyingCoupon: store.isApplyingCoupon(),
|
||||
isRemovingCoupon: store.isRemovingCoupon(),
|
||||
};
|
||||
},
|
||||
[ createErrorNotice, createNotice ]
|
||||
);
|
||||
|
||||
const { applyCoupon, removeCoupon, receiveApplyingCoupon } =
|
||||
useDispatch( storeKey );
|
||||
|
||||
const applyCouponWithNotices = ( couponCode: string ) => {
|
||||
applyCoupon( couponCode )
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
export const ACTION_TYPES = {
|
||||
RECEIVE_CART: 'RECEIVE_CART',
|
||||
SET_CART_DATA: 'SET_CART_DATA',
|
||||
RECEIVE_ERROR: 'RECEIVE_ERROR',
|
||||
REPLACE_ERRORS: 'REPLACE_ERRORS',
|
||||
APPLYING_COUPON: 'APPLYING_COUPON',
|
||||
|
|
|
@ -9,35 +9,32 @@ import type {
|
|||
BillingAddressShippingAddress,
|
||||
} from '@woocommerce/types';
|
||||
import { camelCase, mapKeys } from 'lodash';
|
||||
import type { AddToCartEventDetail } from '@woocommerce/type-defs/events';
|
||||
import { BillingAddress, ShippingAddress } from '@woocommerce/settings';
|
||||
import { controls } from '@wordpress/data';
|
||||
import {
|
||||
triggerAddedToCartEvent,
|
||||
triggerAddingToCartEvent,
|
||||
} from '@woocommerce/base-utils';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { ACTION_TYPES as types } from './action-types';
|
||||
import { STORE_KEY as CART_STORE_KEY } from './constants';
|
||||
import { apiFetchWithHeaders } from '../shared-controls';
|
||||
import type { ResponseError } from '../types';
|
||||
import { ReturnOrGeneratorYieldUnion } from '../mapped-types';
|
||||
import { CartDispatchFromMap, CartResolveSelectFromMap } from './index';
|
||||
|
||||
// Thunks are functions that can be dispatched, similar to actions creators
|
||||
export * from './thunks';
|
||||
|
||||
/**
|
||||
* Returns an action object used in updating the store with the provided items
|
||||
* retrieved from a request using the given querystring.
|
||||
* An action creator that dispatches the plain action responsible for setting the cart data in the store.
|
||||
*
|
||||
* This is a generic response action.
|
||||
*
|
||||
* @param {CartResponse} response
|
||||
* @param cart the parsed cart object. (Parsed into camelCase).
|
||||
*/
|
||||
export const receiveCart = (
|
||||
response: CartResponse
|
||||
): { type: string; response: Cart } => {
|
||||
const cart = mapKeys( response, ( _, key ) =>
|
||||
camelCase( key )
|
||||
) as unknown as Cart;
|
||||
export const setCartData = ( cart: Cart ): { type: string; response: Cart } => {
|
||||
return {
|
||||
type: types.RECEIVE_CART,
|
||||
type: types.SET_CART_DATA,
|
||||
response: cart,
|
||||
};
|
||||
};
|
||||
|
@ -60,7 +57,7 @@ export const receiveCartContents = (
|
|||
) as unknown as Cart;
|
||||
const { shippingAddress, billingAddress, ...cartWithoutAddress } = cart;
|
||||
return {
|
||||
type: types.RECEIVE_CART,
|
||||
type: types.SET_CART_DATA,
|
||||
response: cartWithoutAddress,
|
||||
};
|
||||
};
|
||||
|
@ -184,53 +181,34 @@ export const shippingRatesBeingSelected = ( isResolving: boolean ) =>
|
|||
isResolving,
|
||||
} as const );
|
||||
|
||||
/**
|
||||
* Triggers an adding to cart event so other blocks can update accordingly.
|
||||
*/
|
||||
export const triggerAddingToCartEvent = () =>
|
||||
( {
|
||||
type: types.TRIGGER_ADDING_TO_CART_EVENT,
|
||||
} as const );
|
||||
|
||||
/**
|
||||
* Triggers an added to cart event so other blocks can update accordingly.
|
||||
*/
|
||||
export const triggerAddedToCartEvent = ( {
|
||||
preserveCartData,
|
||||
}: AddToCartEventDetail ) =>
|
||||
( {
|
||||
type: types.TRIGGER_ADDED_TO_CART_EVENT,
|
||||
preserveCartData,
|
||||
} 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 function* applyExtensionCartUpdate(
|
||||
args: ExtensionCartUpdateArgs
|
||||
): Generator< unknown, CartResponse, { response: CartResponse } > {
|
||||
try {
|
||||
const { response } = yield apiFetchWithHeaders( {
|
||||
path: '/wc/store/v1/cart/extensions',
|
||||
method: 'POST',
|
||||
data: { namespace: args.namespace, data: args.data },
|
||||
cache: 'no-store',
|
||||
} );
|
||||
yield receiveCart( response );
|
||||
return response;
|
||||
} catch ( error ) {
|
||||
yield receiveError( error );
|
||||
// If updated cart state was returned, also update that.
|
||||
if ( error.data?.cart ) {
|
||||
yield receiveCart( error.data.cart );
|
||||
}
|
||||
export const applyExtensionCartUpdate =
|
||||
( args: ExtensionCartUpdateArgs ) =>
|
||||
async ( { dispatch } ) => {
|
||||
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 );
|
||||
// If updated cart state was returned, also update that.
|
||||
if ( error.data?.cart ) {
|
||||
dispatch.receiveCart( error.data.cart );
|
||||
}
|
||||
|
||||
// Re-throw the error.
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// Re-throw the error.
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies a coupon code and either invalidates caches, or receives an error if
|
||||
|
@ -239,38 +217,37 @@ export function* applyExtensionCartUpdate(
|
|||
* @param {string} couponCode The coupon code to apply to the cart.
|
||||
* @throws Will throw an error if there is an API problem.
|
||||
*/
|
||||
export function* applyCoupon(
|
||||
couponCode: string
|
||||
): Generator< unknown, boolean, { response: CartResponse } > {
|
||||
yield receiveApplyingCoupon( couponCode );
|
||||
export const applyCoupon =
|
||||
( couponCode: string ) =>
|
||||
async ( { dispatch } ) => {
|
||||
dispatch.receiveApplyingCoupon( couponCode );
|
||||
try {
|
||||
const { response } = await apiFetchWithHeaders( {
|
||||
path: '/wc/store/v1/cart/apply-coupon',
|
||||
method: 'POST',
|
||||
data: {
|
||||
code: couponCode,
|
||||
},
|
||||
cache: 'no-store',
|
||||
} );
|
||||
|
||||
try {
|
||||
const { response } = yield apiFetchWithHeaders( {
|
||||
path: '/wc/store/v1/cart/apply-coupon',
|
||||
method: 'POST',
|
||||
data: {
|
||||
code: couponCode,
|
||||
},
|
||||
cache: 'no-store',
|
||||
} );
|
||||
dispatch.receiveCart( response );
|
||||
} catch ( error ) {
|
||||
dispatch.receiveError( error );
|
||||
|
||||
yield receiveCart( response );
|
||||
yield receiveApplyingCoupon( '' );
|
||||
} catch ( error ) {
|
||||
yield receiveError( error );
|
||||
yield receiveApplyingCoupon( '' );
|
||||
// If updated cart state was returned, also update that.
|
||||
if ( error.data?.cart ) {
|
||||
dispatch.receiveCart( error.data.cart );
|
||||
}
|
||||
|
||||
// If updated cart state was returned, also update that.
|
||||
if ( error.data?.cart ) {
|
||||
yield receiveCart( error.data.cart );
|
||||
// Re-throw the error.
|
||||
throw error;
|
||||
} finally {
|
||||
dispatch.receiveApplyingCoupon( '' );
|
||||
}
|
||||
|
||||
// Re-throw the error.
|
||||
throw error;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a coupon code and either invalidates caches, or receives an error if
|
||||
|
@ -279,38 +256,38 @@ export function* applyCoupon(
|
|||
* @param {string} couponCode The coupon code to remove from the cart.
|
||||
* @throws Will throw an error if there is an API problem.
|
||||
*/
|
||||
export function* removeCoupon(
|
||||
couponCode: string
|
||||
): Generator< unknown, boolean, { response: CartResponse } > {
|
||||
yield receiveRemovingCoupon( couponCode );
|
||||
export const removeCoupon =
|
||||
( couponCode: string ) =>
|
||||
async ( { dispatch } ) => {
|
||||
dispatch.receiveRemovingCoupon( couponCode );
|
||||
|
||||
try {
|
||||
const { response } = yield apiFetchWithHeaders( {
|
||||
path: '/wc/store/v1/cart/remove-coupon',
|
||||
method: 'POST',
|
||||
data: {
|
||||
code: couponCode,
|
||||
},
|
||||
cache: 'no-store',
|
||||
} );
|
||||
try {
|
||||
const { response } = await apiFetchWithHeaders( {
|
||||
path: '/wc/store/v1/cart/remove-coupon',
|
||||
method: 'POST',
|
||||
data: {
|
||||
code: couponCode,
|
||||
},
|
||||
cache: 'no-store',
|
||||
} );
|
||||
|
||||
yield receiveCart( response );
|
||||
yield receiveRemovingCoupon( '' );
|
||||
} catch ( error ) {
|
||||
yield receiveError( error );
|
||||
yield receiveRemovingCoupon( '' );
|
||||
dispatch.receiveCart( response );
|
||||
} catch ( error ) {
|
||||
dispatch.receiveError( error );
|
||||
|
||||
// If updated cart state was returned, also update that.
|
||||
if ( error.data?.cart ) {
|
||||
yield receiveCart( error.data.cart );
|
||||
// If updated cart state was returned, also update that.
|
||||
if ( error.data?.cart ) {
|
||||
dispatch.receiveCart( error.data.cart );
|
||||
}
|
||||
|
||||
// Re-throw the error.
|
||||
throw error;
|
||||
} finally {
|
||||
dispatch.receiveRemovingCoupon( '' );
|
||||
}
|
||||
|
||||
// Re-throw the error.
|
||||
throw error;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds an item to the cart:
|
||||
|
@ -322,36 +299,35 @@ export function* removeCoupon(
|
|||
* @param {number} [quantity=1] Number of product ID being added to cart.
|
||||
* @throws Will throw an error if there is an API problem.
|
||||
*/
|
||||
export function* addItemToCart(
|
||||
productId: number,
|
||||
quantity = 1
|
||||
): Generator< unknown, void, { response: CartResponse } > {
|
||||
try {
|
||||
yield triggerAddingToCartEvent();
|
||||
const { response } = yield apiFetchWithHeaders( {
|
||||
path: `/wc/store/v1/cart/add-item`,
|
||||
method: 'POST',
|
||||
data: {
|
||||
id: productId,
|
||||
quantity,
|
||||
},
|
||||
cache: 'no-store',
|
||||
} );
|
||||
export const addItemToCart =
|
||||
( productId: number, quantity = 1 ) =>
|
||||
async ( { dispatch } ) => {
|
||||
try {
|
||||
triggerAddingToCartEvent();
|
||||
const { response } = await apiFetchWithHeaders( {
|
||||
path: `/wc/store/v1/cart/add-item`,
|
||||
method: 'POST',
|
||||
data: {
|
||||
id: productId,
|
||||
quantity,
|
||||
},
|
||||
cache: 'no-store',
|
||||
} );
|
||||
|
||||
yield receiveCart( response );
|
||||
yield triggerAddedToCartEvent( { preserveCartData: true } );
|
||||
} catch ( error ) {
|
||||
yield receiveError( error );
|
||||
dispatch.receiveCart( response );
|
||||
triggerAddedToCartEvent( { preserveCartData: true } );
|
||||
} catch ( error ) {
|
||||
dispatch.receiveError( error );
|
||||
|
||||
// If updated cart state was returned, also update that.
|
||||
if ( error.data?.cart ) {
|
||||
yield receiveCart( error.data.cart );
|
||||
// If updated cart state was returned, also update that.
|
||||
if ( error.data?.cart ) {
|
||||
dispatch.receiveCart( error.data.cart );
|
||||
}
|
||||
|
||||
// Re-throw the error.
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Re-throw the error.
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes specified item from the cart:
|
||||
|
@ -362,32 +338,33 @@ export function* addItemToCart(
|
|||
*
|
||||
* @param {string} cartItemKey Cart item being updated.
|
||||
*/
|
||||
export function* removeItemFromCart(
|
||||
cartItemKey: string
|
||||
): Generator< unknown, void, { response: CartResponse } > {
|
||||
yield itemIsPendingDelete( cartItemKey );
|
||||
export const removeItemFromCart =
|
||||
( cartItemKey: string ) =>
|
||||
async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => {
|
||||
dispatch.itemIsPendingDelete( cartItemKey );
|
||||
|
||||
try {
|
||||
const { response } = yield apiFetchWithHeaders( {
|
||||
path: `/wc/store/v1/cart/remove-item`,
|
||||
data: {
|
||||
key: cartItemKey,
|
||||
},
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
} );
|
||||
try {
|
||||
const { response } = await apiFetchWithHeaders( {
|
||||
path: `/wc/store/v1/cart/remove-item`,
|
||||
data: {
|
||||
key: cartItemKey,
|
||||
},
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
} );
|
||||
|
||||
yield receiveCart( response );
|
||||
} catch ( error ) {
|
||||
yield receiveError( error );
|
||||
dispatch.receiveCart( response );
|
||||
} catch ( error ) {
|
||||
dispatch.receiveError( error );
|
||||
|
||||
// If updated cart state was returned, also update that.
|
||||
if ( error.data?.cart ) {
|
||||
yield receiveCart( error.data.cart );
|
||||
// If updated cart state was returned, also update that.
|
||||
if ( error.data?.cart ) {
|
||||
dispatch.receiveCart( error.data.cart );
|
||||
}
|
||||
} finally {
|
||||
dispatch.itemIsPendingDelete( cartItemKey, false );
|
||||
}
|
||||
}
|
||||
yield itemIsPendingDelete( cartItemKey, false );
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Persists a quantity change the for specified cart item:
|
||||
|
@ -398,42 +375,47 @@ export function* removeItemFromCart(
|
|||
* @param {string} cartItemKey Cart item being updated.
|
||||
* @param {number} quantity Specified (new) quantity.
|
||||
*/
|
||||
export function* changeCartItemQuantity(
|
||||
cartItemKey: string,
|
||||
quantity: number
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- unclear how to represent multiple different yields as type
|
||||
): Generator< unknown, void, any > {
|
||||
const cartItem = yield controls.resolveSelect(
|
||||
CART_STORE_KEY,
|
||||
'getCartItem',
|
||||
cartItemKey
|
||||
);
|
||||
if ( cartItem?.quantity === quantity ) {
|
||||
return;
|
||||
}
|
||||
yield itemIsPendingQuantity( cartItemKey );
|
||||
try {
|
||||
const { response } = yield apiFetchWithHeaders( {
|
||||
path: '/wc/store/v1/cart/update-item',
|
||||
method: 'POST',
|
||||
data: {
|
||||
key: cartItemKey,
|
||||
quantity,
|
||||
},
|
||||
cache: 'no-store',
|
||||
} );
|
||||
|
||||
yield receiveCart( response );
|
||||
} catch ( error ) {
|
||||
yield receiveError( error );
|
||||
|
||||
// If updated cart state was returned, also update that.
|
||||
if ( error.data?.cart ) {
|
||||
yield receiveCart( error.data.cart );
|
||||
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,
|
||||
resolveSelect,
|
||||
}: {
|
||||
dispatch: CartDispatchFromMap;
|
||||
resolveSelect: CartResolveSelectFromMap;
|
||||
} ) => {
|
||||
const cartItem = await resolveSelect.getCartItem( cartItemKey );
|
||||
if ( cartItem?.quantity === quantity ) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
yield itemIsPendingQuantity( cartItemKey, false );
|
||||
}
|
||||
dispatch.itemIsPendingQuantity( cartItemKey );
|
||||
try {
|
||||
const { response } = await apiFetchWithHeaders( {
|
||||
path: '/wc/store/v1/cart/update-item',
|
||||
method: 'POST',
|
||||
data: {
|
||||
key: cartItemKey,
|
||||
quantity,
|
||||
},
|
||||
cache: 'no-store',
|
||||
} );
|
||||
|
||||
dispatch.receiveCart( response );
|
||||
} catch ( error ) {
|
||||
dispatch.receiveError( error );
|
||||
|
||||
// If updated cart state was returned, also update that.
|
||||
if ( error.data?.cart ) {
|
||||
dispatch.receiveCart( error.data.cart );
|
||||
}
|
||||
} finally {
|
||||
dispatch.itemIsPendingQuantity( cartItemKey, false );
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Selects a shipping rate.
|
||||
|
@ -442,38 +424,37 @@ export function* changeCartItemQuantity(
|
|||
* @param {number | string} [packageId] The key of the packages that we will
|
||||
* select within.
|
||||
*/
|
||||
export function* selectShippingRate(
|
||||
rateId: string,
|
||||
packageId = 0
|
||||
): Generator< unknown, boolean, { response: CartResponse } > {
|
||||
try {
|
||||
yield shippingRatesBeingSelected( true );
|
||||
const { response } = yield apiFetchWithHeaders( {
|
||||
path: `/wc/store/v1/cart/select-shipping-rate`,
|
||||
method: 'POST',
|
||||
data: {
|
||||
package_id: packageId,
|
||||
rate_id: rateId,
|
||||
},
|
||||
cache: 'no-store',
|
||||
} );
|
||||
export const selectShippingRate =
|
||||
( rateId: string, packageId = 0 ) =>
|
||||
async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => {
|
||||
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',
|
||||
} );
|
||||
|
||||
yield receiveCart( response );
|
||||
} catch ( error ) {
|
||||
yield receiveError( error );
|
||||
yield shippingRatesBeingSelected( false );
|
||||
dispatch.receiveCart( response );
|
||||
} catch ( error ) {
|
||||
dispatch.receiveError( error );
|
||||
|
||||
// If updated cart state was returned, also update that.
|
||||
if ( error.data?.cart ) {
|
||||
yield receiveCart( error.data.cart );
|
||||
// If updated cart state was returned, also update that.
|
||||
if ( error.data?.cart ) {
|
||||
dispatch.receiveCart( error.data.cart );
|
||||
}
|
||||
|
||||
// Re-throw the error.
|
||||
throw error;
|
||||
} finally {
|
||||
dispatch.shippingRatesBeingSelected( false );
|
||||
}
|
||||
|
||||
// Re-throw the error.
|
||||
throw error;
|
||||
}
|
||||
yield shippingRatesBeingSelected( false );
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets billing address locally, as opposed to updateCustomerData which sends it to the server.
|
||||
|
@ -496,39 +477,37 @@ export const setShippingAddress = (
|
|||
* @param {BillingAddressShippingAddress} customerData Address data to be updated; can contain both
|
||||
* billing_address and shipping_address.
|
||||
*/
|
||||
export function* updateCustomerData(
|
||||
customerData: Partial< BillingAddressShippingAddress >
|
||||
): Generator< unknown, boolean, { response: CartResponse } > {
|
||||
yield updatingCustomerData( true );
|
||||
export const updateCustomerData =
|
||||
( customerData: Partial< BillingAddressShippingAddress > ) =>
|
||||
async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => {
|
||||
dispatch.updatingCustomerData( true );
|
||||
|
||||
try {
|
||||
const { response } = yield apiFetchWithHeaders( {
|
||||
path: '/wc/store/v1/cart/update-customer',
|
||||
method: 'POST',
|
||||
data: customerData,
|
||||
cache: 'no-store',
|
||||
} );
|
||||
try {
|
||||
const { response } = await apiFetchWithHeaders( {
|
||||
path: '/wc/store/v1/cart/update-customer',
|
||||
method: 'POST',
|
||||
data: customerData,
|
||||
cache: 'no-store',
|
||||
} );
|
||||
|
||||
yield receiveCartContents( response );
|
||||
} catch ( error ) {
|
||||
yield receiveError( error );
|
||||
yield updatingCustomerData( false );
|
||||
dispatch.receiveCartContents( response );
|
||||
} catch ( error ) {
|
||||
dispatch.receiveError( error );
|
||||
|
||||
// If updated cart state was returned, also update that.
|
||||
if ( error.data?.cart ) {
|
||||
yield receiveCart( error.data.cart );
|
||||
// If updated cart state was returned, also update that.
|
||||
if ( error.data?.cart ) {
|
||||
dispatch.receiveCart( error.data.cart );
|
||||
}
|
||||
|
||||
// rethrow error.
|
||||
throw error;
|
||||
} finally {
|
||||
dispatch.updatingCustomerData( false );
|
||||
}
|
||||
|
||||
// rethrow error.
|
||||
throw error;
|
||||
}
|
||||
|
||||
yield updatingCustomerData( false );
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export type CartAction = ReturnOrGeneratorYieldUnion<
|
||||
| typeof receiveCart
|
||||
| typeof receiveCartContents
|
||||
| typeof setBillingAddress
|
||||
| typeof setShippingAddress
|
||||
|
@ -545,4 +524,8 @@ export type CartAction = ReturnOrGeneratorYieldUnion<
|
|||
| typeof removeItemFromCart
|
||||
| typeof changeCartItemQuantity
|
||||
| typeof addItemToCart
|
||||
| typeof setCartData
|
||||
| typeof applyCoupon
|
||||
| typeof removeCoupon
|
||||
| typeof selectShippingRate
|
||||
>;
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
triggerAddedToCartEvent,
|
||||
triggerAddingToCartEvent,
|
||||
} from '@woocommerce/base-utils';
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
TRIGGER_ADDING_TO_CART_EVENT() {
|
||||
triggerAddingToCartEvent();
|
||||
},
|
||||
TRIGGER_ADDED_TO_CART_EVENT( preserveCartData ) {
|
||||
triggerAddedToCartEvent( preserveCartData );
|
||||
},
|
||||
};
|
|
@ -12,14 +12,13 @@ import * as selectors from './selectors';
|
|||
import * as actions from './actions';
|
||||
import * as resolvers from './resolvers';
|
||||
import reducer, { State } from './reducers';
|
||||
import { controls as sharedControls } from '../shared-controls';
|
||||
import { controls } from './controls';
|
||||
import type { SelectFromMap, DispatchFromMap } from '../mapped-types';
|
||||
import { pushChanges } 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
|
||||
|
@ -27,7 +26,7 @@ const registeredStore = registerStore< State >( STORE_KEY, {
|
|||
reducer,
|
||||
actions,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
controls: { ...dataControls, ...sharedControls, ...controls } as any,
|
||||
controls: dataControls,
|
||||
selectors,
|
||||
resolvers,
|
||||
} );
|
||||
|
@ -60,3 +59,21 @@ declare module '@wordpress/data' {
|
|||
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 >;
|
||||
|
|
|
@ -0,0 +1,222 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { Cart, CartItem } from '@woocommerce/types';
|
||||
import { dispatch } from '@wordpress/data';
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
|
||||
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 ) => {
|
||||
notifyIfRemoved( oldCart, newCart, cartItemsPendingDelete );
|
||||
notifyIfQuantityLimitsChanged( oldCart, newCart );
|
||||
notifyIfQuantityChanged( oldCart, newCart, cartItemsPendingQuantity );
|
||||
};
|
|
@ -64,7 +64,7 @@ const reducer: Reducer< CartState > = (
|
|||
};
|
||||
}
|
||||
break;
|
||||
case types.RECEIVE_CART:
|
||||
case types.SET_CART_DATA:
|
||||
if ( action.response ) {
|
||||
state = {
|
||||
...state,
|
||||
|
|
|
@ -1,37 +1,44 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { apiFetch } from '@wordpress/data-controls';
|
||||
import { controls } from '@wordpress/data';
|
||||
import { CartResponse, Cart } from '@woocommerce/types';
|
||||
import apiFetch from '@wordpress/api-fetch';
|
||||
import { CartResponse } from '@woocommerce/type-defs/cart-response';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { receiveCart, receiveError } from './actions';
|
||||
import { STORE_KEY, CART_API_ERROR } from './constants';
|
||||
import { CART_API_ERROR } from './constants';
|
||||
import type { CartDispatchFromMap, CartResolveSelectFromMap } from './index';
|
||||
|
||||
/**
|
||||
* Resolver for retrieving all cart data.
|
||||
*/
|
||||
export function* getCartData(): Generator< unknown, void, CartResponse > {
|
||||
const cartData = yield apiFetch( {
|
||||
path: '/wc/store/v1/cart',
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
} );
|
||||
export const getCartData =
|
||||
() =>
|
||||
async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => {
|
||||
const cartData = await apiFetch< CartResponse >( {
|
||||
path: '/wc/store/v1/cart',
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
} );
|
||||
|
||||
if ( ! cartData ) {
|
||||
yield receiveError( CART_API_ERROR );
|
||||
return;
|
||||
}
|
||||
|
||||
yield receiveCart( cartData );
|
||||
}
|
||||
const { receiveCart, receiveError } = dispatch;
|
||||
if ( ! cartData ) {
|
||||
receiveError( CART_API_ERROR );
|
||||
return;
|
||||
}
|
||||
receiveCart( cartData );
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolver for retrieving cart totals.
|
||||
*/
|
||||
export function* getCartTotals(): Generator< unknown, void, Cart > {
|
||||
yield controls.resolveSelect( STORE_KEY, 'getCartData' );
|
||||
}
|
||||
export const getCartTotals =
|
||||
() =>
|
||||
async ( {
|
||||
resolveSelect,
|
||||
}: {
|
||||
resolveSelect: CartResolveSelectFromMap;
|
||||
} ) => {
|
||||
await resolveSelect.getCartData();
|
||||
};
|
||||
|
|
|
@ -212,3 +212,16 @@ export const isCustomerDataUpdating = ( state: CartState ): boolean => {
|
|||
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;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,182 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { dispatch } from '@wordpress/data';
|
||||
import { previewCart } from '@woocommerce/resource-previews';
|
||||
import { camelCase, cloneDeep, mapKeys } from 'lodash';
|
||||
import { Cart } from '@woocommerce/type-defs/cart';
|
||||
import { CartResponse } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { notifyQuantityChanges } from '../notify-quantity-changes';
|
||||
|
||||
jest.mock( '@wordpress/data' );
|
||||
|
||||
const mockedCreateInfoNotice = jest.fn();
|
||||
dispatch.mockImplementation( ( store ) => {
|
||||
if ( store === 'core/notices' ) {
|
||||
return {
|
||||
createInfoNotice: mockedCreateInfoNotice,
|
||||
};
|
||||
}
|
||||
} );
|
||||
|
||||
/**
|
||||
* Clones the preview cart and turns it into a `Cart`.
|
||||
*/
|
||||
const getFreshCarts = (): { oldCart: Cart; newCart: Cart } => {
|
||||
const oldCart = mapKeys(
|
||||
cloneDeep< CartResponse >( previewCart ),
|
||||
( _, key ) => camelCase( key )
|
||||
) as unknown as Cart;
|
||||
const newCart = mapKeys(
|
||||
cloneDeep< CartResponse >( previewCart ),
|
||||
( _, key ) => camelCase( key )
|
||||
) as unknown 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',
|
||||
}
|
||||
);
|
||||
} );
|
||||
} );
|
|
@ -31,7 +31,7 @@ describe( 'cartReducer', () => {
|
|||
} );
|
||||
it( 'sets expected state when a cart is received', () => {
|
||||
const testAction = {
|
||||
type: types.RECEIVE_CART,
|
||||
type: types.SET_CART_DATA,
|
||||
response: {
|
||||
coupons: [],
|
||||
items: [],
|
||||
|
|
|
@ -1,61 +0,0 @@
|
|||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { getCartData } from '../resolvers';
|
||||
import { receiveCart, receiveError } from '../actions';
|
||||
import { CART_API_ERROR } from '../constants';
|
||||
|
||||
jest.mock( '@wordpress/data-controls' );
|
||||
|
||||
describe( 'getCartData', () => {
|
||||
describe( 'yields with expected responses', () => {
|
||||
let fulfillment;
|
||||
const rewind = () => ( fulfillment = getCartData() );
|
||||
test(
|
||||
'when apiFetch returns a valid response, yields expected ' +
|
||||
'action',
|
||||
() => {
|
||||
rewind();
|
||||
fulfillment.next( 'https://example.org' );
|
||||
const { value } = fulfillment.next( {
|
||||
coupons: [],
|
||||
items: [],
|
||||
fees: [],
|
||||
itemsCount: 0,
|
||||
itemsWeight: 0,
|
||||
needsShipping: true,
|
||||
totals: {},
|
||||
} );
|
||||
expect( value ).toEqual(
|
||||
receiveCart( {
|
||||
coupons: [],
|
||||
items: [],
|
||||
fees: [],
|
||||
itemsCount: 0,
|
||||
itemsWeight: 0,
|
||||
needsShipping: true,
|
||||
totals: {},
|
||||
} )
|
||||
);
|
||||
const { done } = fulfillment.next();
|
||||
expect( done ).toBe( true );
|
||||
}
|
||||
);
|
||||
} );
|
||||
describe( 'yields with expected response when there is an error', () => {
|
||||
let fulfillment;
|
||||
const rewind = () => ( fulfillment = getCartData() );
|
||||
test(
|
||||
'when apiFetch returns a valid response, yields expected ' +
|
||||
'action',
|
||||
() => {
|
||||
rewind();
|
||||
fulfillment.next( 'https://example.org' );
|
||||
const { value } = fulfillment.next( undefined );
|
||||
expect( value ).toEqual( receiveError( CART_API_ERROR ) );
|
||||
const { done } = fulfillment.next();
|
||||
expect( done ).toBe( true );
|
||||
}
|
||||
);
|
||||
} );
|
||||
} );
|
|
@ -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();
|
||||
} );
|
||||
} );
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { CartResponse } from '@woocommerce/type-defs/cart-response';
|
||||
import { camelCase, mapKeys } from 'lodash';
|
||||
import { Cart } from '@woocommerce/type-defs/cart';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { notifyQuantityChanges } from './notify-quantity-changes';
|
||||
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 cart = mapKeys( response, ( _, key ) =>
|
||||
camelCase( key )
|
||||
) as unknown as Cart;
|
||||
notifyQuantityChanges( {
|
||||
oldCart: select.getCartData(),
|
||||
newCart: cart,
|
||||
cartItemsPendingQuantity: select.getItemsPendingQuantityUpdate(),
|
||||
cartItemsPendingDelete: select.getItemsPendingDelete(),
|
||||
} );
|
||||
dispatch.setCartData( cart );
|
||||
};
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { camelCase, mapKeys } from 'lodash';
|
||||
import { Cart } from '@woocommerce/type-defs/cart';
|
||||
import { CartResponse } from '@woocommerce/type-defs/cart-response';
|
||||
|
||||
export const mapCartResponseToCart = ( responseCart: CartResponse ): Cart => {
|
||||
return mapKeys( responseCart, ( _, key ) =>
|
||||
camelCase( key )
|
||||
) as unknown as Cart;
|
||||
};
|
|
@ -10,7 +10,7 @@ import { addQueryArgs } from '@wordpress/url';
|
|||
import { receiveCollection, receiveCollectionError } from './actions';
|
||||
import { STORE_KEY as SCHEMA_STORE_KEY } from '../schema/constants';
|
||||
import { STORE_KEY, DEFAULT_EMPTY_ARRAY } from './constants';
|
||||
import { apiFetchWithHeaders } from '../shared-controls';
|
||||
import { apiFetchWithHeadersControl } from '../shared-controls';
|
||||
|
||||
/**
|
||||
* Check if the store needs invalidating due to a change in last modified headers.
|
||||
|
@ -55,7 +55,7 @@ export function* getCollection( namespace, resourceName, query, ids ) {
|
|||
|
||||
try {
|
||||
const { response = DEFAULT_EMPTY_ARRAY, headers } =
|
||||
yield apiFetchWithHeaders( { path: route + queryString } );
|
||||
yield apiFetchWithHeadersControl( { path: route + queryString } );
|
||||
|
||||
if ( headers && headers.get && headers.has( 'last-modified' ) ) {
|
||||
// Do any invalidation before the collection is received to prevent
|
||||
|
|
|
@ -10,7 +10,7 @@ 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 { apiFetchWithHeaders } from '../../shared-controls';
|
||||
import { apiFetchWithHeadersControl } from '../../shared-controls';
|
||||
|
||||
jest.mock( '@wordpress/data' );
|
||||
|
||||
|
@ -73,7 +73,7 @@ describe( 'getCollection', () => {
|
|||
fulfillment.next();
|
||||
const { value } = fulfillment.next( 'https://example.org' );
|
||||
expect( value ).toEqual(
|
||||
apiFetchWithHeaders( {
|
||||
apiFetchWithHeadersControl( {
|
||||
path: 'https://example.org?foo=bar',
|
||||
} )
|
||||
);
|
||||
|
|
|
@ -30,6 +30,21 @@ export type SelectFromMap< S extends object > = {
|
|||
) => 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.
|
||||
*
|
||||
|
@ -40,11 +55,25 @@ export type DispatchFromMap<
|
|||
> = {
|
||||
[ actionCreator in keyof A ]: (
|
||||
...args: Parameters< A[ actionCreator ] >
|
||||
) => A[ actionCreator ] extends ( ...args: any[] ) => Generator
|
||||
) => // 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.
|
||||
*
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
import { __ } from '@wordpress/i18n';
|
||||
import triggerFetch, { APIFetchOptions } from '@wordpress/api-fetch';
|
||||
import DataLoader from 'dataloader';
|
||||
import { isWpVersion } from '@woocommerce/settings';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
|
@ -15,18 +14,6 @@ import {
|
|||
ApiResponse,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* 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 apiFetchWithHeaders = ( options: APIFetchOptions ) =>
|
||||
( {
|
||||
type: 'API_FETCH_WITH_HEADERS',
|
||||
options,
|
||||
} as const );
|
||||
|
||||
const EMPTY_OBJECT = {};
|
||||
|
||||
/**
|
||||
|
@ -113,6 +100,100 @@ 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.
|
||||
*
|
||||
|
@ -122,76 +203,9 @@ const batchFetch = async ( request: APIFetchOptions ) => {
|
|||
export const controls = {
|
||||
API_FETCH_WITH_HEADERS: ( {
|
||||
options,
|
||||
}: ReturnType< typeof apiFetchWithHeaders > ): Promise< unknown > => {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
// GET Requests cannot be batched.
|
||||
if (
|
||||
! options.method ||
|
||||
options.method === 'GET' ||
|
||||
isWpVersion( '5.6', '<' )
|
||||
) {
|
||||
// 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 );
|
||||
}
|
||||
} );
|
||||
}
|
||||
} );
|
||||
}: ReturnType<
|
||||
typeof apiFetchWithHeadersControl
|
||||
> ): Promise< unknown > => {
|
||||
return doApiFetchWithHeaders( options );
|
||||
},
|
||||
};
|
||||
|
|
|
@ -51,6 +51,12 @@ export const previewCart: CartResponse = {
|
|||
backorders_allowed: false,
|
||||
show_backorder_badge: false,
|
||||
sold_individually: false,
|
||||
quantity_limits: {
|
||||
minimum: 1,
|
||||
maximum: 99,
|
||||
multiple_of: 1,
|
||||
editable: true,
|
||||
},
|
||||
images: [
|
||||
{
|
||||
id: 10,
|
||||
|
@ -121,6 +127,12 @@ export const previewCart: CartResponse = {
|
|||
backorders_allowed: false,
|
||||
show_backorder_badge: false,
|
||||
sold_individually: false,
|
||||
quantity_limits: {
|
||||
minimum: 1,
|
||||
maximum: 99,
|
||||
multiple_of: 1,
|
||||
editable: true,
|
||||
},
|
||||
images: [
|
||||
{
|
||||
id: 11,
|
||||
|
|
|
@ -78,7 +78,7 @@ class CartItemsByKey extends AbstractCartRoute {
|
|||
$cart_item = $this->cart_controller->get_cart_item( $request['key'] );
|
||||
|
||||
if ( empty( $cart_item ) ) {
|
||||
throw new RouteException( 'woocommerce_rest_cart_invalid_key', __( 'Cart item does not exist.', 'woo-gutenberg-products-block' ), 404 );
|
||||
throw new RouteException( 'woocommerce_rest_cart_invalid_key', __( 'Cart item does not exist.', 'woo-gutenberg-products-block' ), 409 );
|
||||
}
|
||||
|
||||
$data = $this->prepare_item_for_response( $cart_item, $request );
|
||||
|
@ -116,7 +116,7 @@ class CartItemsByKey extends AbstractCartRoute {
|
|||
$cart_item = $this->cart_controller->get_cart_item( $request['key'] );
|
||||
|
||||
if ( empty( $cart_item ) ) {
|
||||
throw new RouteException( 'woocommerce_rest_cart_invalid_key', __( 'Cart item does not exist.', 'woo-gutenberg-products-block' ), 404 );
|
||||
throw new RouteException( 'woocommerce_rest_cart_invalid_key', __( 'Cart item does not exist.', 'woo-gutenberg-products-block' ), 409 );
|
||||
}
|
||||
|
||||
$cart->remove_cart_item( $request['key'] );
|
||||
|
|
|
@ -182,7 +182,7 @@ class CartController {
|
|||
$cart_item = $this->get_cart_item( $item_id );
|
||||
|
||||
if ( empty( $cart_item ) ) {
|
||||
throw new RouteException( 'woocommerce_rest_cart_invalid_key', __( 'Cart item does not exist.', 'woo-gutenberg-products-block' ), 404 );
|
||||
throw new RouteException( 'woocommerce_rest_cart_invalid_key', __( 'Cart item does not exist.', 'woo-gutenberg-products-block' ), 409 );
|
||||
}
|
||||
|
||||
$product = $cart_item['data'];
|
||||
|
|
|
@ -24,7 +24,7 @@ final class QuantityLimits {
|
|||
if ( ! $product instanceof \WC_Product ) {
|
||||
return [
|
||||
'minimum' => 1,
|
||||
'maximum' => null,
|
||||
'maximum' => 9999,
|
||||
'multiple_of' => 1,
|
||||
'editable' => true,
|
||||
];
|
||||
|
|
|
@ -221,7 +221,7 @@ class CartItems extends ControllerTestCase {
|
|||
$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
|
||||
$this->assertAPIResponse(
|
||||
$request,
|
||||
404
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -286,11 +286,13 @@ class CartItems extends ControllerTestCase {
|
|||
// Simple product.
|
||||
$response = $controller->prepare_item_for_response( current( $cart ), new \WP_REST_Request() );
|
||||
$diff = $validate->get_diff_from_object( $response->get_data() );
|
||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
|
||||
$this->assertEmpty( $diff, print_r( $diff, true ) );
|
||||
|
||||
// Variable product.
|
||||
$response = $controller->prepare_item_for_response( end( $cart ), new \WP_REST_Request() );
|
||||
$diff = $validate->get_diff_from_object( $response->get_data() );
|
||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
|
||||
$this->assertEmpty( $diff, print_r( $diff, true ) );
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue