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:
parent
91eeb179e5
commit
084297a1a9
|
@ -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 >;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 >;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: tweak
|
||||
Comment: Refactored cart data store
|
||||
|
Loading…
Reference in New Issue