import { Box, Divider, MenuItem, Select, SelectChangeEvent, Stack, TextField, Typography, } from '@mui/material' import { Controller, SubmitHandler, useForm } from 'react-hook-form' import React, { forwardRef, Ref, useContext, useEffect, useImperativeHandle, useState, } from 'react' import { Trans, useTranslation } from 'react-i18next' import { CountryCallingCode, CountryCode, getCountries, getCountryCallingCode, } from 'libphonenumber-js' import { getAlpha2Codes, getName } from 'i18n-iso-countries' import { DatePicker } from '@mui/lab' import { subYears } from 'date-fns/fp' import { isValidFnr, isValidMobilePhoneNumber, extractGenderOrBlank, extractBirthdateFromNationalId, } from 'utils' import { GuestInviteInformation } from '../guestDataForm' import { GuestRegisterData } from '../enteredGuestData' import { GuestRegisterCallableMethods } from '../guestRegisterCallableMethods' import AuthenticationMethod from '../authenticationMethod' import { FeatureContext } from '../../../../contexts' import { FormSetup } from './formSetup' interface GuestRegisterProperties { nextHandler(registerData: GuestRegisterData): void initialGuestData: GuestInviteInformation registerData: GuestRegisterData | null } /** * This component is the form where the guest enters missing information about himself and * where he can see the data the sponsor has entered and the role. The page may also * be populated with data from a third-party like Feide if the guest logged in using that. */ const GuestRegisterStep = forwardRef( (props: GuestRegisterProperties, ref: Ref<GuestRegisterCallableMethods>) => { const { i18n, t } = useTranslation(['common']) const { nextHandler, initialGuestData, registerData } = props const { showGenderFieldForGuest } = useContext(FeatureContext) // For select-components it seems to be easier to tie them to a state // and then handle the updating of the form using this, than to tie the // components directly to the form-field using useForm const [countryCode, setCountryCode] = useState< CountryCallingCode | undefined >(undefined) const [passportNationality, setPassportNationality] = useState< string | undefined >(undefined) // Set suggestion for gender field is a gender is not already given in the input const [gender, setGender] = useState<string>( initialGuestData.gender ?? extractGenderOrBlank(initialGuestData.fnr) ) const [idErrorState, setIdErrorState] = useState<string>('') const [phoneErrorState, setPhoneErrorState] = useState<string>('') const { displayContactAtUnitGuestInput } = useContext(FeatureContext) console.log('register step registerData', registerData) const { register, handleSubmit, setValue, setError, clearErrors, control, trigger, formState: { errors }, } = useForm<GuestRegisterData>({ defaultValues: registerData ?? {}, }) // If there is no already a date of birth set, add a suggestion for // this value based on the national ID, if it is set if ( (!registerData || !registerData.dateOfBirth) && !initialGuestData.date_of_birth && initialGuestData.fnr ) { const dateOfBirth = extractBirthdateFromNationalId(initialGuestData.fnr) if (dateOfBirth) { setValue('dateOfBirth', dateOfBirth) } } const submit: SubmitHandler<GuestRegisterData> = async (data) => { console.log('submit data is', data) const result = await trigger() console.log('trigger result is', result) const tohandler = data // Both mobilePhone and mobilePhoneCountry must be set if (!data.mobilePhoneCountry || !data.mobilePhone) { setPhoneErrorState(t('validation.phoneNumberAndCountryCode')) return } // Phone checks passed, clear error message setPhoneErrorState('') if ( !data.nationalIdNumber && !data.passportNumber && !data.passportNationality ) { // The user has not entered a national ID number nor passport information. // In this case the user should not be allowed to send in the registration setIdErrorState(t('validation.nationalIdOrPassport')) return } // Users should only input NIN or passport if (data.passportNumber && data.nationalIdNumber) { setIdErrorState(t('validation.doubleIdentity')) return } // Reset passportNationality if NIN is set and passport is empty if ( data.nationalIdNumber && !data.passportNumber && data.passportNationality ) { setValue('passportNationality', '') setPassportNationality('') tohandler.passportNationality = '' } // if one on the passport fields are set, check that both are set if ( (data.passportNumber && !data.passportNationality) || (!data.passportNumber && data.passportNationality) ) { setIdErrorState(t('validation.passportNationalityAndNumber')) return } setIdErrorState('') console.log('register submit errors', errors) if (!Object.keys(errors).length) { nextHandler(tohandler) } } const onSubmit = handleSubmit<GuestRegisterData>(submit) const handlePassportNationalityChange = (event: SelectChangeEvent) => { if (event.target.value) { const passportValue = event.target.value as string setValue('passportNationality', passportValue) setPassportNationality(passportValue) } } 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, }) } } setValue('mobilePhone', value.target.value) } const handleGenderChange = (event: SelectChangeEvent) => { if (event.target.value) { setGender(event.target.value) setValue('gender', event.target.value) } else { setGender('') setValue('gender', null) } } const today = new Date() const minBirthDate = subYears(100)(today) const maxBirthDate = subYears(1)(today) useEffect(() => { if (registerData?.passportNationality) { setPassportNationality(registerData.passportNationality) } if (registerData?.mobilePhoneCountry) { setCountryCode(registerData.mobilePhoneCountry) } }, [registerData]) register('mobilePhoneCountry') register('passportNationality') useImperativeHandle(ref, () => ({ doSubmit: () => onSubmit() })) const passportCountries = Object.keys(getAlpha2Codes()) .map((countryAlphaCode: string) => { const countryTuple: [string, string] = [ countryAlphaCode, getName(countryAlphaCode, i18n.language), ] return countryTuple }) .filter( (countryTuple: [string, string]) => // All countries are expected to have a name, this filtering // is here to make some tests run in an environment where the // internationalization is not set up countryTuple[1] !== undefined ) .sort( (countryTuple1: [string, string], countryTuple2: [string, string]) => countryTuple1[1].localeCompare(countryTuple2[1]) ) const inviteOrIdPorten = initialGuestData.authentication_method === AuthenticationMethod.Invite || initialGuestData.authentication_method === AuthenticationMethod.IdPorten // There is no name coming from ID-porten so allow the name to be editable const formSetup: FormSetup = { allowFirstNameEditable: inviteOrIdPorten || initialGuestData.first_name.length === 0, allowLastNameEditable: inviteOrIdPorten || initialGuestData.last_name.length === 0, // If there is a national ID number (presumably from Feide or ID porten), // already present, then do not allow the user to change the data // in the identifier fields disableIdentifierFields: (initialGuestData.authentication_method === AuthenticationMethod.Feide || initialGuestData.authentication_method === AuthenticationMethod.IdPorten) && initialGuestData.fnr !== null && initialGuestData.fnr?.length !== 0, } const sortedPhoneCountries = getCountries() .map((country: CountryCode) => { // Make a tuple including the name to avoid // having to get it again further down const countryTuple: [CountryCode, string, CountryCallingCode] = [ country, getName(country, i18n.language), getCountryCallingCode(country), ] return countryTuple }) .filter( // A few country codes do not have a country name. Assuming // these are not needed and filtering them out to make the // list look nicer (countryTuple: [CountryCode, string, CountryCallingCode]) => countryTuple[1] !== undefined ) .sort( ( country1: [CountryCode, string, CountryCallingCode], country2: [CountryCode, string, CountryCallingCode] ) => country1[1].localeCompare(country2[1]) ) return ( <> <Box sx={{ maxWidth: '30rem' }}> <Typography variant="h2" sx={{ paddingTop: '1rem', paddingBottom: '1rem', }} > {t('guestRegisterWizardText.yourContactInformation')} </Typography> <Typography> {t('guestRegisterWizardText.contactInformationDescription')} </Typography> <Divider sx={{ marginBottom: '2rem', border: '1px solid' }} /> <form onSubmit={onSubmit}> <Stack spacing={2}> <Controller name="firstName" control={control} rules={{ required: t('common:validation.firstNameRequired').toString(), }} render={({ field: { onChange, value } }) => ( <TextField id="firstName" label={t('input.firstName')} value={value} onChange={onChange} error={!!errors.firstName} helperText={errors.firstName && errors.firstName.message} disabled={!formSetup.allowFirstNameEditable} inputProps={{ 'data-testid': 'first-name-input-text', }} /> )} /> <Controller name="lastName" control={control} rules={{ required: t('common:validation.lastNameRequired').toString(), }} render={({ field: { onChange, value } }) => ( <TextField id="lastName" label={t('input.lastName')} value={value} onChange={onChange} error={!!errors.lastName} helperText={errors.lastName && errors.lastName.message} disabled={!formSetup.allowLastNameEditable} inputProps={{ 'data-testid': 'last-name-input-text', }} /> )} /> {showGenderFieldForGuest && ( <Select sx={{ maxHeight: '2.5rem', minWidth: '5rem', marginRight: '0.5rem', }} labelId="gender-select" id="gender-select-id" displayEmpty onChange={handleGenderChange} value={gender} renderValue={(selected: any) => { if (!selected) { return t('input.gender') } return t(`input.${selected}`) }} > {/* Keep it simple and hardcode the gender values */} <MenuItem disabled value=""> {t('input.gender')} </MenuItem> <MenuItem value="male">{t('input.male')}</MenuItem> <MenuItem value="female">{t('input.female')}</MenuItem> </Select> )} <Controller name="dateOfBirth" control={control} rules={{ required: t( 'common:validation.dateOfBirthRequired' ).toString(), }} render={({ field }) => ( <DatePicker mask="____-__-__" label={t('input.dateOfBirth')} // If value is set to undefined the birth date is set to today. Using null makes the field blank value={field.value ?? null} minDate={minBirthDate} maxDate={maxBirthDate} inputFormat="yyyy-MM-dd" onChange={(value) => { field.onChange(value) }} renderInput={(params) => ( <TextField data-testid="date-of-birth-input-text" // FIXME: works, but color is wrong // error={!!errors.dateOfBirth} // helperText={errors.dateOfBirth && errors.dateOfBirth.message} {...params} /> )} /> )} /> {errors.dateOfBirth && ( <Typography color="error"> {errors.dateOfBirth.message} </Typography> )} <TextField id="email" label={t('input.email')} value={initialGuestData.email || ''} disabled /> {/* Only show the Feide ID field if the value is present */} {initialGuestData.feide_id && ( <TextField id="feide_id" label={t('feideId')} value={initialGuestData.feide_id} disabled /> )} {/* Box with phone country code and mobile phone */} <Box sx={{ display: 'flex', flexDirection: 'row', }} > <Select sx={{ maxHeight: '2.5rem', minWidth: '5rem', marginRight: '0.5rem', }} labelId="phone-number-select" id="phone-number-select" displayEmpty onChange={handleCountryCodeChange} label={t('input.countryCallingCode')} value={countryCode ? countryCode.toString() : ''} renderValue={(selected: any) => { if (!selected || selected.length === 0) { return t('input.countryCallingCode') } return `${getName( selected, 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')} </MenuItem> {sortedPhoneCountries.map((country) => ( <MenuItem key={country[0]} value={country[0]}> {country[1]} ({country[2]}) </MenuItem> ))} </Select> <Controller name="mobilePhone" control={control} rules={{ required: true, }} render={({ field }) => ( <TextField sx={{ flexGrow: 2 }} label={t('input.mobilePhone')} error={!!errors.mobilePhone} value={field.value ?? ''} helperText={ errors.mobilePhone && errors.mobilePhone.message } onChange={handleMobilePhoneChange} /> )} /> </Box> {phoneErrorState && ( <Typography color="error">{phoneErrorState}</Typography> )} <Typography variant="h2" sx={{ paddingTop: '1rem' }}> {t('guestRegisterWizardText.identityHeader')} </Typography> <Typography> <Trans i18nKey="common:guestRegisterWizardText.identityBody"> Enter national identity number if you have one. <strong>Otherwise</strong> use passport information. </Trans> </Typography> <Divider sx={{ marginBottom: '1rem', border: '1px solid' }} /> {/* The guest should fill in one of national ID number or passport number */} <Controller name="nationalIdNumber" control={control} rules={{ // It is not required that the national ID number be filled in, the guest may not have // one, so allow empty values for the validation to pass. Note that both "fødselsnummer" and // D-number are allowed as input validate: (value) => isValidFnr(value, true), }} render={({ field }) => ( <TextField id="nationalIdNumber" label={t('input.nationalIdNumber')} error={!!errors.nationalIdNumber} value={field.value} onChange={field.onChange} helperText={ errors.nationalIdNumber && errors.nationalIdNumber.message } disabled={formSetup.disableIdentifierFields} inputProps={{ 'data-testid': 'national-id-number-input', }} /> )} /> <Box sx={{ display: 'flex', flexDirection: 'row', }} > <Select sx={{ maxHeight: '2.5rem', minWidth: '5rem', marginRight: '0.5rem', }} id="passport-nationality-id" labelId="passport-nationality-label" label={t('input.passportNationality')} displayEmpty value={passportNationality ?? ''} onChange={handlePassportNationalityChange} renderValue={(selected: any) => { if (!selected || selected.length === 0) { return t('input.passportNationality') } return selected }} disabled={formSetup.disableIdentifierFields} inputProps={{ 'data-testid': 'passport-nationality-input', }} > <MenuItem disabled value=""> {t('input.passportNationality')} </MenuItem> {passportCountries.map((countryTuple) => ( <MenuItem key={countryTuple[1]} value={countryTuple[0]}> {`${countryTuple[1]} (${countryTuple[0]})`} </MenuItem> ))} </Select> <Controller name="passportNumber" control={control} render={({ field }) => ( <TextField id="passportNumber" inputProps={{ 'data-testid': 'passport_number_input', }} value={field.value} label={t('input.passportNumber')} onChange={field.onChange} disabled={formSetup.disableIdentifierFields} /> )} /> </Box> {idErrorState && ( <Typography color="error">{idErrorState}</Typography> )} <Typography variant="h2" sx={{ paddingTop: '1rem' }}> {t('guestRegisterWizardText.yourGuestPeriod')} </Typography> <Typography> {t('guestRegisterWizardText.guestPeriodDescription')} </Typography> <Divider sx={{ marginBottom: '1rem', border: '1px solid' }} /> <TextField id="ou-unit" value={ i18n.language === 'en' ? initialGuestData.ou_name_en : initialGuestData.ou_name_nb } label={t('ou')} disabled /> <TextField id="roleType" label={t('input.roleType')} value={ i18n.language === 'en' ? initialGuestData.role_name_en : initialGuestData.role_name_nb } disabled /> <TextField id="rolePeriod" label={t('period')} value={`${initialGuestData.role_start} - ${initialGuestData.role_end}`} disabled /> {displayContactAtUnitGuestInput && ( <TextField id="contactPersonUnit" label={t('input.contactPersonUnit')} value={initialGuestData.contact_person_unit} disabled /> )} </Stack> </form> </Box> </> ) } ) export default GuestRegisterStep