From 2ee88e9643998c84ca724469948f256802a5209e Mon Sep 17 00:00:00 2001 From: Pedro Ladaria Date: Thu, 19 Sep 2024 12:41:11 +0200 Subject: [PATCH] feat(PhoneNumberField): Custom formatter support + lazy load libphonenumber on demand (#1244) * load libphonenumber on demand * allow custom formatter which allows to not use libphonenumber at all --------- Co-authored-by: Pedro Ladaria --- src/__tests__/phone-number-field-test.tsx | 81 +++++++++++++++++++- src/phone-number-field.tsx | 93 ++++++++++++++++++----- src/test-utils/ssr.tsx | 5 ++ src/text-field-base.tsx | 2 +- 4 files changed, 160 insertions(+), 21 deletions(-) diff --git a/src/__tests__/phone-number-field-test.tsx b/src/__tests__/phone-number-field-test.tsx index c7d7bbe02c..9406312a91 100644 --- a/src/__tests__/phone-number-field-test.tsx +++ b/src/__tests__/phone-number-field-test.tsx @@ -1,8 +1,7 @@ import * as React from 'react'; -import {PhoneNumberField} from '..'; -import {render, screen} from '@testing-library/react'; +import {Form, PhoneNumberField, ThemeContextProvider} from '..'; +import {render, screen, waitFor} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import ThemeContextProvider from '../theme-context-provider'; import {makeTheme} from './test-utils'; test.each` @@ -47,3 +46,79 @@ test.each` expect(onChangeValueSpy).toHaveBeenLastCalledWith(expectedValue, expectedValueRaw); } ); + +test.each` + contextRegionCode | prefix | typed | expectedValue | expectedValueRaw + ${'ES'} | ${undefined} | ${'+123123123'} | ${'123123123'} | ${'1-2-3-1-2-3-1-2-3'} + ${'ES'} | ${'+49'} | ${'69654321'} | ${'69654321'} | ${'6-9-6-5-4-3-2-1'} + ${'DE'} | ${undefined} | ${'069654321'} | ${'069654321'} | ${'0-6-9-6-5-4-3-2-1'} +`( + `PhoneNumberField with custom formatter ($contextRegionCode, $prefix, $typed, $expectedValue, $expectedValueRaw)`, + async ({contextRegionCode, prefix, typed, expectedValue, expectedValueRaw}) => { + const onChangeValueSpy = jest.fn(); + const theme = makeTheme(); + + render( + + { + // dumb formatter that just adds a dash between each digit + return number.replace(/[^\d]/g, '').split('').join('-'); + }} + /> + + ); + + await userEvent.type(screen.getByLabelText('Enter Phone'), typed); + + expect(onChangeValueSpy).toHaveBeenLastCalledWith(expectedValue, expectedValueRaw); + } +); + +test('PhoneNumberField gets formatted when libphonenumber loads', async () => { + const onChangeValueSpy = jest.fn(); + const theme = makeTheme(); + + const TestComponent = () => { + const [isLib, setIsLib] = React.useState(false); + return ( + +
{}}> + number} + /> + + +
+ ); + }; + + render(); + + await userEvent.type(screen.getByLabelText('Enter Phone'), '654834455'); + + expect(onChangeValueSpy).toHaveBeenLastCalledWith('654834455', '654834455'); + + await userEvent.click(screen.getByText('enable libphonenumber')); + + await waitFor(() => { + expect(onChangeValueSpy).toHaveBeenLastCalledWith('654834455', '654 83 44 55'); + }); +}); diff --git a/src/phone-number-field.tsx b/src/phone-number-field.tsx index 90a97183af..ffde6e0d4d 100644 --- a/src/phone-number-field.tsx +++ b/src/phone-number-field.tsx @@ -1,7 +1,6 @@ 'use client'; import * as React from 'react'; import {useRifm} from 'rifm'; -import {formatAsYouType, formatToE164, parse, getRegionCodeForCountryCode} from '@telefonica/libphonenumber'; import {useFieldProps} from './form-context'; import {TextFieldBaseAutosuggest} from './text-field-base'; import {useTheme} from './hooks'; @@ -11,8 +10,14 @@ import {combineRefs} from './utils/common'; import type {CommonFormFieldProps} from './text-field-base'; import type {RegionCode} from './utils/region-code'; -const formatPhone = (regionCode: RegionCode, number: string): string => - formatAsYouType(number.replace(/[^\d+*#]/g, ''), regionCode); +let libphonenumber: typeof import('@telefonica/libphonenumber'); + +type NumberFormatter = (number: string, regionCode: RegionCode) => string; + +const formatPhoneDummy: NumberFormatter = (number) => number; + +const formatPhoneUsingLibphonenumber: NumberFormatter = (number, regionCode) => + libphonenumber.formatAsYouType(number.replace(/[^\d+*#]/g, ''), regionCode); type InputProps = Omit, 'value' | 'onInput'> & { inputRef?: React.Ref; @@ -21,18 +26,47 @@ type InputProps = Omit, 'value' | 'o onInput?: (event: React.FormEvent) => void; prefix?: string; e164?: boolean; + format?: NumberFormatter; }; const isValidPrefix = (prefix: string): boolean => !!prefix.match(/^\+\d+$/); -const PhoneInput = ({inputRef, value, defaultValue, onChange, prefix, e164, ...other}: InputProps) => { +const PhoneInput = ({ + inputRef, + value, + defaultValue, + onChange: onChangeFromProps, + prefix, + e164, + format: formatFromProps, + ...other +}: InputProps) => { const [selfValue, setSelfValue] = React.useState(defaultValue ?? ''); const ref = React.useRef(null); const {i18n} = useTheme(); - + const formatRef = React.useRef(formatFromProps || formatPhoneDummy); + /** this state is used to force a re-render when libphonenumber is loaded */ + const [isLibphonenumberLoaded, setIsLibphonenumberloaded] = React.useState(false); const regionCode = i18n.phoneNumberFormattingRegionCode; const isControlledByParent = typeof value !== 'undefined'; const controlledValue = (isControlledByParent ? value : selfValue) as string; + const onChangeRef = React.useRef(onChangeFromProps); + + React.useEffect(() => { + onChangeRef.current = onChangeFromProps; + }, [onChangeFromProps]); + + React.useEffect(() => { + if (formatFromProps) { + formatRef.current = formatFromProps; + } else { + import('@telefonica/libphonenumber' /* webpackChunkName: "libphonenumber" */).then((lib) => { + libphonenumber = lib; + formatRef.current = formatPhoneUsingLibphonenumber; + setIsLibphonenumberloaded(true); + }); + } + }, [formatFromProps]); const handleChangeValue = React.useCallback( (newFormattedValue: string) => { @@ -40,10 +74,10 @@ const PhoneInput = ({inputRef, value, defaultValue, onChange, prefix, e164, ...o setSelfValue(newFormattedValue); } if (ref.current) { - onChange?.(createChangeEvent(ref.current, newFormattedValue)); + onChangeRef.current?.(createChangeEvent(ref.current, newFormattedValue)); } }, - [isControlledByParent, onChange] + [isControlledByParent] ); const format = React.useCallback( @@ -57,15 +91,15 @@ const PhoneInput = ({inputRef, value, defaultValue, onChange, prefix, e164, ...o // then remove the prefix from the result if (prefix && isValidPrefix(prefix)) { const prefixedValue = prefix + value; - result = formatPhone(regionCode, prefixedValue); + result = formatRef.current(prefixedValue, regionCode); if (result.startsWith(prefix)) { result = result.slice(prefix.length).trim(); } else { // fallback to regular formatting - result = formatPhone(regionCode, value); + result = formatRef.current(value, regionCode); } } else { - result = formatPhone(regionCode, value); + result = formatRef.current(value, regionCode); } return result.replace(/-/g, '@'); }, @@ -75,11 +109,18 @@ const PhoneInput = ({inputRef, value, defaultValue, onChange, prefix, e164, ...o const rifm = useRifm({ format, value: controlledValue, - onChange: handleChangeValue, + // Instead of calling `handleChangeValue` here, we call it in `useEffect` below. + // When the formatter changes (libphonenumber is lazy loaded), rifm should call `onChange` + // with the new formatted value but it doesn't, so we need to call it manually. + onChange: () => {}, accept: /[\d\-+#*]+/g, replace: (s) => s.replace(/@/g, '-'), }); + React.useEffect(() => { + handleChangeValue(rifm.value); + }, [rifm.value, handleChangeValue]); + return ( ); }; @@ -96,6 +138,7 @@ export interface PhoneNumberFieldProps extends CommonFormFieldProps { prefix?: string; getSuggestions?: (value: string) => Array; e164?: boolean; + format?: NumberFormatter; } const PhoneNumberField = ({ @@ -110,21 +153,34 @@ const PhoneNumberField = ({ onBlur, value, defaultValue, - e164, dataAttributes, + /** + * By default this component will use google's libphonenumber library to format numbers. + * The component will load libphonenumber on demand, so it won't impact the initial load time. + * You can opt-out of using libphonenumber by providing a custom formatter. + */ + format, + /** enabling e164 is incompatible with custom formatters because this requires libphonenumber */ + e164, ...rest }: PhoneNumberFieldProps): JSX.Element => { const {i18n} = useTheme(); + if (process.env.NODE_ENV !== 'production') { + if (e164 && format) { + console.error('[PhoneNumberField] enabling e164 is incompatible with custom formatters'); + } + } + const processValue = (value: string) => { - if (e164) { + if (e164 && libphonenumber && !format) { try { const numericPrefix = (rest.prefix ?? '').replace(/[^\d]/g, ''); - let regionCode = getRegionCodeForCountryCode(numericPrefix); + let regionCode = libphonenumber.getRegionCodeForCountryCode(numericPrefix); if (!regionCode || regionCode === 'ZZ') { regionCode = i18n.phoneNumberFormattingRegionCode; } - return formatToE164(parse(value, regionCode)); + return libphonenumber.formatToE164(libphonenumber.parse(value, regionCode)); } catch (e) { return ''; } @@ -154,9 +210,12 @@ const PhoneNumberField = ({ {...rest} {...fieldProps} type="phone" - inputProps={{prefix: rest.prefix}} + inputProps={{prefix: rest.prefix, format}} inputComponent={PhoneInput} - dataAttributes={{'component-name': 'PhoneNumberField', ...dataAttributes}} + dataAttributes={{ + 'component-name': 'PhoneNumberField', + ...dataAttributes, + }} /> ); }; diff --git a/src/test-utils/ssr.tsx b/src/test-utils/ssr.tsx index 2ec3dac30c..4528919850 100644 --- a/src/test-utils/ssr.tsx +++ b/src/test-utils/ssr.tsx @@ -144,6 +144,11 @@ export const createServer = (): http.Server => { return; } + if (moduleName.includes('telefonica_libphonenumber')) { + serveFileInPath(path.join(__dirname, '..', '..', 'public', 'ssr', `${moduleName}`)); + return; + } + let Component; try { // eslint-disable-next-line @typescript-eslint/no-var-requires diff --git a/src/text-field-base.tsx b/src/text-field-base.tsx index dc896088f9..6323e0c937 100644 --- a/src/text-field-base.tsx +++ b/src/text-field-base.tsx @@ -165,7 +165,7 @@ interface TextFieldBaseProps { onBlur?: React.FocusEventHandler; onFocus?: React.FocusEventHandler; onKeyDown?: (event: React.KeyboardEvent) => void; - inputProps?: {[name: string]: string | number | undefined}; + inputProps?: {[name: string]: unknown}; inputComponent?: React.ComponentType; shrinkLabel?: boolean; focus?: boolean;