Customer Data Store and revised handling for Shipping As Billing (https://github.com/woocommerce/woocommerce-blocks/pull/5817)

* Add address-related items to wc/store/cart data store

* include shippingAsBilling in return value of useCustomerData

* Add useUpdateCustomerData hook

This allows us to have a single hook responsible for updating the customer information on the server.

* Add useUpdateCustomerData hook in Checkout block

* Remove the updating customer data work from the useCustomerData hook

* Remove shippingAsBilling from previousCustomerData ref type

* Add useShippingAsBillingCheckbox hook

* Control shippingAsBilling from single hook

* Remove checkbox handling from useCheckoutAddress

* Remove CustomerDataContext typedef

* Merge with woocommerce/woocommerce-blocks#5810 changes

* Move shipping as billing to checkout state context provider

* Unused import

* Subscribe to changes

* Only receiveCartContents when updating customer data via checkout

* Cache customerDataToUpdate

* rename debounced function

* Combine customerDataType and customerDataContextType

* Change case of CustomerDataType

* debouncedUpdateCustomerData typo

* Fix notice context

* Clean up inline docs for push changes

* Comment on dirty state

* Phone is always set

* shippingAddress is never undefined

* setBillingPhone

* receiveCartContents explanation

* Tweak customerData to avoid null

* useShippingAsBilling

Co-authored-by: Thomas Roberts <thomas.roberts@automattic.com>
This commit is contained in:
Mike Jolley 2022-02-22 17:45:01 +00:00 committed by GitHub
parent a02e79ea38
commit 0714fa41bd
26 changed files with 570 additions and 516 deletions

View File

@ -83,26 +83,26 @@ describe( 'AddressForm Component', () => {
const WrappedAddressForm = ( { type } ) => {
const {
defaultAddressFields,
setShippingFields,
shippingFields,
setShippingAddress,
shippingAddress,
} = useCheckoutAddress();
return (
<AddressForm
type={ type }
onChange={ setShippingFields }
values={ shippingFields }
onChange={ setShippingAddress }
values={ shippingAddress }
fields={ Object.keys( defaultAddressFields ) }
/>
);
};
const ShippingFields = () => {
const { shippingFields } = useCheckoutAddress();
const { shippingAddress } = useCheckoutAddress();
return (
<ul>
{ Object.keys( shippingFields ).map( ( key ) => (
<li key={ key }>{ key + ': ' + shippingFields[ key ] }</li>
{ Object.keys( shippingAddress ).map( ( key ) => (
<li key={ key }>{ key + ': ' + shippingAddress[ key ] }</li>
) ) }
</ul>
);

View File

@ -1,125 +0,0 @@
/**
* External dependencies
*/
import { defaultAddressFields } from '@woocommerce/settings';
import { useEffect, useCallback, useRef } from '@wordpress/element';
/**
* Internal dependencies
*/
import {
useShippingDataContext,
useCustomerDataContext,
} from '../providers/cart-checkout';
/**
* Custom hook for exposing address related functionality for the checkout address form.
*/
export const useCheckoutAddress = () => {
const { needsShipping } = useShippingDataContext();
const {
billingData,
setBillingData,
shippingAddress,
setShippingAddress,
shippingAsBilling,
setShippingAsBilling,
} = useCustomerDataContext();
const currentShippingAsBilling = useRef( shippingAsBilling );
const previousBillingData = useRef();
/**
* Sets shipping address data, and also billing if using the same address.
*/
const setShippingFields = useCallback(
( value ) => {
setShippingAddress( value );
if ( shippingAsBilling ) {
setBillingData( value );
}
},
[ shippingAsBilling, setShippingAddress, setBillingData ]
);
/**
* Sets billing address data, and also shipping if shipping is disabled.
*/
const setBillingFields = useCallback(
( value ) => {
setBillingData( value );
if ( ! needsShipping ) {
setShippingAddress( value );
}
},
[ needsShipping, setShippingAddress, setBillingData ]
);
// When the "Use same address" checkbox is toggled we need to update the current billing address to reflect this.
// This either sets the billing address to the shipping address, or restores the billing address to it's previous state.
useEffect( () => {
if ( currentShippingAsBilling.current !== shippingAsBilling ) {
if ( shippingAsBilling ) {
previousBillingData.current = billingData;
setBillingData( shippingAddress );
} else {
const {
// We need to pluck out email from previous billing data because they can be empty, causing the current email to get emptied. See issue #4155
/* eslint-disable no-unused-vars */
email,
/* eslint-enable no-unused-vars */
...billingAddress
} = previousBillingData.current || billingData;
setBillingData( {
...billingAddress,
} );
}
currentShippingAsBilling.current = shippingAsBilling;
}
}, [ shippingAsBilling, setBillingData, shippingAddress, billingData ] );
const setEmail = useCallback(
( value ) =>
void setBillingData( {
email: value,
} ),
[ setBillingData ]
);
const setPhone = useCallback(
( value ) =>
void setBillingData( {
phone: value,
} ),
[ setBillingData ]
);
const setShippingPhone = useCallback(
( value ) =>
void setShippingFields( {
phone: value,
} ),
[ setShippingFields ]
);
// Note that currentShippingAsBilling is returned rather than the current state of shippingAsBilling--this is so that
// the billing fields are not rendered before sync (billing field values are debounced and would be outdated)
return {
defaultAddressFields,
shippingFields: shippingAddress,
setShippingFields,
billingFields: billingData,
setBillingFields,
setEmail,
setPhone,
setShippingPhone,
shippingAsBilling,
setShippingAsBilling,
showShippingFields: needsShipping,
showBillingFields:
! needsShipping || ! currentShippingAsBilling.current,
};
};

View File

@ -0,0 +1,91 @@
/**
* External dependencies
*/
import {
defaultAddressFields,
AddressFields,
EnteredAddress,
ShippingAddress,
BillingAddress,
} from '@woocommerce/settings';
import { useCallback } from '@wordpress/element';
/**
* Internal dependencies
*/
import {
useShippingDataContext,
useCheckoutContext,
} from '../providers/cart-checkout';
import { useCustomerData } from './use-customer-data';
interface CheckoutAddress {
shippingAddress: ShippingAddress;
billingData: BillingAddress;
setShippingAddress: ( data: Partial< EnteredAddress > ) => void;
setBillingData: ( data: Partial< EnteredAddress > ) => void;
setEmail: ( value: string ) => void;
setBillingPhone: ( value: string ) => void;
setShippingPhone: ( value: string ) => void;
useShippingAsBilling: boolean;
setUseShippingAsBilling: ( useShippingAsBilling: boolean ) => void;
defaultAddressFields: AddressFields;
showShippingFields: boolean;
showBillingFields: boolean;
}
/**
* Custom hook for exposing address related functionality for the checkout address form.
*/
export const useCheckoutAddress = (): CheckoutAddress => {
const { needsShipping } = useShippingDataContext();
const {
useShippingAsBilling,
setUseShippingAsBilling,
} = useCheckoutContext();
const {
billingData,
setBillingData,
shippingAddress,
setShippingAddress,
} = useCustomerData();
const setEmail = useCallback(
( value ) =>
void setBillingData( {
email: value,
} ),
[ setBillingData ]
);
const setBillingPhone = useCallback(
( value ) =>
void setBillingData( {
phone: value,
} ),
[ setBillingData ]
);
const setShippingPhone = useCallback(
( value ) =>
void setShippingAddress( {
phone: value,
} ),
[ setShippingAddress ]
);
return {
shippingAddress,
billingData,
setShippingAddress,
setBillingData,
setEmail,
setBillingPhone,
setShippingPhone,
defaultAddressFields,
useShippingAsBilling,
setUseShippingAsBilling,
showShippingFields: needsShipping,
showBillingFields: ! needsShipping || ! useShippingAsBilling,
};
};

View File

@ -1,225 +1,33 @@
/**
* External dependencies
*/
import { useDispatch } from '@wordpress/data';
import { useEffect, useState, useCallback, useRef } from '@wordpress/element';
import { useDispatch, useSelect } from '@wordpress/data';
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { useDebouncedCallback } from 'use-debounce';
import isShallowEqual from '@wordpress/is-shallow-equal';
import {
formatStoreApiErrorMessage,
pluckAddress,
pluckEmail,
} from '@woocommerce/base-utils';
import {
CartResponseBillingAddress,
CartResponseShippingAddress,
BillingAddressShippingAddress,
} from '@woocommerce/types';
import type { BillingAddress, ShippingAddress } from '@woocommerce/settings';
declare type CustomerData = {
billingData: CartResponseBillingAddress;
shippingAddress: CartResponseShippingAddress;
};
/**
* Internal dependencies
*/
import { useStoreCart } from './cart/use-store-cart';
import { useStoreNotices } from './use-store-notices';
function instanceOfCartResponseBillingAddress(
address: CartResponseBillingAddress | CartResponseShippingAddress
): address is CartResponseBillingAddress {
return 'email' in address;
export interface CustomerDataType {
isInitialized: boolean;
billingData: BillingAddress;
shippingAddress: ShippingAddress;
setBillingData: ( data: Partial< BillingAddress > ) => void;
setShippingAddress: ( data: Partial< ShippingAddress > ) => void;
}
/**
* Does a shallow compare of important address data to determine if the cart needs updating on the server.
*
* This takes the current and previous address into account, as well as the billing email field.
*
* @param {Object} previousAddress An object containing all previous address information
* @param {Object} address An object containing all address information
*
* @return {boolean} True if the store needs updating due to changed data.
*/
const shouldUpdateAddressStore = <
T extends CartResponseBillingAddress | CartResponseShippingAddress
>(
previousAddress: T,
address: T
): boolean => {
if (
instanceOfCartResponseBillingAddress( address ) &&
pluckEmail( address ) !==
pluckEmail( previousAddress as CartResponseBillingAddress )
) {
return true;
}
return (
!! address.country &&
! isShallowEqual(
pluckAddress( previousAddress ),
pluckAddress( address )
)
);
};
/**
* This is a custom hook for syncing customer address data (billing and shipping) with the server.
*/
export const useCustomerData = (): {
billingData: CartResponseBillingAddress;
shippingAddress: CartResponseShippingAddress;
setBillingData: ( data: CartResponseBillingAddress ) => void;
setShippingAddress: ( data: CartResponseShippingAddress ) => void;
} => {
const { updateCustomerData } = useDispatch( storeKey );
const { addErrorNotice, removeNotice } = useStoreNotices();
// Grab the initial values from the store cart hook.
// NOTE: The initial values may not be current if the cart has not yet finished loading. See cartIsLoading.
const {
billingAddress: initialBillingAddress,
shippingAddress: initialShippingAddress,
cartIsLoading,
} = useStoreCart();
// We only want to update the local state once, otherwise the data on the checkout page gets overwritten
// with the initial state of the addresses. We also only want to start triggering updates to the server when the
// initial data has fully initialized. Track that header.
const [ isInitialized, setIsInitialized ] = useState< boolean >( false );
// State of customer data is tracked here from this point, using the initial values from the useStoreCart hook.
const [ customerData, setCustomerData ] = useState< CustomerData >( {
billingData: initialBillingAddress,
shippingAddress: initialShippingAddress,
} );
// Store values last sent to the server in a ref to avoid requests unless important fields are changed.
const previousCustomerData = useRef< CustomerData >( customerData );
// When the cart data is resolved from server for the first time (using cartIsLoading) we need to update
// the initial billing and shipping values to respect customer data from the server.
useEffect( () => {
if ( isInitialized || cartIsLoading ) {
return;
}
const initializedCustomerData = {
billingData: initialBillingAddress,
shippingAddress: initialShippingAddress,
export const useCustomerData = (): CustomerDataType => {
const { customerData, isInitialized } = useSelect( ( select ) => {
const store = select( storeKey );
return {
customerData: store.getCustomerData(),
isInitialized: store.hasFinishedResolution( 'getCartData' ),
};
// Updates local state to the now-resolved cart address.
previousCustomerData.current = initializedCustomerData;
setCustomerData( initializedCustomerData );
// We are now initialized.
setIsInitialized( true );
}, [
cartIsLoading,
isInitialized,
initialBillingAddress,
initialShippingAddress,
] );
/**
* Set billing data.
*
* Callback used to set billing data for the customer. This merges the previous and new state, and in turn,
* will trigger an update to the server if enough data has changed (see the useEffect call below).
*
* This callback contains special handling for the "email" address field so that field is never overwritten if
* simply updating the billing address and not the email address.
*/
const setBillingData = useCallback( ( newData ) => {
setCustomerData( ( prevState ) => {
return {
...prevState,
billingData: {
...prevState.billingData,
...newData,
},
};
} );
}, [] );
/**
* Set shipping address.
*
* Callback used to set shipping data for the customer. This merges the previous and new state, and in turn, will
* trigger an update to the server if enough data has changed (see the useEffect call below).
*/
const setShippingAddress = useCallback( ( newData ) => {
setCustomerData( ( prevState ) => {
return {
...prevState,
shippingAddress: {
...prevState.shippingAddress,
...newData,
},
};
} );
}, [] );
/**
* This pushes changes to the API when the local state differs from the address in the cart.
*
* The function shouldUpdateAddressStore() determines if enough data has changed to trigger the update.
*/
const pushCustomerData = () => {
const customerDataToUpdate: Partial< BillingAddressShippingAddress > = {};
if (
shouldUpdateAddressStore(
previousCustomerData.current.billingData,
customerData.billingData
)
) {
customerDataToUpdate.billing_address = customerData.billingData;
}
if (
shouldUpdateAddressStore(
previousCustomerData.current.shippingAddress,
customerData.shippingAddress
)
) {
customerDataToUpdate.shipping_address =
customerData.shippingAddress;
}
if ( Object.keys( customerDataToUpdate ).length === 0 ) {
return;
}
previousCustomerData.current = customerData;
updateCustomerData( customerDataToUpdate )
.then( () => {
removeNotice( 'checkout' );
} )
.catch( ( response ) => {
addErrorNotice( formatStoreApiErrorMessage( response ), {
id: 'checkout',
} );
} );
};
const debouncedPushCustomerData = useDebouncedCallback(
pushCustomerData,
1000
);
// If data changes, trigger an update to the server only if initialized.
useEffect( () => {
if ( ! isInitialized ) {
return;
}
debouncedPushCustomerData();
}, [ isInitialized, customerData, debouncedPushCustomerData ] );
} );
const { setShippingAddress, setBillingData } = useDispatch( storeKey );
return {
isInitialized,
billingData: customerData.billingData,
shippingAddress: customerData.shippingAddress,
setBillingData,

View File

@ -19,6 +19,7 @@ export enum ACTION {
SET_ORDER_NOTES = 'set_checkout_order_notes',
INCREMENT_CALCULATING = 'increment_calculating',
DECREMENT_CALCULATING = 'decrement_calculating',
SET_SHIPPING_ADDRESS_AS_BILLING_ADDRESS = 'set_shipping_address_as_billing_address',
SET_SHOULD_CREATE_ACCOUNT = 'set_should_create_account',
SET_EXTENSION_DATA = 'set_extension_data',
}
@ -92,6 +93,11 @@ export const actions = {
type: ACTION.SET_ORDER_ID,
orderId,
} as const ),
setUseShippingAsBilling: ( useShippingAsBilling: boolean ) =>
( {
type: ACTION.SET_SHIPPING_ADDRESS_AS_BILLING_ADDRESS,
useShippingAsBilling,
} as const ),
setShouldCreateAccount: ( shouldCreateAccount: boolean ) =>
( {
type: ACTION.SET_SHOULD_CREATE_ACCOUNT,

View File

@ -1,7 +1,8 @@
/**
* External dependencies
*/
import { getSetting } from '@woocommerce/settings';
import { getSetting, EnteredAddress } from '@woocommerce/settings';
import { isSameAddress } from '@woocommerce/base-utils';
/**
* Internal dependencies
@ -34,6 +35,8 @@ const preloadedCheckoutData = getSetting( 'checkoutData', {} ) as Record<
const checkoutData = {
order_id: 0,
customer_id: 0,
billing_address: {} as EnteredAddress,
shipping_address: {} as EnteredAddress,
...( preloadedCheckoutData || {} ),
};
@ -68,6 +71,8 @@ export const DEFAULT_CHECKOUT_STATE_DATA: CheckoutStateContextType = {
onCheckoutValidationBeforeProcessing: () => () => void null,
hasOrder: false,
isCart: false,
useShippingAsBilling: false,
setUseShippingAsBilling: ( value ) => void value,
shouldCreateAccount: false,
setShouldCreateAccount: ( value ) => void value,
extensionData: {},
@ -81,6 +86,10 @@ export const DEFAULT_STATE: CheckoutStateContextState = {
orderId: checkoutData.order_id,
orderNotes: '',
customerId: checkoutData.customer_id,
useShippingAsBilling: isSameAddress(
checkoutData.billing_address,
checkoutData.shipping_address
),
shouldCreateAccount: false,
processingResponse: null,
extensionData: {},

View File

@ -381,6 +381,9 @@ export const CheckoutStateProvider = ( {
hasOrder: !! checkoutState.orderId,
customerId: checkoutState.customerId,
orderNotes: checkoutState.orderNotes,
useShippingAsBilling: checkoutState.useShippingAsBilling,
setUseShippingAsBilling: ( value ) =>
dispatch( actions.setUseShippingAsBilling( value ) ),
shouldCreateAccount: checkoutState.shouldCreateAccount,
setShouldCreateAccount: ( value ) =>
dispatch( actions.setShouldCreateAccount( value ) ),

View File

@ -17,6 +17,7 @@ export const reducer = (
orderId,
orderNotes,
extensionData,
useShippingAsBilling,
shouldCreateAccount,
data,
}: ActionType
@ -152,6 +153,17 @@ export const reducer = (
}
: state;
break;
case ACTION.SET_SHIPPING_ADDRESS_AS_BILLING_ADDRESS:
if (
useShippingAsBilling !== undefined &&
useShippingAsBilling !== state.useShippingAsBilling
) {
newState = {
...state,
useShippingAsBilling,
};
}
break;
case ACTION.SET_SHOULD_CREATE_ACCOUNT:
if (
shouldCreateAccount !== undefined &&

View File

@ -45,6 +45,7 @@ export interface CheckoutStateContextState {
orderId: number;
orderNotes: string;
customerId: number;
useShippingAsBilling: boolean;
shouldCreateAccount: boolean;
processingResponse: PaymentResultDataType | null;
extensionData: extensionData;
@ -88,6 +89,8 @@ export type CheckoutStateContextType = {
onCheckoutBeforeProcessing: ReturnType< typeof emitterCallback >;
// Used to register a callback that will fire when the checkout has been submitted before being sent off to the server.
onCheckoutValidationBeforeProcessing: ReturnType< typeof emitterCallback >;
// Toggle using shipping address as billing address.
setUseShippingAsBilling: ( useShippingAsBilling: boolean ) => void;
// Set if user account should be created.
setShouldCreateAccount: ( shouldCreateAccount: boolean ) => void;
// True when the checkout has a draft order from the API.
@ -104,6 +107,8 @@ export type CheckoutStateContextType = {
orderNotes: CheckoutStateContextState[ 'orderNotes' ];
// This is the ID of the customer the draft order belongs to.
customerId: CheckoutStateContextState[ 'customerId' ];
// Should the billing form be hidden and inherit the shipping address?
useShippingAsBilling: CheckoutStateContextState[ 'useShippingAsBilling' ];
// Should a user account be created?
shouldCreateAccount: CheckoutStateContextState[ 'shouldCreateAccount' ];
// Custom checkout data passed to the store API on processing.

View File

@ -0,0 +1,31 @@
/**
* Internal dependencies
*/
import type { CustomerDataType } from '../../../hooks/use-customer-data';
export const defaultBillingData: CustomerDataType[ 'billingData' ] = {
first_name: '',
last_name: '',
company: '',
address_1: '',
address_2: '',
city: '',
state: '',
postcode: '',
country: '',
email: '',
phone: '',
};
export const defaultShippingAddress: CustomerDataType[ 'shippingAddress' ] = {
first_name: '',
last_name: '',
company: '',
address_1: '',
address_2: '',
city: '',
state: '',
postcode: '',
country: '',
phone: '',
};

View File

@ -1,122 +0,0 @@
/**
* External dependencies
*/
import { createContext, useContext, useState } from '@wordpress/element';
import { defaultAddressFields } from '@woocommerce/settings';
/**
* Internal dependencies
*/
import { useCustomerData } from '../../../hooks/use-customer-data';
import { useCheckoutContext } from '../checkout-state';
import { useStoreCart } from '../../../hooks/cart/use-store-cart';
/**
* @typedef {import('@woocommerce/type-defs/contexts').CustomerDataContext} CustomerDataContext
* @typedef {import('@woocommerce/type-defs/billing').BillingData} BillingData
* @typedef {import('@woocommerce/type-defs/shipping').ShippingAddress} ShippingAddress
*/
/**
* @type {BillingData}
*/
const defaultBillingData = {
first_name: '',
last_name: '',
company: '',
address_1: '',
address_2: '',
city: '',
state: '',
postcode: '',
country: '',
email: '',
phone: '',
};
/**
* @type {ShippingAddress}
*/
export const defaultShippingAddress = {
first_name: '',
last_name: '',
company: '',
address_1: '',
address_2: '',
city: '',
state: '',
postcode: '',
country: '',
phone: '',
};
/**
* Creates CustomerDataContext
*/
const CustomerDataContext = createContext( {
billingData: defaultBillingData,
shippingAddress: defaultShippingAddress,
setBillingData: () => null,
setShippingAddress: () => null,
shippingAsBilling: true,
setShippingAsBilling: () => null,
} );
/**
* @return {CustomerDataContext} Returns data and functions related to customer billing and shipping addresses.
*/
export const useCustomerDataContext = () => {
return useContext( CustomerDataContext );
};
/**
* Compare two addresses and see if they are the same.
*
* @param {Object} address1 First address.
* @param {Object} address2 Second address.
*/
const isSameAddress = ( address1, address2 ) => {
return Object.keys( defaultAddressFields ).every(
( field ) => address1[ field ] === address2[ field ]
);
};
/**
* Customer Data context provider.
*
* @param {Object} props Incoming props for the provider.
* @param {Object} props.children The children being wrapped.
*/
export const CustomerDataProvider = ( { children } ) => {
const {
billingData,
shippingAddress,
setBillingData,
setShippingAddress,
} = useCustomerData();
const { cartNeedsShipping: needsShipping } = useStoreCart();
const { customerId } = useCheckoutContext();
const [ shippingAsBilling, setShippingAsBilling ] = useState(
() =>
needsShipping &&
( ! customerId || isSameAddress( shippingAddress, billingData ) )
);
/**
* @type {CustomerDataContext}
*/
const contextValue = {
billingData,
shippingAddress,
setBillingData,
setShippingAddress,
shippingAsBilling,
setShippingAsBilling,
};
return (
<CustomerDataContext.Provider value={ contextValue }>
{ children }
</CustomerDataContext.Provider>
);
};

View File

@ -0,0 +1,42 @@
/**
* External dependencies
*/
import { createContext, useContext } from '@wordpress/element';
/**
* Internal dependencies
*/
import { defaultBillingData, defaultShippingAddress } from './constants';
import {
useCustomerData,
CustomerDataType,
} from '../../../hooks/use-customer-data';
const CustomerDataContext = createContext< CustomerDataType >( {
isInitialized: false,
billingData: defaultBillingData,
shippingAddress: defaultShippingAddress,
setBillingData: () => void 0,
setShippingAddress: () => void 0,
} );
export const useCustomerDataContext = (): CustomerDataType => {
return useContext( CustomerDataContext );
};
/**
* Customer Data context provider.
*/
export const CustomerDataProvider = ( {
children,
}: {
children: JSX.Element | JSX.Element[];
} ): JSX.Element => {
const contextValue = useCustomerData();
return (
<CustomerDataContext.Provider value={ contextValue }>
{ children }
</CustomerDataContext.Provider>
);
};

View File

@ -1,13 +1,27 @@
/**
* External dependencies
*/
import { defaultAddressFields } from '@woocommerce/settings';
import prepareAddressFields from '@woocommerce/base-components/cart-checkout/address-form/prepare-address-fields';
import { isEmail } from '@wordpress/url';
import type {
CartResponseBillingAddress,
CartResponseShippingAddress,
} from '@woocommerce/types';
import { defaultAddressFields, EnteredAddress } from '@woocommerce/settings';
/**
* Compare two addresses and see if they are the same.
*/
export const isSameAddress = (
address1: EnteredAddress,
address2: EnteredAddress
): boolean => {
return Object.keys( defaultAddressFields ).every(
( field: string ) =>
address1[ field as keyof EnteredAddress ] ===
address2[ field as keyof EnteredAddress ]
);
};
/**
* pluckAddress takes a full address object and returns relevant fields for calculating

View File

@ -9,6 +9,11 @@ import {
} from '@woocommerce/base-context';
import { AddressForm } from '@woocommerce/base-components/cart-checkout';
import Noninteractive from '@woocommerce/base-components/noninteractive';
import type {
BillingAddress,
AddressField,
AddressFields,
} from '@woocommerce/settings';
/**
* Internal dependencies
@ -30,9 +35,9 @@ const Block = ( {
} ): JSX.Element => {
const {
defaultAddressFields,
billingFields,
setBillingFields,
setPhone,
billingData,
setBillingData,
setBillingPhone,
} = useCheckoutAddress();
const { dispatchCheckoutEvent } = useStoreEvents();
const { isEditor } = useEditorContext();
@ -40,9 +45,9 @@ const Block = ( {
// Clears data if fields are hidden.
useEffect( () => {
if ( ! showPhoneField ) {
setPhone( '' );
setBillingPhone( '' );
}
}, [ showPhoneField, setPhone ] );
}, [ showPhoneField, setBillingPhone ] );
const addressFieldsConfig = useMemo( () => {
return {
@ -54,7 +59,11 @@ const Block = ( {
hidden: ! showApartmentField,
},
};
}, [ showCompanyField, requireCompanyField, showApartmentField ] );
}, [
showCompanyField,
requireCompanyField,
showApartmentField,
] ) as Record< keyof AddressFields, Partial< AddressField > >;
const AddressFormWrapperComponent = isEditor ? Noninteractive : Fragment;
@ -63,20 +72,24 @@ const Block = ( {
<AddressForm
id="billing"
type="billing"
onChange={ ( values: Record< string, unknown > ) => {
setBillingFields( values );
onChange={ ( values: Partial< BillingAddress > ) => {
setBillingData( values );
dispatchCheckoutEvent( 'set-billing-address' );
} }
values={ billingFields }
fields={ Object.keys( defaultAddressFields ) }
values={ billingData }
fields={
Object.keys(
defaultAddressFields
) as ( keyof AddressFields )[]
}
fieldConfig={ addressFieldsConfig }
/>
{ showPhoneField && (
<PhoneNumber
isRequired={ requirePhoneField }
value={ billingFields.phone }
value={ billingData.phone }
onChange={ ( value ) => {
setPhone( value );
setBillingPhone( value );
dispatchCheckoutEvent( 'set-phone-number', {
step: 'billing',
} );

View File

@ -28,7 +28,6 @@ const FrontendBlock = ( {
className?: string;
} ): JSX.Element | null => {
const { isProcessing: checkoutIsProcessing } = useCheckoutContext();
const { showBillingFields } = useCheckoutAddress();
const {
requireCompanyField,
requirePhoneField,
@ -36,6 +35,7 @@ const FrontendBlock = ( {
showCompanyField,
showPhoneField,
} = useCheckoutBlockContext();
const { showBillingFields } = useCheckoutAddress();
if ( ! showBillingFields ) {
return null;

View File

@ -25,7 +25,7 @@ const Block = ( {
shouldCreateAccount,
setShouldCreateAccount,
} = useCheckoutContext();
const { billingFields, setEmail } = useCheckoutAddress();
const { billingData, setEmail } = useCheckoutAddress();
const { dispatchCheckoutEvent } = useStoreEvents();
const onChangeEmail = ( value ) => {
@ -54,7 +54,7 @@ const Block = ( {
id="email"
type="email"
label={ __( 'Email address', 'woo-gutenberg-products-block' ) }
value={ billingFields.email }
value={ billingData.email }
autoComplete="email"
onChange={ onChangeEmail }
required={ true }

View File

@ -11,6 +11,12 @@ import {
} from '@woocommerce/base-context';
import { CheckboxControl } from '@woocommerce/blocks-checkout';
import Noninteractive from '@woocommerce/base-components/noninteractive';
import type {
BillingAddress,
ShippingAddress,
AddressField,
AddressFields,
} from '@woocommerce/settings';
/**
* Internal dependencies
@ -32,11 +38,12 @@ const Block = ( {
} ): JSX.Element => {
const {
defaultAddressFields,
setShippingFields,
shippingFields,
setShippingAsBilling,
shippingAsBilling,
setShippingAddress,
setBillingData,
shippingAddress,
setShippingPhone,
useShippingAsBilling,
setUseShippingAsBilling,
} = useCheckoutAddress();
const { dispatchCheckoutEvent } = useStoreEvents();
const { isEditor } = useEditorContext();
@ -58,7 +65,11 @@ const Block = ( {
hidden: ! showApartmentField,
},
};
}, [ showCompanyField, requireCompanyField, showApartmentField ] );
}, [
showCompanyField,
requireCompanyField,
showApartmentField,
] ) as Record< keyof AddressFields, Partial< AddressField > >;
const AddressFormWrapperComponent = isEditor ? Noninteractive : Fragment;
@ -68,19 +79,26 @@ const Block = ( {
<AddressForm
id="shipping"
type="shipping"
onChange={ ( values: Record< string, unknown > ) => {
setShippingFields( values );
onChange={ ( values: Partial< ShippingAddress > ) => {
setShippingAddress( values );
if ( useShippingAsBilling ) {
setBillingData( values );
}
dispatchCheckoutEvent( 'set-shipping-address' );
} }
values={ shippingFields }
fields={ Object.keys( defaultAddressFields ) }
values={ shippingAddress }
fields={
Object.keys(
defaultAddressFields
) as ( keyof AddressFields )[]
}
fieldConfig={ addressFieldsConfig }
/>
{ showPhoneField && (
<PhoneNumber
id="shipping-phone"
isRequired={ requirePhoneField }
value={ shippingFields.phone }
value={ shippingAddress.phone }
onChange={ ( value ) => {
setShippingPhone( value );
dispatchCheckoutEvent( 'set-phone-number', {
@ -96,10 +114,13 @@ const Block = ( {
'Use same address for billing',
'woo-gutenberg-products-block'
) }
checked={ shippingAsBilling }
onChange={ ( checked: boolean ) =>
setShippingAsBilling( checked )
}
checked={ useShippingAsBilling }
onChange={ ( checked: boolean ) => {
setUseShippingAsBilling( checked );
if ( checked ) {
setBillingData( shippingAddress as BillingAddress );
}
} }
/>
</>
);

View File

@ -9,6 +9,8 @@ export const ACTION_TYPES = {
SET_IS_CART_DATA_STALE: 'SET_IS_CART_DATA_STALE',
RECEIVE_REMOVED_ITEM: 'RECEIVE_REMOVED_ITEM',
UPDATING_CUSTOMER_DATA: 'UPDATING_CUSTOMER_DATA',
SET_BILLING_DATA: 'SET_BILLING_DATA',
SET_SHIPPING_ADDRESS: 'SET_SHIPPING_ADDRESS',
UPDATING_SELECTED_SHIPPING_RATE: 'UPDATING_SELECTED_SHIPPING_RATE',
UPDATE_LEGACY_CART_FRAGMENTS: 'UPDATE_LEGACY_CART_FRAGMENTS',
TRIGGER_ADDING_TO_CART_EVENT: 'TRIGGER_ADDING_TO_CART_EVENT',

View File

@ -11,6 +11,7 @@ import type {
} from '@woocommerce/types';
import { camelCase, mapKeys } from 'lodash';
import type { AddToCartEventDetail } from '@woocommerce/type-defs/events';
import { BillingAddress, ShippingAddress } from '@woocommerce/settings';
/**
* Internal dependencies
@ -41,6 +42,29 @@ export const receiveCart = (
};
};
/**
* Returns an action object used in updating the store with the provided cart.
*
* This omits the customer addresses so that only updates to cart items and totals are received. This is useful when
* currently editing address information to prevent it being overwritten from the server.
*
* This is a generic response action.
*
* @param {CartResponse} response
*/
export const receiveCartContents = (
response: CartResponse
): { type: string; response: Partial< Cart > } => {
const cart = ( mapKeys( response, ( _, key ) =>
camelCase( key )
) as unknown ) as Cart;
const { shippingAddress, billingAddress, ...cartWithoutAddress } = cart;
return {
type: types.RECEIVE_CART,
response: cartWithoutAddress,
};
};
/**
* Returns an action object used for receiving customer facing errors from the API.
*
@ -461,6 +485,19 @@ export function* selectShippingRate(
return true;
}
/**
* Sets billing data locally, as opposed to updateCustomerData which sends it to the server.
*/
export const setBillingData = ( billingData: Partial< BillingAddress > ) =>
( { type: types.SET_BILLING_DATA, billingData } as const );
/**
* Sets shipping address locally, as opposed to updateCustomerData which sends it to the server.
*/
export const setShippingAddress = (
shippingAddress: Partial< ShippingAddress >
) => ( { type: types.SET_SHIPPING_ADDRESS, shippingAddress } as const );
/**
* Updates the shipping and/or billing address for the customer and returns an
* updated cart.
@ -481,7 +518,7 @@ export function* updateCustomerData(
cache: 'no-store',
} );
yield receiveCart( response );
yield receiveCartContents( response );
} catch ( error ) {
yield receiveError( error );
yield updatingCustomerData( false );
@ -501,6 +538,9 @@ export function* updateCustomerData(
export type CartAction = ReturnOrGeneratorYieldUnion<
| typeof receiveCart
| typeof receiveCartContents
| typeof setBillingData
| typeof setShippingAddress
| typeof receiveError
| typeof receiveApplyingCoupon
| typeof receiveRemovingCoupon

View File

@ -15,8 +15,9 @@ import reducer, { State } from './reducers';
import { controls as sharedControls } from '../shared-controls';
import { controls } from './controls';
import type { SelectFromMap, DispatchFromMap } from '../mapped-types';
import { pushChanges } from './push-changes';
registerStore< State >( STORE_KEY, {
const registeredStore = registerStore< State >( STORE_KEY, {
reducer,
actions,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -25,6 +26,8 @@ registerStore< State >( STORE_KEY, {
resolvers,
} );
registeredStore.subscribe( pushChanges );
export const CART_STORE_KEY = STORE_KEY;
declare module '@wordpress/data' {
@ -33,5 +36,7 @@ declare module '@wordpress/data' {
): DispatchFromMap< typeof actions >;
function select(
key: typeof CART_STORE_KEY
): SelectFromMap< typeof selectors >;
): SelectFromMap< typeof selectors > & {
hasFinishedResolution: ( selector: string ) => boolean;
};
}

View File

@ -0,0 +1,164 @@
/**
* External dependencies
*/
import { debounce } from 'lodash';
import { select, dispatch } from '@wordpress/data';
import {
formatStoreApiErrorMessage,
pluckAddress,
pluckEmail,
} from '@woocommerce/base-utils';
import {
CartResponseBillingAddress,
CartResponseShippingAddress,
} from '@woocommerce/type-defs/cart-response';
import isShallowEqual from '@wordpress/is-shallow-equal';
import { BillingAddressShippingAddress } from '@woocommerce/type-defs/cart';
/**
* Internal dependencies
*/
import { STORE_KEY } from './constants';
declare type CustomerData = {
billingData: CartResponseBillingAddress;
shippingAddress: CartResponseShippingAddress;
};
/**
* Checks if a cart response contains an email property.
*/
const isCartResponseBillingAddress = (
address: CartResponseBillingAddress | CartResponseShippingAddress
): address is CartResponseBillingAddress => {
return 'email' in address;
};
/**
* Does a shallow compare of important address data to determine if the cart needs updating on the server. This takes
* the current and previous address into account, as well as the billing email field.
*/
const isAddressDirty = <
T extends CartResponseBillingAddress | CartResponseShippingAddress
>(
// An object containing all previous address information
previousAddress: T,
// An object containing all address information.
address: T
): boolean => {
if (
isCartResponseBillingAddress( address ) &&
pluckEmail( address ) !==
pluckEmail( previousAddress as CartResponseBillingAddress )
) {
return true;
}
return (
!! address.country &&
! isShallowEqual(
pluckAddress( previousAddress ),
pluckAddress( address )
)
);
};
/**
* Local cache of customerData used for comparisons.
*/
let customerData = <CustomerData>{
billingData: {},
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 = {
billingData: false,
shippingAddress: false,
};
/**
* Function to dispatch an update to the server. This is debounced.
*/
const updateCustomerData = debounce( (): void => {
const { billingData, shippingAddress } = customerData;
const customerDataToUpdate = {} as Partial< BillingAddressShippingAddress >;
if ( dirtyProps.billingData ) {
customerDataToUpdate.billing_address = billingData;
dirtyProps.billingData = false;
}
if ( dirtyProps.shippingAddress ) {
customerDataToUpdate.shipping_address = shippingAddress;
dirtyProps.shippingAddress = false;
}
if ( Object.keys( customerDataToUpdate ).length ) {
dispatch( STORE_KEY )
.updateCustomerData( customerDataToUpdate )
.then( () => {
dispatch( 'core/notices' ).removeNotice(
'checkout',
'wc/checkout'
);
} )
.catch( ( response ) => {
dispatch( 'core/notices' ).createNotice(
'error',
formatStoreApiErrorMessage( response ),
{
id: 'checkout',
context: 'wc/checkout',
}
);
} );
}
}, 1000 );
/**
* 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.
*/
export const pushChanges = (): void => {
const store = select( STORE_KEY );
const isInitialized = store.hasFinishedResolution( 'getCartData' );
if ( ! isInitialized ) {
return;
}
const newCustomerData = store.getCustomerData();
if ( ! customerDataIsInitialized ) {
customerData = newCustomerData;
customerDataIsInitialized = true;
return;
}
// An address is dirty and needs pushing to the server if the email, country, state, city, or postcode have changed.
if (
isAddressDirty( customerData.billingData, newCustomerData.billingData )
) {
dirtyProps.billingData = true;
}
if (
isAddressDirty(
customerData.shippingAddress,
newCustomerData.shippingAddress
)
) {
dirtyProps.shippingAddress = true;
}
customerData = newCustomerData;
if ( dirtyProps.billingData || dirtyProps.shippingAddress ) {
updateCustomerData();
}
};

View File

@ -69,7 +69,10 @@ const reducer: Reducer< CartState > = (
state = {
...state,
errors: EMPTY_CART_ERRORS,
cartData: action.response,
cartData: {
...state.cartData,
...action.response,
},
};
}
break;
@ -84,6 +87,31 @@ const reducer: Reducer< CartState > = (
};
}
break;
case types.SET_BILLING_DATA:
state = {
...state,
cartData: {
...state.cartData,
billingAddress: {
...state.cartData.billingAddress,
...action.billingData,
},
},
};
break;
case types.SET_SHIPPING_ADDRESS:
state = {
...state,
cartData: {
...state.cartData,
shippingAddress: {
...state.cartData.shippingAddress,
...action.shippingAddress,
},
},
};
break;
case types.REMOVING_COUPON:
if ( action.couponCode || action.couponCode === '' ) {
state = {

View File

@ -2,6 +2,7 @@
* External dependencies
*/
import type { Cart, CartTotals, CartMeta, CartItem } from '@woocommerce/types';
import { BillingAddress, ShippingAddress } from '@woocommerce/settings';
/**
* Internal dependencies
@ -19,6 +20,18 @@ export const getCartData = ( state: CartState ): Cart => {
return state.cartData;
};
export const getCustomerData = (
state: CartState
): {
shippingAddress: ShippingAddress;
billingData: BillingAddress;
} => {
return {
shippingAddress: state.cartData.shippingAddress,
billingData: state.cartData.billingAddress,
};
};
/**
* Retrieves cart totals from state.
*

View File

@ -47,6 +47,7 @@ export interface EnteredAddress {
city: string;
state: string;
postcode: string;
phone: string;
}
export type KeyedAddressField = AddressField & {
@ -54,7 +55,9 @@ export type KeyedAddressField = AddressField & {
errorMessage?: string;
};
export type ShippingAddress = EnteredAddress;
export type BillingAddress = EnteredAddress;
export interface BillingAddress extends EnteredAddress {
email: string;
}
export type CountryAddressFields = Record< string, AddressFields >;
/**

View File

@ -8,17 +8,6 @@
* @typedef {import('./add-to-cart-form').AddToCartFormEventRegistration} AddToCartFormEventRegistration
*/
/**
* @typedef {Object} CustomerDataContext
*
* @property {BillingData} billingData The current billing data, including address and email.
* @property {CartShippingAddress} shippingAddress The current set address for shipping.
* @property {Function} setBillingData A function for setting billing data.
* @property {Function} setShippingAddress A function for setting shipping address.
* @property {boolean} shippingAsBilling A boolean which tracks if the customer is using the same billing and shipping address.
* @property {Function} setShippingAsBilling A function for toggling shipping as billing.
*/
/**
* @typedef {Object} ShippingDataContext
*

View File

@ -1,8 +1,8 @@
/**
* External dependencies
*/
import { withInstanceId } from '@wordpress/compose';
import classNames from 'classnames';
import { useInstanceId } from '@wordpress/compose';
/**
* Internal dependencies
@ -13,10 +13,10 @@ export type CheckboxControlProps = {
className?: string;
label?: string;
id?: string;
instanceId: string;
onChange: ( value: boolean ) => void;
children: React.ReactChildren;
hasError: boolean;
children?: React.ReactChildren;
hasError?: boolean;
checked?: boolean;
};
/**
@ -26,12 +26,13 @@ export const CheckboxControl = ( {
className,
label,
id,
instanceId,
onChange,
children,
hasError = false,
checked = false,
...rest
}: CheckboxControlProps ): JSX.Element => {
const instanceId = useInstanceId( CheckboxControl );
const checkboxId = id || `checkbox-control-${ instanceId }`;
return (
@ -51,6 +52,7 @@ export const CheckboxControl = ( {
type="checkbox"
onChange={ ( event ) => onChange( event.target.checked ) }
aria-invalid={ hasError === true }
checked={ checked }
{ ...rest }
/>
<svg
@ -72,4 +74,4 @@ export const CheckboxControl = ( {
);
};
export default withInstanceId( CheckboxControl );
export default CheckboxControl;