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:
Thomas Roberts 2022-12-16 16:06:37 +00:00 committed by GitHub
parent 6ea35ccc93
commit ebe05700ab
24 changed files with 972 additions and 479 deletions

View File

@ -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 )

View File

@ -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',

View File

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

View File

@ -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 );
},
};

View File

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

View File

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

View File

@ -64,7 +64,7 @@ const reducer: Reducer< CartState > = (
};
}
break;
case types.RECEIVE_CART:
case types.SET_CART_DATA:
if ( action.response ) {
state = {
...state,

View File

@ -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();
};

View File

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

View File

@ -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',
}
);
} );
} );

View File

@ -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: [],

View File

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

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

View File

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

View File

@ -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

View File

@ -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',
} )
);

View File

@ -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.
*

View File

@ -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 );
},
};

View File

@ -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,

View File

@ -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'] );

View File

@ -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'];

View File

@ -24,7 +24,7 @@ final class QuantityLimits {
if ( ! $product instanceof \WC_Product ) {
return [
'minimum' => 1,
'maximum' => null,
'maximum' => 9999,
'multiple_of' => 1,
'editable' => true,
];

View File

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