From 2ea96ed899ba5945517d993a48c50d8a88c0d7ed Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Fri, 13 Jan 2023 15:54:35 +0000 Subject: [PATCH] Custom validation messages using the field name/label (#8143) * Custom validation strings using a new function named getValidityMessageForInput * getValidityMessageForInput tests * Added integration test for error message * Clear value * update test strings --- .../block.tsx | 6 +- .../text-input/test/validated-text-input.tsx | 20 ++++++ .../text-input/validated-text-input.tsx | 37 ++++++----- packages/checkout/utils/validation/index.ts | 31 +++++++++ .../checkout/utils/validation/test/index.tsx | 66 +++++++++++++++++++ .../shopper/cart-checkout/checkout.test.js | 12 ++-- 6 files changed, 146 insertions(+), 26 deletions(-) create mode 100644 packages/checkout/utils/validation/test/index.tsx diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-contact-information-block/block.tsx b/assets/js/blocks/checkout/inner-blocks/checkout-contact-information-block/block.tsx index 58a9e32bc13..7b62d52d860 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-contact-information-block/block.tsx +++ b/assets/js/blocks/checkout/inner-blocks/checkout-contact-information-block/block.tsx @@ -69,15 +69,11 @@ const Block = (): JSX.Element => { value={ billingAddress.email } 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', + 'Please enter a valid email address', 'woo-gutenberg-products-block' ) ); diff --git a/packages/checkout/components/text-input/test/validated-text-input.tsx b/packages/checkout/components/text-input/test/validated-text-input.tsx index 97cbd550da5..b6dd35f6dc8 100644 --- a/packages/checkout/components/text-input/test/validated-text-input.tsx +++ b/packages/checkout/components/text-input/test/validated-text-input.tsx @@ -148,4 +148,24 @@ describe( 'ValidatedTextInput', () => { select( VALIDATION_STORE_KEY ).getValidationError( 'test-input' ) ).toBe( undefined ); } ); + it( 'Shows a custom error message for an invalid required input', async () => { + const TestComponent = () => { + const [ inputValue, setInputValue ] = useState( '' ); + return ( + setInputValue( value ) } + value={ inputValue } + label={ 'Test Input' } + /> + ); + }; + render( ); + const textInputElement = await screen.getByLabelText( 'Test Input' ); + await userEvent.type( textInputElement, '{selectall}{del}' ); + await expect( + select( VALIDATION_STORE_KEY ).getValidationError( 'test-input' ) + ).not.toBe( 'Please enter a valid test input' ); + } ); } ); diff --git a/packages/checkout/components/text-input/validated-text-input.tsx b/packages/checkout/components/text-input/validated-text-input.tsx index baf45d67300..fb79925bd70 100644 --- a/packages/checkout/components/text-input/validated-text-input.tsx +++ b/packages/checkout/components/text-input/validated-text-input.tsx @@ -1,7 +1,6 @@ /** * External dependencies */ -import { __ } from '@wordpress/i18n'; import { useRef, useEffect, @@ -22,24 +21,36 @@ import { usePrevious } from '@woocommerce/base-hooks'; import TextInput from './text-input'; import './style.scss'; import { ValidationInputError } from '../validation-input-error'; +import { getValidityMessageForInput } from '../../utils'; interface ValidatedTextInputProps extends Omit< InputHTMLAttributes< HTMLInputElement >, 'onChange' | 'onBlur' > { + // id to use for the input. If not provided, an id will be generated. id?: string; + // Unique instance ID. id will be used instead if provided. instanceId: string; + // Class name to add to the input. className?: string | undefined; + // aria-describedby attribute to add to the input. ariaDescribedBy?: string | undefined; + // id to use for the error message. If not provided, an id will be generated. errorId?: string; + // if true, the input will be focused on mount. focusOnMount?: boolean; - showError?: boolean; - errorMessage?: string | undefined; + // Callback to run on change which is passed the updated value. onChange: ( newValue: string ) => void; + // Optional label for the field. label?: string | undefined; + // Field value. value: string; - requiredMessage?: string | undefined; + // If true, validation errors will be shown. + showError?: boolean; + // Error message to display alongside the field regardless of validation. + errorMessage?: string | undefined; + // Custom validation function that is run on change. Use setCustomValidity to set an error message. customValidation?: | ( ( inputObject: HTMLInputElement ) => boolean ) | undefined; @@ -56,8 +67,8 @@ const ValidatedTextInput = ( { showError = true, errorMessage: passedErrorMessage = '', value = '', - requiredMessage, customValidation, + label, ...rest }: ValidatedTextInputProps ): JSX.Element => { const [ isPristine, setIsPristine ] = useState( true ); @@ -99,17 +110,11 @@ const ValidatedTextInput = ( { return; } - const validityState = inputObject.validity; - - if ( validityState.valueMissing && requiredMessage ) { - inputObject.setCustomValidity( requiredMessage ); - } - setValidationErrors( { [ errorIdString ]: { - message: - inputObject.validationMessage || - __( 'Invalid value.', 'woo-gutenberg-products-block' ), + message: label + ? getValidityMessageForInput( label, inputObject ) + : inputObject.validationMessage, hidden: errorsHidden, }, } ); @@ -118,8 +123,8 @@ const ValidatedTextInput = ( { clearValidationError, customValidation, errorIdString, - requiredMessage, setValidationErrors, + label, ] ); @@ -211,6 +216,8 @@ const ValidatedTextInput = ( { } } ariaDescribedBy={ describedBy } value={ value } + title="" + label={ label } { ...rest } /> ); diff --git a/packages/checkout/utils/validation/index.ts b/packages/checkout/utils/validation/index.ts index d5625faff4b..1d4e4212c7a 100644 --- a/packages/checkout/utils/validation/index.ts +++ b/packages/checkout/utils/validation/index.ts @@ -25,3 +25,34 @@ export const mustContain = ( } return true; }; + +/** + * Converts an input's validityState to a string to display on the frontend. + * + * This returns custom messages for invalid/required fields. Other error types use defaults from the browser (these + * could be implemented in the future but are not currently used by the block checkout). + */ +export const getValidityMessageForInput = ( + label: string, + inputElement: HTMLInputElement +): string => { + const { valid, customError, valueMissing, badInput, typeMismatch } = + inputElement.validity; + + // No errors, or custom error - return early. + if ( valid || customError ) { + return inputElement.validationMessage; + } + + const invalidFieldMessage = sprintf( + /* translators: %s field label */ + __( 'Please enter a valid %s', 'woo-gutenberg-products-block' ), + label.toLowerCase() + ); + + if ( valueMissing || badInput || typeMismatch ) { + return invalidFieldMessage; + } + + return inputElement.validationMessage || invalidFieldMessage; +}; diff --git a/packages/checkout/utils/validation/test/index.tsx b/packages/checkout/utils/validation/test/index.tsx new file mode 100644 index 00000000000..5e130416357 --- /dev/null +++ b/packages/checkout/utils/validation/test/index.tsx @@ -0,0 +1,66 @@ +/** + * External dependencies + */ +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +/** + * Internal dependencies + */ +import { getValidityMessageForInput } from '../index'; + +describe( 'getValidityMessageForInput', () => { + it( 'Returns nothing if the input is valid', async () => { + render( ); + + const textInputElement = ( await screen.getByTestId( + 'custom-input' + ) ) as HTMLInputElement; + + const validityMessage = getValidityMessageForInput( + 'Test', + textInputElement + ); + expect( validityMessage ).toBe( '' ); + } ); + it( 'Returns error message if a required input is empty', async () => { + render( ); + + const textInputElement = ( await screen.getByTestId( + 'custom-input' + ) ) as HTMLInputElement; + + const validityMessage = getValidityMessageForInput( + 'Test', + textInputElement + ); + + expect( validityMessage ).toBe( 'Please enter a valid test' ); + } ); + it( 'Returns a custom error if set, rather than a new message', async () => { + render( + { + event.target.setCustomValidity( 'Custom error' ); + } } + data-testid="custom-input" + /> + ); + + const textInputElement = ( await screen.getByTestId( + 'custom-input' + ) ) as HTMLInputElement; + + await act( async () => { + await userEvent.type( textInputElement, 'Invalid Value' ); + } ); + + const validityMessage = getValidityMessageForInput( + 'Test', + textInputElement + ); + expect( validityMessage ).toBe( 'Custom error' ); + } ); +} ); diff --git a/tests/e2e/specs/shopper/cart-checkout/checkout.test.js b/tests/e2e/specs/shopper/cart-checkout/checkout.test.js index 3265b1880f4..39a76e0c2fc 100644 --- a/tests/e2e/specs/shopper/cart-checkout/checkout.test.js +++ b/tests/e2e/specs/shopper/cart-checkout/checkout.test.js @@ -145,37 +145,37 @@ describe( 'Shopper → Checkout', () => { await expect( page ).toMatchElement( '#email ~ .wc-block-components-validation-error p', { - text: 'Please provide a valid email address', + text: 'Please enter a valid email address', } ); await expect( page ).toMatchElement( '#billing-first_name ~ .wc-block-components-validation-error p', { - text: 'Please fill', + text: 'Please enter', } ); await expect( page ).toMatchElement( '#billing-last_name ~ .wc-block-components-validation-error p', { - text: 'Please fill', + text: 'Please enter', } ); await expect( page ).toMatchElement( '#billing-address_1 ~ .wc-block-components-validation-error p', { - text: 'Please fill', + text: 'Please enter', } ); await expect( page ).toMatchElement( '#billing-city ~ .wc-block-components-validation-error p', { - text: 'Please fill', + text: 'Please enter', } ); await expect( page ).toMatchElement( '#billing-postcode ~ .wc-block-components-validation-error p', { - text: 'Please fill', + text: 'Please enter', } ); } );