diff --git a/assets/js/base/context/hooks/cart/use-store-cart-coupons.ts b/assets/js/base/context/hooks/cart/use-store-cart-coupons.ts index b3ff5960823..a4272bba4ab 100644 --- a/assets/js/base/context/hooks/cart/use-store-cart-coupons.ts +++ b/assets/js/base/context/hooks/cart/use-store-cart-coupons.ts @@ -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 { + isApplyingCoupon: store.isApplyingCoupon(), + isRemovingCoupon: store.isRemovingCoupon(), + }; + }, + [ createErrorNotice, createNotice ] + ); - return { - applyCoupon: actions.applyCoupon, - removeCoupon: actions.removeCoupon, - isApplyingCoupon: store.isApplyingCoupon(), - isRemovingCoupon: store.isRemovingCoupon(), - receiveApplyingCoupon: actions.receiveApplyingCoupon, - }; - }, - [ createErrorNotice, createNotice ] - ); + const { applyCoupon, removeCoupon, receiveApplyingCoupon } = + useDispatch( storeKey ); const applyCouponWithNotices = ( couponCode: string ) => { applyCoupon( couponCode ) diff --git a/assets/js/data/cart/action-types.ts b/assets/js/data/cart/action-types.ts index 2a8796ca92b..46bd8e78b2e 100644 --- a/assets/js/data/cart/action-types.ts +++ b/assets/js/data/cart/action-types.ts @@ -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', diff --git a/assets/js/data/cart/actions.ts b/assets/js/data/cart/actions.ts index 8a1fdb037ce..843588b1f98 100644 --- a/assets/js/data/cart/actions.ts +++ b/assets/js/data/cart/actions.ts @@ -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. - * - * This is a generic response action. + * An action creator that dispatches the plain action responsible for setting the cart data in the store. * - * @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 ); - - try { - const { response } = yield apiFetchWithHeaders( { - path: '/wc/store/v1/cart/apply-coupon', - method: 'POST', - data: { - code: couponCode, - }, - cache: 'no-store', - } ); - - 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 ) { - yield receiveCart( error.data.cart ); +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', + } ); + + 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 ); + } + + // 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 ); - - try { - const { response } = yield 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( '' ); - - // If updated cart state was returned, also update that. - if ( error.data?.cart ) { - yield receiveCart( error.data.cart ); +export const removeCoupon = + ( couponCode: string ) => + async ( { dispatch } ) => { + dispatch.receiveRemovingCoupon( couponCode ); + + try { + const { response } = await apiFetchWithHeaders( { + path: '/wc/store/v1/cart/remove-coupon', + method: 'POST', + data: { + code: couponCode, + }, + 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 ); + } + + // 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', - } ); - - yield receiveCart( response ); - yield triggerAddedToCartEvent( { preserveCartData: true } ); - } 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 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', + } ); + + dispatch.receiveCart( response ); + triggerAddedToCartEvent( { preserveCartData: true } ); + } 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; - } -} + }; /** * 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 ); - - try { - const { response } = yield apiFetchWithHeaders( { - path: `/wc/store/v1/cart/remove-item`, - data: { - key: cartItemKey, - }, - method: 'POST', - 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 removeItemFromCart = + ( cartItemKey: string ) => + async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => { + dispatch.itemIsPendingDelete( cartItemKey ); + + try { + const { response } = await apiFetchWithHeaders( { + path: `/wc/store/v1/cart/remove-item`, + data: { + key: cartItemKey, + }, + method: 'POST', + 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.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; + } + 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 ); } - } - yield 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', - } ); - - yield receiveCart( response ); - } catch ( error ) { - yield receiveError( error ); - yield shippingRatesBeingSelected( false ); - - // If updated cart state was returned, also update that. - if ( error.data?.cart ) { - yield receiveCart( error.data.cart ); +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', + } ); + + 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 ); + } + + // 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 ); - - try { - const { response } = yield 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 ); - - // If updated cart state was returned, also update that. - if ( error.data?.cart ) { - yield receiveCart( error.data.cart ); +export const updateCustomerData = + ( customerData: Partial< BillingAddressShippingAddress > ) => + async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => { + dispatch.updatingCustomerData( true ); + + try { + const { response } = await apiFetchWithHeaders( { + path: '/wc/store/v1/cart/update-customer', + method: 'POST', + data: customerData, + cache: 'no-store', + } ); + + dispatch.receiveCartContents( response ); + } catch ( error ) { + dispatch.receiveError( error ); + + // 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 >; diff --git a/assets/js/data/cart/controls.js b/assets/js/data/cart/controls.js deleted file mode 100644 index 22eaac09f01..00000000000 --- a/assets/js/data/cart/controls.js +++ /dev/null @@ -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 ); - }, -}; diff --git a/assets/js/data/cart/index.ts b/assets/js/data/cart/index.ts index f4615f64f13..47ba38793ae 100644 --- a/assets/js/data/cart/index.ts +++ b/assets/js/data/cart/index.ts @@ -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 >; diff --git a/assets/js/data/cart/notify-quantity-changes.ts b/assets/js/data/cart/notify-quantity-changes.ts new file mode 100644 index 00000000000..66fb9a44d28 --- /dev/null +++ b/assets/js/data/cart/notify-quantity-changes.ts @@ -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 ); +}; diff --git a/assets/js/data/cart/reducers.ts b/assets/js/data/cart/reducers.ts index 2fcb25fccab..7fba51c7c74 100644 --- a/assets/js/data/cart/reducers.ts +++ b/assets/js/data/cart/reducers.ts @@ -64,7 +64,7 @@ const reducer: Reducer< CartState > = ( }; } break; - case types.RECEIVE_CART: + case types.SET_CART_DATA: if ( action.response ) { state = { ...state, diff --git a/assets/js/data/cart/resolvers.ts b/assets/js/data/cart/resolvers.ts index 67d250bc862..a2dbcbe5b65 100644 --- a/assets/js/data/cart/resolvers.ts +++ b/assets/js/data/cart/resolvers.ts @@ -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(); + }; diff --git a/assets/js/data/cart/selectors.ts b/assets/js/data/cart/selectors.ts index 73b88c13d22..0db9a336aa9 100644 --- a/assets/js/data/cart/selectors.ts +++ b/assets/js/data/cart/selectors.ts @@ -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; +}; diff --git a/assets/js/data/cart/test/notify-quantity-changes.ts b/assets/js/data/cart/test/notify-quantity-changes.ts new file mode 100644 index 00000000000..0393f42fda9 --- /dev/null +++ b/assets/js/data/cart/test/notify-quantity-changes.ts @@ -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', + } + ); + } ); +} ); diff --git a/assets/js/data/cart/test/reducers.js b/assets/js/data/cart/test/reducers.js index c463e64d2e6..9f2d39d9922 100644 --- a/assets/js/data/cart/test/reducers.js +++ b/assets/js/data/cart/test/reducers.js @@ -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: [], diff --git a/assets/js/data/cart/test/resolvers.js b/assets/js/data/cart/test/resolvers.js deleted file mode 100644 index 735136055d7..00000000000 --- a/assets/js/data/cart/test/resolvers.js +++ /dev/null @@ -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 ); - } - ); - } ); -} ); diff --git a/assets/js/data/cart/test/resolvers.ts b/assets/js/data/cart/test/resolvers.ts new file mode 100644 index 00000000000..cf5f66b57b4 --- /dev/null +++ b/assets/js/data/cart/test/resolvers.ts @@ -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(); + } ); +} ); diff --git a/assets/js/data/cart/thunks.ts b/assets/js/data/cart/thunks.ts new file mode 100644 index 00000000000..288e5945f31 --- /dev/null +++ b/assets/js/data/cart/thunks.ts @@ -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 ); + }; diff --git a/assets/js/data/cart/utils.ts b/assets/js/data/cart/utils.ts new file mode 100644 index 00000000000..9b4a308822a --- /dev/null +++ b/assets/js/data/cart/utils.ts @@ -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; +}; diff --git a/assets/js/data/collections/resolvers.js b/assets/js/data/collections/resolvers.js index 5a1a37f6689..823e83635c2 100644 --- a/assets/js/data/collections/resolvers.js +++ b/assets/js/data/collections/resolvers.js @@ -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 diff --git a/assets/js/data/collections/test/resolvers.js b/assets/js/data/collections/test/resolvers.js index b827a5cda58..b481e659805 100644 --- a/assets/js/data/collections/test/resolvers.js +++ b/assets/js/data/collections/test/resolvers.js @@ -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', } ) ); diff --git a/assets/js/data/mapped-types.ts b/assets/js/data/mapped-types.ts index 01c711c8653..ace786d4269 100644 --- a/assets/js/data/mapped-types.ts +++ b/assets/js/data/mapped-types.ts @@ -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. * diff --git a/assets/js/data/shared-controls.ts b/assets/js/data/shared-controls.ts index 6631f86b9d5..42210295c34 100644 --- a/assets/js/data/shared-controls.ts +++ b/assets/js/data/shared-controls.ts @@ -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 = {}; /** @@ -114,84 +101,111 @@ const batchFetch = async ( request: APIFetchOptions ) => { }; /** - * Default export for registering the controls with the store. + * Dispatched a control action for triggering an api fetch call with no parsing. + * Typically this would be used in scenarios where headers are needed. * - * @return {Object} An object with the controls to register with the store on - * the controls property of the registration object. + * @param {APIFetchOptions} options The options for the API request. */ -export const controls = { - API_FETCH_WITH_HEADERS: ( { +export const apiFetchWithHeadersControl = ( options: APIFetchOptions ) => + ( { + type: '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, + } 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 ); + } ); } ) - .then( ( fetchResponse ) => { - fetchResponse + .catch( ( errorResponse ) => { + setNonceOnFetch( errorResponse.headers ); + if ( typeof errorResponse.json === 'function' ) { + // Parse error response before rejecting it. + errorResponse .json() - .then( ( response ) => { - resolve( { - response, - headers: fetchResponse.headers, - } ); - setNonceOnFetch( fetchResponse.headers ); + .then( ( error: unknown ) => { + reject( error ); } ) .catch( () => { reject( invalidJsonError ); } ); - } ) - .catch( ( errorResponse ) => { + } 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 ( 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 ( errorResponse.body ) { + reject( errorResponse.body ); + } else { + reject( errorResponse ); + } + } ); + } + } ); - if ( response.status >= 200 && response.status < 300 ) { - resolve( { - response: response.body, - headers: response.headers, - } ); - setNonceOnFetch( response.headers ); - } +/** + * 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 ); +}; - // Status code indicates error. - throw response; - } ) - .catch( ( errorResponse: ApiResponse ) => { - if ( errorResponse.headers ) { - setNonceOnFetch( errorResponse.headers ); - } - if ( errorResponse.body ) { - reject( errorResponse.body ); - } else { - reject( errorResponse ); - } - } ); - } - } ); +/** + * Default export for registering the controls with the store. + * + * @return {Object} An object with the controls to register with the store on + * the controls property of the registration object. + */ +export const controls = { + API_FETCH_WITH_HEADERS: ( { + options, + }: ReturnType< + typeof apiFetchWithHeadersControl + > ): Promise< unknown > => { + return doApiFetchWithHeaders( options ); }, }; diff --git a/assets/js/previews/cart.ts b/assets/js/previews/cart.ts index 6852a35ee9b..1c5d5a7a823 100644 --- a/assets/js/previews/cart.ts +++ b/assets/js/previews/cart.ts @@ -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, diff --git a/src/StoreApi/Routes/V1/CartItemsByKey.php b/src/StoreApi/Routes/V1/CartItemsByKey.php index d53ebfd6850..19339acbd6f 100644 --- a/src/StoreApi/Routes/V1/CartItemsByKey.php +++ b/src/StoreApi/Routes/V1/CartItemsByKey.php @@ -78,7 +78,7 @@ protected function get_route_response( \WP_REST_Request $request ) { $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 @@ protected function get_route_delete_response( \WP_REST_Request $request ) { $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'] ); diff --git a/src/StoreApi/Utilities/CartController.php b/src/StoreApi/Utilities/CartController.php index c97698dfd43..2186ba7c6d5 100644 --- a/src/StoreApi/Utilities/CartController.php +++ b/src/StoreApi/Utilities/CartController.php @@ -182,7 +182,7 @@ public function set_cart_item_quantity( $item_id, $quantity = 1 ) { $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']; diff --git a/src/StoreApi/Utilities/QuantityLimits.php b/src/StoreApi/Utilities/QuantityLimits.php index 543007e434b..1a1dc0a82f1 100644 --- a/src/StoreApi/Utilities/QuantityLimits.php +++ b/src/StoreApi/Utilities/QuantityLimits.php @@ -24,7 +24,7 @@ public function get_cart_item_quantity_limits( $cart_item ) { if ( ! $product instanceof \WC_Product ) { return [ 'minimum' => 1, - 'maximum' => null, + 'maximum' => 9999, 'multiple_of' => 1, 'editable' => true, ]; diff --git a/tests/php/StoreApi/Routes/CartItems.php b/tests/php/StoreApi/Routes/CartItems.php index df15b15be9e..ca7ba6ace93 100644 --- a/tests/php/StoreApi/Routes/CartItems.php +++ b/tests/php/StoreApi/Routes/CartItems.php @@ -221,7 +221,7 @@ public function test_delete_item() { $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) ); $this->assertAPIResponse( $request, - 404 + 409 ); } @@ -286,11 +286,13 @@ public function test_get_item_schema() { // 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 ) ); } }