From b1814858442f517a8f987d1ae8755f22ff459530 Mon Sep 17 00:00:00 2001 From: Niels Lange Date: Tue, 10 Jan 2023 11:14:55 +0700 Subject: [PATCH] Add client side postcode validation --- .../address-form/address-form.tsx | 44 ++++- assets/js/base/components/combobox/index.tsx | 2 +- .../components/state-input/StateInputProps.ts | 2 +- package-lock.json | 11 ++ package.json | 1 + packages/checkout/utils/validation/index.ts | 29 +-- .../checkout/utils/validation/isPostcode.ts | 31 +++ .../checkout/utils/validation/mustContain.ts | 26 +++ .../utils/validation/test/isPostcode.ts | 177 ++++++++++++++++++ 9 files changed, 293 insertions(+), 30 deletions(-) create mode 100644 packages/checkout/utils/validation/isPostcode.ts create mode 100644 packages/checkout/utils/validation/mustContain.ts create mode 100644 packages/checkout/utils/validation/test/isPostcode.ts diff --git a/assets/js/base/components/cart-checkout/address-form/address-form.tsx b/assets/js/base/components/cart-checkout/address-form/address-form.tsx index 5898df4b7cf..987b60e9098 100644 --- a/assets/js/base/components/cart-checkout/address-form/address-form.tsx +++ b/assets/js/base/components/cart-checkout/address-form/address-form.tsx @@ -1,7 +1,7 @@ /** * External dependencies */ -import { ValidatedTextInput } from '@woocommerce/blocks-checkout'; +import { ValidatedTextInput, isPostcode } from '@woocommerce/blocks-checkout'; import { BillingCountryInput, ShippingCountryInput, @@ -216,6 +216,48 @@ const AddressForm = ( { ); } + if ( field.key === 'postcode' ) { + return ( + + onChange( { + ...values, + [ field.key ]: newValue, + } ) + } + customValidation={ ( + inputObject: HTMLInputElement + ) => { + if ( + ! isPostcode( { + postcode: values.postcode, + country: values.country, + } ) + ) { + inputObject.setCustomValidity( + __( + 'Please provide a valid postcode', + 'woo-gutenberg-products-block' + ) + ); + return false; + } + return true; + } } + errorMessage={ field.errorMessage } + /> + ); + } + return ( void; required?: boolean; - errorMessage?: string; + errorMessage?: string | undefined; } export type StateInputWithStatesProps = StateInputProps & { diff --git a/package-lock.json b/package-lock.json index 3d098dba675..468d0377236 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "dompurify": "^2.4.0", "downshift": "6.1.7", "html-react-parser": "3.0.4", + "postcode-validator": "3.7.0", "react-number-format": "4.9.3", "reakit": "1.3.11", "snakecase-keys": "5.4.2", @@ -39416,6 +39417,11 @@ "node": ">=0.10.0" } }, + "node_modules/postcode-validator": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/postcode-validator/-/postcode-validator-3.7.0.tgz", + "integrity": "sha512-pl697wPxQ8kb3S0qoIHdNsqPjMY3ieKQtR6+dp0o+NOlM6ImzDpyKSdlXgGzzjoUElDUdXWezFjGGd9yITM+Xg==" + }, "node_modules/postcss": { "version": "8.4.14", "dev": true, @@ -77325,6 +77331,11 @@ "version": "0.1.1", "dev": true }, + "postcode-validator": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/postcode-validator/-/postcode-validator-3.7.0.tgz", + "integrity": "sha512-pl697wPxQ8kb3S0qoIHdNsqPjMY3ieKQtR6+dp0o+NOlM6ImzDpyKSdlXgGzzjoUElDUdXWezFjGGd9yITM+Xg==" + }, "postcss": { "version": "8.4.14", "dev": true, diff --git a/package.json b/package.json index 5c268ed0c69..00f255c1b21 100644 --- a/package.json +++ b/package.json @@ -243,6 +243,7 @@ "dompurify": "^2.4.0", "downshift": "6.1.7", "html-react-parser": "3.0.4", + "postcode-validator": "3.7.0", "react-number-format": "4.9.3", "reakit": "1.3.11", "snakecase-keys": "5.4.2", diff --git a/packages/checkout/utils/validation/index.ts b/packages/checkout/utils/validation/index.ts index d5625faff4b..25246bb5f19 100644 --- a/packages/checkout/utils/validation/index.ts +++ b/packages/checkout/utils/validation/index.ts @@ -1,27 +1,2 @@ -/** - * External dependencies - */ -import { __, sprintf } from '@wordpress/i18n'; - -/** - * Ensures that a given value contains a string, or throws an error. - */ -export const mustContain = ( - value: string, - requiredValue: string -): true | never => { - if ( ! value.includes( requiredValue ) ) { - throw Error( - sprintf( - /* translators: %1$s value passed to filter, %2$s : value that must be included. */ - __( - 'Returned value must include %1$s, you passed "%2$s"', - 'woo-gutenberg-products-block' - ), - requiredValue, - value - ) - ); - } - return true; -}; +export { default as mustContain } from './mustContain'; +export { default as isPostcode } from './isPostcode'; diff --git a/packages/checkout/utils/validation/isPostcode.ts b/packages/checkout/utils/validation/isPostcode.ts new file mode 100644 index 00000000000..db78472008e --- /dev/null +++ b/packages/checkout/utils/validation/isPostcode.ts @@ -0,0 +1,31 @@ +/** + * External dependencies + */ +import { POSTCODE_REGEXES } from 'postcode-validator/lib/cjs/postcode-regexes.js'; + +const getCustomRegexes = () => { + POSTCODE_REGEXES.set( 'BA', /^([7-8]{1})([0-9]{4})$/ ); + POSTCODE_REGEXES.set( + 'GB', + /^([A-Z]){1}([0-9]{1,2}|[A-Z][0-9][A-Z]|[A-Z][0-9]{2}|[A-Z][0-9]|[0-9][A-Z]){1}([ ])?([0-9][A-z]{2}){1}|BFPO(?:\s)?([0-9]{1,4})$|BFPO(c\/o[0-9]{1,3})$/i + ); + POSTCODE_REGEXES.set( 'IN', /^[1-9]{1}[0-9]{2}\s{0,1}[0-9]{3}$/ ); + POSTCODE_REGEXES.set( 'JP', /^([0-9]{3})([-]?)([0-9]{4})$/ ); + POSTCODE_REGEXES.set( 'LI', /^(94[8-9][0-9])$/ ); + POSTCODE_REGEXES.set( 'NL', /^([1-9][0-9]{3})(\s?)(?!SA|SD|SS)[A-Z]{2}$/i ); + POSTCODE_REGEXES.set( 'SI', /^([1-9][0-9]{3})$/ ); + + return POSTCODE_REGEXES; +}; +export interface IsPostcodeProps { + postcode: string; + country: string; +} + +const isPostcode = ( { postcode, country }: IsPostcodeProps ) => { + const CUSTOM_POSTCODE_REGEXES = getCustomRegexes(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return CUSTOM_POSTCODE_REGEXES.get( country )!.test( postcode ); +}; + +export default isPostcode; diff --git a/packages/checkout/utils/validation/mustContain.ts b/packages/checkout/utils/validation/mustContain.ts new file mode 100644 index 00000000000..c50ed227ce1 --- /dev/null +++ b/packages/checkout/utils/validation/mustContain.ts @@ -0,0 +1,26 @@ +/** + * External dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Ensures that a given value contains a string, or throws an error. + */ +const mustContain = ( value: string, requiredValue: string ): true | never => { + if ( ! value.includes( requiredValue ) ) { + throw Error( + sprintf( + /* translators: %1$s value passed to filter, %2$s : value that must be included. */ + __( + 'Returned value must include %1$s, you passed "%2$s"', + 'woo-gutenberg-products-block' + ), + requiredValue, + value + ) + ); + } + return true; +}; + +export default mustContain; diff --git a/packages/checkout/utils/validation/test/isPostcode.ts b/packages/checkout/utils/validation/test/isPostcode.ts new file mode 100644 index 00000000000..5126333aac3 --- /dev/null +++ b/packages/checkout/utils/validation/test/isPostcode.ts @@ -0,0 +1,177 @@ +/** + * Internal dependencies + */ +import isPostcode from '../isPostcode'; +import type { IsPostcodeProps } from '../isPostcode'; + +describe( 'isPostcode', () => { + const cases = [ + // Austrian postcodes + [ true, '1000', 'AT' ], + [ true, '9999', 'AT' ], + [ false, '0000', 'AT' ], + [ false, '10000', 'AT' ], + + // Bosnian postcodes + [ true, '71000', 'BA' ], + [ true, '78256', 'BA' ], + [ true, '89240', 'BA' ], + [ false, '61000', 'BA' ], + [ false, '7850', 'BA' ], + + // Belgian postcodes + [ true, '1111', 'BE' ], + [ false, '111', 'BE' ], + [ false, '11111', 'BE' ], + + // Brazilian postcodes + [ true, '99999-999', 'BR' ], + [ true, '99999999', 'BR' ], + [ false, '99999 999', 'BR' ], + [ false, '99999-ABC', 'BR' ], + + // Canadian postcodes + [ true, 'A9A 9A9', 'CA' ], + [ true, 'A9A9A9', 'CA' ], + [ true, 'a9a9a9', 'CA' ], + [ false, 'D0A 9A9', 'CA' ], + [ false, '99999', 'CA' ], + [ false, 'ABC999', 'CA' ], + [ false, '0A0A0A', 'CA' ], + + // Swiss postcodes + [ true, '9999', 'CH' ], + [ false, '99999', 'CH' ], + [ false, 'ABCDE', 'CH' ], + + // Czech postcodes + [ true, '160 00', 'CZ' ], + [ true, '16000', 'CZ' ], + [ false, '1600', 'CZ' ], + + // German postcodes + [ true, '01234', 'DE' ], + [ true, '12345', 'DE' ], + [ false, '12 345', 'DE' ], + [ false, '1234', 'DE' ], + + // Spanish postcodes + [ true, '03000', 'ES' ], + [ true, '08000', 'ES' ], + [ false, '08 000', 'ES' ], + [ false, '1234', 'ES' ], + + // French postcodes + [ true, '01000', 'FR' ], + [ true, '99999', 'FR' ], + [ true, '01 000', 'FR' ], + [ false, '1234', 'FR' ], + + // British postcodes + [ true, 'AA9A 9AA', 'GB' ], + [ true, 'A9A 9AA', 'GB' ], + [ true, 'A9 9AA', 'GB' ], + [ true, 'A99 9AA', 'GB' ], + [ true, 'AA99 9AA', 'GB' ], + [ true, 'BFPO 801', 'GB' ], + [ false, '99999', 'GB' ], + [ false, '9999 999', 'GB' ], + [ false, '999 999', 'GB' ], + [ false, '99 999', 'GB' ], + [ false, '9A A9A', 'GB' ], + + // Hungarian postcodes + [ true, '1234', 'HU' ], + [ false, '123', 'HU' ], + [ false, '12345', 'HU' ], + + // Irish postcodes + [ true, 'A65F4E2', 'IE' ], + [ true, 'A65 F4E2', 'IE' ], + [ true, 'A65-F4E2', 'IE' ], + [ false, 'B23F854', 'IE' ], + + // Indian postcodes + [ true, '110001', 'IN' ], + [ true, '110 001', 'IN' ], + [ false, '11 0001', 'IN' ], + [ false, '1100 01', 'IN' ], + + // Italian postcodes + [ true, '99999', 'IT' ], + [ false, '9999', 'IT' ], + [ false, 'ABC 999', 'IT' ], + [ false, 'ABC-999', 'IT' ], + [ false, 'ABC_123', 'IT' ], + + // Japanese postcodes + [ true, '1340088', 'JP' ], + [ true, '134-0088', 'JP' ], + [ false, '1340-088', 'JP' ], + [ false, '12345', 'JP' ], + [ false, '0123', 'JP' ], + + // Lichtenstein postcodes + [ true, '9485', 'LI' ], + [ true, '9486', 'LI' ], + [ true, '9499', 'LI' ], + [ false, '9585', 'LI' ], + [ false, '9385', 'LI' ], + [ false, '9475', 'LI' ], + + // Dutch postcodes + [ true, '3852GC', 'NL' ], + [ true, '3852 GC', 'NL' ], + [ true, '3852 gc', 'NL' ], + [ false, '3852SA', 'NL' ], + [ false, '3852 SA', 'NL' ], + [ false, '3852 sa', 'NL' ], + + // Polish postcodes + [ true, '00-001', 'PL' ], + [ true, '99-440', 'PL' ], + [ false, '000-01', 'PL' ], + [ false, '994-40', 'PL' ], + [ false, '00001', 'PL' ], + [ false, '99440', 'PL' ], + + // Puerto Rican postcodes + [ true, '00901', 'PR' ], + [ true, '00617', 'PR' ], + [ true, '00602-1211', 'PR' ], + [ false, '1234', 'PR' ], + [ false, '0060-21211', 'PR' ], + + // Portuguese postcodes + [ true, '1234-567', 'PT' ], + [ true, '2345-678', 'PT' ], + [ false, '123-4567', 'PT' ], + [ false, '234-5678', 'PT' ], + + // Slovenian postcodes + [ true, '1234', 'SI' ], + [ true, '1000', 'SI' ], + [ true, '9876', 'SI' ], + [ false, '12345', 'SI' ], + [ false, '0123', 'SI' ], + + // Slovak postcodes + [ true, '010 01', 'SK' ], + [ true, '01001', 'SK' ], + [ false, '01 001', 'SK' ], + [ false, '1234', 'SK' ], + [ false, '123456', 'SK' ], + + // United States postcodes + [ true, '90210', 'US' ], + [ true, '99577-0727', 'US' ], + [ false, 'ABCDE', 'US' ], + [ false, 'ABCDE-9999', 'US' ], + ]; + + test.each( cases )( '%s: %s for %s', ( result, postcode, country ) => + expect( isPostcode( { postcode, country } as IsPostcodeProps ) ).toBe( + result + ) + ); +} );