initial commit

This commit is contained in:
2021-12-10 12:03:04 +00:00
commit c46c7ddbf0
3643 changed files with 582794 additions and 0 deletions

View File

@ -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 );

View File

@ -0,0 +1 @@
export { default as AddressForm } from './address-form';

View File

@ -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;

View File

@ -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( '' );
} );
} );