* Add Checkout form validation

* Add back validation when filling the address without having set a country

* Split TextInput and Select so they can be used with or without validation

* Cleanup

* Only display the missing country error if city, state or postcode are entered

* Fix CSS specificity conflict

* Remove unnecessary semicolon

* Rename areThereValidationErrors to hasValidationErrors
This commit is contained in:
Albert Juhé Lluveras 2020-03-23 12:22:00 +01:00 committed by GitHub
parent d07b7c6b52
commit 2593fcf7da
19 changed files with 843 additions and 455 deletions

View File

@ -2,7 +2,7 @@
* External dependencies * External dependencies
*/ */
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import TextInput from '@woocommerce/base-components/text-input'; import { ValidatedTextInput } from '@woocommerce/base-components/text-input';
import { import {
BillingCountryInput, BillingCountryInput,
ShippingCountryInput, ShippingCountryInput,
@ -21,7 +21,9 @@ import { __ } from '@wordpress/i18n';
import defaultAddressFields from './default-address-fields'; import defaultAddressFields from './default-address-fields';
import countryAddressFields from './country-address-fields'; import countryAddressFields from './country-address-fields';
const validateCountry = ( // 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, values,
setValidationErrors, setValidationErrors,
clearValidationError, clearValidationError,
@ -30,17 +32,20 @@ const validateCountry = (
if ( if (
! hasValidationError && ! hasValidationError &&
! values.country && ! values.country &&
Object.values( values ).some( ( value ) => value !== '' ) ( values.city || values.state || values.postcode )
) { ) {
setValidationErrors( { setValidationErrors( {
country: __( 'shipping-missing-country': {
message: __(
'Please select a country to calculate rates.', 'Please select a country to calculate rates.',
'woo-gutenberg-products-block' 'woo-gutenberg-products-block'
), ),
hidden: false,
},
} ); } );
} }
if ( hasValidationError && values.country ) { if ( hasValidationError && values.country ) {
clearValidationError( 'country' ); clearValidationError( 'shipping-missing-country' );
} }
}; };
@ -63,26 +68,31 @@ const AddressForm = ( {
const addressFields = fields.map( ( field ) => ( { const addressFields = fields.map( ( field ) => ( {
key: field, key: field,
...defaultAddressFields[ field ], ...defaultAddressFields[ field ],
...fieldConfig[ field ],
...countryLocale[ field ], ...countryLocale[ field ],
...fieldConfig[ field ],
} ) ); } ) );
const sortedAddressFields = addressFields.sort( const sortedAddressFields = addressFields.sort(
( a, b ) => a.index - b.index ( a, b ) => a.index - b.index
); );
const countryValidationError = getValidationError( 'country' ); const countryValidationError =
getValidationError( 'shipping-missing-country' ) || {};
useEffect( () => { useEffect( () => {
validateCountry( if ( type === 'shipping' ) {
validateShippingCountry(
values, values,
setValidationErrors, setValidationErrors,
clearValidationError, clearValidationError,
!! countryValidationError countryValidationError.message &&
! countryValidationError.hidden
); );
}
}, [ }, [
values, values,
countryValidationError, countryValidationError,
setValidationErrors, setValidationErrors,
clearValidationError, clearValidationError,
] ); ] );
return ( return (
<div className="wc-block-address-form"> <div className="wc-block-address-form">
{ sortedAddressFields.map( ( field ) => { { sortedAddressFields.map( ( field ) => {
@ -114,6 +124,12 @@ const AddressForm = ( {
postcode: '', postcode: '',
} ) } )
} }
errorId={
type === 'shipping'
? 'shipping-missing-country'
: null
}
errorMessage={ field.errorMessage }
required={ field.required } required={ field.required }
/> />
); );
@ -141,13 +157,14 @@ const AddressForm = ( {
state: newValue, state: newValue,
} ) } )
} }
errorMessage={ field.errorMessage }
required={ field.required } required={ field.required }
/> />
); );
} }
return ( return (
<TextInput <ValidatedTextInput
key={ field.key } key={ field.key }
className={ `wc-block-address-form__${ field.key }` } className={ `wc-block-address-form__${ field.key }` }
label={ label={
@ -161,6 +178,7 @@ const AddressForm = ( {
[ field.key ]: newValue, [ field.key ]: newValue,
} ) } )
} }
errorMessage={ field.errorMessage }
required={ field.required } required={ field.required }
/> />
); );

View File

@ -2,23 +2,33 @@
* External dependencies * External dependencies
*/ */
import { useCheckoutContext } from '@woocommerce/base-context'; import { useCheckoutContext } from '@woocommerce/base-context';
import PropTypes from 'prop-types';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import Button from '../button'; import Button from '../button';
const PlaceOrderButton = () => { const PlaceOrderButton = ( { validateSubmit } ) => {
const { submitLabel, onSubmit } = useCheckoutContext(); const { submitLabel, onSubmit } = useCheckoutContext();
return ( return (
<Button <Button
className="wc-block-components-checkout-place-order-button" className="wc-block-components-checkout-place-order-button"
onClick={ onSubmit } onClick={ () => {
const isValid = validateSubmit();
if ( isValid ) {
onSubmit();
}
} }
> >
{ submitLabel } { submitLabel }
</Button> </Button>
); );
}; };
PlaceOrderButton.propTypes = {
validateSubmit: PropTypes.func.isRequired,
};
export default PlaceOrderButton; export default PlaceOrderButton;

