Checkout: Add placeholder option to country and state select, add error validation (#49616)
* WIP * Add empty option for state as well * Add client-side validation to country select inputs * Add validation messages for state. * Clean up redundant destructuring. * Fix up the validation logic, fix bug where empty select was shown in some cases. * Remove unused import * Use import aliases instead of large relative paths * Add changefile(s) from automation for the following project(s): woocommerce-blocks * Fix a typo Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com> * use SelectOption type * Also handle when address changes, because we shouldnt validate state when there is an address change * adjust validation to handle any address change, not just country. translate field validations and use lowercase * add a lowercase label for the country select as well * Fix an issue where scroll to would not scroll to invalid select --------- Co-authored-by: github-actions <github-actions@github.com> Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com>
This commit is contained in:
parent
474b7624d0
commit
137a2e8aa6
|
@ -31,11 +31,12 @@ import { objectHasProp } from '@woocommerce/types';
|
|||
*/
|
||||
import { AddressFormProps, AddressFormFields } from './types';
|
||||
import prepareFormFields from './prepare-form-fields';
|
||||
import validateShippingCountry from './validate-shipping-country';
|
||||
import validateCountry from './validate-country';
|
||||
import customValidationHandler from './custom-validation-handler';
|
||||
import AddressLineFields from './address-line-fields';
|
||||
import { createFieldProps, getFieldData } from './utils';
|
||||
import { Select } from '../../select';
|
||||
import { validateState } from './validate-state';
|
||||
|
||||
/**
|
||||
* Checkout form.
|
||||
|
@ -93,15 +94,22 @@ const Form = < T extends AddressFormValues | ContactFormValues >( {
|
|||
}
|
||||
}, [ onChange, addressFormFields, values ] );
|
||||
|
||||
// Maybe validate country when other fields change so user is notified that it's required.
|
||||
// Maybe validate country and state when other fields change so user is notified that they're required.
|
||||
useEffect( () => {
|
||||
if (
|
||||
addressType === 'shipping' &&
|
||||
objectHasProp( values, 'country' )
|
||||
) {
|
||||
validateShippingCountry( values );
|
||||
if ( objectHasProp( values, 'country' ) ) {
|
||||
validateCountry( addressType, values );
|
||||
}
|
||||
}, [ values, addressType ] );
|
||||
|
||||
if ( objectHasProp( values, 'state' ) ) {
|
||||
const stateField = addressFormFields.fields.find(
|
||||
( f ) => f.key === 'state'
|
||||
);
|
||||
|
||||
if ( stateField ) {
|
||||
validateState( addressType, values, stateField );
|
||||
}
|
||||
}
|
||||
}, [ values, addressType, addressFormFields ] );
|
||||
|
||||
// Changing country may change format for postcodes.
|
||||
useEffect( () => {
|
||||
|
|
|
@ -8,10 +8,14 @@ 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 validateCountry = (
|
||||
addressType: string,
|
||||
values: ShippingAddress
|
||||
): void => {
|
||||
const validationErrorId = `${ addressType }_country`;
|
||||
const hasValidationError =
|
||||
select( VALIDATION_STORE_KEY ).getValidationError( validationErrorId );
|
||||
|
||||
if (
|
||||
! values.country &&
|
||||
( values.city || values.state || values.postcode )
|
||||
|
@ -37,4 +41,4 @@ const validateShippingCountry = ( values: ShippingAddress ): void => {
|
|||
}
|
||||
};
|
||||
|
||||
export default validateShippingCountry;
|
||||
export default validateCountry;
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { dispatch, select } from '@wordpress/data';
|
||||
import { KeyedFormField, ShippingAddress } from '@woocommerce/settings';
|
||||
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import isShallowEqual from '@wordpress/is-shallow-equal';
|
||||
|
||||
function previousAddress( initialValue?: ShippingAddress ) {
|
||||
let lastValue = initialValue;
|
||||
|
||||
function track( value: ShippingAddress ) {
|
||||
const currentValue = lastValue;
|
||||
|
||||
lastValue = value;
|
||||
|
||||
// Return the previous value
|
||||
return currentValue;
|
||||
}
|
||||
|
||||
return track;
|
||||
}
|
||||
|
||||
const lastShippingAddress = previousAddress();
|
||||
const lastBillingAddress = previousAddress();
|
||||
|
||||
export const validateState = (
|
||||
addressType: string,
|
||||
values: ShippingAddress,
|
||||
stateField: KeyedFormField
|
||||
) => {
|
||||
const validationErrorId = `${ addressType }_state`;
|
||||
const hasValidationError =
|
||||
select( VALIDATION_STORE_KEY ).getValidationError( validationErrorId );
|
||||
const isRequired = stateField.required;
|
||||
|
||||
const lastAddress =
|
||||
addressType === 'shipping'
|
||||
? lastShippingAddress( values )
|
||||
: lastBillingAddress( values );
|
||||
|
||||
const addressChanged =
|
||||
!! lastAddress && ! isShallowEqual( lastAddress, values );
|
||||
|
||||
if ( hasValidationError ) {
|
||||
if ( ! isRequired || values.state ) {
|
||||
// Validation error has been set, but it's no longer required, or the state was provided, clear the error.
|
||||
dispatch( VALIDATION_STORE_KEY ).clearValidationError(
|
||||
validationErrorId
|
||||
);
|
||||
} else if ( ! addressChanged ) {
|
||||
// Validation error has been set, there has not been an address change so show the error.
|
||||
dispatch( VALIDATION_STORE_KEY ).showValidationError(
|
||||
validationErrorId
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
! hasValidationError &&
|
||||
isRequired &&
|
||||
! values.state &&
|
||||
values.country
|
||||
) {
|
||||
// No validation has been set yet, if it's required, there is a country set and no state, set the error.
|
||||
dispatch( VALIDATION_STORE_KEY ).setValidationErrors( {
|
||||
[ validationErrorId ]: {
|
||||
message: sprintf(
|
||||
/* translators: %s will be the state field label in lowercase e.g. "state" */
|
||||
__( 'Please select a %s', 'woocommerce' ),
|
||||
stateField.label.toLowerCase()
|
||||
),
|
||||
hidden: true,
|
||||
},
|
||||
} );
|
||||
}
|
||||
};
|
|
@ -10,8 +10,7 @@ import CountryInput from './country-input';
|
|||
import type { CountryInputProps } from './CountryInputProps';
|
||||
|
||||
const BillingCountryInput = ( props: CountryInputProps ): JSX.Element => {
|
||||
// TODO - are errorMessage and errorId still relevant when select always has a value?
|
||||
const { errorMessage: _, errorId: __, ...restOfProps } = props;
|
||||
const { ...restOfProps } = props;
|
||||
|
||||
return <CountryInput countries={ ALLOWED_COUNTRIES } { ...restOfProps } />;
|
||||
};
|
||||
|
|
|
@ -4,13 +4,17 @@
|
|||
import { useMemo } from '@wordpress/element';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
import clsx from 'clsx';
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
|
||||
import { ValidationInputError } from '@woocommerce/blocks-components';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
import type { CountryInputWithCountriesProps } from './CountryInputProps';
|
||||
import { Select } from '../select';
|
||||
import { Select, SelectOption } from '../select';
|
||||
|
||||
export const CountryInput = ( {
|
||||
className,
|
||||
|
@ -21,21 +25,42 @@ export const CountryInput = ( {
|
|||
value = '',
|
||||
autoComplete = 'off',
|
||||
required = false,
|
||||
errorId,
|
||||
}: CountryInputWithCountriesProps ): JSX.Element => {
|
||||
const options = useMemo(
|
||||
() =>
|
||||
const emptyCountryOption: SelectOption = {
|
||||
value: '',
|
||||
label: sprintf(
|
||||
// translators: %s will be label of the country input. For example "country/region".
|
||||
__( 'Select a %s', 'woocommerce' ),
|
||||
label?.toLowerCase()
|
||||
),
|
||||
disabled: true,
|
||||
};
|
||||
const options = useMemo< SelectOption[] >( () => {
|
||||
return [ emptyCountryOption ].concat(
|
||||
Object.entries( countries ).map(
|
||||
( [ countryCode, countryName ] ) => ( {
|
||||
value: countryCode,
|
||||
label: decodeEntities( countryName ),
|
||||
} )
|
||||
),
|
||||
[ countries ]
|
||||
);
|
||||
)
|
||||
);
|
||||
}, [ countries ] );
|
||||
|
||||
const validationError = useSelect( ( select ) => {
|
||||
const store = select( VALIDATION_STORE_KEY );
|
||||
return (
|
||||
store.getValidationError( errorId || '' ) || {
|
||||
hidden: true,
|
||||
}
|
||||
);
|
||||
} );
|
||||
|
||||
return (
|
||||
<div
|
||||
className={ clsx( className, 'wc-block-components-country-input' ) }
|
||||
className={ clsx( className, 'wc-block-components-country-input', {
|
||||
'has-error': ! validationError.hidden,
|
||||
} ) }
|
||||
>
|
||||
<Select
|
||||
id={ id }
|
||||
|
@ -46,6 +71,11 @@ export const CountryInput = ( {
|
|||
required={ required }
|
||||
autoComplete={ autoComplete }
|
||||
/>
|
||||
{ validationError && validationError.hidden !== true && (
|
||||
<ValidationInputError
|
||||
errorMessage={ validationError.message }
|
||||
/>
|
||||
) }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -9,11 +9,17 @@ import { useCallback, useId } from '@wordpress/element';
|
|||
*/
|
||||
import './style.scss';
|
||||
|
||||
export type SelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type SelectProps = Omit<
|
||||
React.SelectHTMLAttributes< HTMLSelectElement >,
|
||||
'onChange'
|
||||
> & {
|
||||
options: { value: string; label: string }[];
|
||||
options: SelectOption[];
|
||||
label: string;
|
||||
onChange: ( newVal: string ) => void;
|
||||
};
|
||||
|
@ -56,6 +62,11 @@ export const Select = ( props: SelectProps ) => {
|
|||
key={ option.value }
|
||||
value={ option.value }
|
||||
data-alternate-values={ `[${ option.label }]` }
|
||||
disabled={
|
||||
option.disabled !== undefined
|
||||
? option.disabled
|
||||
: false
|
||||
}
|
||||
>
|
||||
{ option.label }
|
||||
</option>
|
||||
|
|
|
@ -21,6 +21,10 @@
|
|||
box-shadow: 0 0 0 2px $input-border-gray;
|
||||
}
|
||||
}
|
||||
|
||||
.has-error & {
|
||||
border-color: $alert-red;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-blocks-components-select__select {
|
||||
|
@ -43,6 +47,10 @@
|
|||
.has-dark-controls & {
|
||||
color: $input-text-dark;
|
||||
}
|
||||
|
||||
.has-error & {
|
||||
color: $alert-red;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-blocks-components-select__label {
|
||||
|
@ -65,6 +73,11 @@
|
|||
.has-dark-controls & {
|
||||
color: $input-placeholder-dark;
|
||||
}
|
||||
|
||||
.has-error & {
|
||||
color: $alert-red;
|
||||
}
|
||||
|
||||
@media screen and (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
}
|
||||
|
@ -83,5 +96,9 @@
|
|||
.has-dark-controls & {
|
||||
fill: $input-text-dark;
|
||||
}
|
||||
|
||||
.has-error & {
|
||||
fill: $alert-red;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,8 +10,7 @@ import StateInput from './state-input';
|
|||
import type { StateInputProps } from './StateInputProps';
|
||||
|
||||
const BillingStateInput = ( props: StateInputProps ): JSX.Element => {
|
||||
// TODO - are errorMessage and errorId still relevant when select always has a value?
|
||||
const { errorMessage: _, errorId: __, ...restOfProps } = props;
|
||||
const { ...restOfProps } = props;
|
||||
|
||||
return <StateInput states={ ALLOWED_STATES } { ...restOfProps } />;
|
||||
};
|
||||
|
|
|
@ -3,14 +3,21 @@
|
|||
*/
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
import { useCallback, useMemo, useEffect, useRef } from '@wordpress/element';
|
||||
import { ValidatedTextInput } from '@woocommerce/blocks-components';
|
||||
import {
|
||||
ValidatedTextInput,
|
||||
ValidationInputError,
|
||||
} from '@woocommerce/blocks-components';
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
import type { StateInputWithStatesProps } from './StateInputProps';
|
||||
import { Select } from '../select';
|
||||
import { Select, SelectOption } from '../select';
|
||||
|
||||
const optionMatcher = (
|
||||
value: string,
|
||||
|
@ -34,18 +41,40 @@ const StateInput = ( {
|
|||
autoComplete = 'off',
|
||||
value = '',
|
||||
required = false,
|
||||
errorId,
|
||||
}: StateInputWithStatesProps ): JSX.Element => {
|
||||
const countryStates = states[ country ];
|
||||
const options = useMemo(
|
||||
() =>
|
||||
countryStates
|
||||
? Object.keys( countryStates ).map( ( key ) => ( {
|
||||
value: key,
|
||||
label: decodeEntities( countryStates[ key ] ),
|
||||
} ) )
|
||||
: [],
|
||||
[ countryStates ]
|
||||
);
|
||||
const options = useMemo< SelectOption[] >( () => {
|
||||
if ( countryStates && Object.keys( countryStates ).length > 0 ) {
|
||||
const emptyStateOption: SelectOption = {
|
||||
value: '',
|
||||
label: sprintf(
|
||||
/* translators: %s will be the type of province depending on country, e.g "state" or "state/county" or "department" */
|
||||
__( 'Select a %s', 'woocommerce' ),
|
||||
label?.toLowerCase()
|
||||
),
|
||||
disabled: true,
|
||||
};
|
||||
|
||||
return [
|
||||
emptyStateOption,
|
||||
...Object.keys( countryStates ).map( ( key ) => ( {
|
||||
value: key,
|
||||
label: decodeEntities( countryStates[ key ] ),
|
||||
} ) ),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}, [ countryStates, label ] );
|
||||
|
||||
const validationError = useSelect( ( select ) => {
|
||||
const store = select( VALIDATION_STORE_KEY );
|
||||
return (
|
||||
store.getValidationError( errorId || '' ) || {
|
||||
hidden: true,
|
||||
}
|
||||
);
|
||||
} );
|
||||
|
||||
/**
|
||||
* Handles state selection onChange events. Finds a matching state by key or value.
|
||||
|
@ -88,18 +117,35 @@ const StateInput = ( {
|
|||
|
||||
if ( options.length > 0 ) {
|
||||
return (
|
||||
<Select
|
||||
options={ options }
|
||||
label={ label || '' }
|
||||
className={ `wc-block-components-state-input ${
|
||||
className || ''
|
||||
}` }
|
||||
id={ id }
|
||||
onChange={ onChangeState }
|
||||
value={ value }
|
||||
autoComplete={ autoComplete }
|
||||
required={ required }
|
||||
/>
|
||||
<div
|
||||
className={ clsx(
|
||||
className,
|
||||
'wc-block-components-state-input',
|
||||
{
|
||||
'has-error': ! validationError.hidden,
|
||||
}
|
||||
) }
|
||||
>
|
||||
<Select
|
||||
options={ options }
|
||||
label={ label || '' }
|
||||
className={ `${ className || '' }` }
|
||||
id={ id }
|
||||
onChange={ ( newValue ) => {
|
||||
if ( required ) {
|
||||
}
|
||||
onChangeState( newValue );
|
||||
} }
|
||||
value={ value }
|
||||
autoComplete={ autoComplete }
|
||||
required={ required }
|
||||
/>
|
||||
{ validationError && validationError.hidden !== true && (
|
||||
<ValidationInputError
|
||||
errorMessage={ validationError.message }
|
||||
/>
|
||||
) }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -139,7 +139,8 @@ const ScrollOnError = ( {
|
|||
// Scroll after a short timeout to allow a re-render. This will allow focusableSelector to match updated components.
|
||||
scrollToTopTimeout = window.setTimeout( () => {
|
||||
scrollToTop( {
|
||||
focusableSelector: 'input:invalid, .has-error input',
|
||||
focusableSelector:
|
||||
'input:invalid, .has-error input, .has-error select',
|
||||
} );
|
||||
}, 50 );
|
||||
}
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Add placeholder options and validation to checkout country and state select inputs.
|
Loading…
Reference in New Issue