diff --git a/frontend/package.json b/frontend/package.json index 206b6ac5a3f96ffe01f7d9aa4b1c1c4fba923630..1aef0771b8c64246a3e85a30633ee48971f7e995 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -84,6 +84,7 @@ "eslint-plugin-jsx-a11y": "^6.4.1", "eslint-plugin-react": "^7.25.1", "eslint-plugin-react-hooks": "^4.2.0", + "jest-fetch-mock": "^3.0.3", "jest-junit": "^12.2.0" } } diff --git a/frontend/src/routes/guest/register/enteredGuestData.ts b/frontend/src/routes/guest/register/enteredGuestData.ts index 8e4cdb6669a170abdbf636c0948cfecefe17c309..d731360363d3a0ce30826799031b11e2c1a18d6e 100644 --- a/frontend/src/routes/guest/register/enteredGuestData.ts +++ b/frontend/src/routes/guest/register/enteredGuestData.ts @@ -4,6 +4,7 @@ * most of the data there the guest cannot change. */ export type EnteredGuestData = { + mobilePhoneCountry: string mobilePhone: string nationalIdNumber?: string passportNumber?: string diff --git a/frontend/src/routes/guest/register/guestDataForm.ts b/frontend/src/routes/guest/register/guestDataForm.ts index cb52a87bec8507b4077dbaf17dcce03fc9e9aa82..f4f3743601db92a13c93f96bab60c06ee25410a9 100644 --- a/frontend/src/routes/guest/register/guestDataForm.ts +++ b/frontend/src/routes/guest/register/guestDataForm.ts @@ -1,9 +1,11 @@ -/** - * This is data about the guest that the sponsor has entered when the invitation was created - */ import AuthenticationMethod from './authenticationMethod' -export type ContactInformationBySponsor = { +/** + * This is the basis for the data shown in the guest registration form. + * + * It mostly contains data about the guest that was entered during the invite step. + */ +export type GuestInviteInformation = { first_name: string last_name: string ou_name_nb: string @@ -18,10 +20,14 @@ export type ContactInformationBySponsor = { // they are set, with the exception of e-mail, when the guest // first follows the invite link email?: string + + mobile_phone_country_code?: string mobile_phone?: string + fnr?: string passport?: string passportNationality?: string + countryForCallingCode?: string authentication_method: AuthenticationMethod } diff --git a/frontend/src/routes/guest/register/index.test.tsx b/frontend/src/routes/guest/register/index.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bf9d463d752de65de355abd9da580afe84761d8b --- /dev/null +++ b/frontend/src/routes/guest/register/index.test.tsx @@ -0,0 +1,57 @@ +import React from 'react' +import fetchMock, { enableFetchMocks } from 'jest-fetch-mock' +import { render, waitFor, screen } from 'test-utils' +import AdapterDateFns from '@mui/lab/AdapterDateFns' +import { LocalizationProvider } from '@mui/lab' +import GuestRegister from './index' + +enableFetchMocks() + +const testData = { + person: { + first_name: 'Test', + last_name: 'Tester', + mobile_phone: '+4797543910', + email: 'test@example.org', + fnr: '04062141242', + passport: 'NO-123456', + }, + role: { + ou_name_en: 'English organizational unit name', + ou_name_nb: 'Norsk navn organisasjonsenhet', + name_en: 'Guest role', + name_nb: 'Gjesterolle', + start: '2021-08-10', + end: '2021-08-16', + comment: '', + }, + meta: { + session_type: 'invite', + }, +} + +beforeEach(() => { + fetchMock.mockIf('/api/ui/v1/invited', () => + Promise.resolve<any>(JSON.stringify(testData)) + ) +}) + +// This is just an example of how jest-fetch-mock can be used, it is not a proper test +test('Mobile phone number parsed and split correctly', async () => { + render( + <LocalizationProvider dateAdapter={AdapterDateFns}> + <GuestRegister /> + </LocalizationProvider> + ) + + await waitFor(() => screen.queryByText(testData.person.first_name)) + await waitFor(() => screen.queryByText(testData.person.last_name)) + await waitFor(() => screen.queryByText(testData.person.email)) + await waitFor(() => screen.queryByText(testData.person.fnr)) + await waitFor(() => screen.queryByText('NO')) + await waitFor(() => screen.queryByText('123456')) + + // There is no proper i18n loaded so the country name will be undefined, the country code should still show though + await waitFor(() => screen.queryByText('undefined (47)')) + await waitFor(() => screen.queryByText('97543910')) +}) diff --git a/frontend/src/routes/guest/register/index.tsx b/frontend/src/routes/guest/register/index.tsx index c0f75c2828f42cd3af8fab6fc471db2bedd3ee06..6cdc89b0638ac43865a3f5b89c14f8550501a55c 100644 --- a/frontend/src/routes/guest/register/index.tsx +++ b/frontend/src/routes/guest/register/index.tsx @@ -5,13 +5,18 @@ import { Box, Button } from '@mui/material' import Page from 'components/page' import { useHistory } from 'react-router-dom' +import { + CountryCode, + getCountries, + getCountryCallingCode, +} from 'libphonenumber-js' import OverviewGuestButton from '../../components/overviewGuestButton' import GuestRegisterStep from './registerPage' import { GuestRegisterCallableMethods } from './guestRegisterCallableMethods' import { EnteredGuestData } from './enteredGuestData' -import { ContactInformationBySponsor } from './guestDataForm' +import { GuestInviteInformation } from './guestDataForm' import AuthenticationMethod from './authenticationMethod' -import { submitJsonOpts } from '../../../utils' +import { splitPhoneNumber, submitJsonOpts } from '../../../utils' enum SubmitState { NotSubmitted, @@ -25,7 +30,7 @@ enum SubmitState { export default function GuestRegister() { const { t } = useTranslation(['common']) const history = useHistory() - // TODO On submit successful the user should be directed to some page telling h + // TODO On submit successful the user should be directed to some page telling // eslint-disable-next-line @typescript-eslint/no-unused-vars const [submitState, setSubmitState] = useState<SubmitState>( SubmitState.NotSubmitted @@ -36,24 +41,24 @@ export default function GuestRegister() { // eslint-disable-next-line @typescript-eslint/no-unused-vars const [activeStep, setActiveStep] = useState(0) - const [guestFormData, setGuestFormData] = - useState<ContactInformationBySponsor>({ - first_name: '', - last_name: '', - ou_name_en: '', - ou_name_nb: '', - role_name_en: '', - role_name_nb: '', - role_start: '', - role_end: '', - comment: '', - email: '', - mobile_phone: '', - fnr: '', - passport: '', - passportNationality: '', - authentication_method: AuthenticationMethod.Invite, - }) + const [guestFormData, setGuestFormData] = useState<GuestInviteInformation>({ + first_name: '', + last_name: '', + ou_name_en: '', + ou_name_nb: '', + role_name_en: '', + role_name_nb: '', + role_start: '', + role_end: '', + comment: '', + email: '', + mobile_phone: '', + fnr: '', + passport: '', + passportNationality: '', + countryForCallingCode: '', + authentication_method: AuthenticationMethod.Invite, + }) const [errorState, setErrorState] = useState<string>('') @@ -63,13 +68,29 @@ export default function GuestRegister() { if (response.ok) { response.json().then((jsonResponse) => { - console.log(`Guest data from server: ${JSON.stringify(jsonResponse)}`) + // TODO Remove after development + console.log(`Data from server: ${JSON.stringify(jsonResponse)}`) const authenticationMethod = jsonResponse.meta.session_type === 'invite' ? AuthenticationMethod.Invite : AuthenticationMethod.Feide + const [countryCode, nationalNumber] = jsonResponse.person.mobile_phone + ? splitPhoneNumber(jsonResponse.person.mobile_phone) + : ['', ''] + + let extractedCountryCode = '' + if (countryCode) { + const matchingCountries = getCountries().find( + (value) => getCountryCallingCode(value) === countryCode + ) + + if (matchingCountries && matchingCountries.length > 0) { + extractedCountryCode = matchingCountries.toString() + } + } + setGuestFormData({ first_name: jsonResponse.person.first_name, last_name: jsonResponse.person.last_name, @@ -82,11 +103,13 @@ export default function GuestRegister() { comment: jsonResponse.role.comments, email: jsonResponse.person.email, - mobile_phone: jsonResponse.person.mobile_phone, + mobile_phone_country_code: countryCode, + mobile_phone: nationalNumber, fnr: jsonResponse.fnr, passport: jsonResponse.passport, // TODO Separate out nationality based on what is in the server response passportNationality: '', + countryForCallingCode: extractedCountryCode, authentication_method: authenticationMethod, }) @@ -119,7 +142,9 @@ export default function GuestRegister() { ): void => { const payload: any = {} payload.person = {} - payload.person.mobile_phone = updateFormData.mobilePhone + payload.person.mobile_phone = `+${getCountryCallingCode( + updateFormData.mobilePhoneCountry as CountryCode + )}${updateFormData.mobilePhone}` if (updateFormData.passportNumber && updateFormData.passportNationality) { // The user has entered some passport information, check that both nationality and number are present diff --git a/frontend/src/routes/guest/register/registerPage.test.tsx b/frontend/src/routes/guest/register/registerPage.test.tsx index ccf4cfc8af9af38370d1c12f3b008adcc9a7ad26..634f2467b358f2858827e208a8c437f4fa8ec2dd 100644 --- a/frontend/src/routes/guest/register/registerPage.test.tsx +++ b/frontend/src/routes/guest/register/registerPage.test.tsx @@ -5,12 +5,10 @@ import { LocalizationProvider } from '@mui/lab' import GuestRegisterStep from './registerPage' import { EnteredGuestData } from './enteredGuestData' import AuthenticationMethod from './authenticationMethod' +import { GuestInviteInformation } from './guestDataForm' -test('Guest register page showing passport field on manual registration', async () => { - const nextHandler = (enteredGuestData: EnteredGuestData) => { - console.log(`Entered data: ${enteredGuestData}`) - } - const guestData = { +function getEmptyGuestData(): GuestInviteInformation { + return { first_name: '', last_name: '', ou_name_en: '', @@ -24,12 +22,22 @@ test('Guest register page showing passport field on manual registration', async mobile_phone: '', fnr: '', passport: '', + countryForCallingCode: '', authentication_method: AuthenticationMethod.Invite, } +} + +test('Guest register page showing passport field on manual registration', async () => { + const nextHandler = (enteredGuestData: EnteredGuestData) => { + console.log(`Entered data: ${enteredGuestData}`) + } render( <LocalizationProvider dateAdapter={AdapterDateFns}> - <GuestRegisterStep nextHandler={nextHandler} guestData={guestData} /> + <GuestRegisterStep + nextHandler={nextHandler} + guestData={getEmptyGuestData()} + /> </LocalizationProvider> ) diff --git a/frontend/src/routes/guest/register/registerPage.tsx b/frontend/src/routes/guest/register/registerPage.tsx index a8f05d5ec1ef9ce4f2c32963f444fc8e3220db07..f21ca1311f67f2e13bd146159567a25b79d109fb 100644 --- a/frontend/src/routes/guest/register/registerPage.tsx +++ b/frontend/src/routes/guest/register/registerPage.tsx @@ -8,7 +8,13 @@ import { Typography, } from '@mui/material' import { SubmitHandler, useForm } from 'react-hook-form' -import React, { forwardRef, Ref, useImperativeHandle, useState } from 'react' +import React, { + forwardRef, + Ref, + useEffect, + useImperativeHandle, + useState, +} from 'react' import { useTranslation } from 'react-i18next' import { CountryCallingCode, @@ -17,7 +23,7 @@ import { getCountryCallingCode, } from 'libphonenumber-js' import { getAlpha2Codes, getName } from 'i18n-iso-countries' -import { ContactInformationBySponsor } from './guestDataForm' +import { GuestInviteInformation } from './guestDataForm' import { EnteredGuestData } from './enteredGuestData' import { GuestRegisterCallableMethods } from './guestRegisterCallableMethods' import { isValidFnr, isValidMobilePhoneNumber } from '../../../utils' @@ -26,7 +32,7 @@ import AuthenticationMethod from './authenticationMethod' interface GuestRegisterProperties { nextHandler(guestData: EnteredGuestData): void - guestData: ContactInformationBySponsor + guestData: GuestInviteInformation } /** @@ -42,19 +48,7 @@ const GuestRegisterStep = forwardRef( const [countryCode, setCountryCode] = useState< CountryCallingCode | undefined >(undefined) - - const handleCountryCodeChange = (event: SelectChangeEvent) => { - if (event.target.value) { - const countryCodeType = getCountries().find( - (value) => value.toString() === event.target.value - ) - if (countryCodeType) { - setCountryCode(getCountryCallingCode(countryCodeType)) - } - } else { - setCountryCode(undefined) - } - } + const [mobilePhone, setMobilePhone] = useState<string>('') const submit: SubmitHandler<EnteredGuestData> = (data) => { nextHandler(data) @@ -64,19 +58,66 @@ const GuestRegisterStep = forwardRef( register, handleSubmit, setValue, - trigger, + setError, + clearErrors, formState: { errors }, } = useForm<EnteredGuestData>() const onSubmit = handleSubmit<EnteredGuestData>(submit) + const handleCountryCodeChange = (event: SelectChangeEvent) => { + if (event.target.value) { + const countryCodeType = event.target.value as CountryCode + setCountryCode(countryCodeType) + setValue('mobilePhoneCountry', countryCodeType) + } else { + setCountryCode(undefined) + } + } + + const handleMobilePhoneChange = (value: any) => { + if (countryCode) { + // The country code and the rest of the mobile number are in two fields, so cannot + // register the field directly in form, but need to have extra logic defined + // to combine the values before writing them to the form handling + + const phoneNumberWithCountryCode = `+${getCountryCallingCode( + countryCode as CountryCode + )}${value.target.value}` + const isValidPhoneNumber = isValidMobilePhoneNumber( + phoneNumberWithCountryCode + ) + + if (isValidPhoneNumber === true) { + setValue('mobilePhone', value.target.value) + clearErrors('mobilePhone') + } else { + setError('mobilePhone', { + type: 'manual', + message: isValidPhoneNumber || undefined, + }) + } + } + + setMobilePhone(value.target.value) + } + + useEffect(() => { + setCountryCode(guestData.countryForCallingCode) + setMobilePhone(guestData.mobile_phone ? guestData.mobile_phone : '') + setValue( + 'mobilePhoneCountry', + guestData.countryForCallingCode ? guestData.countryForCallingCode : '' + ) + }, [guestData]) + function doSubmit() { return onSubmit() } register('mobilePhone', { required: t<string>('validation.mobilePhoneRequired'), - validate: isValidMobilePhoneNumber, }) + register('mobilePhoneCountry') useImperativeHandle(ref, () => ({ doSubmit })) @@ -132,10 +173,10 @@ const GuestRegisterStep = forwardRef( labelId="phone-number-select" id="phone-number-select" displayEmpty - defaultValue="" onChange={handleCountryCodeChange} + value={countryCode ? countryCode.toString() : ''} renderValue={(selected: any) => { - if (selected.length === 0 || selected === '') { + if (!selected) { return t('input.countryCallingCode') } return `${getName( @@ -143,6 +184,10 @@ const GuestRegisterStep = forwardRef( i18n.language )} (${getCountryCallingCode(selected as CountryCode)})` }} + data-testid="phone-country-code-select" + inputProps={{ + 'data-testid': 'phone-country-code-select-inner', + }} > <MenuItem disabled value=""> {t('input.countryCallingCode')} @@ -159,19 +204,9 @@ const GuestRegisterStep = forwardRef( sx={{ flexGrow: 2 }} label={t('input.mobilePhone')} error={!!errors.mobilePhone} + value={mobilePhone} helperText={errors.mobilePhone && errors.mobilePhone.message} - onChange={(value) => { - if (countryCode) { - // The country code and the rest of the mobile number are in two fields, so cannot - // register the field directly in form, but need to have extra logic defined - // to combine the values before writing them to the form handling - setValue( - 'mobilePhone', - `+${countryCode.toString()}${value.target.value}` - ) - trigger('mobilePhone') - } - }} + onChange={handleMobilePhoneChange} /> </Box> {guestData.authentication_method === diff --git a/frontend/src/setupJest.js b/frontend/src/setupJest.js new file mode 100644 index 0000000000000000000000000000000000000000..1c3dd9b4776ca61a4c51411d1ee07952783c5fc6 --- /dev/null +++ b/frontend/src/setupJest.js @@ -0,0 +1,6 @@ +// adds the 'fetchMock' global variable and rewires 'fetch' global to call 'fetchMock' instead of the real implementation +// eslint-disable-next-line import/no-extraneous-dependencies +require('jest-fetch-mock').enableMocks() +// changes default behavior of fetchMock to use the real 'fetch' implementation and not mock responses +// eslint-disable-next-line no-undef +fetchMock.dontMock() diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 528734dd17370f1f3149f061a59a1a15d5a6e631..fcb7c0da28e37afd12aa65ae24e6ef183cccc783 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -1,6 +1,6 @@ import validator from '@navikt/fnrvalidator' import i18n from 'i18next' -import { isValidPhoneNumber } from 'libphonenumber-js' +import { isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber-js' const validEmailRegex = /^(?=.{1,254}$)(?=.{1,64}@)[-!#$%&'*+/0-9=?A-Z^_`a-z{|}~]+(\.[-!#$%&'*+/0-9=?A-Z^_`a-z{|}~]+)*@[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/ @@ -83,3 +83,17 @@ export function isValidEmail(data: string | undefined): boolean | string { } return i18n.t<string>('common:validation.invalidEmail') } + +/** + * Splits a phone number into a country code and the national number. + * + * @param phoneNumber The phone number to split + */ +export function splitPhoneNumber(phoneNumber: string): [string, string] { + const parsedNumber = parsePhoneNumber(phoneNumber) + + return [ + parsedNumber.countryCallingCode.toString(), + parsedNumber.nationalNumber.toString(), + ] +}