View File

@ -1,16 +1,15 @@
/** /**
* External dependencies * External dependencies
*/ */
import { __ } from '@wordpress/i18n';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { decodeEntities } from '@wordpress/html-entities'; import { decodeEntities } from '@wordpress/html-entities';
import { useValidationContext } from '@woocommerce/base-context';
import classnames from 'classnames'; import classnames from 'classnames';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import Select from '../select'; import { ValidatedSelect } from '../select';
import { ValidationInputError } from '../validation';
const CountryInput = ( { const CountryInput = ( {
className, className,
@ -20,23 +19,27 @@ const CountryInput = ( {
value = '', value = '',
autoComplete = 'off', autoComplete = 'off',
required = false, required = false,
errorId,
errorMessage = __(
'Please select a country.',
'woo-gutenberg-products-block'
),
} ) => { } ) => {
const options = Object.keys( countries ).map( ( key ) => ( { const options = Object.keys( countries ).map( ( key ) => ( {
key, key,
name: decodeEntities( countries[ key ] ), name: decodeEntities( countries[ key ] ),
} ) ); } ) );
const { getValidationError } = useValidationContext();
const errorMessage = getValidationError( 'country' );
return ( return (
<div className={ classnames( className, 'wc-block-country-input' ) }> <div className={ classnames( className, 'wc-block-country-input' ) }>
<Select <ValidatedSelect
label={ label } label={ label }
onChange={ onChange } onChange={ onChange }
options={ options } options={ options }
value={ options.find( ( option ) => option.key === value ) } value={ options.find( ( option ) => option.key === value ) }
errorId={ errorId }
errorMessage={ errorMessage }
required={ required } required={ required }
hasError={ !! errorMessage }
/> />
{ autoComplete !== 'off' && ( { autoComplete !== 'off' && (
<input <input
@ -61,7 +64,6 @@ const CountryInput = ( {
tabIndex={ -1 } tabIndex={ -1 }
/> />
) } ) }
<ValidationInputError errorMessage={ errorMessage } />
</div> </div>
); );
}; };
@ -73,6 +75,8 @@ CountryInput.propTypes = {
label: PropTypes.string, label: PropTypes.string,
value: PropTypes.string, value: PropTypes.string,
autoComplete: PropTypes.string, autoComplete: PropTypes.string,
errorId: PropTypes.string,
errorMessage: PropTypes.string,
}; };
export default CountryInput; export default CountryInput;

View File

@ -10,13 +10,23 @@ import { CustomSelectControl } from 'wordpress-components';
*/ */
import './style.scss'; import './style.scss';
const Select = ( { className, label, onChange, options, value, hasError } ) => { const Select = ( {
className,
feedback,
id,
label,
onChange,
options,
value,
} ) => {
return ( return (
<CustomSelectControl <div
id={ id }
className={ classnames( 'wc-block-select', className, { className={ classnames( 'wc-block-select', className, {
'is-active': value, 'is-active': value,
'has-error': hasError,
} ) } } ) }
>
<CustomSelectControl
label={ label } label={ label }
onChange={ ( { selectedItem } ) => { onChange={ ( { selectedItem } ) => {
onChange( selectedItem.key ); onChange( selectedItem.key );
@ -24,6 +34,8 @@ const Select = ( { className, label, onChange, options, value, hasError } ) => {
options={ options } options={ options }
value={ value } value={ value }
/> />
{ feedback }
</div>
); );
}; };
@ -36,6 +48,8 @@ Select.propTypes = {
} ).isRequired } ).isRequired
).isRequired, ).isRequired,
className: PropTypes.string, className: PropTypes.string,
feedback: PropTypes.node,
id: PropTypes.string,
label: PropTypes.string, label: PropTypes.string,
value: PropTypes.shape( { value: PropTypes.shape( {
key: PropTypes.string.isRequired, key: PropTypes.string.isRequired,
@ -44,3 +58,4 @@ Select.propTypes = {
}; };
export default Select; export default Select;
export { default as ValidatedSelect } from './validated';

View File

@ -0,0 +1,82 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useEffect } from 'react';
import { useValidationContext } from '@woocommerce/base-context';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { withInstanceId } from 'wordpress-compose';
/**
* Internal dependencies
*/
import { ValidationInputError } from '../validation';
import Select from './index';
import './style.scss';
const ValidatedSelect = ( {
className,
id,
value,
instanceId,
required,
errorId,
errorMessage = __(
'Please select a value.',
'woo-gutenberg-products-block'
),
...rest
} ) => {
const selectId = id || 'select-' + instanceId;
errorId = errorId || selectId;
const {
getValidationError,
setValidationErrors,
clearValidationError,
} = useValidationContext();
const validateSelect = () => {
if ( ! required || value ) {
clearValidationError( errorId );
} else {
setValidationErrors( {
[ errorId ]: {
message: errorMessage,
hidden: true,
},
} );
}
};
useEffect( () => {
validateSelect();
}, [ value ] );
const error = getValidationError( errorId ) || {};
return (
<Select
id={ selectId }
className={ classnames( className, {
'has-error': error.message && ! error.hidden,
} ) }
feedback={ <ValidationInputError propertyName={ errorId } /> }
value={ value }
{ ...rest }
/>
);
};
ValidatedSelect.propTypes = {
className: PropTypes.string,
errorId: PropTypes.string,
errorMessage: PropTypes.string,
id: PropTypes.string,
required: PropTypes.bool,
value: PropTypes.shape( {
key: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
} ),
};
export default withInstanceId( ValidatedSelect );

View File

@ -20,24 +20,56 @@ const ShippingCalculatorAddress = ( {
addressFields, addressFields,
} ) => { } ) => {
const [ address, setAddress ] = useState( initialAddress ); const [ address, setAddress ] = useState( initialAddress );
const { getValidationError } = useValidationContext(); const {
hasValidationErrors,
showAllValidationErrors,
} = useValidationContext();
const validateSubmit = () => {
showAllValidationErrors();
if ( hasValidationErrors() ) {
return false;
}
return true;
};
// Make all fields optional except 'country'.
const fieldConfig = {};
addressFields.forEach( ( field ) => {
if ( field === 'country' ) {
fieldConfig[ field ] = {
...fieldConfig[ field ],
errorMessage: __(
'Please select a country to calculate rates.',
'woo-gutenberg-products-block'
),
required: true,
};
} else {
fieldConfig[ field ] = {
...fieldConfig[ field ],
required: false,
};
}
} );
return ( return (
<form className="wc-block-shipping-calculator-address"> <form className="wc-block-shipping-calculator-address">
<AddressForm <AddressForm
fields={ addressFields } fields={ addressFields }
fieldConfig={ fieldConfig }
onChange={ setAddress } onChange={ setAddress }
values={ address } values={ address }
/> />
<Button <Button
className="wc-block-shipping-calculator-address__button" className="wc-block-shipping-calculator-address__button"
disabled={ disabled={ isShallowEqual( address, initialAddress ) }
isShallowEqual( address, initialAddress ) ||
getValidationError( 'country' )
}
onClick={ ( e ) => { onClick={ ( e ) => {
e.preventDefault(); e.preventDefault();
const isAddressValid = validateSubmit();
if ( isAddressValid ) {
return onUpdate( address ); return onUpdate( address );
}
} } } }
type="submit" type="submit"
> >

View File

@ -1,6 +1,7 @@
/** /**
* External dependencies * External dependencies
*/ */
import { __ } from '@wordpress/i18n';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { decodeEntities } from '@wordpress/html-entities'; import { decodeEntities } from '@wordpress/html-entities';
import { useCallback, useEffect } from '@wordpress/element'; import { useCallback, useEffect } from '@wordpress/element';
@ -8,8 +9,8 @@ import { useCallback, useEffect } from '@wordpress/element';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import TextInput from '../text-input'; import { ValidatedTextInput } from '../text-input';
import Select from '../select'; import { ValidatedSelect } from '../select';
const StateInput = ( { const StateInput = ( {
className, className,
@ -61,12 +62,16 @@ const StateInput = ( {
if ( options.length > 0 ) { if ( options.length > 0 ) {
return ( return (
<> <>
<Select <ValidatedSelect
className={ className } className={ className }
label={ label } label={ label }
onChange={ onChangeState } onChange={ onChangeState }
options={ options } options={ options }
value={ options.find( ( option ) => option.key === value ) } value={ options.find( ( option ) => option.key === value ) }
errorMessage={ __(
'Please select a state.',
'woo-gutenberg-products-block'
) }
required={ required } required={ required }
/> />
{ autoComplete !== 'off' && ( { autoComplete !== 'off' && (
@ -92,7 +97,7 @@ const StateInput = ( {
); );
} }
return ( return (
<TextInput <ValidatedTextInput
className={ className } className={ className }
label={ label } label={ label }
onChange={ onChangeState } onChange={ onChangeState }

View File

@ -1,10 +1,10 @@
/** /**
* External dependencies * External dependencies
*/ */
import { forwardRef } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classnames from 'classnames'; import classnames from 'classnames';
import { useState } from '@wordpress/element'; import { useState } from '@wordpress/element';
import { withInstanceId } from 'wordpress-compose';
/** /**
* Internal dependencies * Internal dependencies
@ -12,9 +12,10 @@ import { withInstanceId } from 'wordpress-compose';
import Label from '../label'; import Label from '../label';
import './style.scss'; import './style.scss';
const TextInput = ( { const TextInput = forwardRef(
(
{
className, className,
instanceId,
id, id,
type = 'text', type = 'text',
ariaLabel, ariaLabel,
@ -27,10 +28,12 @@ const TextInput = ( {
value = '', value = '',
onChange, onChange,
required = false, required = false,
} ) => { onBlur = () => {},
feedback,
},
ref
) => {
const [ isActive, setIsActive ] = useState( false ); const [ isActive, setIsActive ] = useState( false );
const onChangeValue = ( event ) => onChange( event.target.value );
const textInputId = id || 'textinput-' + instanceId;
return ( return (
<div <div
@ -40,17 +43,23 @@ const TextInput = ( {
> >
<input <input
type={ type } type={ type }
id={ textInputId } id={ id }
value={ value } value={ value }
ref={ ref }
autoComplete={ autoComplete } autoComplete={ autoComplete }
onChange={ onChangeValue } onChange={ ( event ) => {
onChange( event.target.value );
} }
onFocus={ () => setIsActive( true ) } onFocus={ () => setIsActive( true ) }
onBlur={ () => setIsActive( false ) } onBlur={ () => {
onBlur();
setIsActive( false );
} }
aria-label={ ariaLabel || label } aria-label={ ariaLabel || label }
disabled={ disabled } disabled={ disabled }
aria-describedby={ aria-describedby={
!! help && ! ariaDescribedBy !! help && ! ariaDescribedBy
? textInputId + '__help' ? id + '__help'
: ariaDescribedBy : ariaDescribedBy
} }
required={ required } required={ required }
@ -60,25 +69,27 @@ const TextInput = ( {
screenReaderLabel={ screenReaderLabel || label } screenReaderLabel={ screenReaderLabel || label }
wrapperElement="label" wrapperElement="label"
wrapperProps={ { wrapperProps={ {
htmlFor: textInputId, htmlFor: id,
} } } }
htmlFor={ textInputId } htmlFor={ id }
/> />
{ !! help && ( { !! help && (
<p <p
id={ textInputId + '__help' } id={ id + '__help' }
className="wc-block-text-input__help" className="wc-block-text-input__help"
> >
{ help } { help }
</p> </p>
) } ) }
{ feedback }
</div> </div>
); );
}; }
);
TextInput.propTypes = { TextInput.propTypes = {
id: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
id: PropTypes.string,
value: PropTypes.string, value: PropTypes.string,
ariaLabel: PropTypes.string, ariaLabel: PropTypes.string,
ariaDescribedBy: PropTypes.string, ariaDescribedBy: PropTypes.string,
@ -90,4 +101,5 @@ TextInput.propTypes = {
required: PropTypes.bool, required: PropTypes.bool,
}; };
export default withInstanceId( TextInput ); export default TextInput;
export { default as ValidatedTextInput } from './validated';

View File

@ -3,24 +3,7 @@
margin-bottom: $gap-large; margin-bottom: $gap-large;
white-space: nowrap; white-space: nowrap;
&.has-error input { label {
/**
* This is needed because of the input[type="*"] rules later in this
* file (and in themes like storefront) have higher specificity for
* border
*/
border-color: $error-red !important;
&:focus {
outline-color: $error-red;
}
}
&.has-error label {
color: $error-red;
}
}
.wc-block-text-input label {
position: absolute; position: absolute;
transform: translateY(#{$gap-small}); transform: translateY(#{$gap-small});
left: 0; left: 0;
@ -28,7 +11,7 @@
font-size: 16px; font-size: 16px;
line-height: 22px; line-height: 22px;
color: $gray-50; color: $gray-50;
transition: all 200ms ease; transition: transform 200ms ease;
margin: 0 $gap; margin: 0 $gap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -38,16 +21,19 @@
transition: none; transition: none;
} }
} }
.wc-block-text-input input:-webkit-autofill + label {
input:-webkit-autofill + label {
transform: translateY(#{$gap-smallest}) scale(0.75); transform: translateY(#{$gap-smallest}) scale(0.75);
} }
.wc-block-text-input.is-active label {
&.is-active label {
transform: translateY(#{$gap-smallest}) scale(0.75); transform: translateY(#{$gap-smallest}) scale(0.75);
} }
.wc-block-text-input input[type="tel"],
.wc-block-text-input input[type="url"], input[type="tel"],
.wc-block-text-input input[type="text"], input[type="url"],
.wc-block-text-input input[type="email"] { input[type="text"],
input[type="email"] {
background-color: #fff; background-color: #fff;
padding: $gap-small $gap; padding: $gap-small $gap;
border-radius: 4px; border-radius: 4px;
@ -65,9 +51,22 @@
background-color: #fff; background-color: #fff;
} }
} }
.wc-block-text-input.is-active input[type="tel"],
.wc-block-text-input.is-active input[type="url"], &.is-active input[type="tel"],
.wc-block-text-input.is-active input[type="text"], &.is-active input[type="url"],
.wc-block-text-input.is-active input[type="email"] { &.is-active input[type="text"],
&.is-active input[type="email"] {
padding: $gap-large $gap $gap-smallest; padding: $gap-large $gap $gap-smallest;
} }
&.has-error input {
border-color: $error-red;
&:focus {
outline-color: $error-red;
}
}
&.has-error label {
color: $error-red;
}
}

View File

@ -0,0 +1,101 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useRef, useEffect } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { useValidationContext } from '@woocommerce/base-context';
import { ValidationInputError } from '@woocommerce/base-components/validation';
import { withInstanceId } from 'wordpress-compose';
/**
* Internal dependencies
*/
import TextInput from './index';
import './style.scss';
const ValidatedTextInput = ( {
className,
instanceId,
id,
ariaDescribedBy,
errorId,
validateOnMount = true,
onChange,
showError = true,
...rest
} ) => {
const inputRef = useRef();
const {
getValidationError,
hideValidationError,
setValidationErrors,
clearValidationError,
getValidationErrorId,
} = useValidationContext();
const textInputId = id || 'textinput-' + instanceId;
errorId = errorId || textInputId;
const validateInput = ( errorsHidden = true ) => {
if ( inputRef.current.checkValidity() ) {
clearValidationError( errorId );
} else {
setValidationErrors( {
[ errorId ]: {
message:
inputRef.current.validationMessage ||
__( 'Invalid value.', 'woo-gutenberg-products-block' ),
hidden: errorsHidden,
},
} );
}
};
useEffect( () => {
if ( validateOnMount ) {
validateInput();
}
}, [ validateOnMount ] );
const errorMessage = getValidationError( errorId ) || {};
const hasError = errorMessage.message && ! errorMessage.hidden;
const describedBy =
showError && hasError
? getValidationErrorId( textInputId )
: ariaDescribedBy;
return (
<TextInput
className={ classnames( className, {
'has-error': hasError,
} ) }
id={ textInputId }
onBlur={ () => {
validateInput( false );
} }
feedback={
showError && <ValidationInputError propertyName={ errorId } />
}
ref={ inputRef }
onChange={ ( val ) => {
hideValidationError( errorId );
onChange( val );
} }
ariaDescribedBy={ describedBy }
{ ...rest }
/>
);
};
ValidatedTextInput.propTypes = {
onChange: PropTypes.func.isRequired,
id: PropTypes.string,
value: PropTypes.string,
ariaDescribedBy: PropTypes.string,
errorId: PropTypes.string,
validateOnMount: PropTypes.bool,
showError: PropTypes.bool,
};
export default withInstanceId( ValidatedTextInput );

View File

@ -5,12 +5,11 @@ import { __ } from '@wordpress/i18n';
import { useState, useEffect, useRef } from '@wordpress/element'; import { useState, useEffect, useRef } from '@wordpress/element';
import { PanelBody, PanelRow } from 'wordpress-components'; import { PanelBody, PanelRow } from 'wordpress-components';
import { Button } from '@woocommerce/base-components/cart-checkout'; import { Button } from '@woocommerce/base-components/cart-checkout';
import TextInput from '@woocommerce/base-components/text-input'; import { ValidatedTextInput } from '@woocommerce/base-components/text-input';
import Label from '@woocommerce/base-components/label'; import Label from '@woocommerce/base-components/label';
import { ValidationInputError } from '@woocommerce/base-components/validation'; import { ValidationInputError } from '@woocommerce/base-components/validation';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { withInstanceId } from 'wordpress-compose'; import { withInstanceId } from 'wordpress-compose';
import classnames from 'classnames';
import { useValidationContext } from '@woocommerce/base-context'; import { useValidationContext } from '@woocommerce/base-context';
/** /**
@ -27,21 +26,18 @@ const TotalsCouponCodeInput = ( {
} ) => { } ) => {
const [ couponValue, setCouponValue ] = useState( '' ); const [ couponValue, setCouponValue ] = useState( '' );
const currentIsLoading = useRef( false ); const currentIsLoading = useRef( false );
const { const { getValidationError, getValidationErrorId } = useValidationContext();
getValidationError,
clearValidationError, const validationError = getValidationError( 'coupon' );
getValidationErrorId,
} = useValidationContext();
const validationMessage = getValidationError( 'coupon' );
useEffect( () => { useEffect( () => {
if ( currentIsLoading.current !== isLoading ) { if ( currentIsLoading.current !== isLoading ) {
if ( ! isLoading && couponValue && ! validationMessage ) { if ( ! isLoading && couponValue && ! validationError ) {
setCouponValue( '' ); setCouponValue( '' );
} }
currentIsLoading.current = isLoading; currentIsLoading.current = isLoading;
} }
}, [ isLoading, couponValue, validationMessage ] ); }, [ isLoading, couponValue, validationError ] );
const textInputId = `wc-block-coupon-code__input-${ instanceId }`; const textInputId = `wc-block-coupon-code__input-${ instanceId }`;
@ -73,14 +69,10 @@ const TotalsCouponCodeInput = ( {
> >
<PanelRow className="wc-block-coupon-code__row"> <PanelRow className="wc-block-coupon-code__row">
<form className="wc-block-coupon-code__form"> <form className="wc-block-coupon-code__form">
<TextInput <ValidatedTextInput
id={ textInputId } id={ textInputId }
className={ classnames( errorId="coupon"
'wc-block-coupon-code__input', className="wc-block-coupon-code__input"
{
'has-error': !! validationMessage,
}
) }
label={ __( label={ __(
'Enter code', 'Enter code',
'woo-gutenberg-products-block' 'woo-gutenberg-products-block'
@ -91,8 +83,9 @@ const TotalsCouponCodeInput = ( {
) } ) }
onChange={ ( newCouponValue ) => { onChange={ ( newCouponValue ) => {
setCouponValue( newCouponValue ); setCouponValue( newCouponValue );
clearValidationError( 'coupon' );
} } } }
validateOnMount={ false }
showError={ false }
/> />
<Button <Button
className="wc-block-coupon-code__button" className="wc-block-coupon-code__button"
@ -106,11 +99,11 @@ const TotalsCouponCodeInput = ( {
{ __( 'Apply', 'woo-gutenberg-products-block' ) } { __( 'Apply', 'woo-gutenberg-products-block' ) }
</Button> </Button>
</form> </form>
</PanelRow>
<ValidationInputError <ValidationInputError
errorMessage={ validationMessage } propertyName="coupon"
elementId={ textInputId } elementId={ textInputId }
/> />
</PanelRow>
</LoadingMask> </LoadingMask>
</PanelBody> </PanelBody>
); );

View File

@ -3,6 +3,7 @@
margin-bottom: 0; margin-bottom: 0;
.wc-block-coupon-code__input { .wc-block-coupon-code__input {
margin-bottom: 0;
margin-top: 0; margin-top: 0;
flex-grow: 1; flex-grow: 1;
} }
@ -16,3 +17,13 @@
white-space: nowrap; white-space: nowrap;
} }
} }
.wc-block-coupon-code__row {
flex-direction: column;
.wc-block-form-input-validation-error {
margin-top: $gap-smaller;
position: relative;
width: 100%;
}
}

View File

@ -1,13 +1,18 @@
.wc-block-form-input-validation-error { .wc-block-form-input-validation-error {
color: $error-red; color: $error-red;
@include font-size(12); @include font-size(12);
margin-top: -$gap-large; max-width: 100%;
padding-top: $gap-smallest; position: absolute;
display: block; top: calc(100% - 1px);
white-space: normal;
> p { > p {
padding: 0; align-items: center;
display: flex;
line-height: 12px; // This allows two lines in a 24px bottom margin.
margin: 0; margin: 0;
min-height: 24px;
padding: 0;
} }
} }

View File

@ -15,10 +15,15 @@ export const ValidationInputError = ( {
elementId = '', elementId = '',
} ) => { } ) => {
const { getValidationError, getValidationErrorId } = useValidationContext(); const { getValidationError, getValidationErrorId } = useValidationContext();
if ( ! errorMessage && ! propertyName ) { if ( ! errorMessage ) {
const error = getValidationError( propertyName ) || {};
if ( error.message && ! error.hidden ) {
errorMessage = error.message;
} else {
return null; return null;
} }
errorMessage = errorMessage || getValidationError( propertyName ); }
return ( return (
<div className="wc-block-form-input-validation-error" role="alert"> <div className="wc-block-form-input-validation-error" role="alert">
<p id={ getValidationErrorId( elementId ) }>{ errorMessage }</p> <p id={ getValidationErrorId( elementId ) }>{ errorMessage }</p>

View File

@ -38,11 +38,9 @@ export const ValidationContextProvider = ( { children } ) => {
* *
* @param {string} property The property the error message is for. * @param {string} property The property the error message is for.
* *
* @return {string} Either the error message for the given property or an * @return {Object} The error object for the given property.
* empty string.
*/ */
const getValidationError = ( property ) => const getValidationError = ( property ) => validationErrors[ property ];
validationErrors[ property ] || '';
/** /**
* Clears any validation error that exists in state for the given property * Clears any validation error that exists in state for the given property
@ -73,13 +71,82 @@ export const ValidationContextProvider = ( { children } ) => {
// all values must be a string. // all values must be a string.
newErrors = pickBy( newErrors = pickBy(
newErrors, newErrors,
( message ) => typeof message === 'string' ( { message } ) => typeof message === 'string'
); );
if ( Object.values( newErrors ).length > 0 ) { if ( Object.values( newErrors ).length > 0 ) {
updateValidationErrors( { ...validationErrors, ...newErrors } ); updateValidationErrors( ( prevErrors ) => ( {
...prevErrors,
...newErrors,
} ) );
} }
}; };
const updateValidationError = ( property, newError ) => {
updateValidationErrors( ( prevErrors ) => {
if ( ! prevErrors.hasOwnProperty( property ) ) {
return prevErrors;
}
return {
...prevErrors,
[ property ]: {
...prevErrors[ property ],
...newError,
},
};
} );
};
/**
* Given a property name and if an associated error exists, it sets its
* `hidden` value to true.
*
* @param {string} property The name of the property to set the `hidden`
* value to true.
*/
const hideValidationError = ( property ) => {
updateValidationError( property, {
hidden: true,
} );
};
/**
* Given a property name and if an associated error exists, it sets its
* `hidden` value to false.
*
* @param {string} property The name of the property to set the `hidden`
* value to false.
*/
const showValidationError = ( property ) => {
updateValidationError( property, {
hidden: false,
} );
};
/**
* Sets the `hidden` value of all errors to `false`.
*/
const showAllValidationErrors = () => {
updateValidationErrors( ( prevErrors ) => {
const newErrors = {};
Object.keys( prevErrors ).forEach( ( property ) => {
newErrors[ property ] = {
...prevErrors[ property ],
hidden: false,
};
} );
return newErrors;
} );
};
/**
* Allows checking if the current context has at least one validation error.
*
* @return {boolean} Whether there is at least one error.
*/
const hasValidationErrors = () => {
return Object.keys( validationErrors ).length > 0;
};
/** /**
* Provides an id for the validation error that can be used to fill out * Provides an id for the validation error that can be used to fill out
* aria-describedby attribute values. * aria-describedby attribute values.
@ -98,6 +165,10 @@ export const ValidationContextProvider = ( { children } ) => {
clearValidationError, clearValidationError,
clearAllValidationErrors, clearAllValidationErrors,
getValidationErrorId, getValidationErrorId,
hideValidationError,
showValidationError,
showAllValidationErrors,
hasValidationErrors,
}; };
return ( return (
<ValidationContext.Provider value={ context }> <ValidationContext.Provider value={ context }>

View File

@ -58,7 +58,9 @@ export const useStoreCartCoupons = () => {
} }
} ) } )
.catch( ( error ) => { .catch( ( error ) => {
setValidationErrors( { coupon: error.message } ); setValidationErrors( {
coupon: { message: error.message, hidden: false },
} );
// Finished handling the coupon. // Finished handling the coupon.
receiveApplyingCoupon( '' ); receiveApplyingCoupon( '' );
} ); } );

View File

@ -15,14 +15,14 @@ import {
PlaceOrderButton, PlaceOrderButton,
ReturnToCartButton, ReturnToCartButton,
} from '@woocommerce/base-components/cart-checkout'; } from '@woocommerce/base-components/cart-checkout';
import TextInput from '@woocommerce/base-components/text-input'; import { ValidatedTextInput } from '@woocommerce/base-components/text-input';
import ShippingRatesControl from '@woocommerce/base-components/shipping-rates-control'; import ShippingRatesControl from '@woocommerce/base-components/shipping-rates-control';
import CheckboxControl from '@woocommerce/base-components/checkbox-control'; import CheckboxControl from '@woocommerce/base-components/checkbox-control';
import { getCurrencyFromPriceResponse } from '@woocommerce/base-utils'; import { getCurrencyFromPriceResponse } from '@woocommerce/base-utils';
import FormattedMonetaryAmount from '@woocommerce/base-components/formatted-monetary-amount'; import FormattedMonetaryAmount from '@woocommerce/base-components/formatted-monetary-amount';
import { import {
CheckoutProvider, CheckoutProvider,
ValidationContextProvider, useValidationContext,
} from '@woocommerce/base-context'; } from '@woocommerce/base-context';
import { import {
ExpressCheckoutFormControl, ExpressCheckoutFormControl,
@ -37,6 +37,7 @@ import {
Main, Main,
} from '@woocommerce/base-components/sidebar-layout'; } from '@woocommerce/base-components/sidebar-layout';
import { getSetting } from '@woocommerce/settings'; import { getSetting } from '@woocommerce/settings';
import withScrollToTop from '@woocommerce/base-hocs/with-scroll-to-top';
/** /**
* Internal dependencies * Internal dependencies
@ -52,6 +53,7 @@ const Block = ( {
cartTotals = {}, cartTotals = {},
isEditor = false, isEditor = false,
shippingRates = [], shippingRates = [],
scrollToTop,
} ) => { } ) => {
const [ selectedShippingRate, setSelectedShippingRate ] = useState( {} ); const [ selectedShippingRate, setSelectedShippingRate ] = useState( {} );
const [ contactFields, setContactFields ] = useState( {} ); const [ contactFields, setContactFields ] = useState( {} );
@ -60,6 +62,19 @@ const Block = ( {
const [ useShippingAsBilling, setUseShippingAsBilling ] = useState( const [ useShippingAsBilling, setUseShippingAsBilling ] = useState(
attributes.useShippingAsBilling attributes.useShippingAsBilling
); );
const {
hasValidationErrors,
showAllValidationErrors,
} = useValidationContext();
const validateSubmit = () => {
if ( hasValidationErrors() ) {
showAllValidationErrors();
scrollToTop( { focusableSelector: 'input:invalid' } );
return false;
}
return true;
};
const renderShippingRatesControlOption = ( option ) => ( { const renderShippingRatesControlOption = ( option ) => ( {
label: decodeEntities( option.name ), label: decodeEntities( option.name ),
@ -100,7 +115,6 @@ const Block = ( {
} = useShippingRates(); } = useShippingRates();
return ( return (
<ValidationContextProvider>
<CheckoutProvider isEditor={ isEditor }> <CheckoutProvider isEditor={ isEditor }>
<SidebarLayout className="wc-block-checkout"> <SidebarLayout className="wc-block-checkout">
<Main> <Main>
@ -132,7 +146,7 @@ const Block = ( {
</Fragment> </Fragment>
) } ) }
> >
<TextInput <ValidatedTextInput
type="email" type="email"
label={ __( label={ __(
'Email address', 'Email address',
@ -183,7 +197,7 @@ const Block = ( {
fieldConfig={ addressFields } fieldConfig={ addressFields }
/> />
{ attributes.showPhoneField && ( { attributes.showPhoneField && (
<TextInput <ValidatedTextInput
type="tel" type="tel"
label={ label={
attributes.requirePhoneField attributes.requirePhoneField
@ -295,9 +309,7 @@ const Block = ( {
<CheckboxControl <CheckboxControl
className="wc-block-checkout__add-note" className="wc-block-checkout__add-note"
label="Add order notes?" label="Add order notes?"
checked={ checked={ selectedShippingRate.orderNote }
selectedShippingRate.orderNote
}
onChange={ () => onChange={ () =>
setSelectedShippingRate( { setSelectedShippingRate( {
...selectedShippingRate, ...selectedShippingRate,
@ -329,9 +341,7 @@ const Block = ( {
) } ) }
checked={ shouldSavePayment } checked={ shouldSavePayment }
onChange={ () => onChange={ () =>
setShouldSavePayment( setShouldSavePayment( ! shouldSavePayment )
! shouldSavePayment
)
} }
/> />
</FormStep> </FormStep>
@ -344,7 +354,9 @@ const Block = ( {
) } ) }
/> />
) } ) }
<PlaceOrderButton /> <PlaceOrderButton
validateSubmit={ validateSubmit }
/>
</div> </div>
{ attributes.showPolicyLinks && <Policies /> } { attributes.showPolicyLinks && <Policies /> }
</CheckoutForm> </CheckoutForm>
@ -358,8 +370,7 @@ const Block = ( {
</Sidebar> </Sidebar>
</SidebarLayout> </SidebarLayout>
</CheckoutProvider> </CheckoutProvider>
</ValidationContextProvider>
); );
}; };
export default Block; export default withScrollToTop( Block );

View File

@ -7,7 +7,10 @@ import {
withStoreCartApiHydration, withStoreCartApiHydration,
} from '@woocommerce/block-hocs'; } from '@woocommerce/block-hocs';
import { useStoreCart } from '@woocommerce/base-hooks'; import { useStoreCart } from '@woocommerce/base-hooks';
import { StoreNoticesProvider } from '@woocommerce/base-context'; import {
StoreNoticesProvider,
ValidationContextProvider,
} from '@woocommerce/base-context';
import BlockErrorBoundary from '@woocommerce/base-components/block-error-boundary'; import BlockErrorBoundary from '@woocommerce/base-components/block-error-boundary';
import { CURRENT_USER_IS_ADMIN } from '@woocommerce/block-settings'; import { CURRENT_USER_IS_ADMIN } from '@woocommerce/block-settings';
import { __experimentalCreateInterpolateElement } from 'wordpress-element'; import { __experimentalCreateInterpolateElement } from 'wordpress-element';
@ -59,6 +62,7 @@ const CheckoutFrontend = ( props ) => {
showErrorMessage={ CURRENT_USER_IS_ADMIN } showErrorMessage={ CURRENT_USER_IS_ADMIN }
> >
<StoreNoticesProvider context="wc/checkout"> <StoreNoticesProvider context="wc/checkout">
<ValidationContextProvider>
<Block <Block
{ ...props } { ...props }
cartCoupons={ cartCoupons } cartCoupons={ cartCoupons }
@ -66,6 +70,7 @@ const CheckoutFrontend = ( props ) => {
cartTotals={ cartTotals } cartTotals={ cartTotals }
shippingRates={ shippingRates } shippingRates={ shippingRates }
/> />
</ValidationContextProvider>
</StoreNoticesProvider> </StoreNoticesProvider>
</BlockErrorBoundary> </BlockErrorBoundary>
) } ) }

View File

@ -218,10 +218,9 @@
/** /**
* @typedef {Object} ValidationContext * @typedef {Object} ValidationContext
* *
* @property {function(string):string} getValidationError Return validation error or empty * @property {function(string):Object} getValidationError Return validation error for the
* string if it doesn't exist for the
* given property. * given property.
* @property {function(Object<string>)} setValidationErrors Receive an object of properties and * @property {function(Object<Object>)} setValidationErrors Receive an object of properties and
* error messages as strings and adds * error messages as strings and adds
* to the validation error state. * to the validation error state.
* @property {function(string)} clearValidationError Clears a validation error for the * @property {function(string)} clearValidationError Clears a validation error for the
@ -231,6 +230,14 @@
* @property {function(string)} getValidationErrorId Returns the css id for the * @property {function(string)} getValidationErrorId Returns the css id for the
* validation error using the given * validation error using the given
* inputId string. * inputId string.
* @property {function(string)} hideValidationError Sets the hidden prop of a specific
* error to true.
* @property {function(string)} showValidationError Sets the hidden prop of a specific
* error to false.
* @property {function()} showAllValidationErrors Sets the hidden prop of all
* errors to false.
* @property {function():boolean} hasValidationErrors Returns true if there is at least
* one error.
*/ */
export {}; export {};