Improve inline validation to avoid invalid server pushes (https://github.com/woocommerce/woocommerce-blocks/pull/7755)

* Allows custom validation rules to be applied to fields - in this case, email address

* Add local state to only push valid changes

* Do not need required

* unused isString

* Move to push level

* Update packages/checkout/components/text-input/validated-text-input.tsx

Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com>

* Update packages/checkout/components/text-input/validated-text-input.tsx

Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com>

* Update packages/checkout/components/text-input/validated-text-input.tsx

Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com>

* Update packages/checkout/components/text-input/validated-text-input.tsx

Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com>

* Validate when the data store changes

Co-authored-by: Niels Lange <info@nielslange.de>
Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com>
This commit is contained in:
Mike Jolley 2022-12-06 13:13:21 +00:00 committed by GitHub
parent e58e468685
commit 7758ee05fe
3 changed files with 96 additions and 50 deletions

View File

@ -10,6 +10,7 @@ import {
} from '@woocommerce/blocks-checkout'; } from '@woocommerce/blocks-checkout';
import { useDispatch, useSelect } from '@wordpress/data'; import { useDispatch, useSelect } from '@wordpress/data';
import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data'; import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
import { isEmail } from '@wordpress/url';
/** /**
* Internal dependencies * Internal dependencies
@ -60,11 +61,27 @@ const Block = ( {
<ValidatedTextInput <ValidatedTextInput
id="email" id="email"
type="email" type="email"
autoComplete="email"
label={ __( 'Email address', 'woo-gutenberg-products-block' ) } label={ __( 'Email address', 'woo-gutenberg-products-block' ) }
value={ billingAddress.email } value={ billingAddress.email }
autoComplete="email"
onChange={ onChangeEmail }
required={ true } required={ true }
onChange={ onChangeEmail }
requiredMessage={ __(
'Please provide a valid email address',
'woo-gutenberg-products-block'
) }
customValidation={ ( inputObject: HTMLInputElement ) => {
if ( ! isEmail( inputObject.value ) ) {
inputObject.setCustomValidity(
__(
'Please provide a valid email address',
'woo-gutenberg-products-block'
)
);
return false;
}
return true;
} }
/> />
{ createAccountUI } { createAccountUI }
</> </>

View File

@ -19,6 +19,7 @@ import { BillingAddressShippingAddress } from '@woocommerce/type-defs/cart';
* Internal dependencies * Internal dependencies
*/ */
import { STORE_KEY } from './constants'; import { STORE_KEY } from './constants';
import { VALIDATION_STORE_KEY } from '../validation';
declare type CustomerData = { declare type CustomerData = {
billingAddress: CartResponseBillingAddress; billingAddress: CartResponseBillingAddress;
@ -126,9 +127,11 @@ const updateCustomerData = debounce( (): void => {
*/ */
export const pushChanges = (): void => { export const pushChanges = (): void => {
const store = select( STORE_KEY ); const store = select( STORE_KEY );
const hasValidationErrors =
select( VALIDATION_STORE_KEY ).hasValidationErrors();
const isInitialized = store.hasFinishedResolution( 'getCartData' ); const isInitialized = store.hasFinishedResolution( 'getCartData' );
if ( ! isInitialized ) { if ( ! isInitialized || hasValidationErrors ) {
return; return;
} }

View File

@ -3,17 +3,18 @@
*/ */
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { import {
useCallback,
useRef, useRef,
useEffect, useEffect,
useState, useState,
useCallback,
InputHTMLAttributes, InputHTMLAttributes,
} from 'react'; } from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import { withInstanceId } from '@wordpress/compose'; import { withInstanceId } from '@wordpress/compose';
import { isObject, isString } from '@woocommerce/types'; import { isObject } from '@woocommerce/types';
import { useDispatch, useSelect } from '@wordpress/data'; import { useDispatch, useSelect } from '@wordpress/data';
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data'; import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
import { usePrevious } from '@woocommerce/base-hooks';
/** /**
* Internal dependencies * Internal dependencies
@ -38,6 +39,10 @@ interface ValidatedTextInputProps
onChange: ( newValue: string ) => void; onChange: ( newValue: string ) => void;
label?: string | undefined; label?: string | undefined;
value: string; value: string;
requiredMessage?: string | undefined;
customValidation?:
| ( ( inputObject: HTMLInputElement ) => boolean )
| undefined;
} }
const ValidatedTextInput = ( { const ValidatedTextInput = ( {
@ -51,17 +56,20 @@ const ValidatedTextInput = ( {
showError = true, showError = true,
errorMessage: passedErrorMessage = '', errorMessage: passedErrorMessage = '',
value = '', value = '',
requiredMessage,
customValidation,
...rest ...rest
}: ValidatedTextInputProps ): JSX.Element => { }: ValidatedTextInputProps ): JSX.Element => {
const [ isPristine, setIsPristine ] = useState( true ); const [ isPristine, setIsPristine ] = useState( true );
const inputRef = useRef< HTMLInputElement >( null ); const inputRef = useRef< HTMLInputElement >( null );
const previousValue = usePrevious( value );
const { setValidationErrors, hideValidationError, clearValidationError } =
useDispatch( VALIDATION_STORE_KEY );
const textInputId = const textInputId =
typeof id !== 'undefined' ? id : 'textinput-' + instanceId; typeof id !== 'undefined' ? id : 'textinput-' + instanceId;
const errorIdString = errorId !== undefined ? errorId : textInputId; const errorIdString = errorId !== undefined ? errorId : textInputId;
const { setValidationErrors, hideValidationError, clearValidationError } =
useDispatch( VALIDATION_STORE_KEY );
const { validationError, validationErrorId } = useSelect( ( select ) => { const { validationError, validationErrorId } = useSelect( ( select ) => {
const store = select( VALIDATION_STORE_KEY ); const store = select( VALIDATION_STORE_KEY );
return { return {
@ -73,36 +81,67 @@ const ValidatedTextInput = ( {
const validateInput = useCallback( const validateInput = useCallback(
( errorsHidden = true ) => { ( errorsHidden = true ) => {
const inputObject = inputRef.current || null; const inputObject = inputRef.current || null;
if ( ! inputObject ) {
if ( inputObject === null ) {
return; return;
} }
// Trim white space before validation. // Trim white space before validation.
inputObject.value = inputObject.value.trim(); inputObject.value = inputObject.value.trim();
const inputIsValid = inputObject.checkValidity(); inputObject.setCustomValidity( '' );
const inputIsValid = customValidation
? inputObject.checkValidity() && customValidation( inputObject )
: inputObject.checkValidity();
if ( inputIsValid ) { if ( inputIsValid ) {
clearValidationError( errorIdString ); clearValidationError( errorIdString );
} else { return;
const validationErrors = {
[ errorIdString ]: {
message:
inputObject.validationMessage ||
__(
'Invalid value.',
'woo-gutenberg-products-block'
),
hidden: errorsHidden,
},
};
setValidationErrors( validationErrors );
} }
const validityState = inputObject.validity;
if ( validityState.valueMissing && requiredMessage ) {
inputObject.setCustomValidity( requiredMessage );
}
setValidationErrors( {
[ errorIdString ]: {
message:
inputObject.validationMessage ||
__( 'Invalid value.', 'woo-gutenberg-products-block' ),
hidden: errorsHidden,
},
} );
}, },
[ clearValidationError, errorIdString, setValidationErrors ] [
clearValidationError,
customValidation,
errorIdString,
requiredMessage,
setValidationErrors,
]
); );
/** /**
* Focus on mount * Trigger validation on state change if the current element is not in focus. This is because autofilled elements do not
* * trigger the blur() event, and so values can be validated in the background if the state changes elsewhere.
* If the input is in pristine state, focus the element. */
useEffect( () => {
if (
value !== previousValue &&
( value || previousValue ) &&
inputRef &&
inputRef.current !== null &&
inputRef.current?.ownerDocument?.activeElement !== inputRef.current
) {
validateInput( false );
}
// We need to track value even if it is not directly used so we know when it changes.
}, [ value, previousValue, validateInput ] );
/**
* If the input is in pristine state on mount, focus the element.
*/ */
useEffect( () => { useEffect( () => {
if ( isPristine && focusOnMount ) { if ( isPristine && focusOnMount ) {
@ -111,21 +150,6 @@ const ValidatedTextInput = ( {
setIsPristine( false ); setIsPristine( false );
}, [ focusOnMount, isPristine, setIsPristine ] ); }, [ focusOnMount, isPristine, setIsPristine ] );
/**
* Value Validation
*
* Runs validation on state change if the current element is not in focus. This is because autofilled elements do not
* trigger the blur() event, and so values can be validated in the background if the state changes elsewhere.
*/
useEffect( () => {
if (
inputRef.current?.ownerDocument?.activeElement !== inputRef.current
) {
validateInput( true );
}
// We need to track value even if it is not directly used so we know when it changes.
}, [ value, validateInput ] );
// Remove validation errors when unmounted. // Remove validation errors when unmounted.
useEffect( () => { useEffect( () => {
return () => { return () => {
@ -133,11 +157,7 @@ const ValidatedTextInput = ( {
}; };
}, [ clearValidationError, errorIdString ] ); }, [ clearValidationError, errorIdString ] );
if ( if ( passedErrorMessage !== '' && isObject( validationError ) ) {
isString( passedErrorMessage ) &&
passedErrorMessage !== '' &&
isObject( passedErrorMessage )
) {
validationError.message = passedErrorMessage; validationError.message = passedErrorMessage;
} }
@ -154,9 +174,6 @@ const ValidatedTextInput = ( {
} ) } } ) }
aria-invalid={ hasError === true } aria-invalid={ hasError === true }
id={ textInputId } id={ textInputId }
onBlur={ () => {
validateInput( false );
} }
feedback={ feedback={
showError && ( showError && (
<ValidationInputError <ValidationInputError
@ -167,9 +184,18 @@ const ValidatedTextInput = ( {
} }
ref={ inputRef } ref={ inputRef }
onChange={ ( val ) => { onChange={ ( val ) => {
// Hide errors while typing.
hideValidationError( errorIdString ); hideValidationError( errorIdString );
// Revalidate on user input so we know if the value is valid.
validateInput( true );
// Push the changes up to the parent component if the value is valid.
onChange( val ); onChange( val );
} } } }
onBlur={ () => {
validateInput( false );
} }
ariaDescribedBy={ describedBy } ariaDescribedBy={ describedBy }
value={ value } value={ value }
{ ...rest } { ...rest }