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 * External dependencies
*/ */
import type { import type {
Cart,
CartResponse,
CartResponseItem,
ExtensionCartUpdateArgs,
BillingAddressShippingAddress,
ApiErrorResponse, ApiErrorResponse,
CartShippingPackageShippingRate, Cart,
CartShippingRate, CartResponseItem,
} from '@woocommerce/types'; } from '@woocommerce/types';
import { BillingAddress, ShippingAddress } from '@woocommerce/settings'; import { BillingAddress, ShippingAddress } from '@woocommerce/settings';
import {
triggerAddedToCartEvent,
triggerAddingToCartEvent,
} from '@woocommerce/base-utils';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { ACTION_TYPES as types } from './action-types'; import { ACTION_TYPES as types } from './action-types';
import { apiFetchWithHeaders } from '../shared-controls';
import { ReturnOrGeneratorYieldUnion } from '../mapped-types'; import { ReturnOrGeneratorYieldUnion } from '../mapped-types';
import { CartDispatchFromMap, CartSelectFromMap } from './index';
import type { Thunks } from './thunks'; import type { Thunks } from './thunks';
// Thunks are functions that can be dispatched, similar to actions creators // 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'; export * from './thunks';
/** /**
* An action creator that dispatches the plain action responsible for setting the cart data in the store. * 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 { return {
type: types.SET_CART_DATA, type: types.SET_CART_DATA,
response: cart, response: cart,
}; };
}; }
/** /**
* An action creator that dispatches the plain action responsible for setting the cart error data in the store. * 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 = ( export function setErrorData( error: ApiErrorResponse | null ) {
error: ApiErrorResponse | null
): { type: string; response: ApiErrorResponse | null } => {
return { return {
type: types.SET_ERROR_DATA, type: types.SET_ERROR_DATA,
error, error,
}; };
}; }
/** /**
* Returns an action object used to track when a coupon is applying. * 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, type: types.APPLYING_COUPON,
couponCode, couponCode,
} as const ); };
}
/** /**
* Returns an action object used to track when a coupon is removing. * 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, type: types.REMOVING_COUPON,
couponCode, couponCode,
} as const ); };
}
/** /**
* Returns an action object for updating a single cart item in the store. * 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, type: types.RECEIVE_CART_ITEM,
cartItem: response, cartItem: response,
} as const ); };
}
/** /**
* Returns an action object to indicate if the specified cart item quantity is * 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 * @param {boolean} [isPendingQuantity=true] Flag for update state; true if API
* request is pending. * request is pending.
*/ */
export const itemIsPendingQuantity = ( export function itemIsPendingQuantity(
cartItemKey: string, cartItemKey: string,
isPendingQuantity = true isPendingQuantity = true
) => ) {
( { return {
type: types.ITEM_PENDING_QUANTITY, type: types.ITEM_PENDING_QUANTITY,
cartItemKey, cartItemKey,
isPendingQuantity, isPendingQuantity,
} as const ); };
}
/** /**
* Returns an action object to remove a cart item from the store. * 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 * @param {boolean} [isPendingDelete=true] Flag for update state; true if API
* request is pending. * request is pending.
*/ */
export const itemIsPendingDelete = ( export function itemIsPendingDelete(
cartItemKey: string, cartItemKey: string,
isPendingDelete = true isPendingDelete = true
) => ) {
( { return {
type: types.RECEIVE_REMOVED_ITEM, type: types.RECEIVE_REMOVED_ITEM,
cartItemKey, cartItemKey,
isPendingDelete, isPendingDelete,
} as const ); };
}
/** /**
* Returns an action object to mark the cart data in the store as stale. * 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 * lastCartUpdate timestamp is newer than the
* one in wcSettings. * one in wcSettings.
*/ */
export const setIsCartDataStale = ( isCartDataStale = true ) => export function setIsCartDataStale( isCartDataStale = true ) {
( { return {
type: types.SET_IS_CART_DATA_STALE, type: types.SET_IS_CART_DATA_STALE,
isCartDataStale, isCartDataStale,
} as const ); };
}
/** /**
* Returns an action object used to track when customer data is being updated * Returns an action object used to track when customer data is being updated
* (billing and/or shipping). * (billing and/or shipping).
*/ */
export const updatingCustomerData = ( isResolving: boolean ) => export function updatingCustomerData( isResolving: boolean ) {
( { return {
type: types.UPDATING_CUSTOMER_DATA, type: types.UPDATING_CUSTOMER_DATA,
isResolving, isResolving,
} as const ); };
}
/** /**
* Returns an action object used to track whether the shipping rate is being * 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. * @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, type: types.UPDATING_SELECTED_SHIPPING_RATE,
isResolving, 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. * Sets billing address locally, as opposed to updateCustomerData which sends it to the server.
*/ */
export const setBillingAddress = ( export function setBillingAddress( billingAddress: Partial< BillingAddress > ) {
billingAddress: Partial< BillingAddress > return { type: types.SET_BILLING_ADDRESS, billingAddress };
) => ( { type: types.SET_BILLING_ADDRESS, billingAddress } as const ); }
/** /**
* Sets shipping address locally, as opposed to updateCustomerData which sends it to the server. * Sets shipping address locally, as opposed to updateCustomerData which sends it to the server.
*/ */
export const setShippingAddress = ( export function setShippingAddress(
shippingAddress: Partial< ShippingAddress > 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 = export type Actions =
| typeof addItemToCart
| typeof applyCoupon
| typeof changeCartItemQuantity
| typeof itemIsPendingDelete | typeof itemIsPendingDelete
| typeof itemIsPendingQuantity | typeof itemIsPendingQuantity
| typeof receiveApplyingCoupon | typeof receiveApplyingCoupon
| typeof receiveCartItem | typeof receiveCartItem
| typeof receiveRemovingCoupon | typeof receiveRemovingCoupon
| typeof removeCoupon
| typeof removeItemFromCart
| typeof selectShippingRate
| typeof setBillingAddress | typeof setBillingAddress
| typeof setCartData | typeof setCartData
| typeof setErrorData | typeof setErrorData
| typeof setIsCartDataStale | typeof setIsCartDataStale
| typeof setShippingAddress | typeof setShippingAddress
| typeof shippingRatesBeingSelected | typeof shippingRatesBeingSelected
| typeof updateCustomerData
| typeof updatingCustomerData; | typeof updatingCustomerData;
export type CartAction = ReturnOrGeneratorYieldUnion< Actions | Thunks >; export type CartAction = ReturnOrGeneratorYieldUnion< Actions | Thunks >;

View File

@ -1,7 +1,7 @@
/** /**
* External dependencies * External dependencies
*/ */
import { registerStore } from '@wordpress/data'; import { register, subscribe, createReduxStore } from '@wordpress/data';
import { controls as dataControls } from '@wordpress/data-controls'; import { controls as dataControls } from '@wordpress/data-controls';
/** /**
@ -11,7 +11,7 @@ import { STORE_KEY } from './constants';
import * as selectors from './selectors'; import * as selectors from './selectors';
import * as actions from './actions'; import * as actions from './actions';
import * as resolvers from './resolvers'; import * as resolvers from './resolvers';
import reducer, { State } from './reducers'; import reducer from './reducers';
import type { SelectFromMap, DispatchFromMap } from '../mapped-types'; import type { SelectFromMap, DispatchFromMap } from '../mapped-types';
import { pushChanges, flushChanges } from './push-changes'; import { pushChanges, flushChanges } from './push-changes';
import { import {
@ -20,20 +20,19 @@ import {
} from './update-payment-methods'; } from './update-payment-methods';
import { ResolveSelectFromMap } from '../mapped-types'; import { ResolveSelectFromMap } from '../mapped-types';
// Please update from deprecated "registerStore" to "createReduxStore" when this PR is merged: const store = createReduxStore( STORE_KEY, {
// https://github.com/WordPress/gutenberg/pull/45513
const registeredStore = registerStore< State >( STORE_KEY, {
reducer, reducer,
actions,
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
actions: actions as any,
controls: dataControls, controls: dataControls,
selectors, selectors,
resolvers, resolvers,
__experimentalUseThunks: true,
} ); } );
register( store );
// Pushes changes whenever the store is updated. // 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. // This will skip the debounce and immediately push changes to the server when a field is blurred.
document.body.addEventListener( 'focusout', ( event: FocusEvent ) => { 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 // 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 // as the cart is loaded. After that, we will unsubscribe this function and instead run the
// debouncedUpdatePaymentMethods function on subsequent cart updates. // debouncedUpdatePaymentMethods function on subsequent cart updates.
const unsubscribeUpdatePaymentMethods = registeredStore.subscribe( async () => { const unsubscribeUpdatePaymentMethods = subscribe( async () => {
const didActionDispatch = await updatePaymentMethods(); const didActionDispatch = await updatePaymentMethods();
if ( didActionDispatch ) { if ( didActionDispatch ) {
// The function we're currently in will unsubscribe itself. When we reach this line, this will be the last time // The function we're currently in will unsubscribe itself. When we reach this line, this will be the last time
// this function is called. // this function is called.
unsubscribeUpdatePaymentMethods(); unsubscribeUpdatePaymentMethods();
// Resubscribe, but with the debounced version of updatePaymentMethods. // Resubscribe, but with the debounced version of updatePaymentMethods.
registeredStore.subscribe( debouncedUpdatePaymentMethods ); subscribe( debouncedUpdatePaymentMethods, store );
} }
} ); }, store );
export const CART_STORE_KEY = STORE_KEY; export const CART_STORE_KEY = STORE_KEY;
declare module '@wordpress/data' { declare module '@wordpress/data' {
function dispatch( function dispatch(
key: typeof CART_STORE_KEY key: typeof STORE_KEY
): DispatchFromMap< typeof actions >; ): DispatchFromMap< typeof actions >;
function select( key: typeof CART_STORE_KEY ): SelectFromMap< function select( key: typeof STORE_KEY ): SelectFromMap<
typeof selectors typeof selectors
> & { > & {
hasFinishedResolution: ( selector: string ) => boolean; hasFinishedResolution: ( selector: string ) => boolean;

View File

@ -1,7 +1,6 @@
/** /**
* External dependencies * External dependencies
*/ */
import type { CartItem } from '@woocommerce/types';
import type { Reducer } from 'redux'; import type { Reducer } from 'redux';
/** /**
@ -10,46 +9,14 @@ import type { Reducer } from 'redux';
import { ACTION_TYPES as types } from './action-types'; import { ACTION_TYPES as types } from './action-types';
import { defaultCartState, CartState } from './default-state'; import { defaultCartState, CartState } from './default-state';
import { EMPTY_CART_ERRORS } from '../constants'; 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. * 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 > = ( const reducer: Reducer< CartState > = ( state = defaultCartState, action ) => {
state = defaultCartState,
action: Partial< CartAction >
) => {
switch ( action.type ) { switch ( action.type ) {
case types.SET_ERROR_DATA: case types.SET_ERROR_DATA:
if ( action.error ) { if ( 'error' in action && action.error ) {
state = { state = {
...state, ...state,
errors: [ action.error ], errors: [ action.error ],
@ -142,14 +109,18 @@ const reducer: Reducer< CartState > = (
cartItemsPendingDelete: keysPendingDelete, cartItemsPendingDelete: keysPendingDelete,
}; };
break; break;
// Delegate to cartItemsReducer.
case types.RECEIVE_CART_ITEM: case types.RECEIVE_CART_ITEM:
state = { state = {
...state, ...state,
errors: EMPTY_CART_ERRORS, errors: EMPTY_CART_ERRORS,
cartData: { cartData: {
...state.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; break;

View File

@ -6,8 +6,16 @@ import {
CartResponse, CartResponse,
ApiErrorResponse, ApiErrorResponse,
isApiErrorResponse, isApiErrorResponse,
ExtensionCartUpdateArgs,
CartShippingPackageShippingRate,
CartShippingRate,
BillingAddressShippingAddress,
} from '@woocommerce/types'; } from '@woocommerce/types';
import { camelCaseKeys } from '@woocommerce/base-utils'; import {
camelCaseKeys,
triggerAddedToCartEvent,
triggerAddingToCartEvent,
} from '@woocommerce/base-utils';
/** /**
* Internal dependencies * Internal dependencies
@ -15,12 +23,11 @@ import { camelCaseKeys } from '@woocommerce/base-utils';
import { notifyQuantityChanges } from './notify-quantity-changes'; import { notifyQuantityChanges } from './notify-quantity-changes';
import { notifyCartErrors } from './notify-errors'; import { notifyCartErrors } from './notify-errors';
import { CartDispatchFromMap, CartSelectFromMap } from './index'; 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 * 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. * of any unexpected quantity changes occurred.
*
* @param {CartResponse} response
*/ */
export const receiveCart = export const receiveCart =
( response: Partial< CartResponse > ) => ( response: Partial< CartResponse > ) =>
@ -75,7 +82,332 @@ export const receiveError =
dispatch.setErrorData( response ); 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 = export type Thunks =
| typeof receiveCart | typeof receiveCart
| typeof receiveCartContents | 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,10 +125,11 @@ const doApiFetchWithHeaders = ( options: APIFetchOptions ) =>
...options, ...options,
parse: false, parse: false,
} ) } )
.then( ( fetchResponse ) => { .then( ( fetchResponse: unknown ) => {
if ( fetchResponse instanceof Response ) {
fetchResponse fetchResponse
.json() .json()
.then( ( response ) => { .then( ( response: unknown ) => {
resolve( { resolve( {
response, response,
headers: fetchResponse.headers, headers: fetchResponse.headers,
@ -138,6 +139,9 @@ const doApiFetchWithHeaders = ( options: APIFetchOptions ) =>
.catch( () => { .catch( () => {
reject( invalidJsonError ); reject( invalidJsonError );
} ); } );
} else {
reject( invalidJsonError );
}
} ) } )
.catch( ( errorResponse ) => { .catch( ( errorResponse ) => {
if ( errorResponse.name !== 'AbortError' ) { if ( errorResponse.name !== 'AbortError' ) {
@ -159,7 +163,7 @@ const doApiFetchWithHeaders = ( options: APIFetchOptions ) =>
} ); } );
} else { } else {
batchFetch( options ) batchFetch( options )
.then( ( response: ApiResponse ) => { .then( ( response: ApiResponse< unknown > ) => {
assertResponseIsValid( response ); assertResponseIsValid( response );
if ( response.status >= 200 && response.status < 300 ) { if ( response.status >= 200 && response.status < 300 ) {
@ -173,7 +177,7 @@ const doApiFetchWithHeaders = ( options: APIFetchOptions ) =>
// Status code indicates error. // Status code indicates error.
throw response; throw response;
} ) } )
.catch( ( errorResponse: ApiResponse ) => { .catch( ( errorResponse: ApiResponse< unknown > ) => {
if ( errorResponse.headers ) { if ( errorResponse.headers ) {
setNonceOnFetch( errorResponse.headers ); setNonceOnFetch( errorResponse.headers );
} }
@ -192,8 +196,10 @@ const doApiFetchWithHeaders = ( options: APIFetchOptions ) =>
* *
* @param {APIFetchOptions} options The options for the API request. * @param {APIFetchOptions} options The options for the API request.
*/ */
export const apiFetchWithHeaders = ( options: APIFetchOptions ) => { export const apiFetchWithHeaders = < T = unknown >(
return doApiFetchWithHeaders( options ); 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