rebase on oct-10-2023
This commit is contained in:
@@ -11,11 +11,11 @@ import type {
|
||||
CartShippingPackageShippingRate,
|
||||
CartShippingRate,
|
||||
} from '@woocommerce/types';
|
||||
import { camelCase, mapKeys } from 'lodash';
|
||||
import { BillingAddress, ShippingAddress } from '@woocommerce/settings';
|
||||
import {
|
||||
triggerAddedToCartEvent,
|
||||
triggerAddingToCartEvent,
|
||||
camelCaseKeys,
|
||||
} from '@woocommerce/base-utils';
|
||||
|
||||
/**
|
||||
@@ -70,9 +70,7 @@ export const setErrorData = (
|
||||
export const receiveCartContents = (
|
||||
response: CartResponse
|
||||
): { type: string; response: Partial< Cart > } => {
|
||||
const cart = mapKeys( response, ( _, key ) =>
|
||||
camelCase( key )
|
||||
) as unknown as Cart;
|
||||
const cart = camelCaseKeys( response ) as unknown as Cart;
|
||||
const { shippingAddress, billingAddress, ...cartWithoutAddress } = cart;
|
||||
return {
|
||||
type: types.SET_CART_DATA,
|
||||
@@ -385,7 +383,7 @@ export const changeCartItemQuantity =
|
||||
* @param {number | string} [packageId] The key of the packages that we will select within.
|
||||
*/
|
||||
export const selectShippingRate =
|
||||
( rateId: string, packageId = 0 ) =>
|
||||
( rateId: string, packageId: number | null = null ) =>
|
||||
async ( {
|
||||
dispatch,
|
||||
select,
|
||||
@@ -481,13 +479,6 @@ export const updateCustomerData =
|
||||
}
|
||||
};
|
||||
|
||||
export const setFullShippingAddressPushed = (
|
||||
fullShippingAddressPushed: boolean
|
||||
) => ( {
|
||||
type: types.SET_FULL_SHIPPING_ADDRESS_PUSHED,
|
||||
fullShippingAddressPushed,
|
||||
} );
|
||||
|
||||
type Actions =
|
||||
| typeof addItemToCart
|
||||
| typeof applyCoupon
|
||||
@@ -508,7 +499,6 @@ type Actions =
|
||||
| typeof setShippingAddress
|
||||
| typeof shippingRatesBeingSelected
|
||||
| typeof updateCustomerData
|
||||
| typeof setFullShippingAddressPushed
|
||||
| typeof updatingCustomerData;
|
||||
|
||||
export type CartAction = ReturnOrGeneratorYieldUnion< Actions | Thunks >;
|
||||
|
||||
@@ -100,7 +100,6 @@ export const defaultCartState: CartState = {
|
||||
applyingCoupon: '',
|
||||
removingCoupon: '',
|
||||
isCartDataStale: false,
|
||||
fullShippingAddressPushed: false,
|
||||
},
|
||||
errors: EMPTY_CART_ERRORS,
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ const registeredStore = registerStore< State >( STORE_KEY, {
|
||||
__experimentalUseThunks: true,
|
||||
} );
|
||||
|
||||
// Pushes changes whenever the store is updated.
|
||||
registeredStore.subscribe( pushChanges );
|
||||
|
||||
// This will skip the debounce and immediately push changes to the server when a field is blurred.
|
||||
|
||||
@@ -25,7 +25,7 @@ export const notifyCartErrors = (
|
||||
createNotice( 'error', decodeEntities( error.message ), {
|
||||
id: error.code,
|
||||
context: 'wc/cart',
|
||||
isDismissible: true,
|
||||
isDismissible: false,
|
||||
} );
|
||||
}
|
||||
} );
|
||||
|
||||
@@ -1,271 +1,181 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { debounce, pick } from 'lodash';
|
||||
import { select, dispatch } from '@wordpress/data';
|
||||
import { pluckEmail, removeAllNotices } from '@woocommerce/base-utils';
|
||||
import { removeAllNotices, debounce, pick } from '@woocommerce/base-utils';
|
||||
import {
|
||||
CartBillingAddress,
|
||||
CartShippingAddress,
|
||||
BillingAddressShippingAddress,
|
||||
} from '@woocommerce/types';
|
||||
import { select, dispatch } from '@wordpress/data';
|
||||
import isShallowEqual from '@wordpress/is-shallow-equal';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { STORE_KEY } from './constants';
|
||||
import { VALIDATION_STORE_KEY } from '../validation';
|
||||
import { processErrorResponse } from '../utils';
|
||||
import { shippingAddressHasValidationErrors } from './utils';
|
||||
import { getDirtyKeys, validateDirtyProps, BaseAddressKey } from './utils';
|
||||
|
||||
type CustomerData = {
|
||||
billingAddress: CartBillingAddress;
|
||||
shippingAddress: CartShippingAddress;
|
||||
};
|
||||
|
||||
type BillingOrShippingAddress = CartBillingAddress | CartShippingAddress;
|
||||
|
||||
/**
|
||||
* Checks if a cart response contains an email property.
|
||||
*/
|
||||
const isBillingAddress = (
|
||||
address: BillingOrShippingAddress
|
||||
): address is CartBillingAddress => {
|
||||
return 'email' in address;
|
||||
// This is used to track and cache the local state of push changes.
|
||||
const localState = {
|
||||
// True when the customer data has been initialized.
|
||||
customerDataIsInitialized: false,
|
||||
// True when a push is currently happening to avoid simultaneous pushes.
|
||||
doingPush: false,
|
||||
// Local cache of the last pushed customerData used for comparisons.
|
||||
customerData: {
|
||||
billingAddress: {} as CartBillingAddress,
|
||||
shippingAddress: {} as CartShippingAddress,
|
||||
},
|
||||
// Tracks which props have changed so the correct data gets pushed to the server.
|
||||
dirtyProps: {
|
||||
billingAddress: [] as BaseAddressKey[],
|
||||
shippingAddress: [] as BaseAddressKey[],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Trims and normalizes address data for comparison.
|
||||
* Initializes the customer data cache on the first run.
|
||||
*/
|
||||
export const normalizeAddress = ( address: BillingOrShippingAddress ) => {
|
||||
const trimmedAddress = Object.entries( address ).reduce(
|
||||
( acc, [ key, value ] ) => {
|
||||
if ( key === 'postcode' ) {
|
||||
acc[ key as keyof BillingOrShippingAddress ] = value
|
||||
.replace( ' ', '' )
|
||||
.toUpperCase();
|
||||
} else {
|
||||
acc[ key as keyof BillingOrShippingAddress ] = value.trim();
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as BillingOrShippingAddress
|
||||
);
|
||||
return trimmedAddress;
|
||||
const initialize = () => {
|
||||
localState.customerData = select( STORE_KEY ).getCustomerData();
|
||||
localState.customerDataIsInitialized = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Does a shallow compare of all address data to determine if the cart needs updating on the server.
|
||||
* Checks customer data against new customer data to get a list of dirty props.
|
||||
*/
|
||||
const isAddressDirty = < T extends CartBillingAddress | CartShippingAddress >(
|
||||
// An object containing all previous address information
|
||||
previousAddress: T,
|
||||
// An object containing all address information.
|
||||
address: T
|
||||
): boolean => {
|
||||
if (
|
||||
isBillingAddress( address ) &&
|
||||
pluckEmail( address ) !==
|
||||
pluckEmail( previousAddress as CartBillingAddress )
|
||||
) {
|
||||
return true;
|
||||
const updateDirtyProps = () => {
|
||||
// Returns all current customer data from the store.
|
||||
const newCustomerData = select( STORE_KEY ).getCustomerData();
|
||||
|
||||
localState.dirtyProps.billingAddress = [
|
||||
...localState.dirtyProps.billingAddress,
|
||||
...getDirtyKeys(
|
||||
localState.customerData.billingAddress,
|
||||
newCustomerData.billingAddress
|
||||
),
|
||||
];
|
||||
|
||||
localState.dirtyProps.shippingAddress = [
|
||||
...localState.dirtyProps.shippingAddress,
|
||||
...getDirtyKeys(
|
||||
localState.customerData.shippingAddress,
|
||||
newCustomerData.shippingAddress
|
||||
),
|
||||
];
|
||||
|
||||
// Update local cache of customer data so the next time this runs, it can compare against the latest data.
|
||||
localState.customerData = newCustomerData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to dispatch an update to the server.
|
||||
*/
|
||||
const updateCustomerData = (): void => {
|
||||
if ( localState.doingPush ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const addressMatches = isShallowEqual(
|
||||
normalizeAddress( previousAddress ),
|
||||
normalizeAddress( address )
|
||||
);
|
||||
// Prevent multiple pushes from happening at the same time.
|
||||
localState.doingPush = true;
|
||||
|
||||
return ! addressMatches;
|
||||
};
|
||||
// Get updated list of dirty props by comparing customer data.
|
||||
updateDirtyProps();
|
||||
|
||||
type BaseAddressKey = keyof CartBillingAddress | keyof CartShippingAddress;
|
||||
// Do we need to push anything?
|
||||
const needsPush =
|
||||
localState.dirtyProps.billingAddress.length > 0 ||
|
||||
localState.dirtyProps.shippingAddress.length > 0;
|
||||
|
||||
const getDirtyKeys = < T extends CartBillingAddress | CartShippingAddress >(
|
||||
// An object containing all previous address information
|
||||
previousAddress: T,
|
||||
// An object containing all address information.
|
||||
address: T
|
||||
): BaseAddressKey[] => {
|
||||
const previousAddressKeys = Object.keys(
|
||||
previousAddress
|
||||
) as BaseAddressKey[];
|
||||
|
||||
return previousAddressKeys.filter( ( key ) => {
|
||||
return previousAddress[ key ] !== address[ key ];
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Local cache of customerData used for comparisons.
|
||||
*/
|
||||
let customerData = <CustomerData>{
|
||||
billingAddress: {},
|
||||
shippingAddress: {},
|
||||
};
|
||||
|
||||
// Tracks if customerData has been populated.
|
||||
let customerDataIsInitialized = false;
|
||||
|
||||
/**
|
||||
* Tracks which props have changed so the correct data gets pushed to the server.
|
||||
*/
|
||||
const dirtyProps = <
|
||||
{
|
||||
billingAddress: BaseAddressKey[];
|
||||
shippingAddress: BaseAddressKey[];
|
||||
if ( ! needsPush ) {
|
||||
localState.doingPush = false;
|
||||
return;
|
||||
}
|
||||
>{
|
||||
billingAddress: [],
|
||||
shippingAddress: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to dispatch an update to the server. This is debounced.
|
||||
*/
|
||||
const updateCustomerData = debounce( (): void => {
|
||||
const { billingAddress, shippingAddress } = customerData;
|
||||
const validationStore = select( VALIDATION_STORE_KEY );
|
||||
|
||||
// Before we push anything, we need to ensure that the data we're pushing (dirty fields) are valid, otherwise we will
|
||||
// abort and wait for the validation issues to be resolved.
|
||||
const invalidProps = [
|
||||
...dirtyProps.billingAddress.filter( ( key ) => {
|
||||
return (
|
||||
validationStore.getValidationError( 'billing_' + key ) !==
|
||||
undefined
|
||||
);
|
||||
} ),
|
||||
...dirtyProps.shippingAddress.filter( ( key ) => {
|
||||
return (
|
||||
validationStore.getValidationError( 'shipping_' + key ) !==
|
||||
undefined
|
||||
);
|
||||
} ),
|
||||
].filter( Boolean );
|
||||
|
||||
if ( invalidProps.length ) {
|
||||
// Check props are valid, or abort.
|
||||
if ( ! validateDirtyProps( localState.dirtyProps ) ) {
|
||||
localState.doingPush = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Find valid data from the list of dirtyProps and prepare to push to the server.
|
||||
const customerDataToUpdate = {} as Partial< BillingAddressShippingAddress >;
|
||||
|
||||
if ( dirtyProps.billingAddress.length ) {
|
||||
if ( localState.dirtyProps.billingAddress.length ) {
|
||||
customerDataToUpdate.billing_address = pick(
|
||||
billingAddress,
|
||||
dirtyProps.billingAddress
|
||||
localState.customerData.billingAddress,
|
||||
localState.dirtyProps.billingAddress
|
||||
);
|
||||
dirtyProps.billingAddress = [];
|
||||
}
|
||||
|
||||
if ( dirtyProps.shippingAddress.length ) {
|
||||
if ( localState.dirtyProps.shippingAddress.length ) {
|
||||
customerDataToUpdate.shipping_address = pick(
|
||||
shippingAddress,
|
||||
dirtyProps.shippingAddress
|
||||
localState.customerData.shippingAddress,
|
||||
localState.dirtyProps.shippingAddress
|
||||
);
|
||||
dirtyProps.shippingAddress = [];
|
||||
}
|
||||
|
||||
// If there is customer data to update, push it to the server.
|
||||
if ( Object.keys( customerDataToUpdate ).length ) {
|
||||
dispatch( STORE_KEY )
|
||||
.updateCustomerData( customerDataToUpdate )
|
||||
.then( removeAllNotices )
|
||||
.catch( ( response ) => {
|
||||
processErrorResponse( response );
|
||||
dispatch( STORE_KEY )
|
||||
.updateCustomerData( customerDataToUpdate )
|
||||
.then( () => {
|
||||
localState.dirtyProps.billingAddress = [];
|
||||
localState.dirtyProps.shippingAddress = [];
|
||||
localState.doingPush = false;
|
||||
removeAllNotices();
|
||||
} )
|
||||
.catch( ( response ) => {
|
||||
localState.doingPush = false;
|
||||
processErrorResponse( response );
|
||||
} );
|
||||
};
|
||||
|
||||
// Data did not persist due to an error. Make the props dirty again so they get pushed to the server.
|
||||
if ( customerDataToUpdate.billing_address ) {
|
||||
dirtyProps.billingAddress = [
|
||||
...dirtyProps.billingAddress,
|
||||
...( Object.keys(
|
||||
customerDataToUpdate.billing_address
|
||||
) as BaseAddressKey[] ),
|
||||
];
|
||||
}
|
||||
if ( customerDataToUpdate.shipping_address ) {
|
||||
dirtyProps.shippingAddress = [
|
||||
...dirtyProps.shippingAddress,
|
||||
...( Object.keys(
|
||||
customerDataToUpdate.shipping_address
|
||||
) as BaseAddressKey[] ),
|
||||
];
|
||||
}
|
||||
} )
|
||||
.finally( () => {
|
||||
if ( ! shippingAddressHasValidationErrors() ) {
|
||||
dispatch( STORE_KEY ).setFullShippingAddressPushed( true );
|
||||
}
|
||||
} );
|
||||
/**
|
||||
* Function to dispatch an update to the server. This is debounced.
|
||||
*/
|
||||
const debouncedUpdateCustomerData = debounce( () => {
|
||||
if ( localState.doingPush ) {
|
||||
debouncedUpdateCustomerData();
|
||||
return;
|
||||
}
|
||||
}, 1000 );
|
||||
updateCustomerData();
|
||||
}, 1500 );
|
||||
|
||||
/**
|
||||
* After cart has fully initialized, pushes changes to the server when data in the store is changed. Updates to the
|
||||
* server are debounced to prevent excessive requests.
|
||||
*
|
||||
* Any update to the store triggers this, so we do a shallow compare on the important data to know if we really need to
|
||||
* schedule a push.
|
||||
*/
|
||||
export const pushChanges = (): void => {
|
||||
const store = select( STORE_KEY );
|
||||
|
||||
if ( ! store.hasFinishedResolution( 'getCartData' ) ) {
|
||||
export const pushChanges = ( debounced = true ): void => {
|
||||
if ( ! select( STORE_KEY ).hasFinishedResolution( 'getCartData' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Returns all current customer data from the store.
|
||||
const newCustomerData = store.getCustomerData();
|
||||
|
||||
// On first run, this will populate the customerData cache with the current customer data in the store.
|
||||
// This does not need to be pushed to the server because it's already there.
|
||||
if ( ! customerDataIsInitialized ) {
|
||||
customerData = newCustomerData;
|
||||
customerDataIsInitialized = true;
|
||||
if ( ! localState.customerDataIsInitialized ) {
|
||||
initialize();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the billing and shipping addresses are "dirty"--as in, they've changed since the last push.
|
||||
const billingIsDirty = isAddressDirty(
|
||||
customerData.billingAddress,
|
||||
newCustomerData.billingAddress
|
||||
);
|
||||
const shippingIsDirty = isAddressDirty(
|
||||
customerData.shippingAddress,
|
||||
newCustomerData.shippingAddress
|
||||
);
|
||||
|
||||
// Update local cache of dirty prop keys.
|
||||
if ( billingIsDirty ) {
|
||||
dirtyProps.billingAddress = [
|
||||
...dirtyProps.billingAddress,
|
||||
...getDirtyKeys(
|
||||
customerData.billingAddress,
|
||||
newCustomerData.billingAddress
|
||||
),
|
||||
];
|
||||
}
|
||||
if ( shippingIsDirty ) {
|
||||
dirtyProps.shippingAddress = [
|
||||
...dirtyProps.shippingAddress,
|
||||
...getDirtyKeys(
|
||||
customerData.shippingAddress,
|
||||
newCustomerData.shippingAddress
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// Update local cache of customer data so the next time this runs, it can compare against the latest data.
|
||||
customerData = newCustomerData;
|
||||
|
||||
// Trigger the update if we have any dirty props.
|
||||
if (
|
||||
dirtyProps.billingAddress.length ||
|
||||
dirtyProps.shippingAddress.length
|
||||
isShallowEqual(
|
||||
localState.customerData,
|
||||
select( STORE_KEY ).getCustomerData()
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( debounced ) {
|
||||
debouncedUpdateCustomerData();
|
||||
} else {
|
||||
updateCustomerData();
|
||||
}
|
||||
};
|
||||
|
||||
// Cancel the debounced updateCustomerData function and trigger it immediately.
|
||||
export const flushChanges = (): void => {
|
||||
updateCustomerData.flush();
|
||||
debouncedUpdateCustomerData.flush();
|
||||
};
|
||||
|
||||
@@ -48,15 +48,6 @@ const reducer: Reducer< CartState > = (
|
||||
action: Partial< CartAction >
|
||||
) => {
|
||||
switch ( action.type ) {
|
||||
case types.SET_FULL_SHIPPING_ADDRESS_PUSHED:
|
||||
state = {
|
||||
...state,
|
||||
metaData: {
|
||||
...state.metaData,
|
||||
fullShippingAddressPushed: action.fullShippingAddressPushed,
|
||||
},
|
||||
};
|
||||
break;
|
||||
case types.SET_ERROR_DATA:
|
||||
if ( action.error ) {
|
||||
state = {
|
||||
|
||||
@@ -9,7 +9,6 @@ import { CartResponse } from '@woocommerce/types';
|
||||
*/
|
||||
import { CART_API_ERROR } from './constants';
|
||||
import type { CartDispatchFromMap, CartResolveSelectFromMap } from './index';
|
||||
import { shippingAddressHasValidationErrors } from './utils';
|
||||
|
||||
/**
|
||||
* Resolver for retrieving all cart data.
|
||||
@@ -28,10 +27,6 @@ export const getCartData =
|
||||
receiveError( CART_API_ERROR );
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! shippingAddressHasValidationErrors() ) {
|
||||
dispatch.setFullShippingAddressPushed( true );
|
||||
}
|
||||
receiveCart( cartData );
|
||||
};
|
||||
|
||||
|
||||
@@ -222,10 +222,3 @@ export const getItemsPendingQuantityUpdate = ( state: CartState ): string[] => {
|
||||
export const getItemsPendingDelete = ( state: CartState ): string[] => {
|
||||
return state.cartItemsPendingDelete;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the address has changes that have not been synced with the server.
|
||||
*/
|
||||
export const getFullShippingAddressPushed = ( state: CartState ): boolean => {
|
||||
return state.metaData.fullShippingAddressPushed;
|
||||
};
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
*/
|
||||
import { dispatch, select } from '@wordpress/data';
|
||||
import { previewCart } from '@woocommerce/resource-previews';
|
||||
import { camelCase, cloneDeep, mapKeys } from 'lodash';
|
||||
import { Cart, CartResponse } from '@woocommerce/types';
|
||||
import { Cart } from '@woocommerce/types';
|
||||
import { camelCaseKeys } from '@woocommerce/base-utils';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { notifyQuantityChanges } from '../notify-quantity-changes';
|
||||
|
||||
// Deep clone an object to avoid mutating it later.
|
||||
const cloneObject = ( obj ) => JSON.parse( JSON.stringify( obj ) );
|
||||
|
||||
jest.mock( '@wordpress/data' );
|
||||
|
||||
const mockedCreateInfoNotice = jest.fn();
|
||||
@@ -34,14 +37,8 @@ select.mockImplementation( () => {
|
||||
* Clones the preview cart and turns it into a `Cart`.
|
||||
*/
|
||||
const getFreshCarts = (): { oldCart: Cart; newCart: Cart } => {
|
||||
const oldCart = mapKeys(
|
||||
cloneDeep< CartResponse >( previewCart ),
|
||||
( _, key ) => camelCase( key )
|
||||
) as unknown as Cart;
|
||||
const newCart = mapKeys(
|
||||
cloneDeep< CartResponse >( previewCart ),
|
||||
( _, key ) => camelCase( key )
|
||||
) as unknown as Cart;
|
||||
const oldCart = camelCaseKeys( cloneObject( previewCart ) ) as Cart;
|
||||
const newCart = camelCaseKeys( cloneObject( previewCart ) ) as Cart;
|
||||
return { oldCart, newCart };
|
||||
};
|
||||
|
||||
|
||||
@@ -45,17 +45,10 @@ jest.mock( '@wordpress/data', () => ( {
|
||||
dispatch: jest.fn(),
|
||||
} ) );
|
||||
|
||||
// Mocking lodash here so we can just call the debounced function directly without waiting for debounce.
|
||||
jest.mock( 'lodash', () => ( {
|
||||
...jest.requireActual( 'lodash' ),
|
||||
__esModule: true,
|
||||
debounce: jest.fn( ( callback ) => callback ),
|
||||
} ) );
|
||||
|
||||
// Mocking processErrorResponse because we don't actually care about processing the error response, we just don't want
|
||||
// pushChanges to throw an error.
|
||||
jest.mock( '../utils', () => ( {
|
||||
...jest.requireActual( '../utils' ),
|
||||
jest.mock( '../../utils', () => ( {
|
||||
...jest.requireActual( '../../utils' ),
|
||||
__esModule: true,
|
||||
processErrorResponse: jest.fn(),
|
||||
} ) );
|
||||
@@ -84,7 +77,7 @@ describe( 'pushChanges', () => {
|
||||
...jest
|
||||
.requireActual( '@wordpress/data' )
|
||||
.select( storeName ),
|
||||
getValidationError: () => undefined,
|
||||
getValidationError: jest.fn().mockReturnValue( undefined ),
|
||||
};
|
||||
}
|
||||
return jest.requireActual( '@wordpress/data' ).select( storeName );
|
||||
@@ -105,7 +98,7 @@ describe( 'pushChanges', () => {
|
||||
} );
|
||||
it( 'Keeps props dirty if data did not persist due to an error', async () => {
|
||||
// Run this without changing anything because the first run does not push data (the first run is populating what was received on page load).
|
||||
pushChanges();
|
||||
pushChanges( false );
|
||||
|
||||
// Mock the returned value of `getCustomerData` to simulate a change in the shipping address.
|
||||
getCustomerDataMock.mockReturnValue( {
|
||||
@@ -135,7 +128,7 @@ describe( 'pushChanges', () => {
|
||||
} );
|
||||
|
||||
// Push these changes to the server, the `updateCustomerData` mock is set to reject (in the original mock at the top of the file), to simulate a server error.
|
||||
pushChanges();
|
||||
pushChanges( false );
|
||||
|
||||
// Check that the mock was called with only the updated data.
|
||||
await expect( updateCustomerDataMock ).toHaveBeenCalledWith( {
|
||||
@@ -182,7 +175,7 @@ describe( 'pushChanges', () => {
|
||||
|
||||
// Although only one property was updated between calls, we should expect City, State, and Postcode to be pushed
|
||||
// to the server because the previous push failed when they were originally changed.
|
||||
pushChanges();
|
||||
pushChanges( false );
|
||||
await expect( updateCustomerDataMock ).toHaveBeenLastCalledWith( {
|
||||
shipping_address: {
|
||||
city: 'Houston',
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
ApiErrorResponse,
|
||||
isApiErrorResponse,
|
||||
} from '@woocommerce/types';
|
||||
import { camelCase, mapKeys } from 'lodash';
|
||||
import { camelCaseKeys } from '@woocommerce/base-utils';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
@@ -31,9 +31,7 @@ export const receiveCart =
|
||||
dispatch: CartDispatchFromMap;
|
||||
select: CartSelectFromMap;
|
||||
} ) => {
|
||||
const newCart = mapKeys( response, ( _, key ) =>
|
||||
camelCase( key )
|
||||
) as unknown as Cart;
|
||||
const newCart = camelCaseKeys( response ) as unknown as Cart;
|
||||
const oldCart = select.getCartData();
|
||||
notifyCartErrors( newCart.errors, oldCart.errors );
|
||||
notifyQuantityChanges( {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* External dependencies
|
||||
*/
|
||||
import { dispatch, select } from '@wordpress/data';
|
||||
import { debounce } from 'lodash';
|
||||
import { debounce } from '@woocommerce/base-utils';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { camelCase, mapKeys } from 'lodash';
|
||||
import { Cart, CartResponse } from '@woocommerce/types';
|
||||
import { select } from '@wordpress/data';
|
||||
import { camelCaseKeys } from '@woocommerce/base-utils';
|
||||
import { isEmail } from '@wordpress/url';
|
||||
import {
|
||||
CartBillingAddress,
|
||||
CartShippingAddress,
|
||||
Cart,
|
||||
CartResponse,
|
||||
} from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
@@ -11,9 +17,7 @@ import { select } from '@wordpress/data';
|
||||
import { STORE_KEY as VALIDATION_STORE_KEY } from '../validation/constants';
|
||||
|
||||
export const mapCartResponseToCart = ( responseCart: CartResponse ): Cart => {
|
||||
return mapKeys( responseCart, ( _, key ) =>
|
||||
camelCase( key )
|
||||
) as unknown as Cart;
|
||||
return camelCaseKeys( responseCart ) as unknown as Cart;
|
||||
};
|
||||
|
||||
export const shippingAddressHasValidationErrors = () => {
|
||||
@@ -38,3 +42,77 @@ export const shippingAddressHasValidationErrors = () => {
|
||||
postcodeValidationErrors,
|
||||
].some( ( entry ) => typeof entry !== 'undefined' );
|
||||
};
|
||||
|
||||
export type BaseAddressKey =
|
||||
| keyof CartBillingAddress
|
||||
| keyof CartShippingAddress;
|
||||
|
||||
/**
|
||||
* Normalizes address values before push.
|
||||
*/
|
||||
export const normalizeAddressProp = (
|
||||
key: BaseAddressKey,
|
||||
value?: string | undefined
|
||||
) => {
|
||||
// Skip normalizing for any non string field
|
||||
if ( typeof value !== 'string' ) {
|
||||
return value;
|
||||
}
|
||||
if ( key === 'email' ) {
|
||||
return isEmail( value ) ? value.trim() : '';
|
||||
}
|
||||
if ( key === 'postcode' ) {
|
||||
return value.replace( ' ', '' ).toUpperCase();
|
||||
}
|
||||
return value.trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* Compares two address objects and returns an array of keys that have changed.
|
||||
*/
|
||||
export const getDirtyKeys = <
|
||||
T extends CartBillingAddress & CartShippingAddress
|
||||
>(
|
||||
// An object containing all previous address information
|
||||
previousAddress: Partial< T >,
|
||||
// An object containing all address information.
|
||||
address: Partial< T >
|
||||
): BaseAddressKey[] => {
|
||||
const previousAddressKeys = Object.keys(
|
||||
previousAddress
|
||||
) as BaseAddressKey[];
|
||||
|
||||
return previousAddressKeys.filter( ( key: BaseAddressKey ) => {
|
||||
return (
|
||||
normalizeAddressProp( key, previousAddress[ key ] ) !==
|
||||
normalizeAddressProp( key, address[ key ] )
|
||||
);
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates dirty props before push.
|
||||
*/
|
||||
export const validateDirtyProps = ( dirtyProps: {
|
||||
billingAddress: BaseAddressKey[];
|
||||
shippingAddress: BaseAddressKey[];
|
||||
} ): boolean => {
|
||||
const validationStore = select( VALIDATION_STORE_KEY );
|
||||
|
||||
const invalidProps = [
|
||||
...dirtyProps.billingAddress.filter( ( key ) => {
|
||||
return (
|
||||
validationStore.getValidationError( 'billing_' + key ) !==
|
||||
undefined
|
||||
);
|
||||
} ),
|
||||
...dirtyProps.shippingAddress.filter( ( key ) => {
|
||||
return (
|
||||
validationStore.getValidationError( 'shipping_' + key ) !==
|
||||
undefined
|
||||
);
|
||||
} ),
|
||||
].filter( Boolean );
|
||||
|
||||
return invalidProps.length === 0;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
CanMakePaymentArgument,
|
||||
ExpressPaymentMethodConfigInstance,
|
||||
PaymentMethodConfigInstance,
|
||||
} from '@woocommerce/types';
|
||||
@@ -31,54 +32,12 @@ import {
|
||||
} from '../../../data/constants';
|
||||
import { defaultCartState } from '../../../data/cart/default-state';
|
||||
|
||||
const registrationErrorNotice = (
|
||||
paymentMethod:
|
||||
| ExpressPaymentMethodConfigInstance
|
||||
| PaymentMethodConfigInstance,
|
||||
errorMessage: string,
|
||||
express = false
|
||||
) => {
|
||||
const { createErrorNotice } = dispatch( 'core/notices' );
|
||||
const noticeContext = express
|
||||
? noticeContexts.EXPRESS_PAYMENTS
|
||||
: noticeContexts.PAYMENTS;
|
||||
const errorText = sprintf(
|
||||
/* translators: %s the id of the payment method being registered (bank transfer, cheque...) */
|
||||
__(
|
||||
`There was an error registering the payment method with id '%s': `,
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
paymentMethod.paymentMethodId
|
||||
);
|
||||
createErrorNotice( `${ errorText } ${ errorMessage }`, {
|
||||
context: noticeContext,
|
||||
id: `wc-${ paymentMethod.paymentMethodId }-registration-error`,
|
||||
} );
|
||||
};
|
||||
|
||||
export const checkPaymentMethodsCanPay = async ( express = false ) => {
|
||||
/**
|
||||
* Get the argument that will be passed to a payment method's `canMakePayment` method.
|
||||
*/
|
||||
export const getCanMakePaymentArg = (): CanMakePaymentArgument => {
|
||||
const isEditor = !! select( 'core/editor' );
|
||||
|
||||
let availablePaymentMethods = {};
|
||||
|
||||
const paymentMethods = express
|
||||
? getExpressPaymentMethods()
|
||||
: getPaymentMethods();
|
||||
|
||||
const addAvailablePaymentMethod = (
|
||||
paymentMethod:
|
||||
| PaymentMethodConfigInstance
|
||||
| ExpressPaymentMethodConfigInstance
|
||||
) => {
|
||||
const { name } = paymentMethod;
|
||||
availablePaymentMethods = {
|
||||
...availablePaymentMethods,
|
||||
[ paymentMethod.name ]: { name },
|
||||
};
|
||||
};
|
||||
|
||||
let cartForCanPayArgument: Record< string, unknown > = {};
|
||||
let canPayArgument: Record< string, unknown > = {};
|
||||
let canPayArgument: CanMakePaymentArgument;
|
||||
|
||||
if ( ! isEditor ) {
|
||||
const store = select( CART_STORE_KEY );
|
||||
@@ -91,7 +50,7 @@ export const checkPaymentMethodsCanPay = async ( express = false ) => {
|
||||
cart.shippingRates
|
||||
);
|
||||
|
||||
cartForCanPayArgument = {
|
||||
const cartForCanPayArgument = {
|
||||
cartCoupons: cart.coupons,
|
||||
cartItems: cart.items,
|
||||
crossSellsProducts: cart.crossSells,
|
||||
@@ -126,7 +85,7 @@ export const checkPaymentMethodsCanPay = async ( express = false ) => {
|
||||
paymentRequirements: cart.paymentRequirements,
|
||||
};
|
||||
} else {
|
||||
cartForCanPayArgument = {
|
||||
const cartForCanPayArgument = {
|
||||
cartCoupons: previewCart.coupons,
|
||||
cartItems: previewCart.items,
|
||||
crossSellsProducts: previewCart.cross_sells,
|
||||
@@ -151,8 +110,8 @@ export const checkPaymentMethodsCanPay = async ( express = false ) => {
|
||||
};
|
||||
canPayArgument = {
|
||||
cart: cartForCanPayArgument,
|
||||
cartTotals: cartForCanPayArgument.totals,
|
||||
cartNeedsShipping: cartForCanPayArgument.needsShipping,
|
||||
cartTotals: cartForCanPayArgument.cartTotals,
|
||||
cartNeedsShipping: cartForCanPayArgument.cartNeedsShipping,
|
||||
billingData: cartForCanPayArgument.billingAddress,
|
||||
billingAddress: cartForCanPayArgument.billingAddress,
|
||||
shippingAddress: cartForCanPayArgument.shippingAddress,
|
||||
@@ -164,16 +123,65 @@ export const checkPaymentMethodsCanPay = async ( express = false ) => {
|
||||
};
|
||||
}
|
||||
|
||||
return canPayArgument;
|
||||
};
|
||||
|
||||
const registrationErrorNotice = (
|
||||
paymentMethod:
|
||||
| ExpressPaymentMethodConfigInstance
|
||||
| PaymentMethodConfigInstance,
|
||||
errorMessage: string,
|
||||
express = false
|
||||
) => {
|
||||
const { createErrorNotice } = dispatch( 'core/notices' );
|
||||
const noticeContext = express
|
||||
? noticeContexts.EXPRESS_PAYMENTS
|
||||
: noticeContexts.PAYMENTS;
|
||||
const errorText = sprintf(
|
||||
/* translators: %s the id of the payment method being registered (bank transfer, cheque...) */
|
||||
__(
|
||||
`There was an error registering the payment method with id '%s': `,
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
paymentMethod.paymentMethodId
|
||||
);
|
||||
createErrorNotice( `${ errorText } ${ errorMessage }`, {
|
||||
context: noticeContext,
|
||||
id: `wc-${ paymentMethod.paymentMethodId }-registration-error`,
|
||||
} );
|
||||
};
|
||||
|
||||
export const checkPaymentMethodsCanPay = async ( express = false ) => {
|
||||
let availablePaymentMethods = {};
|
||||
|
||||
const paymentMethods = express
|
||||
? getExpressPaymentMethods()
|
||||
: getPaymentMethods();
|
||||
|
||||
const addAvailablePaymentMethod = (
|
||||
paymentMethod:
|
||||
| PaymentMethodConfigInstance
|
||||
| ExpressPaymentMethodConfigInstance
|
||||
) => {
|
||||
const { name } = paymentMethod;
|
||||
availablePaymentMethods = {
|
||||
...availablePaymentMethods,
|
||||
[ paymentMethod.name ]: { name },
|
||||
};
|
||||
};
|
||||
|
||||
// Order payment methods.
|
||||
const paymentMethodsOrder = express
|
||||
? Object.keys( paymentMethods )
|
||||
: Array.from(
|
||||
new Set( [
|
||||
...( getSetting( 'paymentGatewaySortOrder', [] ) as [] ),
|
||||
...( getSetting( 'paymentMethodSortOrder', [] ) as [] ),
|
||||
...Object.keys( paymentMethods ),
|
||||
] )
|
||||
);
|
||||
const canPayArgument = getCanMakePaymentArg();
|
||||
const cartPaymentMethods = canPayArgument.paymentMethods as string[];
|
||||
const isEditor = !! select( 'core/editor' );
|
||||
|
||||
for ( let i = 0; i < paymentMethodsOrder.length; i++ ) {
|
||||
const paymentMethodName = paymentMethodsOrder[ i ];
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { has } from 'lodash';
|
||||
|
||||
/**
|
||||
* Utility for returning whether the given path exists in the state.
|
||||
*
|
||||
* @param {Object} state The state being checked
|
||||
* @param {Array} path The path to check
|
||||
*
|
||||
* @return {boolean} True means this exists in the state.
|
||||
*/
|
||||
export default function hasInState( state, path ) {
|
||||
return has( state, path );
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
const has = ( obj: Record< string, unknown >, path: string[] ): boolean => {
|
||||
return (
|
||||
!! path &&
|
||||
!! path.reduce< unknown >(
|
||||
( prevObj, key ) =>
|
||||
typeof prevObj === 'object' && prevObj !== null
|
||||
? ( prevObj as Record< string, unknown > )[ key ]
|
||||
: undefined,
|
||||
obj
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility for returning whether the given path exists in the state.
|
||||
*
|
||||
* @param {Object} state The state being checked
|
||||
* @param {Array} path The path to check
|
||||
*
|
||||
* @return {boolean} True means this exists in the state.
|
||||
*/
|
||||
export default function hasInState(
|
||||
state: Record< string, unknown >,
|
||||
path: string[]
|
||||
): boolean {
|
||||
return has( state, path );
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { setWith, clone } from 'lodash';
|
||||
|
||||
/**
|
||||
* Utility for updating state and only cloning objects in the path that changed.
|
||||
*
|
||||
* @param {Object} state The state being updated
|
||||
* @param {Array} path The path being updated
|
||||
* @param {*} value The value to update for the path
|
||||
*
|
||||
* @return {Object} The new state
|
||||
*/
|
||||
export default function updateState( state, path, value ) {
|
||||
return setWith( clone( state ), path, value, clone );
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Utility for updating nested state in the path that changed.
|
||||
*/
|
||||
function updateNested< T >( // The state being updated
|
||||
state: T,
|
||||
// The path being updated
|
||||
path: string[],
|
||||
// The value to update for the path
|
||||
value: unknown,
|
||||
// The current index in the path
|
||||
index = 0
|
||||
): T {
|
||||
const key = path[ index ] as keyof T;
|
||||
if ( index === path.length - 1 ) {
|
||||
return { ...state, [ key ]: value };
|
||||
}
|
||||
|
||||
const nextState = state[ key ] || {};
|
||||
return {
|
||||
...state,
|
||||
[ key ]: updateNested( nextState, path, value, index + 1 ),
|
||||
} as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility for updating state and only cloning objects in the path that changed.
|
||||
*/
|
||||
export default function updateState< T >(
|
||||
// The state being updated
|
||||
state: T,
|
||||
// The path being updated
|
||||
path: string[],
|
||||
// The value to update for the path
|
||||
value: unknown
|
||||
): T {
|
||||
return updateNested( state, path, value );
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
* External dependencies
|
||||
*/
|
||||
import type { Reducer } from 'redux';
|
||||
import { pickBy } from 'lodash';
|
||||
import isShallowEqual from '@wordpress/is-shallow-equal';
|
||||
import { isString, FieldValidationStatus } from '@woocommerce/types';
|
||||
|
||||
@@ -19,16 +18,24 @@ const reducer: Reducer< Record< string, FieldValidationStatus > > = (
|
||||
const newState = { ...state };
|
||||
switch ( action.type ) {
|
||||
case types.SET_VALIDATION_ERRORS:
|
||||
const newErrors = pickBy( action.errors, ( error, property ) => {
|
||||
if ( typeof error?.message !== 'string' ) {
|
||||
return false;
|
||||
if ( ! action.errors ) {
|
||||
return state;
|
||||
}
|
||||
const hasNewError = Object.entries( action.errors ).some(
|
||||
( [ property, error ] ) => {
|
||||
if ( typeof error?.message !== 'string' ) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
state.hasOwnProperty( property ) &&
|
||||
isShallowEqual( state[ property ], error )
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if ( state.hasOwnProperty( property ) ) {
|
||||
return ! isShallowEqual( state[ property ], error );
|
||||
}
|
||||
return true;
|
||||
} );
|
||||
if ( Object.values( newErrors ).length === 0 ) {
|
||||
);
|
||||
if ( ! hasNewError ) {
|
||||
return state;
|
||||
}
|
||||
return { ...state, ...action.errors };
|
||||
|
||||
Reference in New Issue
Block a user