rebase code on oct-10-2023

This commit is contained in:
Rachit Bhargava
2023-10-10 17:51:46 -04:00
parent b16ad94b69
commit 8f1a2c3a66
2197 changed files with 184921 additions and 35568 deletions

View File

@@ -1,11 +1,7 @@
/**
* External dependencies
*/
import {
ValidatedTextInput,
isPostcode,
type ValidatedTextInputHandle,
} from '@woocommerce/blocks-checkout';
import { ValidatedTextInput, isPostcode } from '@woocommerce/blocks-checkout';
import {
BillingCountryInput,
ShippingCountryInput,
@@ -14,110 +10,195 @@ import {
BillingStateInput,
ShippingStateInput,
} from '@woocommerce/base-components/state-input';
import { useEffect, useMemo, useRef } from '@wordpress/element';
import { useEffect, useMemo } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { withInstanceId } from '@wordpress/compose';
import { useShallowEqual } from '@woocommerce/base-hooks';
import { defaultAddressFields } from '@woocommerce/settings';
import isShallowEqual from '@wordpress/is-shallow-equal';
import {
AddressField,
AddressFields,
AddressType,
defaultAddressFields,
ShippingAddress,
} from '@woocommerce/settings';
import { useSelect, useDispatch, dispatch } from '@wordpress/data';
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
import { FieldValidationStatus } from '@woocommerce/types';
/**
* Internal dependencies
*/
import {
AddressFormProps,
FieldType,
FieldConfig,
AddressFormFields,
} from './types';
import prepareAddressFields from './prepare-address-fields';
import validateShippingCountry from './validate-shipping-country';
import customValidationHandler from './custom-validation-handler';
const defaultFields = Object.keys(
defaultAddressFields
) as unknown as FieldType[];
// If it's the shipping address form and the user starts entering address
// values without having set the country first, show an error.
const validateShippingCountry = (
values: ShippingAddress,
setValidationErrors: (
errors: Record< string, FieldValidationStatus >
) => void,
clearValidationError: ( error: string ) => void,
hasValidationError: boolean
): void => {
const validationErrorId = 'shipping_country';
if (
! hasValidationError &&
! values.country &&
( values.city || values.state || values.postcode )
) {
setValidationErrors( {
[ validationErrorId ]: {
message: __(
'Please select a country to calculate rates.',
'woo-gutenberg-products-block'
),
hidden: false,
},
} );
}
if ( hasValidationError && values.country ) {
clearValidationError( validationErrorId );
}
};
interface AddressFormProps {
// Id for component.
id?: string;
// Unique id for form.
instanceId: string;
// Array of fields in form.
fields: ( keyof AddressFields )[];
// Field configuration for fields in form.
fieldConfig?: Record< keyof AddressFields, Partial< AddressField > >;
// Function to all for an form onChange event.
onChange: ( newValue: ShippingAddress ) => void;
// Type of form.
type?: AddressType;
// Values for fields.
values: ShippingAddress;
}
/**
* Checkout address form.
*/
const AddressForm = ( {
id = '',
fields = defaultFields,
fieldConfig = {} as FieldConfig,
fields = Object.keys(
defaultAddressFields
) as unknown as ( keyof AddressFields )[],
fieldConfig = {} as Record< keyof AddressFields, Partial< AddressField > >,
instanceId,
onChange,
type = 'shipping',
values,
}: AddressFormProps ): JSX.Element => {
// Track incoming props.
const validationErrorId = 'shipping_country';
const { setValidationErrors, clearValidationError } =
useDispatch( VALIDATION_STORE_KEY );
const countryValidationError = useSelect( ( select ) => {
const store = select( VALIDATION_STORE_KEY );
return store.getValidationError( validationErrorId );
} );
const currentFields = useShallowEqual( fields );
const currentFieldConfig = useShallowEqual( fieldConfig );
const currentCountry = useShallowEqual( values.country );
// Memoize the address form fields passed in from the parent component.
const addressFormFields = useMemo( (): AddressFormFields => {
const preparedFields = prepareAddressFields(
const addressFormFields = useMemo( () => {
return prepareAddressFields(
currentFields,
currentFieldConfig,
currentCountry
fieldConfig,
values.country
);
return {
fields: preparedFields,
type,
required: preparedFields.filter( ( field ) => field.required ),
hidden: preparedFields.filter( ( field ) => field.hidden ),
};
}, [ currentFields, currentFieldConfig, currentCountry, type ] );
// Stores refs for rendered fields so we can access them later.
const fieldsRef = useRef<
Record< string, ValidatedTextInputHandle | null >
>( {} );
}, [ currentFields, fieldConfig, values.country ] );
// Clear values for hidden fields.
useEffect( () => {
const newValues = {
...values,
...Object.fromEntries(
addressFormFields.hidden.map( ( field ) => [ field.key, '' ] )
),
};
if ( ! isShallowEqual( values, newValues ) ) {
onChange( newValues );
}
}, [ onChange, addressFormFields, values ] );
addressFormFields.forEach( ( field ) => {
if ( field.hidden && values[ field.key ] ) {
onChange( {
...values,
[ field.key ]: '',
} );
}
} );
}, [ addressFormFields, onChange, values ] );
// Clear postcode validation error if postcode is not required.
useEffect( () => {
addressFormFields.forEach( ( field ) => {
if ( field.key === 'postcode' && field.required === false ) {
const store = dispatch( 'wc/store/validation' );
if ( type === 'shipping' ) {
store.clearValidationError( 'shipping_postcode' );
}
if ( type === 'billing' ) {
store.clearValidationError( 'billing_postcode' );
}
}
} );
}, [ addressFormFields, type, clearValidationError ] );
// Maybe validate country when other fields change so user is notified that it's required.
useEffect( () => {
if ( type === 'shipping' ) {
validateShippingCountry( values );
validateShippingCountry(
values,
setValidationErrors,
clearValidationError,
!! countryValidationError?.message &&
! countryValidationError?.hidden
);
}
}, [ values, type ] );
// Changing country may change format for postcodes.
useEffect( () => {
fieldsRef.current?.postcode?.revalidate();
}, [ currentCountry ] );
}, [
values,
countryValidationError?.message,
countryValidationError?.hidden,
setValidationErrors,
clearValidationError,
type,
] );
id = id || instanceId;
/**
* Custom validation handler for fields with field specific handling.
*/
const customValidationHandler = (
inputObject: HTMLInputElement,
field: string,
customValues: {
country: string;
}
): boolean => {
if (
field === 'postcode' &&
customValues.country &&
! isPostcode( {
postcode: inputObject.value,
country: customValues.country,
} )
) {
inputObject.setCustomValidity(
__(
'Please enter a valid postcode',
'woo-gutenberg-products-block'
)
);
return false;
}
return true;
};
return (
<div id={ id } className="wc-block-components-address-form">
{ addressFormFields.fields.map( ( field ) => {
{ addressFormFields.map( ( field ) => {
if ( field.hidden ) {
return null;
}
const fieldProps = {
id: `${ id }-${ field.key }`,
errorId: `${ type }_${ field.key }`,
label: field.required ? field.label : field.optionalLabel,
autoCapitalize: field.autocapitalize,
autoComplete: field.autocomplete,
errorMessage: field.errorMessage,
required: field.required,
className: `wc-block-components-address-form__${ field.key }`,
};
// Create a consistent error ID based on the field key and type
const errorId = `${ type }_${ field.key }`;
if ( field.key === 'country' ) {
const Tag =
@@ -127,26 +208,24 @@ const AddressForm = ( {
return (
<Tag
key={ field.key }
{ ...fieldProps }
id={ `${ id }-${ field.key }` }
errorId={ errorId }
label={
field.required
? field.label
: field.optionalLabel
}
value={ values.country }
onChange={ ( newCountry ) => {
const newValues = {
autoComplete={ field.autocomplete }
onChange={ ( newValue ) =>
onChange( {
...values,
country: newCountry,
country: newValue,
state: '',
};
// Country will impact postcode too. Do we need to clear it?
if (
values.postcode &&
! isPostcode( {
postcode: values.postcode,
country: newCountry,
} )
) {
newValues.postcode = '';
}
onChange( newValues );
} }
} )
}
errorMessage={ field.errorMessage }
required={ field.required }
/>
);
}
@@ -159,15 +238,24 @@ const AddressForm = ( {
return (
<Tag
key={ field.key }
{ ...fieldProps }
id={ `${ id }-${ field.key }` }
errorId={ errorId }
country={ values.country }
label={
field.required
? field.label
: field.optionalLabel
}
value={ values.state }
autoComplete={ field.autocomplete }
onChange={ ( newValue ) =>
onChange( {
...values,
state: newValue,
} )
}
errorMessage={ field.errorMessage }
required={ field.required }
/>
);
}
@@ -175,30 +263,35 @@ const AddressForm = ( {
return (
<ValidatedTextInput
key={ field.key }
ref={ ( el ) =>
( fieldsRef.current[ field.key ] = el )
id={ `${ id }-${ field.key }` }
errorId={ errorId }
className={ `wc-block-components-address-form__${ field.key }` }
label={
field.required ? field.label : field.optionalLabel
}
{ ...fieldProps }
value={ values[ field.key ] }
autoCapitalize={ field.autocapitalize }
autoComplete={ field.autocomplete }
onChange={ ( newValue: string ) =>
onChange( {
...values,
[ field.key ]: newValue,
[ field.key ]:
field.key === 'postcode'
? newValue.trimStart().toUpperCase()
: newValue,
} )
}
customFormatter={ ( value: string ) => {
if ( field.key === 'postcode' ) {
return value.trimStart().toUpperCase();
}
return value;
} }
customValidation={ ( inputObject: HTMLInputElement ) =>
customValidationHandler(
inputObject,
field.key,
values
)
field.required || inputObject.value
? customValidationHandler(
inputObject,
field.key,
values
)
: true
}
errorMessage={ field.errorMessage }
required={ field.required }
/>
);
} ) }

View File

@@ -1,41 +0,0 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { isPostcode } from '@woocommerce/blocks-checkout';
/**
* Custom validation handler for fields with field specific handling.
*/
const customValidationHandler = (
inputObject: HTMLInputElement,
field: string,
customValues: {
country: string;
}
): boolean => {
// Pass validation if the field is not required and is empty.
if ( ! inputObject.required && ! inputObject.value ) {
return true;
}
if (
field === 'postcode' &&
customValues.country &&
! isPostcode( {
postcode: inputObject.value,
country: customValues.country,
} )
) {
inputObject.setCustomValidity(
__(
'Please enter a valid postcode',
'woo-gutenberg-products-block'
)
);
return false;
}
return true;
};
export default customValidationHandler;

View File

@@ -1,41 +0,0 @@
/**
* External dependencies
*/
import type {
AddressField,
AddressFields,
AddressType,
ShippingAddress,
KeyedAddressField,
} from '@woocommerce/settings';
export type FieldConfig = Record<
keyof AddressFields,
Partial< AddressField >
>;
export type FieldType = keyof AddressFields;
export type AddressFormFields = {
fields: KeyedAddressField[];
type: AddressType;
required: KeyedAddressField[];
hidden: KeyedAddressField[];
};
export interface AddressFormProps {
// Id for component.
id?: string;
// Unique id for form.
instanceId: string;
// Type of form (billing or shipping).
type?: AddressType;
// Array of fields in form.
fields: FieldType[];
// Field configuration for fields in form.
fieldConfig?: FieldConfig;
// Called with the new address data when the address form changes. This is only called when all required fields are filled and there are no validation errors.
onChange: ( newValue: ShippingAddress ) => void;
// Values for fields.
values: ShippingAddress;
}

View File

@@ -1,43 +0,0 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { type ShippingAddress } from '@woocommerce/settings';
import { select, dispatch } from '@wordpress/data';
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
// If it's the shipping address form and the user starts entering address
// values without having set the country first, show an error.
const validateShippingCountry = ( values: ShippingAddress ): void => {
const validationErrorId = 'shipping_country';
const hasValidationError =
select( VALIDATION_STORE_KEY ).getValidationError( validationErrorId );
if (
! values.country &&
( values.city || values.state || values.postcode )
) {
if ( hasValidationError ) {
dispatch( VALIDATION_STORE_KEY ).showValidationError(
validationErrorId
);
} else {
dispatch( VALIDATION_STORE_KEY ).setValidationErrors( {
[ validationErrorId ]: {
message: __(
'Please select your country',
'woo-gutenberg-products-block'
),
hidden: false,
},
} );
}
}
if ( hasValidationError && values.country ) {
dispatch( VALIDATION_STORE_KEY ).clearValidationError(
validationErrorId
);
}
};
export default validateShippingCountry;

View File

@@ -7,8 +7,12 @@ import { decodeEntities } from '@wordpress/html-entities';
import { Panel } from '@woocommerce/blocks-checkout';
import Label from '@woocommerce/base-components/label';
import { useCallback } from '@wordpress/element';
import { useShippingData } from '@woocommerce/base-context/hooks';
import {
useShippingData,
useStoreEvents,
} from '@woocommerce/base-context/hooks';
import { sanitizeHTML } from '@woocommerce/utils';
import { useDebouncedCallback } from 'use-debounce';
import type { ReactElement } from 'react';
/**
@@ -27,7 +31,8 @@ export const ShippingRatesControlPackage = ( {
collapsible,
showItems,
}: PackageProps ): ReactElement => {
const { selectShippingRate, isSelectingRate } = useShippingData();
const { selectShippingRate } = useShippingData();
const { dispatchCheckoutEvent } = useStoreEvents();
const multiplePackages =
document.querySelectorAll(
'.wc-block-components-shipping-rates-control__package'
@@ -90,32 +95,28 @@ export const ShippingRatesControlPackage = ( {
const onSelectRate = useCallback(
( newShippingRateId: string ) => {
selectShippingRate( newShippingRateId, packageId );
dispatchCheckoutEvent( 'set-selected-shipping-rate', {
shippingRateId: newShippingRateId,
} );
},
[ packageId, selectShippingRate ]
[ dispatchCheckoutEvent, packageId, selectShippingRate ]
);
const debouncedOnSelectRate = useDebouncedCallback( onSelectRate, 1000 );
const packageRatesProps = {
className,
noResultsMessage,
rates: packageData.shipping_rates,
onSelectRate,
onSelectRate: debouncedOnSelectRate,
selectedRate: packageData.shipping_rates.find(
( rate ) => rate.selected
),
renderOption,
disabled: isSelectingRate,
};
if ( shouldBeCollapsible ) {
return (
<Panel
className={ classNames(
'wc-block-components-shipping-rates-control__package',
className,
{
'wc-block-components-shipping-rates-control__package--disabled':
isSelectingRate,
}
) }
className="wc-block-components-shipping-rates-control__package"
// initialOpen remembers only the first value provided to it, so by the
// time we know we have several packages, initialOpen would be hardcoded to true.
// If we're rendering a panel, we're more likely rendering several
@@ -132,11 +133,7 @@ export const ShippingRatesControlPackage = ( {
<div
className={ classNames(
'wc-block-components-shipping-rates-control__package',
className,
{
'wc-block-components-shipping-rates-control__package--disabled':
isSelectingRate,
}
className
) }
>
{ header }

View File

@@ -6,7 +6,6 @@ import RadioControl, {
RadioControlOptionLayout,
} from '@woocommerce/base-components/radio-control';
import type { CartShippingPackageShippingRate } from '@woocommerce/types';
import { usePrevious } from '@woocommerce/base-hooks';
/**
* Internal dependencies
@@ -21,7 +20,6 @@ interface PackageRates {
className?: string;
noResultsMessage: JSX.Element;
selectedRate: CartShippingPackageShippingRate | undefined;
disabled?: boolean;
}
const PackageRates = ( {
@@ -31,37 +29,34 @@ const PackageRates = ( {
rates,
renderOption = renderPackageRateOption,
selectedRate,
disabled = false,
}: PackageRates ): JSX.Element => {
const selectedRateId = selectedRate?.rate_id || '';
const previousSelectedRateId = usePrevious( selectedRateId );
// Store selected rate ID in local state so shipping rates changes are shown in the UI instantly.
const [ selectedOption, setSelectedOption ] = useState( () => {
if ( selectedRateId ) {
return selectedRateId;
}
// Default to first rate if no rate is selected.
return rates[ 0 ]?.rate_id;
} );
const [ selectedOption, setSelectedOption ] = useState( selectedRateId );
// Update the selected option if cart state changes in the data store.
// Update the selected option if cart state changes in the data stores.
useEffect( () => {
if (
selectedRateId &&
selectedRateId !== previousSelectedRateId &&
selectedRateId !== selectedOption
) {
if ( selectedRateId ) {
setSelectedOption( selectedRateId );
}
}, [ selectedRateId, selectedOption, previousSelectedRateId ] );
}, [ selectedRateId ] );
// Update the data store when the local selected rate changes.
// Update the selected option if there is no rate selected on mount.
useEffect( () => {
if ( selectedOption ) {
onSelectRate( selectedOption );
// Check the rates to see if any are marked as selected. At least one should be. If no rate is selected, it could be
// that the user toggled quickly from local pickup back to shipping.
const isRateSelectedInDataStore = rates.some(
( { selected } ) => selected
);
if (
( ! selectedOption && rates[ 0 ] ) ||
! isRateSelectedInDataStore
) {
setSelectedOption( rates[ 0 ]?.rate_id );
onSelectRate( rates[ 0 ]?.rate_id );
}
}, [ onSelectRate, selectedOption ] );
}, [ onSelectRate, rates, selectedOption ] );
if ( rates.length === 0 ) {
return noResultsMessage;
@@ -75,7 +70,6 @@ const PackageRates = ( {
setSelectedOption( value );
onSelectRate( value );
} }
disabled={ disabled }
selected={ selectedOption }
options={ rates.map( renderOption ) }
/>

View File

@@ -44,11 +44,6 @@
.wc-block-components-radio-control__description-group {
@include font-size(smaller);
}
&--disabled {
opacity: 0.5;
transition: opacity 200ms ease;
}
}
.wc-block-components-shipping-rates-control__package-items {

View File

@@ -61,7 +61,6 @@ jest.mock( '@woocommerce/base-context/hooks', () => {
} );
baseContextHooks.useShippingData.mockReturnValue( {
needsShipping: true,
selectShippingRate: jest.fn(),
shippingRates: [
{
package_id: 0,

View File

@@ -24,7 +24,7 @@ export const CountryInput = ( {
required = false,
errorId,
errorMessage = __(
'Please select a country',
'Please select a country.',
'woo-gutenberg-products-block'
),
}: CountryInputWithCountriesProps ): JSX.Element => {

View File

@@ -43,7 +43,7 @@
}
// Legacy notice compatibility.
.wc-forward {
.wc-forward.wp-element-button {
float: right;
color: $gray-800 !important;
background: transparent;
@@ -52,8 +52,6 @@
border: 0;
appearance: none;
opacity: 0.6;
text-decoration-line: underline;
text-underline-position: under;
&:hover,
&:focus,

View File

@@ -169,6 +169,7 @@
width: 100%;
height: 0;
display: block;
position: relative;
pointer-events: none;
outline: none !important;
position: absolute;
@@ -365,6 +366,7 @@
@include ie11() {
.wc-block-components-price-slider__range-input-wrapper {
border: 0;
height: auto;
position: relative;
height: 50px;
}

View File

@@ -54,13 +54,9 @@
}
&__container {
> * {
vertical-align: middle;
}
}
&__stars + &__reviews_count {
margin-left: $gap-smaller;
display: flex;
align-items: center;
column-gap: $gap-smaller;
}
&__norating-container {

View File

@@ -16,7 +16,6 @@ const RadioControl = ( {
selected = '',
onChange,
options = [],
disabled = false,
}: RadioControlProps ): JSX.Element | null => {
const instanceId = useInstanceId( RadioControl );
const radioControlId = id || instanceId;
@@ -44,7 +43,6 @@ const RadioControl = ( {
option.onChange( value );
}
} }
disabled={ disabled }
/>
) ) }
</div>

View File

@@ -14,7 +14,6 @@ const Option = ( {
name,
onChange,
option,
disabled = false,
}: RadioControlOptionProps ): JSX.Element => {
const { value, label, description, secondaryLabel, secondaryDescription } =
option;
@@ -47,7 +46,6 @@ const Option = ( {
[ `${ name }-${ value }__secondary-description` ]:
secondaryDescription,
} ) }
disabled={ disabled }
/>
<OptionLayout
id={ `${ name }-${ value }` }

View File

@@ -3,11 +3,13 @@
@include reset-typography();
display: block;
margin: em($gap) 0;
margin-top: 0;
padding: 0 0 0 em($gap-larger);
position: relative;
cursor: pointer;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
@@ -97,12 +99,6 @@
background: $input-text-dark;
}
}
&[disabled] {
cursor: not-allowed;
opacity: 0.5;
transition: opacity 200ms ease;
}
}
}

View File

@@ -14,8 +14,6 @@ export interface RadioControlProps {
onChange: ( value: string ) => void;
// List of radio control options.
options: RadioControlOption[];
// Is the control disabled.
disabled?: boolean;
}
export interface RadioControlOptionProps {
@@ -23,7 +21,6 @@ export interface RadioControlOptionProps {
name?: string;
onChange: ( value: string ) => void;
option: RadioControlOption;
disabled?: boolean;
}
interface RadioControlOptionContent {

View File

@@ -14,6 +14,7 @@
display: inline-flex;
width: auto;
max-width: 600px;
margin: 0;
pointer-events: all;
border: 1px solid transparent;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);

View File

@@ -55,15 +55,13 @@ const StateInput = ( {
*/
const onChangeState = useCallback(
( stateValue: string ) => {
const newValue =
onChange(
options.length > 0
? optionMatcher( stateValue, options )
: stateValue;
if ( newValue !== value ) {
onChange( newValue );
}
: stateValue
);
},
[ onChange, options, value ]
[ onChange, options ]
);
/**

View File

@@ -124,7 +124,11 @@ export const useShippingData = (): ShippingData => {
processErrorResponse( error );
} );
},
[ dispatchSelectShippingRate, dispatchCheckoutEvent ]
[
hasSelectedLocalPickup,
dispatchSelectShippingRate,
dispatchCheckoutEvent,
]
);
return {

View File

@@ -2,8 +2,12 @@
* External dependencies
*/
import { doAction } from '@wordpress/hooks';
import { select } from '@wordpress/data';
import { useCallback } from '@wordpress/element';
import { useCallback, useRef, useEffect } from '@wordpress/element';
/**
* Internal dependencies
*/
import { useStoreCart } from './cart/use-store-cart';
type StoreEvent = (
eventName: string,
@@ -17,6 +21,15 @@ export const useStoreEvents = (): {
dispatchStoreEvent: StoreEvent;
dispatchCheckoutEvent: StoreEvent;
} => {
const storeCart = useStoreCart();
const currentStoreCart = useRef( storeCart );
// Track the latest version of the cart so we can use the current value in our callback function below without triggering
// other useEffect hooks using dispatchCheckoutEvent as a dependency.
useEffect( () => {
currentStoreCart.current = storeCart;
}, [ storeCart ] );
const dispatchStoreEvent = useCallback( ( eventName, eventParams = {} ) => {
try {
doAction(
@@ -37,7 +50,7 @@ export const useStoreEvents = (): {
`experimental__woocommerce_blocks-checkout-${ eventName }`,
{
...eventParams,
storeCart: select( 'wc/store/cart' ).getCartData(),
storeCart: currentStoreCart.current,
}
);
} catch ( e ) {

View File

@@ -50,8 +50,8 @@ function getBorderClassName( attributes: {
: '';
return classnames( {
'has-border-color': !! borderColor || !! style?.border?.color,
[ borderColorClass ]: !! borderColorClass,
'has-border-color': borderColor || style?.border?.color,
borderColorClass,
} );
}

View File

@@ -14,5 +14,4 @@ export * from './camel-case-keys';
export * from './snake-case-keys';
export * from './debounce';
export * from './keyby';
export * from './pick';
export * from './get-inline-styles';

View File

@@ -1,11 +0,0 @@
/**
* Creates an object composed of the picked object properties.
*/
export const pick = < Type >( object: Type, keys: string[] ): Type => {
return keys.reduce( ( obj, key ) => {
if ( object && object.hasOwnProperty( key ) ) {
obj[ key as keyof Type ] = object[ key as keyof Type ];
}
return obj;
}, {} as Type );
};