diff --git a/src/components/AddressForm.js b/src/components/AddressForm.tsx similarity index 69% rename from src/components/AddressForm.js rename to src/components/AddressForm.tsx index 1bd55004074a..626da4cfd5d4 100644 --- a/src/components/AddressForm.js +++ b/src/components/AddressForm.tsx @@ -1,107 +1,124 @@ -import {CONST as COMMON_CONST} from 'expensify-common/lib/CONST'; -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; import React, {useCallback} from 'react'; import {View} from 'react-native'; -import _ from 'underscore'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import type {MaybePhraseKey} from '@libs/Localize'; import Navigation from '@libs/Navigation/Navigation'; import * as ValidationUtils from '@libs/ValidationUtils'; import CONST from '@src/CONST'; +import type {Country} from '@src/CONST'; +import type ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/HomeAddressForm'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; import AddressSearch from './AddressSearch'; import CountrySelector from './CountrySelector'; import FormProvider from './Form/FormProvider'; import InputWrapper from './Form/InputWrapper'; +import type {FormOnyxValues} from './Form/types'; import StatePicker from './StatePicker'; import TextInput from './TextInput'; -const propTypes = { +type CountryZipRegex = { + regex?: RegExp; + samples?: string; +}; + +type AddressFormProps = { /** Address city field */ - city: PropTypes.string, + city?: string; /** Address country field */ - country: PropTypes.string, + country?: Country | ''; /** Address state field */ - state: PropTypes.string, + state?: string; /** Address street line 1 field */ - street1: PropTypes.string, + street1?: string; /** Address street line 2 field */ - street2: PropTypes.string, + street2?: string; /** Address zip code field */ - zip: PropTypes.string, + zip?: string; /** Callback which is executed when the user changes address, city or state */ - onAddressChanged: PropTypes.func, + onAddressChanged?: (value: unknown, key: unknown) => void; /** Callback which is executed when the user submits his address changes */ - onSubmit: PropTypes.func.isRequired, + onSubmit: (values: FormOnyxValues) => void; /** Whether or not should the form data should be saved as draft */ - shouldSaveDraft: PropTypes.bool, + shouldSaveDraft?: boolean; /** Text displayed on the bottom submit button */ - submitButtonText: PropTypes.string, + submitButtonText?: string; /** A unique Onyx key identifying the form */ - formID: PropTypes.string.isRequired, + formID: typeof ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM | typeof ONYXKEYS.FORMS.HOME_ADDRESS_FORM; }; -const defaultProps = { - city: '', - country: '', - onAddressChanged: () => {}, - shouldSaveDraft: false, - state: '', - street1: '', - street2: '', - submitButtonText: '', - zip: '', -}; - -function AddressForm({city, country, formID, onAddressChanged, onSubmit, shouldSaveDraft, state, street1, street2, submitButtonText, zip}) { +function AddressForm({ + city = '', + country = '', + formID, + onAddressChanged = () => {}, + onSubmit, + shouldSaveDraft = false, + state = '', + street1 = '', + street2 = '', + submitButtonText = '', + zip = '', +}: AddressFormProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const zipSampleFormat = lodashGet(CONST.COUNTRY_ZIP_REGEX_DATA, [country, 'samples'], ''); - const zipFormat = ['common.zipCodeExampleFormat', {zipSampleFormat}]; + + const zipSampleFormat = (country && (CONST.COUNTRY_ZIP_REGEX_DATA[country] as CountryZipRegex)?.samples) ?? ''; + + const zipFormat: MaybePhraseKey = ['common.zipCodeExampleFormat', {zipSampleFormat}]; + const isUSAForm = country === CONST.COUNTRY.US; /** - * @param {Function} translate - translate function - * @param {Boolean} isUSAForm - selected country ISO code is US - * @param {Object} values - form input values - * @returns {Object} - An object containing the errors for each inputID + * @param translate - translate function + * @param isUSAForm - selected country ISO code is US + * @param values - form input values + * @returns - An object containing the errors for each inputID */ - const validator = useCallback((values) => { - const errors = {}; - const requiredFields = ['addressLine1', 'city', 'country', 'state']; + + const validator = useCallback((values: FormOnyxValues): Errors => { + const errors: Errors & { + zipPostCode?: string | string[]; + } = {}; + const requiredFields = ['addressLine1', 'city', 'country', 'state'] as const; // Check "State" dropdown is a valid state if selected Country is USA - if (values.country === CONST.COUNTRY.US && !COMMON_CONST.STATES[values.state]) { + if (values.country === CONST.COUNTRY.US && !values.state) { errors.state = 'common.error.fieldRequired'; } // Add "Field required" errors if any required field is empty - _.each(requiredFields, (fieldKey) => { - if (ValidationUtils.isRequiredFulfilled(values[fieldKey])) { + requiredFields.forEach((fieldKey) => { + const fieldValue = values[fieldKey] ?? ''; + if (ValidationUtils.isRequiredFulfilled(fieldValue)) { return; } + errors[fieldKey] = 'common.error.fieldRequired'; }); // If no country is selected, default value is an empty string and there's no related regex data so we default to an empty object - const countryRegexDetails = lodashGet(CONST.COUNTRY_ZIP_REGEX_DATA, values.country, {}); + const countryRegexDetails = (values.country ? CONST.COUNTRY_ZIP_REGEX_DATA?.[values.country] : {}) as CountryZipRegex; // The postal code system might not exist for a country, so no regex either for them. - const countrySpecificZipRegex = lodashGet(countryRegexDetails, 'regex'); - const countryZipFormat = lodashGet(countryRegexDetails, 'samples'); + const countrySpecificZipRegex = countryRegexDetails?.regex; + const countryZipFormat = countryRegexDetails?.samples ?? ''; + + ErrorUtils.addErrorMessage(errors, 'firstName', 'bankAccount.error.firstName'); - if (countrySpecificZipRegex) { + if (countrySpecificZipRegex && values.zipPostCode) { if (!countrySpecificZipRegex.test(values.zipPostCode.trim().toUpperCase())) { if (ValidationUtils.isRequiredFulfilled(values.zipPostCode.trim())) { errors.zipPostCode = ['privatePersonalDetails.error.incorrectZipFormat', countryZipFormat]; @@ -109,7 +126,7 @@ function AddressForm({city, country, formID, onAddressChanged, onSubmit, shouldS errors.zipPostCode = 'common.error.fieldRequired'; } } - } else if (!CONST.GENERIC_ZIP_CODE_REGEX.test(values.zipPostCode.trim().toUpperCase())) { + } else if (!CONST.GENERIC_ZIP_CODE_REGEX.test(values?.zipPostCode?.trim()?.toUpperCase() ?? '')) { errors.zipPostCode = 'privatePersonalDetails.error.incorrectZipFormat'; } @@ -130,12 +147,12 @@ function AddressForm({city, country, formID, onAddressChanged, onSubmit, shouldS InputComponent={AddressSearch} inputID={INPUT_IDS.ADDRESS_LINE_1} label={translate('common.addressLine', {lineNumber: 1})} - onValueChange={(data, key) => { + onValueChange={(data: unknown, key: unknown) => { onAddressChanged(data, key); // This enforces the country selector to use the country from address instead of the country from URL Navigation.setParams({country: undefined}); }} - defaultValue={street1 || ''} + defaultValue={street1} renamedInputKeys={{ street: INPUT_IDS.ADDRESS_LINE_1, street2: INPUT_IDS.ADDRESS_LINE_2, @@ -154,8 +171,8 @@ function AddressForm({city, country, formID, onAddressChanged, onSubmit, shouldS inputID={INPUT_IDS.ADDRESS_LINE_2} label={translate('common.addressLine', {lineNumber: 2})} aria-label={translate('common.addressLine', {lineNumber: 2})} - role={CONST.ACCESSIBILITY_ROLE.TEXT} - defaultValue={street2 || ''} + role={CONST.ROLE.PRESENTATION} + defaultValue={street2} maxLength={CONST.FORM_CHARACTER_LIMIT} spellCheck={false} shouldSaveDraft={shouldSaveDraft} @@ -186,8 +203,8 @@ function AddressForm({city, country, formID, onAddressChanged, onSubmit, shouldS inputID={INPUT_IDS.STATE} label={translate('common.stateOrProvince')} aria-label={translate('common.stateOrProvince')} - role={CONST.ACCESSIBILITY_ROLE.TEXT} - value={state || ''} + role={CONST.ROLE.PRESENTATION} + value={state} maxLength={CONST.FORM_CHARACTER_LIMIT} spellCheck={false} onValueChange={onAddressChanged} @@ -200,8 +217,8 @@ function AddressForm({city, country, formID, onAddressChanged, onSubmit, shouldS inputID={INPUT_IDS.CITY} label={translate('common.city')} aria-label={translate('common.city')} - role={CONST.ACCESSIBILITY_ROLE.TEXT} - defaultValue={city || ''} + role={CONST.ROLE.PRESENTATION} + defaultValue={city} maxLength={CONST.FORM_CHARACTER_LIMIT} spellCheck={false} onValueChange={onAddressChanged} @@ -213,9 +230,9 @@ function AddressForm({city, country, formID, onAddressChanged, onSubmit, shouldS inputID={INPUT_IDS.ZIP_POST_CODE} label={translate('common.zipPostCode')} aria-label={translate('common.zipPostCode')} - role={CONST.ACCESSIBILITY_ROLE.TEXT} + role={CONST.ROLE.PRESENTATION} autoCapitalize="characters" - defaultValue={zip || ''} + defaultValue={zip} maxLength={CONST.BANK_ACCOUNT.MAX_LENGTH.ZIP_CODE} hint={zipFormat} onValueChange={onAddressChanged} @@ -225,8 +242,6 @@ function AddressForm({city, country, formID, onAddressChanged, onSubmit, shouldS ); } -AddressForm.defaultProps = defaultProps; AddressForm.displayName = 'AddressForm'; -AddressForm.propTypes = propTypes; export default AddressForm; diff --git a/src/components/AddressSearch/types.ts b/src/components/AddressSearch/types.ts index e4735e9d0020..27e068cd1777 100644 --- a/src/components/AddressSearch/types.ts +++ b/src/components/AddressSearch/types.ts @@ -17,8 +17,8 @@ type RenamedInputKeysProps = { street2: string; city: string; state: string; - lat: string; - lng: string; + lat?: string; + lng?: string; zipCode: string; address?: string; country?: string; diff --git a/src/components/CountrySelector.tsx b/src/components/CountrySelector.tsx index 5b5e99ac0621..dc0201747da2 100644 --- a/src/components/CountrySelector.tsx +++ b/src/components/CountrySelector.tsx @@ -16,10 +16,10 @@ type CountrySelectorProps = { errorText?: MaybePhraseKey; /** Callback called when the country changes. */ - onInputChange: (value?: string) => void; + onInputChange?: (value?: string) => void; /** Current selected country */ - value?: Country; + value?: Country | ''; /** inputID used by the Form component */ // eslint-disable-next-line react/no-unused-prop-types @@ -29,7 +29,7 @@ type CountrySelectorProps = { onBlur?: () => void; }; -function CountrySelector({errorText = '', value: countryCode, onInputChange, onBlur}: CountrySelectorProps, ref: ForwardedRef) { +function CountrySelector({errorText = '', value: countryCode, onInputChange = () => {}, onBlur}: CountrySelectorProps, ref: ForwardedRef) { const styles = useThemeStyles(); const {translate} = useLocalize(); diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts index b300c73533b6..33d127308449 100644 --- a/src/components/Form/types.ts +++ b/src/components/Form/types.ts @@ -16,6 +16,7 @@ import type TextInput from '@components/TextInput'; import type ValuePicker from '@components/ValuePicker'; import type {MaybePhraseKey} from '@libs/Localize'; import type BusinessTypePicker from '@pages/ReimbursementAccount/BusinessInfo/substeps/TypeBusiness/BusinessTypePicker'; +import type {Country} from '@src/CONST'; import type {OnyxFormKey, OnyxValues} from '@src/ONYXKEYS'; import type {BaseForm} from '@src/types/form/Form'; @@ -42,11 +43,12 @@ type ValidInputs = | typeof DatePicker | typeof RadioButtons; -type ValueTypeKey = 'string' | 'boolean' | 'date'; +type ValueTypeKey = 'string' | 'boolean' | 'date' | 'country'; type ValueTypeMap = { string: string; boolean: boolean; date: Date; + country: Country | ''; }; type FormValue = ValueOf; diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index 8009c963ade7..4cd6a141bd3b 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -245,7 +245,7 @@ function closeFullScreen() { /** * Update route params for the specified route. */ -function setParams(params: Record, routeKey: string) { +function setParams(params: Record, routeKey = '') { navigationRef.current?.dispatch({ ...CommonActions.setParams(params), source: routeKey, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index a1e558869ebe..3d9bc25bb19d 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -12,6 +12,7 @@ import type { } from '@react-navigation/native'; import type {ValueOf} from 'type-fest'; import type CONST from '@src/CONST'; +import type {Country} from '@src/CONST'; import type NAVIGATORS from '@src/NAVIGATORS'; import type {HybridAppRoute, Route as Routes} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; @@ -107,7 +108,7 @@ type SettingsNavigatorParamList = { [SCREENS.SETTINGS.PROFILE.LEGAL_NAME]: undefined; [SCREENS.SETTINGS.PROFILE.DATE_OF_BIRTH]: undefined; [SCREENS.SETTINGS.PROFILE.ADDRESS]: { - country?: string; + country?: Country | ''; }; [SCREENS.SETTINGS.PROFILE.ADDRESS_COUNTRY]: { backTo?: Routes; diff --git a/src/libs/actions/PersonalDetails.ts b/src/libs/actions/PersonalDetails.ts index 5ae37bb85f10..5becaee1593c 100644 --- a/src/libs/actions/PersonalDetails.ts +++ b/src/libs/actions/PersonalDetails.ts @@ -18,6 +18,7 @@ import DateUtils from '@libs/DateUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as UserUtils from '@libs/UserUtils'; +import type {Country} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -137,7 +138,7 @@ function updateDateOfBirth({dob}: DateOfBirthForm) { Navigation.goBack(); } -function updateAddress(street: string, street2: string, city: string, state: string, zip: string, country: string) { +function updateAddress(street: string, street2: string, city: string, state: string, zip: string, country: Country | '') { const parameters: UpdateHomeAddressParams = { homeAddressStreet: street, addressStreet2: street2, diff --git a/src/pages/settings/Profile/PersonalDetails/AddressPage.tsx b/src/pages/settings/Profile/PersonalDetails/AddressPage.tsx index adc721ea0ea1..d85dae9d3abb 100644 --- a/src/pages/settings/Profile/PersonalDetails/AddressPage.tsx +++ b/src/pages/settings/Profile/PersonalDetails/AddressPage.tsx @@ -12,6 +12,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import * as PersonalDetails from '@userActions/PersonalDetails'; +import type {FormOnyxValues} from '@src/components/Form/types'; +import type {Country} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import type {PrivatePersonalDetails} from '@src/types/onyx'; @@ -28,7 +30,7 @@ type AddressPageProps = StackScreenProps) { PersonalDetails.updateAddress( values.addressLine1?.trim() ?? '', values.addressLine2?.trim() ?? '', @@ -62,29 +64,32 @@ function AddressPage({privatePersonalDetails, route}: AddressPageProps) { setZipcode(address.zip); }, [address]); - const handleAddressChange = useCallback((value: string, key: keyof Address) => { - if (key !== 'country' && key !== 'state' && key !== 'city' && key !== 'zipPostCode') { + const handleAddressChange = useCallback((value: unknown, key: unknown) => { + const countryValue = value as Country | ''; + const addressKey = key as keyof Address; + + if (addressKey !== 'country' && addressKey !== 'state' && addressKey !== 'city' && addressKey !== 'zipPostCode') { return; } - if (key === 'country') { - setCurrentCountry(value); + if (addressKey === 'country') { + setCurrentCountry(countryValue); setState(''); setCity(''); setZipcode(''); return; } - if (key === 'state') { - setState(value); + if (addressKey === 'state') { + setState(countryValue); setCity(''); setZipcode(''); return; } - if (key === 'city') { - setCity(value); + if (addressKey === 'city') { + setCity(countryValue); setZipcode(''); return; } - setZipcode(value); + setZipcode(countryValue); }, []); useEffect(() => { diff --git a/src/types/form/GetPhysicalCardForm.ts b/src/types/form/GetPhysicalCardForm.ts index c8fc6f3cce9e..1fa7ea872620 100644 --- a/src/types/form/GetPhysicalCardForm.ts +++ b/src/types/form/GetPhysicalCardForm.ts @@ -1,4 +1,5 @@ import type {ValueOf} from 'type-fest'; +import type {Country} from '@src/CONST'; import type Form from './Form'; import ADDRESS_INPUT_IDS from './HomeAddressForm'; @@ -16,7 +17,7 @@ type GetPhysicalCardForm = Form< { [INPUT_IDS.ADDRESS_LINE_1]: string; [INPUT_IDS.ADDRESS_LINE_2]: string; - [INPUT_IDS.COUNTRY]: string; + [INPUT_IDS.COUNTRY]: Country | ''; [INPUT_IDS.STATE]: string; [INPUT_IDS.CITY]: string; [INPUT_IDS.ZIP_POST_CODE]: string; diff --git a/src/types/form/HomeAddressForm.ts b/src/types/form/HomeAddressForm.ts index 6d9ef8580078..7117e9de3b64 100644 --- a/src/types/form/HomeAddressForm.ts +++ b/src/types/form/HomeAddressForm.ts @@ -1,4 +1,5 @@ import type {ValueOf} from 'type-fest'; +import type {Country} from '@src/CONST'; import type Form from './Form'; const INPUT_IDS = { @@ -17,7 +18,7 @@ type HomeAddressForm = Form< { [INPUT_IDS.ADDRESS_LINE_1]: string; [INPUT_IDS.ADDRESS_LINE_2]: string; - [INPUT_IDS.COUNTRY]: string; + [INPUT_IDS.COUNTRY]: Country | ''; [INPUT_IDS.STATE]: string; [INPUT_IDS.CITY]: string; [INPUT_IDS.ZIP_POST_CODE]: string; diff --git a/src/types/onyx/PrivatePersonalDetails.ts b/src/types/onyx/PrivatePersonalDetails.ts index 5a9dae0a5523..68199cf16e6e 100644 --- a/src/types/onyx/PrivatePersonalDetails.ts +++ b/src/types/onyx/PrivatePersonalDetails.ts @@ -1,10 +1,12 @@ +import type {Country} from '@src/CONST'; + type Address = { street: string; street2?: string; city: string; state: string; zip: string; - country: string; + country: Country | ''; zipPostCode?: string; addressLine1?: string; addressLine2?: string;