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

View File

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

View File

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

View File

@ -10,20 +10,32 @@ import { CustomSelectControl } from 'wordpress-components';
*/
import './style.scss';
const Select = ( { className, label, onChange, options, value, hasError } ) => {
const Select = ( {
className,
feedback,
id,
label,
onChange,
options,
value,
} ) => {
return (
<CustomSelectControl
<div
id={ id }
className={ classnames( 'wc-block-select', className, {
'is-active': value,
'has-error': hasError,
} ) }
label={ label }
onChange={ ( { selectedItem } ) => {
onChange( selectedItem.key );
} }
options={ options }
value={ value }
/>
>
<CustomSelectControl
label={ label }
onChange={ ( { selectedItem } ) => {
onChange( selectedItem.key );
} }
options={ options }
value={ value }
/>
{ feedback }
</div>
);
};
@ -36,6 +48,8 @@ Select.propTypes = {
} ).isRequired
).isRequired,
className: PropTypes.string,
feedback: PropTypes.node,
id: PropTypes.string,
label: PropTypes.string,
value: PropTypes.shape( {
key: PropTypes.string.isRequired,
@ -44,3 +58,4 @@ Select.propTypes = {
};
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,
} ) => {
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 (
<form className="wc-block-shipping-calculator-address">
<AddressForm
fields={ addressFields }
fieldConfig={ fieldConfig }
onChange={ setAddress }
values={ address }
/>
<Button
className="wc-block-shipping-calculator-address__button"
disabled={
isShallowEqual( address, initialAddress ) ||
getValidationError( 'country' )
}
disabled={ isShallowEqual( address, initialAddress ) }
onClick={ ( e ) => {
e.preventDefault();
return onUpdate( address );
const isAddressValid = validateSubmit();
if ( isAddressValid ) {
return onUpdate( address );
}
} }
type="submit"
>

View File

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

View File

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

View File

@ -3,13 +3,64 @@
margin-bottom: $gap-large;
white-space: nowrap;
label {
position: absolute;
transform: translateY(#{$gap-small});
left: 0;
transform-origin: top left;
font-size: 16px;
line-height: 22px;
color: $gray-50;
transition: transform 200ms ease;
margin: 0 $gap;
overflow: hidden;
text-overflow: ellipsis;
max-width: calc(100% - #{ 2 * $gap });
@media screen and (prefers-reduced-motion: reduce) {
transition: none;
}
}
input:-webkit-autofill + label {
transform: translateY(#{$gap-smallest}) scale(0.75);
}
&.is-active label {
transform: translateY(#{$gap-smallest}) scale(0.75);
}
input[type="tel"],
input[type="url"],
input[type="text"],
input[type="email"] {
background-color: #fff;
padding: $gap-small $gap;
border-radius: 4px;
border: 1px solid $input-border-gray;
width: 100%;
font-size: 16px;
line-height: 22px;
font-family: inherit;
margin: 0;
box-sizing: border-box;
height: 48px;
color: $input-text-active;
&:focus {
background-color: #fff;
}
}
&.is-active input[type="tel"],
&.is-active input[type="url"],
&.is-active input[type="text"],
&.is-active input[type="email"] {
padding: $gap-large $gap $gap-smallest;
}
&.has-error input {
/**
* 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;
border-color: $error-red;
&:focus {
outline-color: $error-red;
}
@ -19,55 +70,3 @@
color: $error-red;
}
}
.wc-block-text-input label {
position: absolute;
transform: translateY(#{$gap-small});
left: 0;
transform-origin: top left;
font-size: 16px;
line-height: 22px;
color: $gray-50;
transition: all 200ms ease;
margin: 0 $gap;
overflow: hidden;
text-overflow: ellipsis;
max-width: calc(100% - #{ 2 * $gap });
@media screen and (prefers-reduced-motion: reduce) {
transition: none;
}
}
.wc-block-text-input input:-webkit-autofill + label {
transform: translateY(#{$gap-smallest}) scale(0.75);
}
.wc-block-text-input.is-active label {
transform: translateY(#{$gap-smallest}) scale(0.75);
}
.wc-block-text-input input[type="tel"],
.wc-block-text-input input[type="url"],
.wc-block-text-input input[type="text"],
.wc-block-text-input input[type="email"] {
background-color: #fff;
padding: $gap-small $gap;
border-radius: 4px;
border: 1px solid $input-border-gray;
width: 100%;
font-size: 16px;
line-height: 22px;
font-family: inherit;
margin: 0;
box-sizing: border-box;
height: 48px;
color: $input-text-active;
&:focus {
background-color: #fff;
}
}
.wc-block-text-input.is-active input[type="tel"],
.wc-block-text-input.is-active input[type="url"],
.wc-block-text-input.is-active input[type="text"],
.wc-block-text-input.is-active input[type="email"] {
padding: $gap-large $gap $gap-smallest;
}

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

View File

@ -3,6 +3,7 @@
margin-bottom: 0;
.wc-block-coupon-code__input {
margin-bottom: 0;
margin-top: 0;
flex-grow: 1;
}
@ -16,3 +17,13 @@
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 {
color: $error-red;
@include font-size(12);
margin-top: -$gap-large;
padding-top: $gap-smallest;
display: block;
max-width: 100%;
position: absolute;
top: calc(100% - 1px);
white-space: normal;
> p {
padding: 0;
align-items: center;
display: flex;
line-height: 12px; // This allows two lines in a 24px bottom margin.
margin: 0;
min-height: 24px;
padding: 0;
}
}

View File

@ -15,10 +15,15 @@ export const ValidationInputError = ( {
elementId = '',
} ) => {
const { getValidationError, getValidationErrorId } = useValidationContext();
if ( ! errorMessage && ! propertyName ) {
return null;
if ( ! errorMessage ) {
const error = getValidationError( propertyName ) || {};
if ( error.message && ! error.hidden ) {
errorMessage = error.message;
} else {
return null;
}
}
errorMessage = errorMessage || getValidationError( propertyName );
return (
<div className="wc-block-form-input-validation-error" role="alert">
<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.
*
* @return {string} Either the error message for the given property or an
* empty string.
* @return {Object} The error object for the given property.
*/
const getValidationError = ( property ) =>
validationErrors[ property ] || '';
const getValidationError = ( property ) => validationErrors[ 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.
newErrors = pickBy(
newErrors,
( message ) => typeof message === 'string'
( { message } ) => typeof message === 'string'
);
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
* aria-describedby attribute values.
@ -98,6 +165,10 @@ export const ValidationContextProvider = ( { children } ) => {
clearValidationError,
clearAllValidationErrors,
getValidationErrorId,
hideValidationError,
showValidationError,
showAllValidationErrors,
hasValidationErrors,
};
return (
<ValidationContext.Provider value={ context }>

View File

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

View File

@ -15,14 +15,14 @@ import {
PlaceOrderButton,
ReturnToCartButton,
} 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 CheckboxControl from '@woocommerce/base-components/checkbox-control';
import { getCurrencyFromPriceResponse } from '@woocommerce/base-utils';
import FormattedMonetaryAmount from '@woocommerce/base-components/formatted-monetary-amount';
import {
CheckoutProvider,
ValidationContextProvider,
useValidationContext,
} from '@woocommerce/base-context';
import {
ExpressCheckoutFormControl,
@ -37,6 +37,7 @@ import {
Main,
} from '@woocommerce/base-components/sidebar-layout';
import { getSetting } from '@woocommerce/settings';
import withScrollToTop from '@woocommerce/base-hocs/with-scroll-to-top';
/**
* Internal dependencies
@ -52,6 +53,7 @@ const Block = ( {
cartTotals = {},
isEditor = false,
shippingRates = [],
scrollToTop,
} ) => {
const [ selectedShippingRate, setSelectedShippingRate ] = useState( {} );
const [ contactFields, setContactFields ] = useState( {} );
@ -60,6 +62,19 @@ const Block = ( {
const [ useShippingAsBilling, setUseShippingAsBilling ] = useState(
attributes.useShippingAsBilling
);
const {
hasValidationErrors,
showAllValidationErrors,
} = useValidationContext();
const validateSubmit = () => {
if ( hasValidationErrors() ) {
showAllValidationErrors();
scrollToTop( { focusableSelector: 'input:invalid' } );
return false;
}
return true;
};
const renderShippingRatesControlOption = ( option ) => ( {
label: decodeEntities( option.name ),
@ -100,266 +115,262 @@ const Block = ( {
} = useShippingRates();
return (
<ValidationContextProvider>
<CheckoutProvider isEditor={ isEditor }>
<SidebarLayout className="wc-block-checkout">
<Main>
<ExpressCheckoutFormControl />
<CheckoutForm>
<CheckoutProvider isEditor={ isEditor }>
<SidebarLayout className="wc-block-checkout">
<Main>
<ExpressCheckoutFormControl />
<CheckoutForm>
<FormStep
id="contact-fields"
className="wc-block-checkout__contact-fields"
title={ __(
'Contact information',
'woo-gutenberg-products-block'
) }
description={ __(
"We'll use this email to send you details and updates about your order.",
'woo-gutenberg-products-block'
) }
stepHeadingContent={ () => (
<Fragment>
{ __(
'Already have an account? ',
'woo-gutenberg-products-block'
) }
<a href="/wp-login.php">
{ __(
'Log in.',
'woo-gutenberg-products-block'
) }
</a>
</Fragment>
) }
>
<ValidatedTextInput
type="email"
label={ __(
'Email address',
'woo-gutenberg-products-block'
) }
value={ contactFields.email }
autoComplete="email"
onChange={ ( newValue ) =>
setContactFields( {
...contactFields,
email: newValue,
} )
}
required={ true }
/>
<CheckboxControl
className="wc-block-checkout__keep-updated"
label={ __(
'Keep me up to date on news and exclusive offers',
'woo-gutenberg-products-block'
) }
checked={ contactFields.keepUpdated }
onChange={ () =>
setContactFields( {
...contactFields,
keepUpdated: ! contactFields.keepUpdated,
} )
}
/>
</FormStep>
{ SHIPPING_ENABLED && (
<FormStep
id="contact-fields"
className="wc-block-checkout__contact-fields"
id="shipping-fields"
className="wc-block-checkout__shipping-fields"
title={ __(
'Contact information',
'Shipping address',
'woo-gutenberg-products-block'
) }
description={ __(
"We'll use this email to send you details and updates about your order.",
'Enter the physical address where you want us to deliver your order.',
'woo-gutenberg-products-block'
) }
stepHeadingContent={ () => (
<Fragment>
{ __(
'Already have an account? ',
'woo-gutenberg-products-block'
) }
<a href="/wp-login.php">
{ __(
'Log in.',
'woo-gutenberg-products-block'
) }
</a>
</Fragment>
) }
>
<TextInput
type="email"
label={ __(
'Email address',
'woo-gutenberg-products-block'
) }
value={ contactFields.email }
autoComplete="email"
onChange={ ( newValue ) =>
setContactFields( {
...contactFields,
email: newValue,
} )
}
required={ true }
<AddressForm
onChange={ setShippingFields }
values={ shippingFields }
fields={ Object.keys( addressFields ) }
fieldConfig={ addressFields }
/>
<CheckboxControl
className="wc-block-checkout__keep-updated"
label={ __(
'Keep me up to date on news and exclusive offers',
'woo-gutenberg-products-block'
) }
checked={ contactFields.keepUpdated }
onChange={ () =>
setContactFields( {
...contactFields,
keepUpdated: ! contactFields.keepUpdated,
} )
}
/>
</FormStep>
{ SHIPPING_ENABLED && (
<FormStep
id="shipping-fields"
className="wc-block-checkout__shipping-fields"
title={ __(
'Shipping address',
'woo-gutenberg-products-block'
) }
description={ __(
'Enter the physical address where you want us to deliver your order.',
'woo-gutenberg-products-block'
) }
>
<AddressForm
onChange={ setShippingFields }
values={ shippingFields }
fields={ Object.keys( addressFields ) }
fieldConfig={ addressFields }
/>
{ attributes.showPhoneField && (
<TextInput
type="tel"
label={
attributes.requirePhoneField
? __(
'Phone',
'woo-gutenberg-products-block'
)
: __(
'Phone (optional)',
'woo-gutenberg-products-block'
)
}
value={ shippingFields.phone }
autoComplete="tel"
onChange={ ( newValue ) =>
setShippingFields( {
...shippingFields,
phone: newValue,
} )
}
required={
attributes.requirePhoneField
}
/>
) }
<CheckboxControl
className="wc-block-checkout__use-address-for-billing"
label={ __(
'Use same address for billing',
'woo-gutenberg-products-block'
) }
checked={ useShippingAddressAsBilling }
onChange={ ( isChecked ) =>
setUseShippingAsBilling( isChecked )
{ attributes.showPhoneField && (
<ValidatedTextInput
type="tel"
label={
attributes.requirePhoneField
? __(
'Phone',
'woo-gutenberg-products-block'
)
: __(
'Phone (optional)',
'woo-gutenberg-products-block'
)
}
/>
</FormStep>
) }
{ showBillingFields && (
<FormStep
id="billing-fields"
className="wc-block-checkout__billing-fields"
title={ __(
'Billing address',
'woo-gutenberg-products-block'
) }
description={ __(
'Enter the address that matches your card or payment method.',
'woo-gutenberg-products-block'
) }
>
<AddressForm
onChange={ setBillingFields }
type="billing"
values={ billingFields }
fields={ Object.keys( addressFields ) }
fieldConfig={ addressFields }
/>
</FormStep>
) }
{ SHIPPING_ENABLED && (
<FormStep
id="shipping-option"
className="wc-block-checkout__shipping-option"
title={ __(
'Shipping options',
'woo-gutenberg-products-block'
) }
description={ __(
'Select a shipping method below.',
'woo-gutenberg-products-block'
) }
>
{ shippingRates.length === 0 && isEditor ? (
<NoShipping />
) : (
<ShippingRatesControl
address={
shippingFields.country
? {
address_1:
shippingFields.address_1,
address_2:
shippingFields.apartment,
city:
shippingFields.city,
state:
shippingFields.state,
postcode:
shippingFields.postcode,
country:
shippingFields.country,
}
: null
}
noResultsMessage={ __(
'There are no shipping options available. Please ensure that your address has been entered correctly, or contact us if you need any help.',
'woo-gutenberg-products-block'
) }
renderOption={
renderShippingRatesControlOption
}
shippingRates={ shippingRates }
shippingRatesLoading={
shippingRatesLoading
}
/>
) }
<CheckboxControl
className="wc-block-checkout__add-note"
label="Add order notes?"
checked={
selectedShippingRate.orderNote
}
onChange={ () =>
setSelectedShippingRate( {
...selectedShippingRate,
orderNote: ! selectedShippingRate.orderNote,
value={ shippingFields.phone }
autoComplete="tel"
onChange={ ( newValue ) =>
setShippingFields( {
...shippingFields,
phone: newValue,
} )
}
required={
attributes.requirePhoneField
}
/>
</FormStep>
) }
<FormStep
id="payment-method"
className="wc-block-checkout__payment-method"
title={ __(
'Payment method',
'woo-gutenberg-products-block'
) }
description={ __(
'Select a payment method below.',
'woo-gutenberg-products-block'
) }
>
<PaymentMethods />
{ /*@todo this should be something the payment method controls*/ }
<CheckboxControl
className="wc-block-checkout__save-card-info"
className="wc-block-checkout__use-address-for-billing"
label={ __(
'Save payment information to my account for future purchases.',
'Use same address for billing',
'woo-gutenberg-products-block'
) }
checked={ shouldSavePayment }
onChange={ () =>
setShouldSavePayment(
! shouldSavePayment
)
checked={ useShippingAddressAsBilling }
onChange={ ( isChecked ) =>
setUseShippingAsBilling( isChecked )
}
/>
</FormStep>
<div className="wc-block-checkout__actions">
{ attributes.showReturnToCart && (
<ReturnToCartButton
link={ getSetting(
'page-' + attributes?.cartPageId,
false
) }
{ showBillingFields && (
<FormStep
id="billing-fields"
className="wc-block-checkout__billing-fields"
title={ __(
'Billing address',
'woo-gutenberg-products-block'
) }
description={ __(
'Enter the address that matches your card or payment method.',
'woo-gutenberg-products-block'
) }
>
<AddressForm
onChange={ setBillingFields }
type="billing"
values={ billingFields }
fields={ Object.keys( addressFields ) }
fieldConfig={ addressFields }
/>
</FormStep>
) }
{ SHIPPING_ENABLED && (
<FormStep
id="shipping-option"
className="wc-block-checkout__shipping-option"
title={ __(
'Shipping options',
'woo-gutenberg-products-block'
) }
description={ __(
'Select a shipping method below.',
'woo-gutenberg-products-block'
) }
>
{ shippingRates.length === 0 && isEditor ? (
<NoShipping />
) : (
<ShippingRatesControl
address={
shippingFields.country
? {
address_1:
shippingFields.address_1,
address_2:
shippingFields.apartment,
city:
shippingFields.city,
state:
shippingFields.state,
postcode:
shippingFields.postcode,
country:
shippingFields.country,
}
: null
}
noResultsMessage={ __(
'There are no shipping options available. Please ensure that your address has been entered correctly, or contact us if you need any help.',
'woo-gutenberg-products-block'
) }
renderOption={
renderShippingRatesControlOption
}
shippingRates={ shippingRates }
shippingRatesLoading={
shippingRatesLoading
}
/>
) }
<PlaceOrderButton />
</div>
{ attributes.showPolicyLinks && <Policies /> }
</CheckoutForm>
</Main>
<Sidebar className="wc-block-checkout__sidebar">
<CheckoutSidebar
cartCoupons={ cartCoupons }
cartItems={ cartItems }
cartTotals={ cartTotals }
/>
</Sidebar>
</SidebarLayout>
</CheckoutProvider>
</ValidationContextProvider>
<CheckboxControl
className="wc-block-checkout__add-note"
label="Add order notes?"
checked={ selectedShippingRate.orderNote }
onChange={ () =>
setSelectedShippingRate( {
...selectedShippingRate,
orderNote: ! selectedShippingRate.orderNote,
} )
}
/>
</FormStep>
) }
<FormStep
id="payment-method"
className="wc-block-checkout__payment-method"
title={ __(
'Payment method',
'woo-gutenberg-products-block'
) }
description={ __(
'Select a payment method below.',
'woo-gutenberg-products-block'
) }
>
<PaymentMethods />
{ /*@todo this should be something the payment method controls*/ }
<CheckboxControl
className="wc-block-checkout__save-card-info"
label={ __(
'Save payment information to my account for future purchases.',
'woo-gutenberg-products-block'
) }
checked={ shouldSavePayment }
onChange={ () =>
setShouldSavePayment( ! shouldSavePayment )
}
/>
</FormStep>
<div className="wc-block-checkout__actions">
{ attributes.showReturnToCart && (
<ReturnToCartButton
link={ getSetting(
'page-' + attributes?.cartPageId,
false
) }
/>
) }
<PlaceOrderButton
validateSubmit={ validateSubmit }
/>
</div>
{ attributes.showPolicyLinks && <Policies /> }
</CheckoutForm>
</Main>
<Sidebar className="wc-block-checkout__sidebar">
<CheckoutSidebar
cartCoupons={ cartCoupons }
cartItems={ cartItems }
cartTotals={ cartTotals }
/>
</Sidebar>
</SidebarLayout>
</CheckoutProvider>
);
};
export default Block;
export default withScrollToTop( Block );

View File

@ -7,7 +7,10 @@ import {
withStoreCartApiHydration,
} from '@woocommerce/block-hocs';
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 { CURRENT_USER_IS_ADMIN } from '@woocommerce/block-settings';
import { __experimentalCreateInterpolateElement } from 'wordpress-element';
@ -59,13 +62,15 @@ const CheckoutFrontend = ( props ) => {
showErrorMessage={ CURRENT_USER_IS_ADMIN }
>
<StoreNoticesProvider context="wc/checkout">
<Block
{ ...props }
cartCoupons={ cartCoupons }
cartItems={ cartItems }
cartTotals={ cartTotals }
shippingRates={ shippingRates }
/>
<ValidationContextProvider>
<Block
{ ...props }
cartCoupons={ cartCoupons }
cartItems={ cartItems }
cartTotals={ cartTotals }
shippingRates={ shippingRates }
/>
</ValidationContextProvider>
</StoreNoticesProvider>
</BlockErrorBoundary>
) }

View File

@ -218,10 +218,9 @@
/**
* @typedef {Object} ValidationContext
*
* @property {function(string):string} getValidationError Return validation error or empty
* string if it doesn't exist for the
* @property {function(string):Object} getValidationError Return validation error for the
* 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
* to the validation error state.
* @property {function(string)} clearValidationError Clears a validation error for the
@ -231,6 +230,14 @@
* @property {function(string)} getValidationErrorId Returns the css id for the
* validation error using the given
* 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 {};