From 7a0e1cb483e35e608b78ab27c565faeb7f6f1154 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Mon, 30 Jan 2023 15:12:17 +0000 Subject: [PATCH] Cart Action Promises with success/reject handling (#8272) * move thinks and create consistent promise rejection * Remove notifyErrors * Combine error handling * Ensure thunk usage handles errors * Use promise in coupon form * onsubmit type * receiveCartContents during checkout * Update mocks * receiveCartContents mocks/types * Fix receiveCartContents tests * Move thunks back to actions, add todo for follow up * Sort actions alphabetically * Null check is unnecessary --- .../cart-checkout/totals/coupon/index.tsx | 36 +++---- .../totals/coupon/stories/index.tsx | 11 +- .../context/hooks/cart/test/use-store-cart.js | 35 ++++-- .../hooks/cart/use-store-cart-coupons.ts | 21 ++-- .../cart/use-store-cart-item-quantity.ts | 21 +++- .../base/context/hooks/cart/use-store-cart.ts | 8 +- .../hooks/shipping/use-shipping-data.ts | 19 ++-- .../cart-checkout/checkout-processor.ts | 8 +- assets/js/base/utils/create-notice.ts | 20 ---- assets/js/data/cart/actions.ts | 88 +++++++-------- assets/js/data/cart/notify-errors.ts | 29 +---- assets/js/data/cart/thunks.ts | 10 +- .../js/data/utils/process-error-response.ts | 100 ++++++++++-------- assets/js/types/type-defs/hooks.ts | 5 +- 14 files changed, 200 insertions(+), 211 deletions(-) diff --git a/assets/js/base/components/cart-checkout/totals/coupon/index.tsx b/assets/js/base/components/cart-checkout/totals/coupon/index.tsx index 40967d36893..93384977e65 100644 --- a/assets/js/base/components/cart-checkout/totals/coupon/index.tsx +++ b/assets/js/base/components/cart-checkout/totals/coupon/index.tsx @@ -2,7 +2,7 @@ * External dependencies */ import { __ } from '@wordpress/i18n'; -import { useState, useEffect, useRef } from '@wordpress/element'; +import { useState } from '@wordpress/element'; import Button from '@woocommerce/base-components/button'; import LoadingMask from '@woocommerce/base-components/loading-mask'; import { withInstanceId } from '@wordpress/compose'; @@ -35,35 +35,29 @@ export interface TotalsCouponProps { /** * Submit handler */ - onSubmit?: ( couponValue: string ) => void; + onSubmit?: ( couponValue: string ) => Promise< boolean > | undefined; } export const TotalsCoupon = ( { instanceId, isLoading = false, + onSubmit, displayCouponForm = false, - onSubmit = () => void 0, }: TotalsCouponProps ): JSX.Element => { const [ couponValue, setCouponValue ] = useState( '' ); const [ isCouponFormHidden, setIsCouponFormHidden ] = useState( ! displayCouponForm ); - const currentIsLoading = useRef( false ); - - const validationErrorKey = 'coupon'; const textInputId = `wc-block-components-totals-coupon__input-${ instanceId }`; - const formWrapperClass = classnames( 'wc-block-components-totals-coupon__content', { 'screen-reader-text': isCouponFormHidden, } ); - - const { validationError, validationErrorId } = useSelect( ( select ) => { + const { validationErrorId } = useSelect( ( select ) => { const store = select( VALIDATION_STORE_KEY ); return { - validationError: store.getValidationError( validationErrorKey ), validationErrorId: store.getValidationErrorId( textInputId ), }; } ); @@ -77,18 +71,18 @@ export const TotalsCoupon = ( { e: React.MouseEvent< HTMLButtonElement, MouseEvent > ) => { e.preventDefault(); - onSubmit( couponValue ); - }; - - useEffect( () => { - if ( currentIsLoading.current !== isLoading ) { - if ( ! isLoading && couponValue && ! validationError ) { - setCouponValue( '' ); - setIsCouponFormHidden( true ); - } - currentIsLoading.current = isLoading; + if ( onSubmit !== undefined ) { + onSubmit( couponValue ).then( ( result ) => { + if ( result ) { + setCouponValue( '' ); + setIsCouponFormHidden( true ); + } + } ); + } else { + setCouponValue( '' ); + setIsCouponFormHidden( true ); } - }, [ isLoading, couponValue, validationError ] ); + }; return (
diff --git a/assets/js/base/components/cart-checkout/totals/coupon/stories/index.tsx b/assets/js/base/components/cart-checkout/totals/coupon/stories/index.tsx index 79b816e0bb4..e5bae041855 100644 --- a/assets/js/base/components/cart-checkout/totals/coupon/stories/index.tsx +++ b/assets/js/base/components/cart-checkout/totals/coupon/stories/index.tsx @@ -31,11 +31,12 @@ const Template: Story< TotalsCouponProps > = ( args ) => { const onSubmit = ( code: string ) => { args.onSubmit?.( code ); setArgs( { isLoading: true } ); - - setTimeout( - () => setArgs( { isLoading: false } ), - INTERACTION_TIMEOUT - ); + return new Promise( ( resolve ) => { + setTimeout( () => { + setArgs( { isLoading: false } ); + resolve( true ); + }, INTERACTION_TIMEOUT ); + } ); }; return ; diff --git a/assets/js/base/context/hooks/cart/test/use-store-cart.js b/assets/js/base/context/hooks/cart/test/use-store-cart.js index c92712b8d10..17674d13327 100644 --- a/assets/js/base/context/hooks/cart/test/use-store-cart.js +++ b/assets/js/base/context/hooks/cart/test/use-store-cart.js @@ -26,6 +26,7 @@ describe( 'useStoreCart', () => { let registry, renderer; const receiveCartMock = () => {}; + const receiveCartContentsMock = () => {}; const previewCartData = { cartCoupons: previewCart.coupons, @@ -102,8 +103,9 @@ describe( 'useStoreCart', () => { hasCalculatedShipping: true, extensions: {}, errors: [], - receiveCart: undefined, paymentRequirements: [], + receiveCart: undefined, + receiveCartContents: undefined, }; const mockCartTotals = { currency_code: 'USD', @@ -129,8 +131,9 @@ describe( 'useStoreCart', () => { extensions: {}, isLoadingRates: false, cartHasCalculatedShipping: true, - receiveCart: undefined, paymentRequirements: [], + receiveCart: undefined, + receiveCartContents: undefined, }; const getWrappedComponents = ( Component ) => ( @@ -140,8 +143,15 @@ describe( 'useStoreCart', () => { ); const getTestComponent = ( options ) => () => { - const { receiveCart, ...results } = useStoreCart( options ); - return
; + const { receiveCart, receiveCartContents, ...results } = + useStoreCart( options ); + return ( +
+ ); }; const setUpMocks = () => { @@ -190,12 +200,16 @@ describe( 'useStoreCart', () => { ); } ); - const { results, receiveCart } = + const { results, receiveCart, receiveCartContents } = renderer.root.findByType( 'div' ).props; //eslint-disable-line testing-library/await-async-query - const { receiveCart: defaultReceiveCart, ...remaining } = - defaultCartData; + const { + receiveCart: defaultReceiveCart, + receiveCartContents: defaultReceiveCartContents, + ...remaining + } = defaultCartData; expect( results ).toEqual( remaining ); expect( receiveCart ).toEqual( defaultReceiveCart ); + expect( receiveCartContents ).toEqual( defaultReceiveCartContents ); } ); it( 'return store data when shouldSelect is true', () => { @@ -209,11 +223,12 @@ describe( 'useStoreCart', () => { ); } ); - const { results, receiveCart } = + const { results, receiveCart, receiveCartContents } = renderer.root.findByType( 'div' ).props; //eslint-disable-line testing-library/await-async-query expect( results ).toEqual( mockStoreCartData ); expect( receiveCart ).toBeUndefined(); + expect( receiveCartContents ).toBeUndefined(); } ); } ); @@ -225,6 +240,7 @@ describe( 'useStoreCart', () => { previewCart: { ...previewCart, receiveCart: receiveCartMock, + receiveCartContents: receiveCartContentsMock, }, }, } ); @@ -239,11 +255,12 @@ describe( 'useStoreCart', () => { ); } ); - const { results, receiveCart } = + const { results, receiveCart, receiveCartContents } = renderer.root.findByType( 'div' ).props; //eslint-disable-line testing-library/await-async-query expect( results ).toEqual( previewCartData ); expect( receiveCart ).toEqual( receiveCartMock ); + expect( receiveCartContents ).toEqual( receiveCartContentsMock ); } ); } ); } ); 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 4ba4807d0da..2923fb7e031 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 @@ -40,14 +40,12 @@ export const useStoreCartCoupons = ( context = '' ): StoreCartCoupon => { [ createErrorNotice, createNotice ] ); - const { applyCoupon, removeCoupon, receiveApplyingCoupon } = - useDispatch( CART_STORE_KEY ); + const { applyCoupon, removeCoupon } = useDispatch( CART_STORE_KEY ); const applyCouponWithNotices = ( couponCode: string ) => { - applyCoupon( couponCode ) - .then( ( result ) => { + return applyCoupon( couponCode ) + .then( () => { if ( - result === true && __experimentalApplyCheckoutFilter( { filterName: 'showApplyCouponNotice', defaultValue: true, @@ -71,6 +69,7 @@ export const useStoreCartCoupons = ( context = '' ): StoreCartCoupon => { } ); } + return Promise.resolve( true ); } ) .catch( ( error ) => { setValidationErrors( { @@ -79,16 +78,14 @@ export const useStoreCartCoupons = ( context = '' ): StoreCartCoupon => { hidden: false, }, } ); - // Finished handling the coupon. - receiveApplyingCoupon( '' ); + return Promise.resolve( false ); } ); }; const removeCouponWithNotices = ( couponCode: string ) => { - removeCoupon( couponCode ) - .then( ( result ) => { + return removeCoupon( couponCode ) + .then( () => { if ( - result === true && __experimentalApplyCheckoutFilter( { filterName: 'showRemoveCouponNotice', defaultValue: true, @@ -112,14 +109,14 @@ export const useStoreCartCoupons = ( context = '' ): StoreCartCoupon => { } ); } + return Promise.resolve( true ); } ) .catch( ( error ) => { createErrorNotice( error.message, { id: 'coupon-form', context, } ); - // Finished handling the coupon. - receiveApplyingCoupon( '' ); + return Promise.resolve( false ); } ); }; diff --git a/assets/js/base/context/hooks/cart/use-store-cart-item-quantity.ts b/assets/js/base/context/hooks/cart/use-store-cart-item-quantity.ts index 5a0d306ed94..5998e7a3cf5 100644 --- a/assets/js/base/context/hooks/cart/use-store-cart-item-quantity.ts +++ b/assets/js/base/context/hooks/cart/use-store-cart-item-quantity.ts @@ -3,7 +3,11 @@ */ import { useSelect, useDispatch } from '@wordpress/data'; import { useCallback, useState, useEffect } from '@wordpress/element'; -import { CART_STORE_KEY, CHECKOUT_STORE_KEY } from '@woocommerce/block-data'; +import { + CART_STORE_KEY, + CHECKOUT_STORE_KEY, + processErrorResponse, +} from '@woocommerce/block-data'; import { useDebounce } from 'use-debounce'; import { usePrevious } from '@woocommerce/base-hooks'; import { @@ -84,9 +88,12 @@ export const useStoreCartItemQuantity = ( ); const removeItem = useCallback( () => { - return cartItemKey - ? removeItemFromCart( cartItemKey ) - : Promise.resolve( false ); + if ( cartItemKey ) { + return removeItemFromCart( cartItemKey ).catch( ( error ) => { + processErrorResponse( error ); + } ); + } + return Promise.resolve( false ); }, [ cartItemKey, removeItemFromCart ] ); // Observe debounced quantity value, fire action to update server on change. @@ -97,7 +104,11 @@ export const useStoreCartItemQuantity = ( Number.isFinite( previousDebouncedQuantity ) && previousDebouncedQuantity !== debouncedQuantity ) { - changeCartItemQuantity( cartItemKey, debouncedQuantity ); + changeCartItemQuantity( cartItemKey, debouncedQuantity ).catch( + ( error ) => { + processErrorResponse( error ); + } + ); } }, [ cartItemKey, diff --git a/assets/js/base/context/hooks/cart/use-store-cart.ts b/assets/js/base/context/hooks/cart/use-store-cart.ts index ffd38a2cfea..1be0d7a8d45 100644 --- a/assets/js/base/context/hooks/cart/use-store-cart.ts +++ b/assets/js/base/context/hooks/cart/use-store-cart.ts @@ -114,6 +114,7 @@ export const defaultCartData: StoreCart = { cartHasCalculatedShipping: false, paymentRequirements: EMPTY_PAYMENT_REQUIREMENTS, receiveCart: () => undefined, + receiveCartContents: () => undefined, extensions: EMPTY_EXTENSIONS, }; @@ -174,6 +175,10 @@ export const useStoreCart = ( typeof previewCart?.receiveCart === 'function' ? previewCart.receiveCart : () => undefined, + receiveCartContents: + typeof previewCart?.receiveCartContents === 'function' + ? previewCart.receiveCartContents + : () => undefined, }; } @@ -185,7 +190,7 @@ export const useStoreCart = ( ! store.hasFinishedResolution( 'getCartData' ); const isLoadingRates = store.isCustomerDataUpdating(); - const { receiveCart } = dispatch( storeKey ); + const { receiveCart, receiveCartContents } = dispatch( storeKey ); const billingAddress = decodeValues( cartData.billingAddress ); const shippingAddress = cartData.needsShipping ? decodeValues( cartData.shippingAddress ) @@ -232,6 +237,7 @@ export const useStoreCart = ( cartHasCalculatedShipping: cartData.hasCalculatedShipping, paymentRequirements: cartData.paymentRequirements, receiveCart, + receiveCartContents, }; }, [ shouldSelect ] diff --git a/assets/js/base/context/hooks/shipping/use-shipping-data.ts b/assets/js/base/context/hooks/shipping/use-shipping-data.ts index e0d59691793..5ea56ac57b7 100644 --- a/assets/js/base/context/hooks/shipping/use-shipping-data.ts +++ b/assets/js/base/context/hooks/shipping/use-shipping-data.ts @@ -1,14 +1,16 @@ /** * External dependencies */ -import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data'; +import { + CART_STORE_KEY as storeKey, + processErrorResponse, +} from '@woocommerce/block-data'; import { useSelect, useDispatch } from '@wordpress/data'; import { isObject } from '@woocommerce/types'; import { useEffect, useRef, useCallback } from '@wordpress/element'; import { deriveSelectedShippingRates } from '@woocommerce/base-utils'; import isShallowEqual from '@wordpress/is-shallow-equal'; import { previewCart } from '@woocommerce/resource-previews'; -import { useThrowError } from '@woocommerce/base-hooks'; /** * Internal dependencies @@ -72,12 +74,11 @@ export const useShippingData = (): ShippingData => { } as { selectShippingRate: ( newShippingRateId: string, - packageId?: string | number + packageId?: string | number | undefined ) => Promise< unknown >; }; // Selects a shipping rate, fires an event, and catch any errors. - const throwError = useThrowError(); const { dispatchCheckoutEvent } = useStoreEvents(); const selectShippingRate = useCallback( ( @@ -113,16 +114,10 @@ export const useShippingData = (): ShippingData => { } ); } ) .catch( ( error ) => { - // Throw an error because an error when selecting a rate is problematic. - throwError( error ); + processErrorResponse( error ); } ); }, - [ - dispatchSelectShippingRate, - dispatchCheckoutEvent, - throwError, - selectedRates, - ] + [ dispatchSelectShippingRate, dispatchCheckoutEvent, selectedRates ] ); return { diff --git a/assets/js/base/context/providers/cart-checkout/checkout-processor.ts b/assets/js/base/context/providers/cart-checkout/checkout-processor.ts index 75289e7e1d5..7024107f250 100644 --- a/assets/js/base/context/providers/cart-checkout/checkout-processor.ts +++ b/assets/js/base/context/providers/cart-checkout/checkout-processor.ts @@ -84,7 +84,8 @@ const CheckoutProcessor = () => { select( CART_STORE_KEY ).getCustomerData() ); - const { cartNeedsPayment, cartNeedsShipping, receiveCart } = useStoreCart(); + const { cartNeedsPayment, cartNeedsShipping, receiveCartContents } = + useStoreCart(); const { activePaymentMethod, @@ -275,7 +276,8 @@ const CheckoutProcessor = () => { ) .then( ( response: CheckoutResponseError ) => { if ( response.data?.cart ) { - receiveCart( response.data.cart ); + // We don't want to receive the address here because it will overwrite fields. + receiveCartContents( response.data.cart ); } processErrorResponse( response ); __internalProcessCheckoutResponse( response ); @@ -304,7 +306,7 @@ const CheckoutProcessor = () => { shouldCreateAccount, extensionData, cartNeedsShipping, - receiveCart, + receiveCartContents, __internalSetHasError, __internalProcessCheckoutResponse, ] ); diff --git a/assets/js/base/utils/create-notice.ts b/assets/js/base/utils/create-notice.ts index 18fb6da2b19..90343f8d786 100644 --- a/assets/js/base/utils/create-notice.ts +++ b/assets/js/base/utils/create-notice.ts @@ -24,13 +24,6 @@ export const getNoticeContexts = () => { return Object.values( noticeContexts ); }; -const hasStoreNoticesContainer = ( container: string ): boolean => { - const containers = select( - 'wc/store/store-notices' - ).getRegisteredContainers(); - return containers.includes( container ); -}; - /** * Wrapper for @wordpress/notices createNotice. */ @@ -54,19 +47,6 @@ export const createNotice = ( } ); }; -/** - * Creates a notice only if the Store Notice Container is visible. - */ -export const createNoticeIfVisible = ( - status: 'error' | 'warning' | 'info' | 'success', - message: string, - options: Partial< NoticeOptions > -) => { - if ( options?.context && hasStoreNoticesContainer( options.context ) ) { - createNotice( status, message, options ); - } -}; - /** * Remove notices from all contexts. * diff --git a/assets/js/data/cart/actions.ts b/assets/js/data/cart/actions.ts index 80ae3c95f3a..c30f558ffcd 100644 --- a/assets/js/data/cart/actions.ts +++ b/assets/js/data/cart/actions.ts @@ -23,8 +23,10 @@ import { ACTION_TYPES as types } from './action-types'; import { apiFetchWithHeaders } from '../shared-controls'; import { ReturnOrGeneratorYieldUnion } from '../mapped-types'; import { CartDispatchFromMap, CartResolveSelectFromMap } from './index'; +import type { Thunks } from './thunks'; // Thunks are functions that can be dispatched, similar to actions creators +// @todo Many of the functions that return promises in this file need to be moved to thunks.ts. export * from './thunks'; /** @@ -143,6 +145,7 @@ export const itemIsPendingDelete = ( cartItemKey, isPendingDelete, } as const ); + /** * Returns an action object to mark the cart data in the store as stale. * @@ -197,6 +200,7 @@ export const applyExtensionCartUpdate = return response; } catch ( error ) { dispatch.receiveError( error ); + return Promise.reject( error ); } }; @@ -210,8 +214,8 @@ export const applyExtensionCartUpdate = export const applyCoupon = ( couponCode: string ) => async ( { dispatch } ) => { - dispatch.receiveApplyingCoupon( couponCode ); try { + dispatch.receiveApplyingCoupon( couponCode ); const { response } = await apiFetchWithHeaders( { path: '/wc/store/v1/cart/apply-coupon', method: 'POST', @@ -220,14 +224,14 @@ export const applyCoupon = }, cache: 'no-store', } ); - dispatch.receiveApplyingCoupon( '' ); - dispatch.receiveCart( response ); + return response; } catch ( error ) { dispatch.receiveError( error ); + return Promise.reject( error ); + } finally { + dispatch.receiveApplyingCoupon( '' ); } - - return true; }; /** @@ -240,9 +244,8 @@ export const applyCoupon = export const removeCoupon = ( couponCode: string ) => async ( { dispatch } ) => { - dispatch.receiveRemovingCoupon( couponCode ); - try { + dispatch.receiveRemovingCoupon( couponCode ); const { response } = await apiFetchWithHeaders( { path: '/wc/store/v1/cart/remove-coupon', method: 'POST', @@ -251,15 +254,14 @@ export const removeCoupon = }, cache: 'no-store', } ); - dispatch.receiveCart( response ); + return response; } catch ( error ) { dispatch.receiveError( error ); + return Promise.reject( error ); } finally { dispatch.receiveRemovingCoupon( '' ); } - - return true; }; /** @@ -286,11 +288,12 @@ export const addItemToCart = }, cache: 'no-store', } ); - dispatch.receiveCart( response ); triggerAddedToCartEvent( { preserveCartData: true } ); + return response; } catch ( error ) { dispatch.receiveError( error ); + return Promise.reject( error ); } }; @@ -306,9 +309,8 @@ export const addItemToCart = export const removeItemFromCart = ( cartItemKey: string ) => async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => { - dispatch.itemIsPendingDelete( cartItemKey ); - try { + dispatch.itemIsPendingDelete( cartItemKey ); const { response } = await apiFetchWithHeaders( { path: `/wc/store/v1/cart/remove-item`, data: { @@ -317,10 +319,11 @@ export const removeItemFromCart = method: 'POST', cache: 'no-store', } ); - dispatch.receiveCart( response ); + return response; } catch ( error ) { dispatch.receiveError( error ); + return Promise.reject( error ); } finally { dispatch.itemIsPendingDelete( cartItemKey, false ); } @@ -352,8 +355,8 @@ export const changeCartItemQuantity = if ( cartItem?.quantity === quantity ) { return; } - dispatch.itemIsPendingQuantity( cartItemKey ); try { + dispatch.itemIsPendingQuantity( cartItemKey ); const { response } = await apiFetchWithHeaders( { path: '/wc/store/v1/cart/update-item', method: 'POST', @@ -363,10 +366,11 @@ export const changeCartItemQuantity = }, cache: 'no-store', } ); - dispatch.receiveCart( response ); + return response; } catch ( error ) { dispatch.receiveError( error ); + return Promise.reject( error ); } finally { dispatch.itemIsPendingQuantity( cartItemKey, false ); } @@ -376,8 +380,7 @@ export const changeCartItemQuantity = * Selects a shipping rate. * * @param {string} rateId The id of the rate being selected. - * @param {number | string} [packageId] The key of the packages that we will - * select within. + * @param {number | string} [packageId] The key of the packages that we will select within. */ export const selectShippingRate = ( rateId: string, packageId = 0 ) => @@ -393,14 +396,14 @@ export const selectShippingRate = }, cache: 'no-store', } ); - dispatch.receiveCart( response ); + return response as CartResponse; } catch ( error ) { dispatch.receiveError( error ); + return Promise.reject( error ); } finally { dispatch.shippingRatesBeingSelected( false ); } - return true; }; /** @@ -428,9 +431,8 @@ export const updateCustomerData = editing = true ) => async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => { - dispatch.updatingCustomerData( true ); - try { + dispatch.updatingCustomerData( true ); const { response } = await apiFetchWithHeaders( { path: '/wc/store/v1/cart/update-customer', method: 'POST', @@ -442,35 +444,35 @@ export const updateCustomerData = } else { dispatch.receiveCart( response ); } - dispatch.updatingCustomerData( false ); + return response; } catch ( error ) { dispatch.receiveError( error ); - dispatch.updatingCustomerData( false ); - return Promise.reject( error ); + } finally { + dispatch.updatingCustomerData( false ); } - return Promise.resolve( true ); }; -export type CartAction = ReturnOrGeneratorYieldUnion< +type Actions = + | typeof addItemToCart + | typeof applyCoupon + | typeof changeCartItemQuantity + | typeof itemIsPendingDelete + | typeof itemIsPendingQuantity + | typeof receiveApplyingCoupon | typeof receiveCartContents + | typeof receiveCartItem + | typeof receiveRemovingCoupon + | typeof removeCoupon + | typeof removeItemFromCart + | typeof selectShippingRate | typeof setBillingAddress - | typeof setShippingAddress + | typeof setCartData | typeof setErrorData - | typeof receiveApplyingCoupon - | typeof receiveRemovingCoupon - | typeof receiveCartItem - | typeof itemIsPendingQuantity - | typeof itemIsPendingDelete - | typeof updatingCustomerData - | typeof shippingRatesBeingSelected | typeof setIsCartDataStale + | typeof setShippingAddress + | typeof shippingRatesBeingSelected | typeof updateCustomerData - | typeof removeItemFromCart - | typeof changeCartItemQuantity - | typeof addItemToCart - | typeof setCartData - | typeof applyCoupon - | typeof removeCoupon - | typeof selectShippingRate ->; + | typeof updatingCustomerData; + +export type CartAction = ReturnOrGeneratorYieldUnion< Actions | Thunks >; diff --git a/assets/js/data/cart/notify-errors.ts b/assets/js/data/cart/notify-errors.ts index 2fe2a6e0a6f..ab076483172 100644 --- a/assets/js/data/cart/notify-errors.ts +++ b/assets/js/data/cart/notify-errors.ts @@ -1,38 +1,11 @@ /** * External dependencies */ -import { __ } from '@wordpress/i18n'; import { ApiErrorResponse, isApiErrorResponse } from '@woocommerce/types'; -import { createNotice, DEFAULT_ERROR_MESSAGE } from '@woocommerce/base-utils'; +import { createNotice } from '@woocommerce/base-utils'; import { decodeEntities } from '@wordpress/html-entities'; import { dispatch } from '@wordpress/data'; -/** - * This function is used to notify the user of cart errors. - */ -export const notifyErrors = ( error: ApiErrorResponse | null = null ) => { - if ( error === null || ! isApiErrorResponse( error ) ) { - return; - } - - let errorMessage = error.message || DEFAULT_ERROR_MESSAGE; - - // Replace the generic invalid JSON message with something more user friendly. - if ( error.code === 'invalid_json' ) { - errorMessage = __( - 'Something went wrong. Please contact us for assistance.', - 'woo-gutenberg-products-block' - ); - } - - // Create a new notice with a consistent error ID. - createNotice( 'error', errorMessage, { - id: 'woocommerce_cart_data_request_error', - context: 'wc/cart', - isDismissible: true, - } ); -}; - /** * This function is used to notify the user of cart item errors/conflicts */ diff --git a/assets/js/data/cart/thunks.ts b/assets/js/data/cart/thunks.ts index 82bcb3a040c..5528668326b 100644 --- a/assets/js/data/cart/thunks.ts +++ b/assets/js/data/cart/thunks.ts @@ -2,8 +2,8 @@ * External dependencies */ import { - CartResponse, Cart, + CartResponse, ApiErrorResponse, isApiErrorResponse, } from '@woocommerce/types'; @@ -13,7 +13,7 @@ import { camelCase, mapKeys } from 'lodash'; * Internal dependencies */ import { notifyQuantityChanges } from './notify-quantity-changes'; -import { notifyErrors, notifyCartErrors } from './notify-errors'; +import { notifyCartErrors } from './notify-errors'; import { CartDispatchFromMap, CartSelectFromMap } from './index'; /** @@ -46,7 +46,7 @@ export const receiveCart = }; /** - * A thunk used in updating the store with cart errors retrieved from a request. This also notifies the shopper of any errors that occur. + * A thunk used in updating the store with cart errors retrieved from a request. */ export const receiveError = ( response: ApiErrorResponse | null = null ) => @@ -57,7 +57,7 @@ export const receiveError = if ( response.data?.cart ) { dispatch.receiveCart( response?.data?.cart ); } - - notifyErrors( response ); } }; + +export type Thunks = typeof receiveCart | typeof receiveError; diff --git a/assets/js/data/utils/process-error-response.ts b/assets/js/data/utils/process-error-response.ts index bd6fb6fcd58..bc4b22fd42b 100644 --- a/assets/js/data/utils/process-error-response.ts +++ b/assets/js/data/utils/process-error-response.ts @@ -1,11 +1,7 @@ /** * External dependencies */ -import { - createNotice, - createNoticeIfVisible, - DEFAULT_ERROR_MESSAGE, -} from '@woocommerce/base-utils'; +import { createNotice, DEFAULT_ERROR_MESSAGE } from '@woocommerce/base-utils'; import { decodeEntities } from '@wordpress/html-entities'; import { objectHasProp, @@ -82,6 +78,35 @@ export const getErrorDetails = ( ); }; +/** + * Gets appropriate error context from error code. + */ +const getErrorContextFromCode = ( code: string ): string => { + switch ( code ) { + case 'woocommerce_rest_missing_email_address': + case 'woocommerce_rest_invalid_email_address': + return noticeContexts.CONTACT_INFORMATION; + default: + return noticeContexts.CART; + } +}; + +/** + * Gets appropriate error context from error param name. + */ +const getErrorContextFromParam = ( param: string ): string | undefined => { + switch ( param ) { + case 'invalid_email': + return noticeContexts.CONTACT_INFORMATION; + case 'billing_address': + return noticeContexts.BILLING_ADDRESS; + case 'shipping_address': + return noticeContexts.SHIPPING_ADDRESS; + default: + return undefined; + } +}; + /** * Processes the response for an invalid param error, with response code rest_invalid_param. */ @@ -92,28 +117,13 @@ const processInvalidParamResponse = ( const errorDetails = getErrorDetails( response ); errorDetails.forEach( ( { code, message, id, param } ) => { - switch ( code ) { - case 'invalid_email': - createNotice( 'error', message, { - id, - context: context || noticeContexts.CONTACT_INFORMATION, - } ); - return; - } - switch ( param ) { - case 'billing_address': - createNoticeIfVisible( 'error', message, { - id, - context: context || noticeContexts.BILLING_ADDRESS, - } ); - break; - case 'shipping_address': - createNoticeIfVisible( 'error', message, { - id, - context: context || noticeContexts.SHIPPING_ADDRESS, - } ); - break; - } + createNotice( 'error', message, { + id, + context: + context || + getErrorContextFromParam( param ) || + getErrorContextFromCode( code ), + } ); } ); }; @@ -123,27 +133,27 @@ const processInvalidParamResponse = ( * This is where we can handle specific error codes and display notices in specific contexts. */ export const processErrorResponse = ( - response: ApiErrorResponse, - context: string | undefined + response: ApiErrorResponse | null, + context?: string | undefined ) => { if ( ! isApiErrorResponse( response ) ) { return; } - switch ( response.code ) { - case 'woocommerce_rest_missing_email_address': - case 'woocommerce_rest_invalid_email_address': - createNotice( 'error', response.message, { - id: response.code, - context: context || noticeContexts.CONTACT_INFORMATION, - } ); - break; - case 'rest_invalid_param': - processInvalidParamResponse( response, context ); - break; - default: - createNotice( 'error', response.message || DEFAULT_ERROR_MESSAGE, { - id: response.code, - context: context || noticeContexts.CHECKOUT, - } ); + + if ( response.code === 'rest_invalid_param' ) { + return processInvalidParamResponse( response, context ); + } + + let errorMessage = + decodeEntities( response.message ) || DEFAULT_ERROR_MESSAGE; + + // Replace the generic invalid JSON message with something more user friendly. + if ( response.code === 'invalid_json' ) { + errorMessage = DEFAULT_ERROR_MESSAGE; } + + createNotice( 'error', errorMessage, { + id: response.code, + context: context || getErrorContextFromCode( response.code ), + } ); }; diff --git a/assets/js/types/type-defs/hooks.ts b/assets/js/types/type-defs/hooks.ts index d57ddff68b4..eb967a9e492 100644 --- a/assets/js/types/type-defs/hooks.ts +++ b/assets/js/types/type-defs/hooks.ts @@ -31,8 +31,8 @@ export interface StoreCartItemQuantity { export interface StoreCartCoupon { appliedCoupons: CartResponseCouponItem[]; isLoading: boolean; - applyCoupon: ( coupon: string ) => void; - removeCoupon: ( coupon: string ) => void; + applyCoupon: ( coupon: string ) => Promise< boolean >; + removeCoupon: ( coupon: string ) => Promise< boolean >; isApplyingCoupon: boolean; isRemovingCoupon: boolean; } @@ -58,6 +58,7 @@ export interface StoreCart { cartHasCalculatedShipping: boolean; paymentRequirements: string[]; receiveCart: ( cart: CartResponse ) => void; + receiveCartContents: ( cart: CartResponse ) => void; } export type Query = {