From 084297a1a9bffe47898d28072e9ed73f350bb37a Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Wed, 23 Oct 2024 17:15:41 +0100 Subject: [PATCH] Cart Data Store: Use createReduxStore and fix type warnings (#52219) * Use createReduxStore instead of deprecated registerStore * Move thunks to correct file * Fix type definitions * Add changefile(s) from automation for the following project(s): woocommerce-blocks * changelog * reduce diff size * Add changefile(s) from automation for the following project(s): woocommerce-blocks * Remove todo --------- Co-authored-by: github-actions --- .../assets/js/data/cart/actions.ts | 416 ++---------------- .../assets/js/data/cart/index.ts | 25 +- .../assets/js/data/cart/reducers.ts | 45 +- .../assets/js/data/cart/thunks.ts | 340 +++++++++++++- .../assets/js/data/shared-controls.ts | 38 +- ...9-updated-createReduxStore-cart-data-store | 4 + 6 files changed, 429 insertions(+), 439 deletions(-) create mode 100644 plugins/woocommerce/changelog/52219-updated-createReduxStore-cart-data-store diff --git a/plugins/woocommerce-blocks/assets/js/data/cart/actions.ts b/plugins/woocommerce-blocks/assets/js/data/cart/actions.ts index bedb8c68dd8..2f5752b5aa5 100644 --- a/plugins/woocommerce-blocks/assets/js/data/cart/actions.ts +++ b/plugins/woocommerce-blocks/assets/js/data/cart/actions.ts @@ -2,92 +2,71 @@ * External dependencies */ import type { - Cart, - CartResponse, - CartResponseItem, - ExtensionCartUpdateArgs, - BillingAddressShippingAddress, ApiErrorResponse, - CartShippingPackageShippingRate, - CartShippingRate, + Cart, + CartResponseItem, } from '@woocommerce/types'; import { BillingAddress, ShippingAddress } from '@woocommerce/settings'; -import { - triggerAddedToCartEvent, - triggerAddingToCartEvent, -} from '@woocommerce/base-utils'; /** * Internal dependencies */ import { ACTION_TYPES as types } from './action-types'; -import { apiFetchWithHeaders } from '../shared-controls'; import { ReturnOrGeneratorYieldUnion } from '../mapped-types'; -import { CartDispatchFromMap, CartSelectFromMap } from './index'; import type { Thunks } from './thunks'; // Thunks are functions that can be dispatched, similar to actions creators -// @todo Many of the functions that return promises in this file need to be moved to thunks.ts. export * from './thunks'; /** * An action creator that dispatches the plain action responsible for setting the cart data in the store. - * - * @param cart the parsed cart object. (Parsed into camelCase). */ -export const setCartData = ( cart: Cart ): { type: string; response: Cart } => { +export function setCartData( cart: Cart ) { return { type: types.SET_CART_DATA, response: cart, }; -}; +} /** * An action creator that dispatches the plain action responsible for setting the cart error data in the store. - * - * @param error the parsed error object (Parsed into camelCase). */ -export const setErrorData = ( - error: ApiErrorResponse | null -): { type: string; response: ApiErrorResponse | null } => { +export function setErrorData( error: ApiErrorResponse | null ) { return { type: types.SET_ERROR_DATA, error, }; -}; +} /** * Returns an action object used to track when a coupon is applying. - * - * @param {string} [couponCode] Coupon being added. */ -export const receiveApplyingCoupon = ( couponCode: string ) => - ( { +export function receiveApplyingCoupon( couponCode: string ) { + return { type: types.APPLYING_COUPON, couponCode, - } as const ); + }; +} /** * Returns an action object used to track when a coupon is removing. - * - * @param {string} [couponCode] Coupon being removed.. */ -export const receiveRemovingCoupon = ( couponCode: string ) => - ( { +export function receiveRemovingCoupon( couponCode: string ) { + return { type: types.REMOVING_COUPON, couponCode, - } as const ); + }; +} /** * Returns an action object for updating a single cart item in the store. - * - * @param {CartResponseItem} [response=null] A cart item API response. */ -export const receiveCartItem = ( response: CartResponseItem | null = null ) => - ( { +export function receiveCartItem( response: CartResponseItem | null = null ) { + return { type: types.RECEIVE_CART_ITEM, cartItem: response, - } as const ); + }; +} /** * Returns an action object to indicate if the specified cart item quantity is @@ -97,15 +76,16 @@ export const receiveCartItem = ( response: CartResponseItem | null = null ) => * @param {boolean} [isPendingQuantity=true] Flag for update state; true if API * request is pending. */ -export const itemIsPendingQuantity = ( +export function itemIsPendingQuantity( cartItemKey: string, isPendingQuantity = true -) => - ( { +) { + return { type: types.ITEM_PENDING_QUANTITY, cartItemKey, isPendingQuantity, - } as const ); + }; +} /** * Returns an action object to remove a cart item from the store. @@ -114,15 +94,16 @@ export const itemIsPendingQuantity = ( * @param {boolean} [isPendingDelete=true] Flag for update state; true if API * request is pending. */ -export const itemIsPendingDelete = ( +export function itemIsPendingDelete( cartItemKey: string, isPendingDelete = true -) => - ( { +) { + return { type: types.RECEIVE_REMOVED_ITEM, cartItemKey, isPendingDelete, - } as const ); + }; +} /** * Returns an action object to mark the cart data in the store as stale. @@ -131,21 +112,23 @@ export const itemIsPendingDelete = ( * lastCartUpdate timestamp is newer than the * one in wcSettings. */ -export const setIsCartDataStale = ( isCartDataStale = true ) => - ( { +export function setIsCartDataStale( isCartDataStale = true ) { + return { type: types.SET_IS_CART_DATA_STALE, isCartDataStale, - } as const ); + }; +} /** * Returns an action object used to track when customer data is being updated * (billing and/or shipping). */ -export const updatingCustomerData = ( isResolving: boolean ) => - ( { +export function updatingCustomerData( isResolving: boolean ) { + return { type: types.UPDATING_CUSTOMER_DATA, isResolving, - } as const ); + }; +} /** * Returns an action object used to track whether the shipping rate is being @@ -153,346 +136,41 @@ export const updatingCustomerData = ( isResolving: boolean ) => * * @param {boolean} isResolving True if shipping rate is being selected. */ -export const shippingRatesBeingSelected = ( isResolving: boolean ) => - ( { +export function shippingRatesBeingSelected( isResolving: boolean ) { + return { type: types.UPDATING_SELECTED_SHIPPING_RATE, isResolving, - } as const ); - -/** - * POSTs to the /cart/extensions endpoint with the data supplied by the extension. - * - * @param {Object} args The data to be posted to the endpoint - */ -export const applyExtensionCartUpdate = - ( args: ExtensionCartUpdateArgs ) => - async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => { - try { - const { response } = await apiFetchWithHeaders( { - path: '/wc/store/v1/cart/extensions', - method: 'POST', - data: { namespace: args.namespace, data: args.data }, - cache: 'no-store', - } ); - dispatch.receiveCart( response ); - return response; - } catch ( error ) { - dispatch.receiveError( error ); - return Promise.reject( error ); - } - }; - -/** - * Applies a coupon code and either invalidates caches, or receives an error if - * the coupon cannot be applied. - * - * @param {string} couponCode The coupon code to apply to the cart. - * @throws Will throw an error if there is an API problem. - */ -export const applyCoupon = - ( couponCode: string ) => - async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => { - try { - dispatch.receiveApplyingCoupon( couponCode ); - const { response } = await apiFetchWithHeaders( { - path: '/wc/store/v1/cart/apply-coupon', - method: 'POST', - data: { - code: couponCode, - }, - cache: 'no-store', - } ); - dispatch.receiveCart( response ); - return response; - } catch ( error ) { - dispatch.receiveError( error ); - return Promise.reject( error ); - } finally { - dispatch.receiveApplyingCoupon( '' ); - } - }; - -/** - * Removes a coupon code and either invalidates caches, or receives an error if - * the coupon cannot be removed. - * - * @param {string} couponCode The coupon code to remove from the cart. - * @throws Will throw an error if there is an API problem. - */ -export const removeCoupon = - ( couponCode: string ) => - async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => { - try { - dispatch.receiveRemovingCoupon( couponCode ); - const { response } = await apiFetchWithHeaders( { - path: '/wc/store/v1/cart/remove-coupon', - method: 'POST', - data: { - code: couponCode, - }, - cache: 'no-store', - } ); - dispatch.receiveCart( response ); - return response; - } catch ( error ) { - dispatch.receiveError( error ); - return Promise.reject( error ); - } finally { - dispatch.receiveRemovingCoupon( '' ); - } - }; - -/** - * Adds an item to the cart: - * - Calls API to add item. - * - If successful, yields action to add item from store. - * - If error, yields action to store error. - * - * @param {number} productId Product ID to add to cart. - * @param {number} [quantity=1] Number of product ID being added to cart. - * @throws Will throw an error if there is an API problem. - */ -export const addItemToCart = - ( productId: number, quantity = 1 ) => - async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => { - try { - triggerAddingToCartEvent(); - const { response } = await apiFetchWithHeaders( { - path: `/wc/store/v1/cart/add-item`, - method: 'POST', - data: { - id: productId, - quantity, - }, - cache: 'no-store', - } ); - dispatch.receiveCart( response ); - triggerAddedToCartEvent( { preserveCartData: true } ); - return response; - } catch ( error ) { - dispatch.receiveError( error ); - return Promise.reject( error ); - } - }; - -/** - * Removes specified item from the cart: - * - Calls API to remove item. - * - If successful, yields action to remove item from store. - * - If error, yields action to store error. - * - Sets cart item as pending while API request is in progress. - * - * @param {string} cartItemKey Cart item being updated. - */ -export const removeItemFromCart = - ( cartItemKey: string ) => - async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => { - try { - dispatch.itemIsPendingDelete( cartItemKey ); - const { response } = await apiFetchWithHeaders( { - path: `/wc/store/v1/cart/remove-item`, - data: { - key: cartItemKey, - }, - method: 'POST', - cache: 'no-store', - } ); - dispatch.receiveCart( response ); - return response; - } catch ( error ) { - dispatch.receiveError( error ); - return Promise.reject( error ); - } finally { - dispatch.itemIsPendingDelete( cartItemKey, false ); - } - }; - -/** - * Persists a quantity change the for specified cart item: - * - Calls API to set quantity. - * - If successful, yields action to update store. - * - If error, yields action to store error. - * - * @param {string} cartItemKey Cart item being updated. - * @param {number} quantity Specified (new) quantity. - */ -export const changeCartItemQuantity = - ( - cartItemKey: string, - quantity: number - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- unclear how to represent multiple different yields as type - ) => - async ( { - dispatch, - select, - }: { - dispatch: CartDispatchFromMap; - select: CartSelectFromMap; - } ) => { - const cartItem = select.getCartItem( cartItemKey ); - if ( cartItem?.quantity === quantity ) { - return; - } - try { - dispatch.itemIsPendingQuantity( cartItemKey ); - const { response } = await apiFetchWithHeaders( { - path: '/wc/store/v1/cart/update-item', - method: 'POST', - data: { - key: cartItemKey, - quantity, - }, - cache: 'no-store', - } ); - dispatch.receiveCart( response ); - return response; - } catch ( error ) { - dispatch.receiveError( error ); - return Promise.reject( error ); - } finally { - dispatch.itemIsPendingQuantity( cartItemKey, false ); - } - }; - -// Facilitates aborting fetch requests. -let abortController = null as AbortController | null; - -/** - * Selects a shipping rate. - * - * @param {string} rateId The id of the rate being selected. - * @param {number | string} [packageId] The key of the packages that we will select within. - */ -export const selectShippingRate = - ( rateId: string, packageId: number | null = null ) => - async ( { - dispatch, - select, - }: { - dispatch: CartDispatchFromMap; - select: CartSelectFromMap; - } ) => { - const selectedShippingRate = select - .getShippingRates() - .find( - ( shippingPackage: CartShippingRate ) => - shippingPackage.package_id === packageId - ) - ?.shipping_rates.find( - ( rate: CartShippingPackageShippingRate ) => - rate.selected === true - ); - - if ( selectedShippingRate?.rate_id === rateId ) { - return; - } - - try { - dispatch.shippingRatesBeingSelected( true ); - if ( abortController ) { - abortController.abort(); - } - abortController = - typeof AbortController === 'undefined' - ? undefined - : new AbortController(); - - const { response } = await apiFetchWithHeaders( { - path: `/wc/store/v1/cart/select-shipping-rate`, - method: 'POST', - data: { - package_id: packageId, - rate_id: rateId, - }, - cache: 'no-store', - signal: abortController?.signal || null, - } ); - - // Remove shipping and billing address from the response, so we don't overwrite what the shopper is - // entering in the form if rates suddenly appear mid-edit. - const { - shipping_address: shippingAddress, - billing_address: billingAddress, - ...rest - } = response; - - dispatch.receiveCart( rest ); - dispatch.shippingRatesBeingSelected( false ); - - return response as CartResponse; - } catch ( error ) { - dispatch.receiveError( error ); - dispatch.shippingRatesBeingSelected( false ); - return Promise.reject( error ); - } }; +} /** * Sets billing address locally, as opposed to updateCustomerData which sends it to the server. */ -export const setBillingAddress = ( - billingAddress: Partial< BillingAddress > -) => ( { type: types.SET_BILLING_ADDRESS, billingAddress } as const ); +export function setBillingAddress( billingAddress: Partial< BillingAddress > ) { + return { type: types.SET_BILLING_ADDRESS, billingAddress }; +} /** * Sets shipping address locally, as opposed to updateCustomerData which sends it to the server. */ -export const setShippingAddress = ( +export function setShippingAddress( shippingAddress: Partial< ShippingAddress > -) => ( { type: types.SET_SHIPPING_ADDRESS, shippingAddress } as const ); +) { + return { type: types.SET_SHIPPING_ADDRESS, shippingAddress }; +} -/** - * Updates the shipping and/or billing address for the customer and returns an updated cart. - */ -export const updateCustomerData = - ( - // Address data to be updated; can contain both billing_address and shipping_address. - customerData: Partial< BillingAddressShippingAddress >, - // If the address is being edited, we don't update the customer data in the store from the response. - editing = true - ) => - async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => { - try { - dispatch.updatingCustomerData( true ); - const { response } = await apiFetchWithHeaders( { - path: '/wc/store/v1/cart/update-customer', - method: 'POST', - data: customerData, - cache: 'no-store', - } ); - if ( editing ) { - dispatch.receiveCartContents( response ); - } else { - dispatch.receiveCart( response ); - } - return response; - } catch ( error ) { - dispatch.receiveError( error ); - return Promise.reject( error ); - } finally { - dispatch.updatingCustomerData( false ); - } - }; - -type Actions = - | typeof addItemToCart - | typeof applyCoupon - | typeof changeCartItemQuantity +export type Actions = | typeof itemIsPendingDelete | typeof itemIsPendingQuantity | typeof receiveApplyingCoupon | typeof receiveCartItem | typeof receiveRemovingCoupon - | typeof removeCoupon - | typeof removeItemFromCart - | typeof selectShippingRate | typeof setBillingAddress | typeof setCartData | typeof setErrorData | typeof setIsCartDataStale | typeof setShippingAddress | typeof shippingRatesBeingSelected - | typeof updateCustomerData | typeof updatingCustomerData; export type CartAction = ReturnOrGeneratorYieldUnion< Actions | Thunks >; diff --git a/plugins/woocommerce-blocks/assets/js/data/cart/index.ts b/plugins/woocommerce-blocks/assets/js/data/cart/index.ts index 643096726ac..269a28efb87 100644 --- a/plugins/woocommerce-blocks/assets/js/data/cart/index.ts +++ b/plugins/woocommerce-blocks/assets/js/data/cart/index.ts @@ -1,7 +1,7 @@ /** * External dependencies */ -import { registerStore } from '@wordpress/data'; +import { register, subscribe, createReduxStore } from '@wordpress/data'; import { controls as dataControls } from '@wordpress/data-controls'; /** @@ -11,7 +11,7 @@ import { STORE_KEY } from './constants'; import * as selectors from './selectors'; import * as actions from './actions'; import * as resolvers from './resolvers'; -import reducer, { State } from './reducers'; +import reducer from './reducers'; import type { SelectFromMap, DispatchFromMap } from '../mapped-types'; import { pushChanges, flushChanges } from './push-changes'; import { @@ -20,20 +20,19 @@ import { } from './update-payment-methods'; import { ResolveSelectFromMap } from '../mapped-types'; -// Please update from deprecated "registerStore" to "createReduxStore" when this PR is merged: -// https://github.com/WordPress/gutenberg/pull/45513 -const registeredStore = registerStore< State >( STORE_KEY, { +const store = createReduxStore( STORE_KEY, { reducer, - actions, // eslint-disable-next-line @typescript-eslint/no-explicit-any + actions: actions as any, controls: dataControls, selectors, resolvers, - __experimentalUseThunks: true, } ); +register( store ); + // Pushes changes whenever the store is updated. -registeredStore.subscribe( pushChanges ); +subscribe( pushChanges, store ); // This will skip the debounce and immediately push changes to the server when a field is blurred. document.body.addEventListener( 'focusout', ( event: FocusEvent ) => { @@ -49,24 +48,24 @@ document.body.addEventListener( 'focusout', ( event: FocusEvent ) => { // First we will run the updatePaymentMethods function without any debounce to ensure payment methods are ready as soon // as the cart is loaded. After that, we will unsubscribe this function and instead run the // debouncedUpdatePaymentMethods function on subsequent cart updates. -const unsubscribeUpdatePaymentMethods = registeredStore.subscribe( async () => { +const unsubscribeUpdatePaymentMethods = subscribe( async () => { const didActionDispatch = await updatePaymentMethods(); if ( didActionDispatch ) { // The function we're currently in will unsubscribe itself. When we reach this line, this will be the last time // this function is called. unsubscribeUpdatePaymentMethods(); // Resubscribe, but with the debounced version of updatePaymentMethods. - registeredStore.subscribe( debouncedUpdatePaymentMethods ); + subscribe( debouncedUpdatePaymentMethods, store ); } -} ); +}, store ); export const CART_STORE_KEY = STORE_KEY; declare module '@wordpress/data' { function dispatch( - key: typeof CART_STORE_KEY + key: typeof STORE_KEY ): DispatchFromMap< typeof actions >; - function select( key: typeof CART_STORE_KEY ): SelectFromMap< + function select( key: typeof STORE_KEY ): SelectFromMap< typeof selectors > & { hasFinishedResolution: ( selector: string ) => boolean; diff --git a/plugins/woocommerce-blocks/assets/js/data/cart/reducers.ts b/plugins/woocommerce-blocks/assets/js/data/cart/reducers.ts index 67a9dcfe471..5617c13931d 100644 --- a/plugins/woocommerce-blocks/assets/js/data/cart/reducers.ts +++ b/plugins/woocommerce-blocks/assets/js/data/cart/reducers.ts @@ -1,7 +1,6 @@ /** * External dependencies */ -import type { CartItem } from '@woocommerce/types'; import type { Reducer } from 'redux'; /** @@ -10,46 +9,14 @@ import type { Reducer } from 'redux'; import { ACTION_TYPES as types } from './action-types'; import { defaultCartState, CartState } from './default-state'; import { EMPTY_CART_ERRORS } from '../constants'; -import type { CartAction } from './actions'; - -/** - * Sub-reducer for cart items array. - * - * @param {Array} state cartData.items state slice. - * @param {CartAction} action Action object. - */ -const cartItemsReducer = ( - state: Array< CartItem > = [], - action: Partial< CartAction > -) => { - switch ( action.type ) { - case types.RECEIVE_CART_ITEM: - // Replace specified cart element with the new data from server. - return state.map( ( cartItem ) => { - if ( cartItem.key === action.cartItem?.key ) { - return action.cartItem; - } - return cartItem; - } ); - } - return state; -}; /** * Reducer for receiving items related to the cart. - * - * @param {CartState} state The current state in the store. - * @param {CartAction} action Action object. - * - * @return {CartState} New or existing state. */ -const reducer: Reducer< CartState > = ( - state = defaultCartState, - action: Partial< CartAction > -) => { +const reducer: Reducer< CartState > = ( state = defaultCartState, action ) => { switch ( action.type ) { case types.SET_ERROR_DATA: - if ( action.error ) { + if ( 'error' in action && action.error ) { state = { ...state, errors: [ action.error ], @@ -142,14 +109,18 @@ const reducer: Reducer< CartState > = ( cartItemsPendingDelete: keysPendingDelete, }; break; - // Delegate to cartItemsReducer. case types.RECEIVE_CART_ITEM: state = { ...state, errors: EMPTY_CART_ERRORS, cartData: { ...state.cartData, - items: cartItemsReducer( state.cartData.items, action ), + items: state.cartData.items.map( ( cartItem ) => { + if ( cartItem.key === action.cartItem?.key ) { + return action.cartItem; + } + return cartItem; + } ), }, }; break; diff --git a/plugins/woocommerce-blocks/assets/js/data/cart/thunks.ts b/plugins/woocommerce-blocks/assets/js/data/cart/thunks.ts index 91dbd299ac0..51da2292fbe 100644 --- a/plugins/woocommerce-blocks/assets/js/data/cart/thunks.ts +++ b/plugins/woocommerce-blocks/assets/js/data/cart/thunks.ts @@ -6,8 +6,16 @@ import { CartResponse, ApiErrorResponse, isApiErrorResponse, + ExtensionCartUpdateArgs, + CartShippingPackageShippingRate, + CartShippingRate, + BillingAddressShippingAddress, } from '@woocommerce/types'; -import { camelCaseKeys } from '@woocommerce/base-utils'; +import { + camelCaseKeys, + triggerAddedToCartEvent, + triggerAddingToCartEvent, +} from '@woocommerce/base-utils'; /** * Internal dependencies @@ -15,12 +23,11 @@ import { camelCaseKeys } from '@woocommerce/base-utils'; import { notifyQuantityChanges } from './notify-quantity-changes'; import { notifyCartErrors } from './notify-errors'; import { CartDispatchFromMap, CartSelectFromMap } from './index'; +import { apiFetchWithHeaders } from '../shared-controls'; /** * 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: Partial< CartResponse > ) => @@ -75,7 +82,332 @@ export const receiveError = dispatch.setErrorData( response ); }; +/** + * POSTs to the /cart/extensions endpoint with the data supplied by the extension. + * + * @param {Object} args The data to be posted to the endpoint + */ +export const applyExtensionCartUpdate = + ( args: ExtensionCartUpdateArgs ) => + async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => { + try { + const { response } = await apiFetchWithHeaders< { + response: CartResponse; + } >( { + 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( isApiErrorResponse( error ) ? error : null ); + return Promise.reject( error ); + } + }; + +/** + * Applies a coupon code and either invalidates caches, or receives an error if + * the coupon cannot be applied. + * + * @param {string} couponCode The coupon code to apply to the cart. + * @throws Will throw an error if there is an API problem. + */ +export const applyCoupon = + ( couponCode: string ) => + async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => { + try { + dispatch.receiveApplyingCoupon( couponCode ); + const { response } = await apiFetchWithHeaders< { + response: CartResponse; + } >( { + path: '/wc/store/v1/cart/apply-coupon', + method: 'POST', + data: { + code: couponCode, + }, + cache: 'no-store', + } ); + dispatch.receiveCart( response ); + return response; + } catch ( error ) { + dispatch.receiveError( isApiErrorResponse( error ) ? error : null ); + return Promise.reject( error ); + } finally { + dispatch.receiveApplyingCoupon( '' ); + } + }; + +/** + * Removes a coupon code and either invalidates caches, or receives an error if + * the coupon cannot be removed. + * + * @param {string} couponCode The coupon code to remove from the cart. + * @throws Will throw an error if there is an API problem. + */ +export const removeCoupon = + ( couponCode: string ) => + async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => { + try { + dispatch.receiveRemovingCoupon( couponCode ); + const { response } = await apiFetchWithHeaders< { + response: CartResponse; + } >( { + path: '/wc/store/v1/cart/remove-coupon', + method: 'POST', + data: { + code: couponCode, + }, + cache: 'no-store', + } ); + dispatch.receiveCart( response ); + return response; + } catch ( error ) { + dispatch.receiveError( isApiErrorResponse( error ) ? error : null ); + return Promise.reject( error ); + } finally { + dispatch.receiveRemovingCoupon( '' ); + } + }; + +/** + * Adds an item to the cart: + * - Calls API to add item. + * - If successful, yields action to add item from store. + * - If error, yields action to store error. + * + * @param {number} productId Product ID to add to cart. + * @param {number} [quantity=1] Number of product ID being added to cart. + * @throws Will throw an error if there is an API problem. + */ +export const addItemToCart = + ( productId: number, quantity = 1 ) => + async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => { + try { + triggerAddingToCartEvent(); + const { response } = await apiFetchWithHeaders< { + response: CartResponse; + } >( { + path: `/wc/store/v1/cart/add-item`, + method: 'POST', + data: { + id: productId, + quantity, + }, + cache: 'no-store', + } ); + dispatch.receiveCart( response ); + triggerAddedToCartEvent( { preserveCartData: true } ); + return response; + } catch ( error ) { + dispatch.receiveError( isApiErrorResponse( error ) ? error : null ); + return Promise.reject( error ); + } + }; + +/** + * Removes specified item from the cart: + * - Calls API to remove item. + * - If successful, yields action to remove item from store. + * - If error, yields action to store error. + * - Sets cart item as pending while API request is in progress. + * + * @param {string} cartItemKey Cart item being updated. + */ +export const removeItemFromCart = + ( cartItemKey: string ) => + async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => { + try { + dispatch.itemIsPendingDelete( cartItemKey ); + const { response } = await apiFetchWithHeaders< { + response: CartResponse; + } >( { + path: `/wc/store/v1/cart/remove-item`, + data: { + key: cartItemKey, + }, + method: 'POST', + cache: 'no-store', + } ); + dispatch.receiveCart( response ); + return response; + } catch ( error ) { + dispatch.receiveError( isApiErrorResponse( error ) ? error : null ); + return Promise.reject( error ); + } finally { + dispatch.itemIsPendingDelete( cartItemKey, false ); + } + }; + +/** + * Persists a quantity change the for specified cart item: + * - Calls API to set quantity. + * - If successful, yields action to update store. + * - If error, yields action to store error. + * + * @param {string} cartItemKey Cart item being updated. + * @param {number} quantity Specified (new) quantity. + */ +export const changeCartItemQuantity = + ( + cartItemKey: string, + quantity: number + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- unclear how to represent multiple different yields as type + ) => + async ( { + dispatch, + select, + }: { + dispatch: CartDispatchFromMap; + select: CartSelectFromMap; + } ) => { + const cartItem = select.getCartItem( cartItemKey ); + if ( cartItem?.quantity === quantity ) { + return; + } + try { + dispatch.itemIsPendingQuantity( cartItemKey ); + const { response } = await apiFetchWithHeaders< { + response: CartResponse; + } >( { + path: '/wc/store/v1/cart/update-item', + method: 'POST', + data: { + key: cartItemKey, + quantity, + }, + cache: 'no-store', + } ); + dispatch.receiveCart( response ); + return response; + } catch ( error ) { + dispatch.receiveError( isApiErrorResponse( error ) ? error : null ); + return Promise.reject( error ); + } finally { + dispatch.itemIsPendingQuantity( cartItemKey, false ); + } + }; + +// Facilitates aborting fetch requests. +let abortController: AbortController | null = null; + +/** + * Selects a shipping rate. + * + * @param {string} rateId The id of the rate being selected. + * @param {number | string} [packageId] The key of the packages that we will select within. + */ +export const selectShippingRate = + ( rateId: string, packageId: number | null = null ) => + async ( { + dispatch, + select, + }: { + dispatch: CartDispatchFromMap; + select: CartSelectFromMap; + } ) => { + const selectedShippingRate = select + .getShippingRates() + .find( + ( shippingPackage: CartShippingRate ) => + shippingPackage.package_id === packageId + ) + ?.shipping_rates.find( + ( rate: CartShippingPackageShippingRate ) => + rate.selected === true + ); + + if ( selectedShippingRate?.rate_id === rateId ) { + return; + } + + try { + dispatch.shippingRatesBeingSelected( true ); + if ( abortController ) { + abortController.abort(); + } + abortController = + typeof AbortController === 'undefined' + ? null + : new AbortController(); + + const { response } = await apiFetchWithHeaders< { + response: CartResponse; + } >( { + path: `/wc/store/v1/cart/select-shipping-rate`, + method: 'POST', + data: { + package_id: packageId, + rate_id: rateId, + }, + cache: 'no-store', + signal: abortController?.signal || null, + } ); + + // Remove shipping and billing address from the response, so we don't overwrite what the shopper is + // entering in the form if rates suddenly appear mid-edit. + const { + shipping_address: shippingAddress, + billing_address: billingAddress, + ...rest + } = response; + + dispatch.receiveCart( rest ); + dispatch.shippingRatesBeingSelected( false ); + + return response as CartResponse; + } catch ( error ) { + dispatch.receiveError( isApiErrorResponse( error ) ? error : null ); + dispatch.shippingRatesBeingSelected( false ); + return Promise.reject( error ); + } + }; + +/** + * Updates the shipping and/or billing address for the customer and returns an updated cart. + */ +export const updateCustomerData = + ( + // Address data to be updated; can contain both billing_address and shipping_address. + customerData: Partial< BillingAddressShippingAddress >, + // If the address is being edited, we don't update the customer data in the store from the response. + editing = true + ) => + async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => { + try { + dispatch.updatingCustomerData( true ); + const { response } = await apiFetchWithHeaders< { + response: CartResponse; + } >( { + path: '/wc/store/v1/cart/update-customer', + method: 'POST', + data: customerData, + cache: 'no-store', + } ); + if ( editing ) { + dispatch.receiveCartContents( response ); + } else { + dispatch.receiveCart( response ); + } + return response; + } catch ( error ) { + dispatch.receiveError( isApiErrorResponse( error ) ? error : null ); + return Promise.reject( error ); + } finally { + dispatch.updatingCustomerData( false ); + } + }; + export type Thunks = | typeof receiveCart | typeof receiveCartContents - | typeof receiveError; + | typeof receiveError + | typeof applyExtensionCartUpdate + | typeof applyCoupon + | typeof removeCoupon + | typeof addItemToCart + | typeof removeItemFromCart + | typeof changeCartItemQuantity + | typeof selectShippingRate + | typeof updateCustomerData; diff --git a/plugins/woocommerce-blocks/assets/js/data/shared-controls.ts b/plugins/woocommerce-blocks/assets/js/data/shared-controls.ts index 894c119bda8..81a21e50c1d 100644 --- a/plugins/woocommerce-blocks/assets/js/data/shared-controls.ts +++ b/plugins/woocommerce-blocks/assets/js/data/shared-controls.ts @@ -125,19 +125,23 @@ const doApiFetchWithHeaders = ( options: APIFetchOptions ) => ...options, parse: false, } ) - .then( ( fetchResponse ) => { - fetchResponse - .json() - .then( ( response ) => { - resolve( { - response, - headers: fetchResponse.headers, + .then( ( fetchResponse: unknown ) => { + if ( fetchResponse instanceof Response ) { + fetchResponse + .json() + .then( ( response: unknown ) => { + resolve( { + response, + headers: fetchResponse.headers, + } ); + setNonceOnFetch( fetchResponse.headers ); + } ) + .catch( () => { + reject( invalidJsonError ); } ); - setNonceOnFetch( fetchResponse.headers ); - } ) - .catch( () => { - reject( invalidJsonError ); - } ); + } else { + reject( invalidJsonError ); + } } ) .catch( ( errorResponse ) => { if ( errorResponse.name !== 'AbortError' ) { @@ -159,7 +163,7 @@ const doApiFetchWithHeaders = ( options: APIFetchOptions ) => } ); } else { batchFetch( options ) - .then( ( response: ApiResponse ) => { + .then( ( response: ApiResponse< unknown > ) => { assertResponseIsValid( response ); if ( response.status >= 200 && response.status < 300 ) { @@ -173,7 +177,7 @@ const doApiFetchWithHeaders = ( options: APIFetchOptions ) => // Status code indicates error. throw response; } ) - .catch( ( errorResponse: ApiResponse ) => { + .catch( ( errorResponse: ApiResponse< unknown > ) => { if ( errorResponse.headers ) { setNonceOnFetch( errorResponse.headers ); } @@ -192,8 +196,10 @@ const doApiFetchWithHeaders = ( options: APIFetchOptions ) => * * @param {APIFetchOptions} options The options for the API request. */ -export const apiFetchWithHeaders = ( options: APIFetchOptions ) => { - return doApiFetchWithHeaders( options ); +export const apiFetchWithHeaders = < T = unknown >( + options: APIFetchOptions +): Promise< T > => { + return doApiFetchWithHeaders( options ) as Promise< T >; }; /** diff --git a/plugins/woocommerce/changelog/52219-updated-createReduxStore-cart-data-store b/plugins/woocommerce/changelog/52219-updated-createReduxStore-cart-data-store new file mode 100644 index 00000000000..0f9edc38051 --- /dev/null +++ b/plugins/woocommerce/changelog/52219-updated-createReduxStore-cart-data-store @@ -0,0 +1,4 @@ +Significance: patch +Type: tweak +Comment: Refactored cart data store +