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 <github-actions@github.com>
This commit is contained in:
Mike Jolley 2024-10-23 17:15:41 +01:00 committed by GitHub
parent 91eeb179e5
commit 084297a1a9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 429 additions and 439 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,4 @@
Significance: patch
Type: tweak
Comment: Refactored cart data store