mirror of
https://github.com/logto-io/logto.git
synced 2025-03-10 22:22:45 -05:00
refactor(ui): refactor country code (#1483)
* refactor(ui): refactor country code refactor country code * test(ui): fix ut fix ut * fix(ui): cr fix cr fix
This commit is contained in:
parent
650cbd4746
commit
64bb5fd159
12 changed files with 286 additions and 339 deletions
|
@ -59,7 +59,6 @@
|
|||
"react-i18next": "^11.15.4",
|
||||
"react-modal": "^3.14.4",
|
||||
"react-modal-promise": "^1.0.2",
|
||||
"react-phone-number-input": "^3.1.46",
|
||||
"react-router-dom": "^6.2.2",
|
||||
"react-string-replace": "^1.0.0",
|
||||
"react-timer-hook": "^3.0.5",
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
import { render, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { defaultCountryCallingCode, countryList } from '@/hooks/use-phone-number';
|
||||
import { getCountryList, getDefaultCountryCallingCode } from '@/utils/country-code';
|
||||
|
||||
import PhoneInput from './PhoneInput';
|
||||
|
||||
jest.mock('i18next', () => ({
|
||||
language: 'en',
|
||||
}));
|
||||
|
||||
describe('Phone Input Field UI Component', () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
|
@ -12,6 +16,8 @@ describe('Phone Input Field UI Component', () => {
|
|||
onChange.mockClear();
|
||||
});
|
||||
|
||||
const defaultCountryCallingCode = getDefaultCountryCallingCode();
|
||||
|
||||
it('render empty PhoneInput', () => {
|
||||
const { queryByText, container } = render(
|
||||
<PhoneInput name="PhoneInput" nationalNumber="" onChange={onChange} />
|
||||
|
@ -21,18 +27,18 @@ describe('Phone Input Field UI Component', () => {
|
|||
});
|
||||
|
||||
it('render with country list', () => {
|
||||
const { queryByText, container } = render(
|
||||
const { queryAllByText, container } = render(
|
||||
<PhoneInput
|
||||
name="PhoneInput"
|
||||
nationalNumber=""
|
||||
countryList={countryList}
|
||||
countryList={getCountryList()}
|
||||
countryCallingCode={defaultCountryCallingCode}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const countryCode = queryByText(`+${defaultCountryCallingCode}`);
|
||||
expect(countryCode).not.toBeNull();
|
||||
const countryCode = queryAllByText(`+${defaultCountryCallingCode}`);
|
||||
expect(countryCode).toHaveLength(2);
|
||||
|
||||
const selector = container.querySelector('select');
|
||||
expect(selector).not.toBeNull();
|
||||
|
|
|
@ -39,7 +39,7 @@ const PhoneInput = ({
|
|||
const inputReference = useRef<HTMLInputElement>(null);
|
||||
|
||||
const countrySelector = useMemo(() => {
|
||||
if (!countryCallingCode || !countryList) {
|
||||
if (countryCallingCode === undefined || !countryList?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -60,9 +60,9 @@ const PhoneInput = ({
|
|||
}
|
||||
}}
|
||||
>
|
||||
{countryList.map(({ countryCode, countryCallingCode, countryName }) => (
|
||||
{countryList.map(({ countryCallingCode, countryCode }) => (
|
||||
<option key={countryCode} value={countryCallingCode}>
|
||||
{`${countryName ?? countryCode}: +${countryCallingCode}`}
|
||||
{`+${countryCallingCode}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
|
@ -6,7 +6,7 @@ import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
|||
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
||||
import { sendRegisterSmsPasscode } from '@/apis/register';
|
||||
import { sendSignInSmsPasscode } from '@/apis/sign-in';
|
||||
import { defaultCountryCallingCode } from '@/hooks/use-phone-number';
|
||||
import { getDefaultCountryCallingCode } from '@/utils/country-code';
|
||||
|
||||
import PhonePasswordless from './PhonePasswordless';
|
||||
|
||||
|
@ -16,9 +16,13 @@ jest.mock('@/apis/sign-in', () => ({
|
|||
jest.mock('@/apis/register', () => ({
|
||||
sendRegisterSmsPasscode: jest.fn(async () => Promise.resolve()),
|
||||
}));
|
||||
jest.mock('i18next', () => ({
|
||||
language: 'en',
|
||||
}));
|
||||
|
||||
describe('<PhonePasswordless/>', () => {
|
||||
const phoneNumber = '18888888888';
|
||||
const phoneNumber = '8573333333';
|
||||
const defaultCountryCallingCode = getDefaultCountryCallingCode();
|
||||
|
||||
test('render', () => {
|
||||
const { queryByText, container } = renderWithPageContext(
|
||||
|
|
|
@ -10,7 +10,7 @@ import TermsOfUse from '@/containers/TermsOfUse';
|
|||
import useApi, { ErrorHandlers } from '@/hooks/use-api';
|
||||
import useForm from '@/hooks/use-form';
|
||||
import { PageContext } from '@/hooks/use-page-context';
|
||||
import usePhoneNumber, { countryList } from '@/hooks/use-phone-number';
|
||||
import usePhoneNumber from '@/hooks/use-phone-number';
|
||||
import useTerms from '@/hooks/use-terms';
|
||||
import { UserFlow, SearchParameters } from '@/types';
|
||||
import { getSearchParameters } from '@/utils';
|
||||
|
@ -35,7 +35,7 @@ const PhonePasswordless = ({ type, autoFocus, className }: Props) => {
|
|||
const { setToast } = useContext(PageContext);
|
||||
const [showPasswordlessConfirmModal, setShowPasswordlessConfirmModal] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const { phoneNumber, setPhoneNumber, isValidPhoneNumber } = usePhoneNumber();
|
||||
const { countryList, phoneNumber, setPhoneNumber, isValidPhoneNumber } = usePhoneNumber();
|
||||
const navigate = useNavigate();
|
||||
const { termsValidation } = useTerms();
|
||||
const { fieldValue, setFieldValue, setFieldErrors, validateForm, register } =
|
||||
|
|
|
@ -5,40 +5,22 @@
|
|||
|
||||
import {
|
||||
parsePhoneNumberWithError,
|
||||
getCountries,
|
||||
getCountryCallingCode,
|
||||
CountryCallingCode,
|
||||
CountryCode,
|
||||
E164Number,
|
||||
ParseError,
|
||||
} from 'libphonenumber-js';
|
||||
CountryCode,
|
||||
} from 'libphonenumber-js/mobile';
|
||||
import { useState } from 'react';
|
||||
// Should not need the react-phone-number-input package, but we use its locale country name for now
|
||||
import en from 'react-phone-number-input/locale/en.json';
|
||||
|
||||
import { getDefaultCountryCallingCode, getCountryList } from '@/utils/country-code';
|
||||
|
||||
export type { CountryCallingCode } from 'libphonenumber-js';
|
||||
|
||||
/**
|
||||
* Provide Country Code Options
|
||||
* TODO: Country Name i18n
|
||||
*/
|
||||
export type CountryMetaData = {
|
||||
countryCode: CountryCode;
|
||||
countryCallingCode: CountryCallingCode;
|
||||
countryName?: string;
|
||||
};
|
||||
|
||||
export const countryList: CountryMetaData[] = getCountries().map((code) => {
|
||||
const callingCode = getCountryCallingCode(code);
|
||||
const countryName = en[code];
|
||||
|
||||
return {
|
||||
countryCode: code,
|
||||
countryCallingCode: callingCode,
|
||||
countryName,
|
||||
};
|
||||
});
|
||||
|
||||
type PhoneNumberData = {
|
||||
countryCallingCode: string;
|
||||
nationalNumber: string;
|
||||
|
@ -65,16 +47,14 @@ const isValidPhoneNumber = (value: string): boolean => {
|
|||
}
|
||||
};
|
||||
|
||||
export const defaultCountryCode: CountryCode = 'CN';
|
||||
export const defaultCountryCallingCode = getCountryCallingCode(defaultCountryCode);
|
||||
|
||||
const usePhoneNumber = () => {
|
||||
const [phoneNumber, setPhoneNumber] = useState<PhoneNumberData>({
|
||||
countryCallingCode: defaultCountryCallingCode,
|
||||
countryCallingCode: getDefaultCountryCallingCode(),
|
||||
nationalNumber: '',
|
||||
});
|
||||
|
||||
return {
|
||||
countryList: getCountryList(),
|
||||
phoneNumber,
|
||||
setPhoneNumber,
|
||||
isValidPhoneNumber,
|
||||
|
|
|
@ -5,6 +5,9 @@ import { Routes, Route, MemoryRouter } from 'react-router-dom';
|
|||
import Register from '@/pages/Register';
|
||||
|
||||
jest.mock('@/apis/register', () => ({ register: jest.fn(async () => Promise.resolve()) }));
|
||||
jest.mock('i18next', () => ({
|
||||
language: 'en',
|
||||
}));
|
||||
|
||||
describe('<Register />', () => {
|
||||
test('renders without exploding', async () => {
|
||||
|
|
|
@ -5,6 +5,9 @@ import { Routes, Route, MemoryRouter } from 'react-router-dom';
|
|||
import SecondarySignIn from '@/pages/SecondarySignIn';
|
||||
|
||||
jest.mock('@/apis/register', () => ({ register: jest.fn(async () => Promise.resolve()) }));
|
||||
jest.mock('i18next', () => ({
|
||||
language: 'en',
|
||||
}));
|
||||
|
||||
describe('<SecondarySignIn />', () => {
|
||||
test('renders without exploding', async () => {
|
||||
|
|
|
@ -7,6 +7,10 @@ import { mockSignInExperienceSettings } from '@/__mocks__/logto';
|
|||
import { defaultSize } from '@/containers/SocialSignIn/SocialSignInList';
|
||||
import SignIn from '@/pages/SignIn';
|
||||
|
||||
jest.mock('i18next', () => ({
|
||||
language: 'en',
|
||||
}));
|
||||
|
||||
describe('<SignIn />', () => {
|
||||
test('renders with username as primary', async () => {
|
||||
const { queryByText, container } = renderWithPageContext(
|
||||
|
|
79
packages/ui/src/utils/country-code.test.ts
Normal file
79
packages/ui/src/utils/country-code.test.ts
Normal file
|
@ -0,0 +1,79 @@
|
|||
import i18nNext from 'i18next';
|
||||
|
||||
import {
|
||||
isValidCountryCode,
|
||||
getDefaultCountryCode,
|
||||
getDefaultCountryCallingCode,
|
||||
getCountryList,
|
||||
} from './country-code';
|
||||
|
||||
describe('country-code', () => {
|
||||
void i18nNext.init();
|
||||
|
||||
it('isValidCountryCode', () => {
|
||||
expect(isValidCountryCode('CN')).toBeTruthy();
|
||||
expect(isValidCountryCode('xy')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('getDefaultCountryCode', async () => {
|
||||
await i18nNext.changeLanguage('zh');
|
||||
expect(getDefaultCountryCode()).toEqual('CN');
|
||||
|
||||
await i18nNext.changeLanguage('en');
|
||||
expect(getDefaultCountryCode()).toEqual('US');
|
||||
|
||||
await i18nNext.changeLanguage('zh-CN');
|
||||
expect(getDefaultCountryCode()).toEqual('CN');
|
||||
|
||||
await i18nNext.changeLanguage('zh-TW');
|
||||
expect(getDefaultCountryCode()).toEqual('TW');
|
||||
|
||||
await i18nNext.changeLanguage('en-US');
|
||||
expect(getDefaultCountryCode()).toEqual('US');
|
||||
|
||||
await i18nNext.changeLanguage('en-CA');
|
||||
expect(getDefaultCountryCode()).toEqual('CA');
|
||||
});
|
||||
|
||||
it('getDefaultCountryCallingCode', async () => {
|
||||
await i18nNext.changeLanguage('zh');
|
||||
expect(getDefaultCountryCallingCode()).toEqual('86');
|
||||
|
||||
await i18nNext.changeLanguage('en');
|
||||
expect(getDefaultCountryCallingCode()).toEqual('1');
|
||||
|
||||
await i18nNext.changeLanguage('zh-CN');
|
||||
expect(getDefaultCountryCallingCode()).toEqual('86');
|
||||
|
||||
await i18nNext.changeLanguage('zh-TW');
|
||||
expect(getDefaultCountryCallingCode()).toEqual('886');
|
||||
|
||||
await i18nNext.changeLanguage('en-US');
|
||||
expect(getDefaultCountryCallingCode()).toEqual('1');
|
||||
|
||||
await i18nNext.changeLanguage('en-CA');
|
||||
expect(getDefaultCountryCallingCode()).toEqual('1');
|
||||
});
|
||||
|
||||
it('getCountryList should sort properly', async () => {
|
||||
await i18nNext.changeLanguage('zh');
|
||||
const countryList = getCountryList();
|
||||
|
||||
expect(countryList[0]).toEqual({
|
||||
countryCode: 'CN',
|
||||
countryCallingCode: '86',
|
||||
});
|
||||
|
||||
expect(countryList[1]?.countryCallingCode).toEqual('1');
|
||||
});
|
||||
|
||||
it('getCountryList should remove duplicate', async () => {
|
||||
await i18nNext.changeLanguage('zh');
|
||||
const countryList = getCountryList();
|
||||
|
||||
expect(countryList.filter(({ countryCallingCode }) => countryCallingCode === '1')).toHaveLength(
|
||||
1
|
||||
);
|
||||
expect(countryList[0]?.countryCallingCode).toEqual('86');
|
||||
});
|
||||
});
|
80
packages/ui/src/utils/country-code.ts
Normal file
80
packages/ui/src/utils/country-code.ts
Normal file
|
@ -0,0 +1,80 @@
|
|||
import i18next from 'i18next';
|
||||
import {
|
||||
getCountries,
|
||||
CountryCode,
|
||||
CountryCallingCode,
|
||||
getCountryCallingCode,
|
||||
} from 'libphonenumber-js';
|
||||
|
||||
export const fallbackCountryCode = 'US';
|
||||
|
||||
export const countryCallingCodeMap: Record<string, CountryCode> = {
|
||||
zh: 'CN',
|
||||
en: 'US',
|
||||
};
|
||||
|
||||
export const isValidCountryCode = (countryCode: string): countryCode is CountryCode => {
|
||||
try {
|
||||
// Use getCountryCallingCode method to guard the input's value is in CountryCode union type, if type not match exceptions are expected
|
||||
getCountryCallingCode(countryCode as CountryCode);
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const getDefaultCountryCode = (): CountryCode => {
|
||||
const { language } = i18next;
|
||||
|
||||
// Extract the country code from language tag suffix
|
||||
const [, countryCode] = language.split('-');
|
||||
|
||||
if (countryCode && isValidCountryCode(countryCode)) {
|
||||
return countryCode;
|
||||
}
|
||||
|
||||
return countryCallingCodeMap[language] ?? fallbackCountryCode;
|
||||
};
|
||||
|
||||
export const getDefaultCountryCallingCode = () => getCountryCallingCode(getDefaultCountryCode());
|
||||
|
||||
/**
|
||||
* Provide Country Code Options
|
||||
*/
|
||||
export type CountryMetaData = {
|
||||
countryCode: CountryCode;
|
||||
countryCallingCode: CountryCallingCode;
|
||||
};
|
||||
|
||||
export const getCountryList = (): CountryMetaData[] => {
|
||||
const defaultCountryCode = getDefaultCountryCode();
|
||||
const defaultCountryCallingCode = getCountryCallingCode(defaultCountryCode);
|
||||
|
||||
const countryList = getCountries()
|
||||
.map((code) => ({
|
||||
countryCode: code,
|
||||
countryCallingCode: getCountryCallingCode(code),
|
||||
}))
|
||||
// Filter the detected default countryCode & duplicates
|
||||
.filter(({ countryCallingCode }, index, self) => {
|
||||
if (countryCallingCode === defaultCountryCallingCode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
self.findIndex((element) => element.countryCallingCode === countryCallingCode) === index
|
||||
);
|
||||
})
|
||||
.slice()
|
||||
// Sort by countryCallingCode
|
||||
.sort((previous, next) => (next.countryCallingCode > previous.countryCallingCode ? -1 : 1));
|
||||
|
||||
return [
|
||||
{
|
||||
countryCode: defaultCountryCode,
|
||||
countryCallingCode: defaultCountryCallingCode,
|
||||
},
|
||||
...countryList,
|
||||
];
|
||||
};
|
389
pnpm-lock.yaml
generated
389
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue