diff --git a/src/features/identityCheck/components/countryPicker/constants.ts b/src/features/identityCheck/components/countryPicker/constants.ts index b6711931401..b6308944a84 100644 --- a/src/features/identityCheck/components/countryPicker/constants.ts +++ b/src/features/identityCheck/components/countryPicker/constants.ts @@ -53,4 +53,9 @@ export const COUNTRIES: Country[] = [ name: 'Wallis-et-Futuna', callingCode: '681', }, + { + id: 'NC', + name: 'Nouvelle-Calédonie', + callingCode: '687', + }, ] diff --git a/src/features/identityCheck/pages/phoneValidation/SetPhoneNumber.tsx b/src/features/identityCheck/pages/phoneValidation/SetPhoneNumber.tsx index 9977db226cc..e5b79ab96c3 100644 --- a/src/features/identityCheck/pages/phoneValidation/SetPhoneNumber.tsx +++ b/src/features/identityCheck/pages/phoneValidation/SetPhoneNumber.tsx @@ -46,7 +46,7 @@ export const SetPhoneNumber = () => { const [country, setCountry] = useState(INITIAL_COUNTRY) const { navigate } = useNavigation() const { goBack } = useGoBack(...homeNavConfig) - const isContinueButtonEnabled = isPhoneNumberValid(phoneNumber) + const isContinueButtonEnabled = isPhoneNumberValid(phoneNumber, country.id) const saveStep = useSaveStep() const { remainingAttempts, isLastAttempt } = usePhoneValidationRemainingAttempts() diff --git a/src/features/identityCheck/pages/phoneValidation/SetPhoneNumberWithoutValidation.native.test.tsx b/src/features/identityCheck/pages/phoneValidation/SetPhoneNumberWithoutValidation.native.test.tsx index 02d31a2fae0..089bbde6656 100644 --- a/src/features/identityCheck/pages/phoneValidation/SetPhoneNumberWithoutValidation.native.test.tsx +++ b/src/features/identityCheck/pages/phoneValidation/SetPhoneNumberWithoutValidation.native.test.tsx @@ -2,11 +2,12 @@ import React from 'react' import { dispatch } from '__mocks__/@react-navigation/native' import * as API from 'api/api' +import { ApiError } from 'api/ApiError' import { UserProfileResponse } from 'api/gen' import { initialSubscriptionState } from 'features/identityCheck/context/reducer' import * as SubscriptionContextProvider from 'features/identityCheck/context/SubscriptionContextProvider' import { reactQueryProviderHOC } from 'tests/reactQueryProviderHOC' -import { act, fireEvent, render, screen, waitFor } from 'tests/utils' +import { act, fireEvent, render, screen } from 'tests/utils' import { SetPhoneNumberWithoutValidation } from './SetPhoneNumberWithoutValidation' @@ -28,8 +29,36 @@ describe('SetPhoneNumberWithoutValidation', () => { expect(screen).toMatchSnapshot() }) + describe('when form validation', () => { + it('should disable the button when phone number is invalid', async () => { + givenStoredPhoneNumber('', { callingCode: '33', countryCode: 'FR' }) + + renderSetPhoneNumberWithoutValidation() + + await fillPhoneNumberInput('123') + + const button = screen.getByText('Continuer') + await act(() => { + expect(button).toBeDisabled() + }) + }) + + it('should enable the button when phone number is valid', async () => { + givenStoredPhoneNumber('', { callingCode: '33', countryCode: 'FR' }) + + renderSetPhoneNumberWithoutValidation() + + await fillPhoneNumberInput('0612345678') + + const button = screen.getByText('Continuer') + await act(() => { + expect(button).toBeEnabled() + }) + }) + }) + describe('when user already given his phone number', () => { - test('Use the phone number already given', () => { + it('should use the phone number already given', () => { givenStoredPhoneNumber('0612345678', { callingCode: '33', countryCode: 'FR' }) const { unmount } = renderSetPhoneNumberWithoutValidation() @@ -41,7 +70,7 @@ describe('SetPhoneNumberWithoutValidation', () => { unmount() // to avoid act warning https://github.com/orgs/react-hook-form/discussions/3108#discussioncomment-8514714 }) - test('Use the country already given', () => { + it('should use the country already given', () => { givenStoredPhoneNumber('0612345678', { callingCode: '596', countryCode: 'MQ' }) const { unmount } = renderSetPhoneNumberWithoutValidation() @@ -60,12 +89,12 @@ describe('SetPhoneNumberWithoutValidation', () => { updatePhoneNumberWillSucceed() }) - test('Redirect to steppers when update phone number is succeed', async () => { + it('should redirect to steppers when update phone number is succeed', async () => { const { unmount } = renderSetPhoneNumberWithoutValidation() await submitWithPhoneNumber('0612345678') - await waitFor(() => { + await act(() => { expect(dispatch).toHaveBeenCalledWith({ payload: { index: 1, routes: [{ name: 'TabNavigator' }, { name: 'Stepper' }] }, type: 'RESET', @@ -75,7 +104,7 @@ describe('SetPhoneNumberWithoutValidation', () => { unmount() }) - test('Store phone number', async () => { + it('should store phone number', async () => { const { unmount } = renderSetPhoneNumberWithoutValidation() await submitWithPhoneNumber('0612345678') @@ -93,7 +122,7 @@ describe('SetPhoneNumberWithoutValidation', () => { }) describe('When failure', () => { - test('Show error message when update phone number is failed', async () => { + it('should show error message when update phone number is failed', async () => { updatePhoneNumberWillFail() const { unmount } = renderSetPhoneNumberWithoutValidation() @@ -106,16 +135,6 @@ describe('SetPhoneNumberWithoutValidation', () => { }) }) - test('User can NOT send form when form is invalid', async () => { - renderSetPhoneNumberWithoutValidation() - - await fillPhoneNumberInput('') - - const button = screen.getByText('Continuer') - - expect(button).toBeDisabled() - }) - function renderSetPhoneNumberWithoutValidation() { return render(reactQueryProviderHOC()) } @@ -125,7 +144,7 @@ describe('SetPhoneNumberWithoutValidation', () => { } function updatePhoneNumberWillFail() { - patchProfile.mockRejectedValueOnce(new Error('Une erreur est survenue')) + patchProfile.mockRejectedValueOnce(new ApiError(500, undefined, 'Une erreur est survenue')) } async function fillPhoneNumberInput(phoneNumber: string) { diff --git a/src/features/identityCheck/pages/phoneValidation/SetPhoneNumberWithoutValidation.tsx b/src/features/identityCheck/pages/phoneValidation/SetPhoneNumberWithoutValidation.tsx index e8966bcdf7a..843149e58b3 100644 --- a/src/features/identityCheck/pages/phoneValidation/SetPhoneNumberWithoutValidation.tsx +++ b/src/features/identityCheck/pages/phoneValidation/SetPhoneNumberWithoutValidation.tsx @@ -4,6 +4,7 @@ import { Controller, useForm } from 'react-hook-form' import { v4 as uuidv4 } from 'uuid' import * as yup from 'yup' +import { isApiError } from 'api/apiHelpers' import { COUNTRIES, METROPOLITAN_FRANCE, @@ -29,9 +30,16 @@ import { getHeadingAttrs } from 'ui/theme/typographyAttrs/getHeadingAttrs' import { invalidateStepperInfoQuery } from '../helpers/invalidateStepperQuery' import { formatPhoneNumberWithPrefix } from './helpers/formatPhoneNumber' +import { isPhoneNumberValid } from './helpers/isPhoneNumberValid' const schema = yup.object({ - phoneNumber: yup.string().required('Le numéro de téléphone est requis'), + phoneNumber: yup + .string() + .required('Le numéro de téléphone est requis') + .test('is-valid-phone', '', (value, { parent }) => { + const country = findCountry(parent.countryId) + return country ? isPhoneNumberValid(value ?? '', country.id) : false + }), countryId: yup.string().required(), }) @@ -51,12 +59,10 @@ export const SetPhoneNumberWithoutValidation = () => { mode: 'onChange', }) - const disableSubmit = !formState.isValid - const { navigateForwardToStepper } = useNavigateForwardToStepper() const saveStep = useSaveStep() - const { mutate: updateProfile } = useUpdateProfileMutation( - () => { + const { mutate: updateProfile } = useUpdateProfileMutation({ + onSuccess: () => { const { phoneNumber, countryId } = getValues() const country = findCountry(countryId) if (!country) { @@ -76,10 +82,10 @@ export const SetPhoneNumberWithoutValidation = () => { invalidateStepperInfoQuery() navigateForwardToStepper() }, - () => { - setError('phoneNumber', { message: 'Une erreur est survenue' }) - } - ) + onError: (error) => { + isApiError(error) && setError('phoneNumber', { message: error.message }) + }, + }) const onSubmit = async ({ phoneNumber, countryId }: FormValues) => { const country = findCountry(countryId) @@ -117,7 +123,7 @@ export const SetPhoneNumberWithoutValidation = () => { { } fixedBottomChildren={ { - it.each` - phoneNumber | isValid - ${'111111111'} | ${true} - ${'0111111111'} | ${true} - ${'011111111'} | ${false} - ${'1111111'} | ${false} - ${'1.11.11.11.11'} | ${true} - ${'01.11.11.11.11'} | ${true} - ${'0-11-11-11-11'} | ${false} - ${'1-11-11-11'} | ${false} - `('should return $isValid if phone number is $phoneNumber', ({ phoneNumber, isValid }) => { - const result = isPhoneNumberValid(phoneNumber) - - expect(result).toBe(isValid) - }) -}) diff --git a/src/features/identityCheck/pages/phoneValidation/helpers/isPhoneNumberValid.ts b/src/features/identityCheck/pages/phoneValidation/helpers/isPhoneNumberValid.ts index d0ee78adb9e..2d058c0190a 100644 --- a/src/features/identityCheck/pages/phoneValidation/helpers/isPhoneNumberValid.ts +++ b/src/features/identityCheck/pages/phoneValidation/helpers/isPhoneNumberValid.ts @@ -1,4 +1,13 @@ -export function isPhoneNumberValid(number: string) { - // 9 digits, 10 if the first is a "0" that can be separated by whitespace, "." or "-". - return Boolean(number.match(/^(?:0)?\s*[1-9](?:[\s.-]*\d{2}){4}$/)) +import parsePhoneNumberFromString, { CountryCode, getCountries } from 'libphonenumber-js' + +function isCountryCode(code: string): code is CountryCode { + const countries: string[] = getCountries() + return countries.includes(code) +} + +export function isPhoneNumberValid(number: string, country: string) { + if (!isCountryCode(country)) return false + + const phoneNumber = parsePhoneNumberFromString(number, country) + return phoneNumber?.isValid() ?? false } diff --git a/src/features/profile/api/useUpdateProfileMutation.ts b/src/features/profile/api/useUpdateProfileMutation.ts index ae69d1bc52e..00fae4022a8 100644 --- a/src/features/profile/api/useUpdateProfileMutation.ts +++ b/src/features/profile/api/useUpdateProfileMutation.ts @@ -4,10 +4,12 @@ import { api } from 'api/api' import { UserProfileResponse, UserProfilePatchRequest } from 'api/gen' import { QueryKeys } from 'libs/queryKeys' -export function useUpdateProfileMutation( - onSuccessCallback: (data: UserProfileResponse, body: UserProfilePatchRequest) => void, - onErrorCallback: (error: unknown) => void -) { +type Options = { + onSuccess?: (data: UserProfileResponse, body: UserProfilePatchRequest) => void + onError?: (error: unknown) => void +} + +export function useUpdateProfileMutation({ onError, onSuccess }: Options) { const client = useQueryClient() return useMutation((body: UserProfilePatchRequest) => api.patchNativeV1Profile(body), { onSuccess(response: UserProfileResponse, variables) { @@ -15,8 +17,8 @@ export function useUpdateProfileMutation( ...(old ?? {}), ...response, })) - onSuccessCallback(response, variables) + onSuccess?.(response, variables) }, - onError: onErrorCallback, + onError, }) } diff --git a/src/features/profile/pages/ChangeCity/ChangeCity.tsx b/src/features/profile/pages/ChangeCity/ChangeCity.tsx index 8c30eb9c903..5cc5e8d2b5d 100644 --- a/src/features/profile/pages/ChangeCity/ChangeCity.tsx +++ b/src/features/profile/pages/ChangeCity/ChangeCity.tsx @@ -31,8 +31,8 @@ export const ChangeCity = () => { resolver: yupResolver(cityResolver), defaultValues: { city: storedCity ?? undefined }, }) - const { mutate: updateProfile } = useUpdateProfileMutation( - (_, variables) => { + const { mutate: updateProfile } = useUpdateProfileMutation({ + onSuccess: (_, variables) => { analytics.logUpdatePostalCode({ newCity: variables.city ?? '', oldCity: user?.city ?? '', @@ -44,13 +44,13 @@ export const ChangeCity = () => { timeout: SNACK_BAR_TIME_OUT, }) }, - () => { + onError: () => { showErrorSnackBar({ message: 'Une erreur est survenue', timeout: SNACK_BAR_TIME_OUT, }) - } - ) + }, + }) const onSubmit = ({ city }: CityForm) => { setCity(city) diff --git a/src/features/profile/pages/ChangeStatus/useSubmitChangeStatus.tsx b/src/features/profile/pages/ChangeStatus/useSubmitChangeStatus.tsx index 87581074605..5a9a0e25fd4 100644 --- a/src/features/profile/pages/ChangeStatus/useSubmitChangeStatus.tsx +++ b/src/features/profile/pages/ChangeStatus/useSubmitChangeStatus.tsx @@ -19,8 +19,8 @@ export const useSubmitChangeStatus = () => { const { user } = useAuthContext() const { navigate } = useNavigation() const { showSuccessSnackBar, showErrorSnackBar } = useSnackBarContext() - const { mutate: patchProfile, isLoading } = useUpdateProfileMutation( - (_, variables) => { + const { mutate: patchProfile, isLoading } = useUpdateProfileMutation({ + onSuccess: (_, variables) => { analytics.logUpdateStatus({ oldStatus: user?.activityId ?? '', newStatus: variables.activityId ?? '', @@ -30,13 +30,14 @@ export const useSubmitChangeStatus = () => { timeout: SNACK_BAR_TIME_OUT, }) }, - () => { + + onError: () => { showErrorSnackBar({ message: 'Une erreur est survenue', timeout: SNACK_BAR_TIME_OUT, }) - } - ) + }, + }) const { control, handleSubmit, diff --git a/src/features/profile/pages/NotificationSettings/NotificationsSettings.tsx b/src/features/profile/pages/NotificationSettings/NotificationsSettings.tsx index a84fb443e46..81c6f13956d 100644 --- a/src/features/profile/pages/NotificationSettings/NotificationsSettings.tsx +++ b/src/features/profile/pages/NotificationSettings/NotificationsSettings.tsx @@ -64,22 +64,23 @@ export const NotificationsSettings = () => { const { pushPermission } = usePushPermission(updatePushPermissionFromSettings) - const { mutate: updateProfile, isLoading: isUpdatingProfile } = useUpdateProfileMutation( - () => { + const { mutate: updateProfile, isLoading: isUpdatingProfile } = useUpdateProfileMutation({ + onSuccess: () => { showSuccessSnackBar({ message: 'Tes modifications ont été enregistrées\u00a0!', timeout: SNACK_BAR_TIME_OUT, }) analytics.logNotificationToggle(!!state.allowEmails, state.allowPush) }, - () => { + + onError: () => { showErrorSnackBar({ message: 'Une erreur est survenue', timeout: SNACK_BAR_TIME_OUT, }) dispatch({ type: 'reset', initialState }) - } - ) + }, + }) const areNotificationsEnabled = Platform.OS === 'web' ? state.allowEmails : state.allowEmails || state.allowPush diff --git a/src/features/subscription/helpers/useThematicSubscription.tsx b/src/features/subscription/helpers/useThematicSubscription.tsx index 89323bf53d8..223675315c7 100644 --- a/src/features/subscription/helpers/useThematicSubscription.tsx +++ b/src/features/subscription/helpers/useThematicSubscription.tsx @@ -53,8 +53,8 @@ export const useThematicSubscription = ({ const isSubscribeButtonActive = isAtLeastOneNotificationTypeActivated && isThemeSubscribed - const { mutate: updateProfile, isLoading: isUpdatingProfile } = useUpdateProfileMutation( - async () => { + const { mutate: updateProfile, isLoading: isUpdatingProfile } = useUpdateProfileMutation({ + onSuccess: async () => { analytics.logNotificationToggle(!!state.allowEmails, !!state.allowPush) const analyticsParams = homeId ? { from: 'thematicHome', entryId: homeId } @@ -72,14 +72,14 @@ export const useThematicSubscription = ({ } as SubscriptionAnalyticsParams) } }, - () => { + onError: () => { showErrorSnackBar({ message: 'Une erreur est survenue, veuillez réessayer', timeout: SNACK_BAR_TIME_OUT, }) setState(initialState) - } - ) + }, + }) if (!thematic) { return { diff --git a/src/features/subscription/page/OnboardingSubscription.tsx b/src/features/subscription/page/OnboardingSubscription.tsx index bae78d2d3d9..ab27e176298 100644 --- a/src/features/subscription/page/OnboardingSubscription.tsx +++ b/src/features/subscription/page/OnboardingSubscription.tsx @@ -65,8 +65,8 @@ export const OnboardingSubscription = () => { initialSubscribedThemes ) - const { mutate: updateProfile, isLoading: isUpdatingProfile } = useUpdateProfileMutation( - () => { + const { mutate: updateProfile, isLoading: isUpdatingProfile } = useUpdateProfileMutation({ + onSuccess: () => { analytics.logSubscriptionUpdate({ type: 'update', from: 'home' }) showSuccessSnackBar({ message: 'Thèmes suivis\u00a0! Tu peux gérer tes alertes depuis ton profil.', @@ -74,13 +74,13 @@ export const OnboardingSubscription = () => { }) replace(...homeNavConfig) }, - () => { + onError: () => { showErrorSnackBar({ message: 'Une erreur est survenue, tu peux réessayer plus tard.', timeout: SNACK_BAR_TIME_OUT, }) - } - ) + }, + }) const isThemeChecked = (theme: SubscriptionTheme) => subscribedThemes.includes(theme)