From 2593fcf7dab6c96aff157ee0a393375653d1ed11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Albert=20Juh=C3=A9=20Lluveras?= Date: Mon, 23 Mar 2020 12:22:00 +0100 Subject: [PATCH] Add Checkout form validation (https://github.com/woocommerce/woocommerce-blocks/pull/1993) * 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 --- .../js/base/components/address-form/index.js | 52 +- .../cart-checkout/place-order-button/index.js | 14 +- .../components/country-input/country-input.js | 20 +- .../assets/js/base/components/select/index.js | 35 +- .../js/base/components/select/validated.js | 82 +++ .../components/shipping-calculator/address.js | 44 +- .../components/state-input/state-input.js | 13 +- .../js/base/components/text-input/index.js | 142 ++--- .../js/base/components/text-input/style.scss | 115 ++--- .../base/components/text-input/validated.js | 101 ++++ .../totals/totals-coupon-code-input/index.js | 37 +- .../totals-coupon-code-input/style.scss | 11 + .../js/base/components/validation/style.scss | 13 +- .../validation/validation-input-error.js | 11 +- .../context/cart-checkout/validation/index.js | 83 ++- .../base/hooks/cart/use-store-cart-coupons.js | 4 +- .../js/blocks/cart-checkout/checkout/block.js | 487 +++++++++--------- .../blocks/cart-checkout/checkout/frontend.js | 21 +- .../assets/js/type-defs/contexts.js | 13 +- 19 files changed, 843 insertions(+), 455 deletions(-) create mode 100644 plugins/woocommerce-blocks/assets/js/base/components/select/validated.js create mode 100644 plugins/woocommerce-blocks/assets/js/base/components/text-input/validated.js diff --git a/plugins/woocommerce-blocks/assets/js/base/components/address-form/index.js b/plugins/woocommerce-blocks/assets/js/base/components/address-form/index.js index 504df967a0b..b3dbd57c836 100644 --- a/plugins/woocommerce-blocks/assets/js/base/components/address-form/index.js +++ b/plugins/woocommerce-blocks/assets/js/base/components/address-form/index.js @@ -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 (
{ 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 ( - ); diff --git a/plugins/woocommerce-blocks/assets/js/base/components/cart-checkout/place-order-button/index.js b/plugins/woocommerce-blocks/assets/js/base/components/cart-checkout/place-order-button/index.js index 1031d846b36..2159592501a 100644 --- a/plugins/woocommerce-blocks/assets/js/base/components/cart-checkout/place-order-button/index.js +++ b/plugins/woocommerce-blocks/assets/js/base/components/cart-checkout/place-order-button/index.js @@ -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 ( ); }; +PlaceOrderButton.propTypes = { + validateSubmit: PropTypes.func.isRequired, +}; + export default PlaceOrderButton; diff --git a/plugins/woocommerce-blocks/assets/js/base/components/country-input/country-input.js b/plugins/woocommerce-blocks/assets/js/base/components/country-input/country-input.js index 3b1fddde95d..c7a95fc4d36 100644 --- a/plugins/woocommerce-blocks/assets/js/base/components/country-input/country-input.js +++ b/plugins/woocommerce-blocks/assets/js/base/components/country-input/country-input.js @@ -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 (
- ) } -
); }; @@ -73,6 +75,8 @@ CountryInput.propTypes = { label: PropTypes.string, value: PropTypes.string, autoComplete: PropTypes.string, + errorId: PropTypes.string, + errorMessage: PropTypes.string, }; export default CountryInput; diff --git a/plugins/woocommerce-blocks/assets/js/base/components/select/index.js b/plugins/woocommerce-blocks/assets/js/base/components/select/index.js index ca3c0257725..26935d3ba92 100644 --- a/plugins/woocommerce-blocks/assets/js/base/components/select/index.js +++ b/plugins/woocommerce-blocks/assets/js/base/components/select/index.js @@ -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 ( - { - onChange( selectedItem.key ); - } } - options={ options } - value={ value } - /> + > + { + onChange( selectedItem.key ); + } } + options={ options } + value={ value } + /> + { feedback } +
); }; @@ -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'; diff --git a/plugins/woocommerce-blocks/assets/js/base/components/select/validated.js b/plugins/woocommerce-blocks/assets/js/base/components/select/validated.js new file mode 100644 index 00000000000..4b9cf7f08e9 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/base/components/select/validated.js @@ -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 ( + option.key === value ) } + errorMessage={ __( + 'Please select a state.', + 'woo-gutenberg-products-block' + ) } required={ required } /> { autoComplete !== 'off' && ( @@ -92,7 +97,7 @@ const StateInput = ( { ); } return ( - { - 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 ( -
- setIsActive( true ) } - onBlur={ () => setIsActive( false ) } - aria-label={ ariaLabel || label } - disabled={ disabled } - aria-describedby={ - !! help && ! ariaDescribedBy - ? textInputId + '__help' - : ariaDescribedBy - } - required={ required } - /> -
- ); -}; + return ( +
+ { + 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 } + /> +
+ ); + } +); 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'; diff --git a/plugins/woocommerce-blocks/assets/js/base/components/text-input/style.scss b/plugins/woocommerce-blocks/assets/js/base/components/text-input/style.scss index 1e858e745b2..765a6110ed8 100644 --- a/plugins/woocommerce-blocks/assets/js/base/components/text-input/style.scss +++ b/plugins/woocommerce-blocks/assets/js/base/components/text-input/style.scss @@ -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; -} diff --git a/plugins/woocommerce-blocks/assets/js/base/components/text-input/validated.js b/plugins/woocommerce-blocks/assets/js/base/components/text-input/validated.js new file mode 100644 index 00000000000..e9c545fa586 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/base/components/text-input/validated.js @@ -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 ( + { + validateInput( false ); + } } + feedback={ + showError && + } + 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 ); diff --git a/plugins/woocommerce-blocks/assets/js/base/components/totals/totals-coupon-code-input/index.js b/plugins/woocommerce-blocks/assets/js/base/components/totals/totals-coupon-code-input/index.js index 0c1ae742e97..778788823ed 100644 --- a/plugins/woocommerce-blocks/assets/js/base/components/totals/totals-coupon-code-input/index.js +++ b/plugins/woocommerce-blocks/assets/js/base/components/totals/totals-coupon-code-input/index.js @@ -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 = ( { >
- { setCouponValue( newCouponValue ); - clearValidationError( 'coupon' ); } } + validateOnMount={ false } + showError={ false } />