initial commit
This commit is contained in:
@ -0,0 +1,254 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import PropTypes from 'prop-types';
|
||||
import { ValidatedTextInput } from '@woocommerce/base-components/text-input';
|
||||
import {
|
||||
BillingCountryInput,
|
||||
ShippingCountryInput,
|
||||
} from '@woocommerce/base-components/country-input';
|
||||
import {
|
||||
BillingStateInput,
|
||||
ShippingStateInput,
|
||||
} from '@woocommerce/base-components/state-input';
|
||||
import { useValidationContext } from '@woocommerce/base-context';
|
||||
import { useEffect, useMemo } from '@wordpress/element';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { withInstanceId } from '@wordpress/compose';
|
||||
import { useShallowEqual } from '@woocommerce/base-hooks';
|
||||
import {
|
||||
AddressField,
|
||||
AddressFields,
|
||||
AddressType,
|
||||
defaultAddressFields,
|
||||
EnteredAddress,
|
||||
} from '@woocommerce/settings';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import prepareAddressFields from './prepare-address-fields';
|
||||
|
||||
// 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: EnteredAddress,
|
||||
setValidationErrors: ( errors: Record< string, unknown > ) => void,
|
||||
clearValidationError: ( error: string ) => void,
|
||||
hasValidationError: boolean
|
||||
): void => {
|
||||
if (
|
||||
! hasValidationError &&
|
||||
! values.country &&
|
||||
( values.city || values.state || values.postcode )
|
||||
) {
|
||||
setValidationErrors( {
|
||||
'shipping-missing-country': {
|
||||
message: __(
|
||||
'Please select a country to calculate rates.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
hidden: false,
|
||||
},
|
||||
} );
|
||||
}
|
||||
if ( hasValidationError && values.country ) {
|
||||
clearValidationError( 'shipping-missing-country' );
|
||||
}
|
||||
};
|
||||
|
||||
interface AddressFormProps {
|
||||
id: string;
|
||||
instanceId: string;
|
||||
fields: ( keyof AddressFields )[];
|
||||
fieldConfig: Record< keyof AddressFields, Partial< AddressField > >;
|
||||
onChange: ( newValue: EnteredAddress ) => void;
|
||||
type: AddressType;
|
||||
values: EnteredAddress;
|
||||
}
|
||||
/**
|
||||
* Checkout address form.
|
||||
*
|
||||
* @param {Object} props Incoming props for component.
|
||||
* @param {string} props.id Id for component.
|
||||
* @param {Array} props.fields Array of fields in form.
|
||||
* @param {Object} props.fieldConfig Field configuration for fields in form.
|
||||
* @param {string} props.instanceId Unique id for form.
|
||||
* @param {function(any):any} props.onChange Function to all for an form onChange event.
|
||||
* @param {string} props.type Type of form.
|
||||
* @param {Object} props.values Values for fields.
|
||||
*/
|
||||
const AddressForm = ( {
|
||||
id,
|
||||
fields = ( Object.keys(
|
||||
defaultAddressFields
|
||||
) as unknown ) as ( keyof AddressFields )[],
|
||||
fieldConfig = {} as Record< keyof AddressFields, Partial< AddressField > >,
|
||||
instanceId,
|
||||
onChange,
|
||||
type = 'shipping',
|
||||
values,
|
||||
}: AddressFormProps ): JSX.Element => {
|
||||
const {
|
||||
getValidationError,
|
||||
setValidationErrors,
|
||||
clearValidationError,
|
||||
} = useValidationContext();
|
||||
|
||||
const currentFields = useShallowEqual( fields );
|
||||
|
||||
const countryValidationError = ( getValidationError(
|
||||
'shipping-missing-country'
|
||||
) || {} ) as {
|
||||
message: string;
|
||||
hidden: boolean;
|
||||
};
|
||||
|
||||
const addressFormFields = useMemo( () => {
|
||||
return prepareAddressFields(
|
||||
currentFields,
|
||||
fieldConfig,
|
||||
values.country
|
||||
);
|
||||
}, [ currentFields, fieldConfig, values.country ] );
|
||||
|
||||
// Clear values for hidden fields.
|
||||
useEffect( () => {
|
||||
addressFormFields.forEach( ( field ) => {
|
||||
if ( field.hidden && values[ field.key ] ) {
|
||||
onChange( {
|
||||
...values,
|
||||
[ field.key ]: '',
|
||||
} );
|
||||
}
|
||||
} );
|
||||
}, [ addressFormFields, onChange, values ] );
|
||||
|
||||
useEffect( () => {
|
||||
if ( type === 'shipping' ) {
|
||||
validateShippingCountry(
|
||||
values,
|
||||
setValidationErrors,
|
||||
clearValidationError,
|
||||
!! countryValidationError.message &&
|
||||
! countryValidationError.hidden
|
||||
);
|
||||
}
|
||||
}, [
|
||||
values,
|
||||
countryValidationError.message,
|
||||
countryValidationError.hidden,
|
||||
setValidationErrors,
|
||||
clearValidationError,
|
||||
type,
|
||||
] );
|
||||
|
||||
id = id || instanceId;
|
||||
|
||||
return (
|
||||
<div id={ id } className="wc-block-components-address-form">
|
||||
{ addressFormFields.map( ( field ) => {
|
||||
if ( field.hidden ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ( field.key === 'country' ) {
|
||||
const Tag =
|
||||
type === 'shipping'
|
||||
? ShippingCountryInput
|
||||
: BillingCountryInput;
|
||||
return (
|
||||
<Tag
|
||||
key={ field.key }
|
||||
id={ `${ id }-${ field.key }` }
|
||||
label={
|
||||
field.required
|
||||
? field.label
|
||||
: field.optionalLabel
|
||||
}
|
||||
value={ values.country }
|
||||
autoComplete={ field.autocomplete }
|
||||
onChange={ ( newValue ) =>
|
||||
onChange( {
|
||||
...values,
|
||||
country: newValue,
|
||||
state: '',
|
||||
} )
|
||||
}
|
||||
errorId={
|
||||
type === 'shipping'
|
||||
? 'shipping-missing-country'
|
||||
: null
|
||||
}
|
||||
errorMessage={ field.errorMessage }
|
||||
required={ field.required }
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if ( field.key === 'state' ) {
|
||||
const Tag =
|
||||
type === 'shipping'
|
||||
? ShippingStateInput
|
||||
: BillingStateInput;
|
||||
return (
|
||||
<Tag
|
||||
key={ field.key }
|
||||
id={ `${ id }-${ field.key }` }
|
||||
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 }
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ValidatedTextInput
|
||||
key={ field.key }
|
||||
id={ `${ id }-${ field.key }` }
|
||||
className={ `wc-block-components-address-form__${ field.key }` }
|
||||
label={
|
||||
field.required ? field.label : field.optionalLabel
|
||||
}
|
||||
value={ values[ field.key ] }
|
||||
autoCapitalize={ field.autocapitalize }
|
||||
autoComplete={ field.autocomplete }
|
||||
onChange={ ( newValue: string ) =>
|
||||
onChange( {
|
||||
...values,
|
||||
[ field.key ]: newValue,
|
||||
} )
|
||||
}
|
||||
errorMessage={ field.errorMessage }
|
||||
required={ field.required }
|
||||
/>
|
||||
);
|
||||
} ) }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
AddressForm.propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
values: PropTypes.object.isRequired,
|
||||
fields: PropTypes.arrayOf(
|
||||
PropTypes.oneOf( Object.keys( defaultAddressFields ) )
|
||||
),
|
||||
fieldConfig: PropTypes.object,
|
||||
type: PropTypes.oneOf( [ 'billing', 'shipping' ] ),
|
||||
};
|
||||
|
||||
export default withInstanceId( AddressForm );
|
@ -0,0 +1 @@
|
||||
export { default as AddressForm } from './address-form';
|
@ -0,0 +1,131 @@
|
||||
/** @typedef { import('@woocommerce/type-defs/address-fields').CountryAddressFields } CountryAddressFields */
|
||||
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
AddressField,
|
||||
AddressFields,
|
||||
CountryAddressFields,
|
||||
defaultAddressFields,
|
||||
getSetting,
|
||||
KeyedAddressField,
|
||||
LocaleSpecificAddressField,
|
||||
} from '@woocommerce/settings';
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import { isNumber, isString } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* This is locale data from WooCommerce countries class. This doesn't match the shape of the new field data blocks uses,
|
||||
* but we can import part of it to set which fields are required.
|
||||
*
|
||||
* This supports new properties such as optionalLabel which are not used by core (yet).
|
||||
*/
|
||||
const coreLocale = getSetting< CountryAddressFields >( 'countryLocale', {} );
|
||||
|
||||
/**
|
||||
* Gets props from the core locale, then maps them to the shape we require in the client.
|
||||
*
|
||||
* Ignores "class", "type", "placeholder", and "autocomplete" props from core.
|
||||
*
|
||||
* @param {Object} localeField Locale fields from WooCommerce.
|
||||
* @return {Object} Supported locale fields.
|
||||
*/
|
||||
const getSupportedCoreLocaleProps = (
|
||||
localeField: LocaleSpecificAddressField
|
||||
): Partial< AddressField > => {
|
||||
const fields: Partial< AddressField > = {};
|
||||
|
||||
if ( localeField.label !== undefined ) {
|
||||
fields.label = localeField.label;
|
||||
}
|
||||
|
||||
if ( localeField.required !== undefined ) {
|
||||
fields.required = localeField.required;
|
||||
}
|
||||
|
||||
if ( localeField.hidden !== undefined ) {
|
||||
fields.hidden = localeField.hidden;
|
||||
}
|
||||
|
||||
if ( localeField.label !== undefined && ! localeField.optionalLabel ) {
|
||||
fields.optionalLabel = sprintf(
|
||||
/* translators: %s Field label. */
|
||||
__( '%s (optional)', 'woo-gutenberg-products-block' ),
|
||||
localeField.label
|
||||
);
|
||||
}
|
||||
|
||||
if ( localeField.priority ) {
|
||||
if ( isNumber( localeField.priority ) ) {
|
||||
fields.index = localeField.priority;
|
||||
}
|
||||
if ( isString( localeField.priority ) ) {
|
||||
fields.index = parseInt( localeField.priority, 10 );
|
||||
}
|
||||
}
|
||||
|
||||
if ( localeField.hidden ) {
|
||||
fields.required = false;
|
||||
}
|
||||
|
||||
return fields;
|
||||
};
|
||||
|
||||
const countryAddressFields: CountryAddressFields = Object.entries( coreLocale )
|
||||
.map( ( [ country, countryLocale ] ) => [
|
||||
country,
|
||||
Object.entries( countryLocale )
|
||||
.map( ( [ localeFieldKey, localeField ] ) => [
|
||||
localeFieldKey,
|
||||
getSupportedCoreLocaleProps( localeField ),
|
||||
] )
|
||||
.reduce( ( obj, [ key, val ] ) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore - Ignoring because it should be fine as long as the data from the server is correct. TS won't catch it anyway if it's not.
|
||||
obj[ key ] = val;
|
||||
return obj;
|
||||
}, {} ),
|
||||
] )
|
||||
.reduce( ( obj, [ key, val ] ) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore - Ignoring because it should be fine as long as the data from the server is correct. TS won't catch it anyway if it's not.
|
||||
obj[ key ] = val;
|
||||
return obj;
|
||||
}, {} );
|
||||
|
||||
/**
|
||||
* Combines address fields, including fields from the locale, and sorts them by index.
|
||||
*
|
||||
* @param {Array} fields List of field keys--only address fields matching these will be returned.
|
||||
* @param {Object} fieldConfigs Fields config contains field specific overrides at block level which may, for example, hide a field.
|
||||
* @param {string} addressCountry Address country code. If unknown, locale fields will not be merged.
|
||||
* @return {CountryAddressFields} Object containing address fields.
|
||||
*/
|
||||
const prepareAddressFields = (
|
||||
fields: ( keyof AddressFields )[],
|
||||
fieldConfigs: Record< string, Partial< AddressField > >,
|
||||
addressCountry = ''
|
||||
): KeyedAddressField[] => {
|
||||
const localeConfigs: AddressFields =
|
||||
addressCountry && countryAddressFields[ addressCountry ] !== undefined
|
||||
? countryAddressFields[ addressCountry ]
|
||||
: ( {} as AddressFields );
|
||||
|
||||
return fields
|
||||
.map( ( field ) => {
|
||||
const defaultConfig = defaultAddressFields[ field ] || {};
|
||||
const localeConfig = localeConfigs[ field ] || {};
|
||||
const fieldConfig = fieldConfigs[ field ] || {};
|
||||
|
||||
return {
|
||||
key: field,
|
||||
...defaultConfig,
|
||||
...localeConfig,
|
||||
...fieldConfig,
|
||||
};
|
||||
} )
|
||||
.sort( ( a, b ) => a.index - b.index );
|
||||
};
|
||||
|
||||
export default prepareAddressFields;
|
@ -0,0 +1,169 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { CheckoutProvider } from '@woocommerce/base-context';
|
||||
import { useCheckoutAddress } from '@woocommerce/base-context/hooks';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import AddressForm from '../address-form';
|
||||
|
||||
const renderInCheckoutProvider = ( ui, options = {} ) => {
|
||||
const Wrapper = ( { children } ) => {
|
||||
return <CheckoutProvider>{ children }</CheckoutProvider>;
|
||||
};
|
||||
return render( ui, { wrapper: Wrapper, ...options } );
|
||||
};
|
||||
|
||||
// Countries used in testing addresses must be in the wcSettings global.
|
||||
// See: tests/js/setup-globals.js
|
||||
const primaryAddress = {
|
||||
country: 'United Kingdom',
|
||||
countryKey: 'GB',
|
||||
city: 'London',
|
||||
state: 'Greater London',
|
||||
postcode: 'ABCD',
|
||||
};
|
||||
const secondaryAddress = {
|
||||
country: 'Austria', // We use Austria because it doesn't have states.
|
||||
countryKey: 'AU',
|
||||
city: 'Vienna',
|
||||
postcode: 'DCBA',
|
||||
};
|
||||
const tertiaryAddress = {
|
||||
country: 'Canada', // We use Canada because it has a select for the state.
|
||||
countryKey: 'CA',
|
||||
city: 'Toronto',
|
||||
state: 'Ontario',
|
||||
postcode: 'EFGH',
|
||||
};
|
||||
|
||||
const countryRegExp = /country/i;
|
||||
const cityRegExp = /city/i;
|
||||
const stateRegExp = /county|province|state/i;
|
||||
const postalCodeRegExp = /postal code|postcode|zip/i;
|
||||
|
||||
const inputAddress = async ( {
|
||||
country = null,
|
||||
city = null,
|
||||
state = null,
|
||||
postcode = null,
|
||||
} ) => {
|
||||
if ( country ) {
|
||||
const countryInput = screen.getByLabelText( countryRegExp );
|
||||
userEvent.type( countryInput, country + '{arrowdown}{enter}' );
|
||||
}
|
||||
if ( city ) {
|
||||
const cityInput = screen.getByLabelText( cityRegExp );
|
||||
userEvent.type( cityInput, city );
|
||||
}
|
||||
if ( state ) {
|
||||
const stateButton = screen.queryByRole( 'combobox', {
|
||||
name: stateRegExp,
|
||||
} );
|
||||
// State input might be a select or a text input.
|
||||
if ( stateButton ) {
|
||||
userEvent.click( stateButton );
|
||||
userEvent.click( screen.getByRole( 'option', { name: state } ) );
|
||||
} else {
|
||||
const stateInput = screen.getByLabelText( stateRegExp );
|
||||
userEvent.type( stateInput, state );
|
||||
}
|
||||
}
|
||||
if ( postcode ) {
|
||||
const postcodeInput = screen.getByLabelText( postalCodeRegExp );
|
||||
userEvent.type( postcodeInput, postcode );
|
||||
}
|
||||
};
|
||||
|
||||
describe( 'AddressForm Component', () => {
|
||||
const WrappedAddressForm = ( { type } ) => {
|
||||
const {
|
||||
defaultAddressFields,
|
||||
setShippingFields,
|
||||
shippingFields,
|
||||
} = useCheckoutAddress();
|
||||
|
||||
return (
|
||||
<AddressForm
|
||||
type={ type }
|
||||
onChange={ setShippingFields }
|
||||
values={ shippingFields }
|
||||
fields={ Object.keys( defaultAddressFields ) }
|
||||
/>
|
||||
);
|
||||
};
|
||||
const ShippingFields = () => {
|
||||
const { shippingFields } = useCheckoutAddress();
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{ Object.keys( shippingFields ).map( ( key ) => (
|
||||
<li key={ key }>{ key + ': ' + shippingFields[ key ] }</li>
|
||||
) ) }
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
it( 'updates context value when interacting with form elements', () => {
|
||||
renderInCheckoutProvider(
|
||||
<>
|
||||
<WrappedAddressForm type="shipping" />
|
||||
<ShippingFields />
|
||||
</>
|
||||
);
|
||||
|
||||
inputAddress( primaryAddress );
|
||||
|
||||
expect( screen.getByText( /country/ ) ).toHaveTextContent(
|
||||
`country: ${ primaryAddress.countryKey }`
|
||||
);
|
||||
expect( screen.getByText( /city/ ) ).toHaveTextContent(
|
||||
`city: ${ primaryAddress.city }`
|
||||
);
|
||||
expect( screen.getByText( /state/ ) ).toHaveTextContent(
|
||||
`state: ${ primaryAddress.state }`
|
||||
);
|
||||
expect( screen.getByText( /postcode/ ) ).toHaveTextContent(
|
||||
`postcode: ${ primaryAddress.postcode }`
|
||||
);
|
||||
} );
|
||||
|
||||
it( 'input fields update when changing the country', () => {
|
||||
renderInCheckoutProvider( <WrappedAddressForm type="shipping" /> );
|
||||
|
||||
inputAddress( primaryAddress );
|
||||
|
||||
// Verify correct labels are used.
|
||||
expect( screen.getByLabelText( /City/ ) ).toBeInTheDocument();
|
||||
expect( screen.getByLabelText( /County/ ) ).toBeInTheDocument();
|
||||
expect( screen.getByLabelText( /Postcode/ ) ).toBeInTheDocument();
|
||||
|
||||
inputAddress( secondaryAddress );
|
||||
|
||||
// Verify state input has been removed.
|
||||
expect( screen.queryByText( stateRegExp ) ).not.toBeInTheDocument();
|
||||
|
||||
inputAddress( tertiaryAddress );
|
||||
|
||||
// Verify postal code input label changed.
|
||||
expect( screen.getByLabelText( /Postal code/ ) ).toBeInTheDocument();
|
||||
} );
|
||||
|
||||
it( 'input values are reset after changing the country', () => {
|
||||
renderInCheckoutProvider( <WrappedAddressForm type="shipping" /> );
|
||||
|
||||
inputAddress( secondaryAddress );
|
||||
// Only update `country` to verify other values are reset.
|
||||
inputAddress( { country: primaryAddress.country } );
|
||||
expect( screen.getByLabelText( stateRegExp ).value ).toBe( '' );
|
||||
|
||||
// Repeat the test with an address which has a select for the state.
|
||||
inputAddress( tertiaryAddress );
|
||||
inputAddress( { country: primaryAddress.country } );
|
||||
expect( screen.getByLabelText( stateRegExp ).value ).toBe( '' );
|
||||
} );
|
||||
} );
|
Reference in New Issue
Block a user