* 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': {
'Please select a country to calculate rates.', message: __(
'woo-gutenberg-products-block' 'Please select a country to calculate rates.',
), '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' ) {
values, validateShippingCountry(
setValidationErrors, values,
clearValidationError, setValidationErrors,
!! countryValidationError clearValidationError,
); 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,20 +10,32 @@ 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,
} ) } } ) }
label={ label } >
onChange={ ( { selectedItem } ) => { <CustomSelectControl
onChange( selectedItem.key ); label={ label }
} } onChange={ ( { selectedItem } ) => {
options={ options } onChange( selectedItem.key );
value={ value } } }
/> options={ options }
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();
return onUpdate( address ); const isAddressValid = validateSubmit();
if ( isAddressValid ) {
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,73 +12,84 @@ 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, (
instanceId, {
id, className,
type = 'text', id,
ariaLabel, type = 'text',
ariaDescribedBy, ariaLabel,
label, ariaDescribedBy,
screenReaderLabel, label,
disabled, screenReaderLabel,
help, disabled,
autoComplete = 'off', help,
value = '', autoComplete = 'off',
onChange, value = '',
required = false, onChange,
} ) => { required = false,
const [ isActive, setIsActive ] = useState( false ); onBlur = () => {},
const onChangeValue = ( event ) => onChange( event.target.value ); feedback,
const textInputId = id || 'textinput-' + instanceId; },
ref
) => {
const [ isActive, setIsActive ] = useState( false );
return ( return (
<div <div
className={ classnames( 'wc-block-text-input', className, { className={ classnames( 'wc-block-text-input', className, {
'is-active': isActive || value, 'is-active': isActive || value,
} ) } } ) }
> >
<input <input
type={ type } type={ type }
id={ textInputId } id={ id }
value={ value } value={ value }
autoComplete={ autoComplete } ref={ ref }
onChange={ onChangeValue } autoComplete={ autoComplete }
onFocus={ () => setIsActive( true ) } onChange={ ( event ) => {
onBlur={ () => setIsActive( false ) } onChange( event.target.value );
aria-label={ ariaLabel || label } } }
disabled={ disabled } onFocus={ () => setIsActive( true ) }
aria-describedby={ onBlur={ () => {
!! help && ! ariaDescribedBy onBlur();
? textInputId + '__help' setIsActive( false );
: ariaDescribedBy } }
} aria-label={ ariaLabel || label }
required={ required } disabled={ disabled }
/> aria-describedby={
<Label !! help && ! ariaDescribedBy
label={ label } ? id + '__help'
screenReaderLabel={ screenReaderLabel || label } : ariaDescribedBy
wrapperElement="label" }
wrapperProps={ { required={ required }
htmlFor: textInputId, />
} } <Label
htmlFor={ textInputId } label={ label }
/> screenReaderLabel={ screenReaderLabel || label }
{ !! help && ( wrapperElement="label"
<p wrapperProps={ {
id={ textInputId + '__help' } htmlFor: id,
className="wc-block-text-input__help" } }
> htmlFor={ id }
{ help } />
</p> { !! help && (
) } <p
</div> id={ id + '__help' }
); className="wc-block-text-input__help"
}; >
{ help }
</p>
) }
{ feedback }
</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,13 +3,64 @@
margin-bottom: $gap-large; margin-bottom: $gap-large;
white-space: nowrap; 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 { &.has-error input {
/** border-color: $error-red;
* 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 { &:focus {
outline-color: $error-red; outline-color: $error-red;
} }
@ -19,55 +70,3 @@
color: $error-red; 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 { 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>
<ValidationInputError
propertyName="coupon"
elementId={ textInputId }
/>
</PanelRow> </PanelRow>
<ValidationInputError
errorMessage={ validationMessage }
elementId={ textInputId }
/>
</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 ) {
return null; const error = getValidationError( propertyName ) || {};
if ( error.message && ! error.hidden ) {
errorMessage = error.message;
} else {
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,266 +115,262 @@ 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> <ExpressCheckoutFormControl />
<ExpressCheckoutFormControl /> <CheckoutForm>
<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 <FormStep
id="contact-fields" id="shipping-fields"
className="wc-block-checkout__contact-fields" className="wc-block-checkout__shipping-fields"
title={ __( title={ __(
'Contact information', 'Shipping address',
'woo-gutenberg-products-block' 'woo-gutenberg-products-block'
) } ) }
description={ __( 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' '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 <AddressForm
type="email" onChange={ setShippingFields }
label={ __( values={ shippingFields }
'Email address', fields={ Object.keys( addressFields ) }
'woo-gutenberg-products-block' fieldConfig={ addressFields }
) }
value={ contactFields.email }
autoComplete="email"
onChange={ ( newValue ) =>
setContactFields( {
...contactFields,
email: newValue,
} )
}
required={ true }
/> />
<CheckboxControl { attributes.showPhoneField && (
className="wc-block-checkout__keep-updated" <ValidatedTextInput
label={ __( type="tel"
'Keep me up to date on news and exclusive offers', label={
'woo-gutenberg-products-block' attributes.requirePhoneField
) } ? __(
checked={ contactFields.keepUpdated } 'Phone',
onChange={ () => 'woo-gutenberg-products-block'
setContactFields( { )
...contactFields, : __(
keepUpdated: ! contactFields.keepUpdated, 'Phone (optional)',
} ) 'woo-gutenberg-products-block'
} )
/>
</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 )
} }
/> value={ shippingFields.phone }
</FormStep> autoComplete="tel"
) } onChange={ ( newValue ) =>
{ showBillingFields && ( setShippingFields( {
<FormStep ...shippingFields,
id="billing-fields" phone: newValue,
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,
} ) } )
} }
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 <CheckboxControl
className="wc-block-checkout__save-card-info" className="wc-block-checkout__use-address-for-billing"
label={ __( label={ __(
'Save payment information to my account for future purchases.', 'Use same address for billing',
'woo-gutenberg-products-block' 'woo-gutenberg-products-block'
) } ) }
checked={ shouldSavePayment } checked={ useShippingAddressAsBilling }
onChange={ () => onChange={ ( isChecked ) =>
setShouldSavePayment( setUseShippingAsBilling( isChecked )
! shouldSavePayment
)
} }
/> />
</FormStep> </FormStep>
<div className="wc-block-checkout__actions"> ) }
{ attributes.showReturnToCart && ( { showBillingFields && (
<ReturnToCartButton <FormStep
link={ getSetting( id="billing-fields"
'page-' + attributes?.cartPageId, className="wc-block-checkout__billing-fields"
false 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 /> <CheckboxControl
</div> className="wc-block-checkout__add-note"
{ attributes.showPolicyLinks && <Policies /> } label="Add order notes?"
</CheckoutForm> checked={ selectedShippingRate.orderNote }
</Main> onChange={ () =>
<Sidebar className="wc-block-checkout__sidebar"> setSelectedShippingRate( {
<CheckoutSidebar ...selectedShippingRate,
cartCoupons={ cartCoupons } orderNote: ! selectedShippingRate.orderNote,
cartItems={ cartItems } } )
cartTotals={ cartTotals } }
/> />
</Sidebar> </FormStep>
</SidebarLayout> ) }
</CheckoutProvider> <FormStep
</ValidationContextProvider> 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, 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,13 +62,15 @@ const CheckoutFrontend = ( props ) => {
showErrorMessage={ CURRENT_USER_IS_ADMIN } showErrorMessage={ CURRENT_USER_IS_ADMIN }
> >
<StoreNoticesProvider context="wc/checkout"> <StoreNoticesProvider context="wc/checkout">
<Block <ValidationContextProvider>
{ ...props } <Block
cartCoupons={ cartCoupons } { ...props }
cartItems={ cartItems } cartCoupons={ cartCoupons }
cartTotals={ cartTotals } cartItems={ cartItems }
shippingRates={ shippingRates } cartTotals={ cartTotals }
/> 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 {};