Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(PhoneNumberField): Custom formatter support + lazy load libphonenumber on demand #1244

Merged
merged 7 commits into from
Sep 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 78 additions & 3 deletions src/__tests__/phone-number-field-test.tsx
Original file line number Diff line number Diff line change
@@ -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`
Expand Down Expand Up @@ -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(
<ThemeContextProvider
theme={{
...theme,
i18n: {...theme.i18n, phoneNumberFormattingRegionCode: contextRegionCode},
}}
>
<PhoneNumberField
prefix={prefix}
label="Enter Phone"
name="phone"
onChangeValue={onChangeValueSpy}
format={(number) => {
// dumb formatter that just adds a dash between each digit
return number.replace(/[^\d]/g, '').split('').join('-');
}}
/>
</ThemeContextProvider>
);

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 (
<ThemeContextProvider
theme={{
...theme,
i18n: {...theme.i18n, phoneNumberFormattingRegionCode: 'ES'},
}}
>
<Form onSubmit={() => {}}>
<PhoneNumberField
label="Enter Phone"
name="phone"
onChangeValue={onChangeValueSpy}
format={isLib ? undefined : (number) => number}
/>
<button onClick={() => setIsLib(true)}>enable libphonenumber</button>
</Form>
</ThemeContextProvider>
);
};

render(<TestComponent />);

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');
});
});
93 changes: 76 additions & 17 deletions src/phone-number-field.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'onInput'> & {
inputRef?: React.Ref<HTMLInputElement>;
Expand All @@ -21,29 +26,58 @@ type InputProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'o
onInput?: (event: React.FormEvent<HTMLInputElement>) => 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<HTMLInputElement | null>(null);
const {i18n} = useTheme();

const formatRef = React.useRef<NumberFormatter>(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) => {
if (!isControlledByParent) {
setSelfValue(newFormattedValue);
}
if (ref.current) {
onChange?.(createChangeEvent(ref.current, newFormattedValue));
onChangeRef.current?.(createChangeEvent(ref.current, newFormattedValue));
}
},
[isControlledByParent, onChange]
[isControlledByParent]
);

const format = React.useCallback(
Expand All @@ -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, '@');
},
Expand All @@ -75,18 +109,26 @@ 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 (
<input
{...other}
value={rifm.value}
onChange={rifm.onChange}
type="tel" // shows telephone keypad in Android and iOS
ref={combineRefs(inputRef, ref)}
data-using-libphonenumber={isLibphonenumberLoaded}
/>
);
};
Expand All @@ -96,6 +138,7 @@ export interface PhoneNumberFieldProps extends CommonFormFieldProps {
prefix?: string;
getSuggestions?: (value: string) => Array<string>;
e164?: boolean;
format?: NumberFormatter;
}

const PhoneNumberField = ({
Expand All @@ -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 '';
}
Expand Down Expand Up @@ -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,
}}
/>
);
};
Expand Down
5 changes: 5 additions & 0 deletions src/test-utils/ssr.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/text-field-base.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>;
shrinkLabel?: boolean;
focus?: boolean;
Expand Down
Loading