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:
Sam Seay 2024-07-24 23:59:15 +08:00 committed by GitHub
parent 474b7624d0
commit 137a2e8aa6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 243 additions and 48 deletions

View File

@ -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( () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add placeholder options and validation to checkout country and state select inputs.