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:
parent
e58e468685
commit
7758ee05fe
|
@ -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 }
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
Loading…
Reference in New Issue