import { yupResolver } from '@hookform/resolvers/yup'; import { ComponentPropsWithRef, useCallback, useEffect, useMemo, useState } from 'react'; import { FormProvider, useForm, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { FlatList, Keyboard, ListRenderItem, TouchableOpacity, View } from 'react-native'; import { object, string } from 'yup'; import { tw } from '../../core/tailwind'; import type { BaseAddressFromPlaces } from '../../hooks'; import { useGoogleAddress } from '../../hooks'; import { Text } from '../form/Text'; import { TextField } from '../form/TextField'; import { FormTextField } from './FormTextField'; export interface FormAddressFieldProps { /** * Optionally pass initial data to populate the form */ initialData?: Partial; /** * Name of the property that encapsulates address fields (e.g. `shippingAddress`, `address`). * If the address fields are not nested in another property, then this is not needed. */ name?: string; /** * Optionally override styles for the `` component * used to search for addresses and trigger the suggestions list. */ textFieldProps?: ComponentPropsWithRef; /** * `onChange` provides an address (`BaseAddress`) object that the user updated by: * - selecting an address from the suggestions list. * - modifying the individual fields `city`, `state`, etc. * * Note: * if you have a parent `` * this is automatically consumed by `react-hook-form`. */ onSelect?: (address: BaseAddressFromPlaces) => void; } /** * This form component should be put under a ``. * @param props - should contain some `props` that `react-hook-form` can consume. * @returns `` */ export function FormAddressField(props: FormAddressFieldProps) { const { initialData, name, textFieldProps: _textFieldProps, onSelect } = props; // This has to be done to avoid annoying type conflicts with `vue`. const { ref: textFieldRef, ...textFieldProps } = _textFieldProps ?? {}; const { t } = useTranslation(); /** * This field should be a descendant of a ``. */ const parentForm = useFormContext(); /** * We handle the fields for `address`, `city`, `state`, etc. by ourselves. * The parent form receives a whole `BaseAddress` object back from us. */ const form = useForm>({ resolver: yupResolver>( object().shape({ address: string().required(), city: string().required(), state: string().required(), country: string().required(), zip: string().min(4).max(10).required(), }) ), defaultValues: { address: initialData?.address || '', city: initialData?.city || '', state: initialData?.state || '', country: initialData?.country || '', zip: initialData?.zip || '', }, }); const address = form.watch('address'); const city = form.watch('city'); const state = form.watch('state'); const country = form.watch('country'); const zip = form.watch('zip'); // If the user taps away from field - we need to hide the suggestions list for UX-purposes // Set to `true` by default so if `initialData` comes in the list is still hidden, until user focuses const [hideSuggestions, setHideSuggestions] = useState(true); // Custom hook handles everything related to Google Autocomplete / Places APIs const { data: addresses } = useGoogleAddress({ search: address }); /** * If this form is nested under a property in the parent form, * then we need this to properly call `setValue` on the correct form property. */ const pre = name ? name + '.' : ''; useEffect(() => parentForm?.setValue?.(`${pre}address`, address), [pre, parentForm, address]); useEffect(() => parentForm?.setValue?.(`${pre}city`, city), [pre, parentForm, city]); useEffect(() => parentForm?.setValue?.(`${pre}state`, state), [pre, parentForm, state]); useEffect(() => parentForm?.setValue?.(`${pre}country`, country), [pre, parentForm, country]); useEffect(() => parentForm?.setValue?.(`${pre}zip`, zip), [pre, parentForm, zip]); /** * If any of either `city`, `state`, or `country` have values, * then we should filter address suggestions based on those provided values. */ const filteredAddresses = addresses; /** * User selects a suggested `address` from the ``. */ const onItemPress = useCallback( (address: BaseAddressFromPlaces) => { Keyboard.dismiss(); // Use `reset` to update all fields in our form with the selected address form.reset(address); onSelect?.(address); }, [form, onSelect] ); // ====================================================================== // Less-relevant memoized data to reduce next render times // ====================================================================== const textFieldStyle = useMemo( () => tw.style({ 'border-b-transparent': Boolean(addresses && addresses.length > 0) }), [addresses] ); const listStyle = useMemo( () => tw.style({ hidden: hideSuggestions || !address || address.length === 0, 'border border-t-0 border-gray-100 rounded-b': !!address && address.length > 0, }), [hideSuggestions, address] ); const onFocus = useCallback(() => setHideSuggestions(false), [setHideSuggestions]); const onBlur = useCallback(() => setHideSuggestions(true), [setHideSuggestions]); // ====================================================================== // Memoized render callbacks for the `` // ====================================================================== /** * Renders an Address (`BaseAddress`) from Google Autocomplete / Places response. */ const renderItem: ListRenderItem = useCallback( ({ item }) => ( onItemPress(item)}> {item.address} ), [onItemPress] ); /** * Renders the clean looking separator between Address items. */ const renderSeparator = useCallback( (sectionId: string, rowId: number) => { if (addresses && rowId !== addresses.length - 1) { return null; } return ; }, [addresses] ); /** * Renders a hint to the user, * that we couldn't find an address which matched their query. */ const renderEmpty = useCallback(() => { if (!address || address.length === 0) { return null; } return ( {t('common.address.notFound')} ); }, [address]); return ( ` scrollEnabled={false} /> ); }