feat: add Google Place Autocomplete for address fields (#13450)

resolve #13253
This PR enables the use of Google Place Autocomplete and Place Details
APIs in the backend. It allows users to automatically fill in address
fields by typing into the address1 input. The input is debounced, then
the Autocomplete API is called. Once the user selects an address, the
Place Details API is used to parse and fill in the individual address
fields.


https://github.com/user-attachments/assets/e04b8474-25b8-48f5-83d0-2074f8d5fc94

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Naifer
2025-07-29 08:56:08 +01:00
committed by GitHub
parent c186b78f67
commit 4eba13e9fb
28 changed files with 1993 additions and 131 deletions
@@ -1,18 +1,26 @@
import styled from '@emotion/styled';
import { RefObject, useEffect, useRef, useState } from 'react';
import { Key } from 'ts-key-enum';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PlaceAutocompleteSelect } from '@/geo-map/components/PlaceAutocompleteSelect';
import { SELECT_AUTOCOMPLETE_LIST_DROPDOWN_ID } from '@/geo-map/constants/selectAutocompleteListDropDownId';
import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents';
import { FieldAddressDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue';
import { FieldAddressValue } from '@/object-record/record-field/types/FieldMetadata';
import { TextInputV2 } from '@/ui/input/components/TextInputV2';
import { TEXT_INPUT_CLICK_OUTSIDE_ID } from '@/ui/input/components/constants/TextInputClickOutsideId';
import { CountrySelect } from '@/ui/input/components/internal/country/components/CountrySelect';
import { SELECT_COUNTRY_DROPDOWN_ID } from '@/ui/input/components/internal/country/constants/SelectCountryDropdownId';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
import { v4 } from 'uuid';
import { useAddressAutocomplete } from '../hooks/useAddressAutocomplete';
import { useCountryUtils } from '../hooks/useCountryUtils';
import { useFocusManagement } from '../hooks/useFocusManagement';
const StyledAddressContainer = styled.div`
padding: 4px 8px;
@@ -46,6 +54,11 @@ const StyledHalfRowContainer = styled.div`
}
`;
const StyledInputWithDropdownContainer = styled.div`
position: relative;
width: 100%;
`;
export type AddressInputProps = {
instanceId: string;
value: FieldAddressValue;
@@ -72,113 +85,137 @@ export const AddressInput = ({
onChange,
}: AddressInputProps) => {
const [internalValue, setInternalValue] = useState(value);
const addressStreet1InputRef = useRef<HTMLInputElement>(null);
const addressStreet2InputRef = useRef<HTMLInputElement>(null);
const addressCityInputRef = useRef<HTMLInputElement>(null);
const addressStateInputRef = useRef<HTMLInputElement>(null);
const addressPostcodeInputRef = useRef<HTMLInputElement>(null);
const inputRefs: {
[key in keyof FieldAddressDraftValue]?: RefObject<HTMLInputElement>;
} = {
addressStreet1: addressStreet1InputRef,
addressStreet2: addressStreet2InputRef,
addressCity: addressCityInputRef,
addressState: addressStateInputRef,
addressPostcode: addressPostcodeInputRef,
};
const [focusPosition, setFocusPosition] =
useState<keyof FieldAddressDraftValue>('addressStreet1');
const wrapperRef = useRef<HTMLDivElement>(null);
const getChangeHandler =
const inputRefs = useMemo(
() => ({
addressStreet1: addressStreet1InputRef,
addressStreet2: addressStreet2InputRef,
addressCity: addressCityInputRef,
addressState: addressStateInputRef,
addressPostcode: addressPostcodeInputRef,
}),
[],
);
const { findCountryCodeByCountryName } = useCountryUtils();
const {
placeAutocompleteData,
tokenForPlaceApi,
typeOfAddressForAutocomplete,
setTokenForPlaceApi,
setTypeOfAddressForAutocomplete,
getAutocompletePlaceData,
autoFillInputsFromPlaceDetails,
closeDropdownOfAutocomplete,
} = useAddressAutocomplete(onChange);
const { getFocusHandler, handleTab, handleShiftTab } = useFocusManagement(
inputRefs,
internalValue,
onTab,
onShiftTab,
);
const getChangeHandler = useCallback(
(field: keyof FieldAddressDraftValue) => (updatedAddressPart: string) => {
const updatedAddress = { ...value, [field]: updatedAddressPart };
const updatedAddress = { ...internalValue, [field]: updatedAddressPart };
setInternalValue(updatedAddress);
onChange?.(updatedAddress);
};
const getFocusHandler = (fieldName: keyof FieldAddressDraftValue) => () => {
setFocusPosition(fieldName);
inputRefs[fieldName]?.current?.focus();
};
const handleTab = () => {
const currentFocusPosition = Object.keys(inputRefs).findIndex(
(key) => key === focusPosition,
);
const maxFocusPosition = Object.keys(inputRefs).length - 1;
const nextFocusPosition = currentFocusPosition + 1;
const isFocusPositionAfterLast = nextFocusPosition > maxFocusPosition;
if (isFocusPositionAfterLast) {
onTab?.(internalValue);
} else {
const nextFocusFieldName = Object.keys(inputRefs)[
nextFocusPosition
] as keyof FieldAddressDraftValue;
setFocusPosition(nextFocusFieldName);
inputRefs[nextFocusFieldName]?.current?.focus();
}
};
const handleShiftTab = () => {
const currentFocusPosition = Object.keys(inputRefs).findIndex(
(key) => key === focusPosition,
);
const nextFocusPosition = currentFocusPosition - 1;
const isFocusPositionBeforeFirst = nextFocusPosition < 0;
if (isFocusPositionBeforeFirst) {
onShiftTab?.(internalValue);
} else {
const nextFocusFieldName = Object.keys(inputRefs)[
nextFocusPosition
] as keyof FieldAddressDraftValue;
setFocusPosition(nextFocusFieldName);
inputRefs[nextFocusFieldName]?.current?.focus();
}
};
useHotkeysOnFocusedElement({
keys: ['tab'],
callback: handleTab,
focusId: instanceId,
dependencies: [handleTab],
});
useHotkeysOnFocusedElement({
keys: ['shift+tab'],
callback: handleShiftTab,
focusId: instanceId,
dependencies: [handleShiftTab],
});
useHotkeysOnFocusedElement({
keys: [Key.Enter],
callback: () => {
onEnter(internalValue);
if (field === 'addressStreet1' || field === 'addressCity') {
const token = tokenForPlaceApi ?? v4();
if (token !== tokenForPlaceApi) {
setTokenForPlaceApi(token);
}
const countryCode = findCountryCodeByCountryName(
updatedAddress.addressCountry ?? '',
);
if (field !== typeOfAddressForAutocomplete) {
setTypeOfAddressForAutocomplete(field);
}
const isFieldCity = field === 'addressCity';
getAutocompletePlaceData(
updatedAddressPart,
token,
countryCode,
isFieldCity,
);
}
},
focusId: instanceId,
dependencies: [onEnter, internalValue],
});
[
internalValue,
onChange,
tokenForPlaceApi,
setTokenForPlaceApi,
findCountryCodeByCountryName,
typeOfAddressForAutocomplete,
setTypeOfAddressForAutocomplete,
getAutocompletePlaceData,
],
);
useHotkeysOnFocusedElement({
keys: [Key.Escape],
callback: () => {
onEscape(internalValue);
const handlePlaceSelection = useCallback(
(placeId: string) => {
const placeAutocomplete = placeAutocompleteData?.find(
(place) => place.placeId === placeId,
);
const token = tokenForPlaceApi ?? '';
if (!isDefined(placeAutocomplete)) return;
const text: string | undefined =
typeOfAddressForAutocomplete !== 'addressCity'
? placeAutocomplete.text
: undefined;
autoFillInputsFromPlaceDetails(placeId, token, text, internalValue);
},
[
placeAutocompleteData,
tokenForPlaceApi,
typeOfAddressForAutocomplete,
autoFillInputsFromPlaceDetails,
internalValue,
],
);
const handleClickOutside = useCallback(() => {
closeDropdownOfAutocomplete();
}, [closeDropdownOfAutocomplete]);
const handleEnter = useCallback(() => {
onEnter(internalValue);
closeDropdownOfAutocomplete();
}, [onEnter, internalValue, closeDropdownOfAutocomplete]);
const handleEscape = useCallback(() => {
onEscape(internalValue);
closeDropdownOfAutocomplete();
}, [onEscape, internalValue, closeDropdownOfAutocomplete]);
const handleOutsideClick = useCallback(
(event: MouseEvent | TouchEvent) => {
onClickOutside?.(event, internalValue);
closeDropdownOfAutocomplete();
},
[onClickOutside, internalValue, closeDropdownOfAutocomplete],
);
useRegisterInputEvents({
focusId: instanceId,
dependencies: [onEscape, internalValue],
inputRef: wrapperRef,
inputValue: internalValue,
onEnter: handleEnter,
onEscape: handleEscape,
onTab: handleTab,
onShiftTab: handleShiftTab,
});
const activeDropdownFocusId = useRecoilValue(activeDropdownFocusIdState);
@@ -186,13 +223,15 @@ export const AddressInput = ({
useListenClickOutside({
refs: [wrapperRef],
callback: (event) => {
if (activeDropdownFocusId === SELECT_COUNTRY_DROPDOWN_ID) {
if (
activeDropdownFocusId === SELECT_COUNTRY_DROPDOWN_ID ||
activeDropdownFocusId === SELECT_AUTOCOMPLETE_LIST_DROPDOWN_ID
) {
return;
}
event.stopImmediatePropagation();
onClickOutside?.(event, internalValue);
handleOutsideClick(event);
},
enabled: isDefined(onClickOutside),
listenerId: 'address-input',
@@ -202,37 +241,98 @@ export const AddressInput = ({
setInternalValue(value);
}, [value]);
const validAutocompleteData = useMemo(
() =>
placeAutocompleteData && placeAutocompleteData.length > 0
? placeAutocompleteData
: null,
[placeAutocompleteData],
);
const renderInputWithAutocomplete = (
inputElement: React.ReactNode,
fieldType: 'addressStreet1' | 'addressCity',
) => {
const shouldShowDropdown =
validAutocompleteData && typeOfAddressForAutocomplete === fieldType;
if (!shouldShowDropdown) {
return inputElement;
}
return (
<StyledInputWithDropdownContainer>
<Dropdown
dropdownId={SELECT_AUTOCOMPLETE_LIST_DROPDOWN_ID}
dropdownPlacement="bottom-start"
excludedClickOutsideIds={[
TEXT_INPUT_CLICK_OUTSIDE_ID,
SELECT_AUTOCOMPLETE_LIST_DROPDOWN_ID,
]}
disableClickForClickableComponent={true}
onClickOutside={handleClickOutside}
clickableComponent={inputElement}
dropdownComponents={
<PlaceAutocompleteSelect
list={validAutocompleteData}
onChange={handlePlaceSelection}
dropdownId={SELECT_AUTOCOMPLETE_LIST_DROPDOWN_ID}
/>
}
/>
</StyledInputWithDropdownContainer>
);
};
return (
<StyledAddressContainer ref={wrapperRef}>
<TextInputV2
autoFocus
value={internalValue.addressStreet1 ?? ''}
ref={inputRefs['addressStreet1']}
label="Address 1"
fullWidth
onChange={getChangeHandler('addressStreet1')}
onFocus={getFocusHandler('addressStreet1')}
/>
{renderInputWithAutocomplete(
<TextInputV2
autoFocus
value={internalValue.addressStreet1 ?? ''}
ref={inputRefs.addressStreet1}
label="Address 1"
fullWidth
onChange={getChangeHandler('addressStreet1')}
onFocus={getFocusHandler('addressStreet1')}
textClickOutsideId={
validAutocompleteData &&
typeOfAddressForAutocomplete === 'addressStreet1'
? TEXT_INPUT_CLICK_OUTSIDE_ID
: undefined
}
/>,
'addressStreet1',
)}
<TextInputV2
value={internalValue.addressStreet2 ?? ''}
ref={inputRefs['addressStreet2']}
ref={inputRefs.addressStreet2}
label="Address 2"
fullWidth
onChange={getChangeHandler('addressStreet2')}
onFocus={getFocusHandler('addressStreet2')}
/>
<StyledHalfRowContainer>
<TextInputV2
value={internalValue.addressCity ?? ''}
ref={inputRefs['addressCity']}
label="City"
fullWidth
onChange={getChangeHandler('addressCity')}
onFocus={getFocusHandler('addressCity')}
/>
{renderInputWithAutocomplete(
<TextInputV2
value={internalValue.addressCity ?? ''}
ref={inputRefs.addressCity}
label="City"
fullWidth
onChange={getChangeHandler('addressCity')}
onFocus={getFocusHandler('addressCity')}
textClickOutsideId={
validAutocompleteData &&
typeOfAddressForAutocomplete === 'addressCity'
? TEXT_INPUT_CLICK_OUTSIDE_ID
: undefined
}
/>,
'addressCity',
)}
<TextInputV2
value={internalValue.addressState ?? ''}
ref={inputRefs['addressState']}
ref={inputRefs.addressState}
label="State"
fullWidth
onChange={getChangeHandler('addressState')}
@@ -242,7 +342,7 @@ export const AddressInput = ({
<StyledHalfRowContainer>
<TextInputV2
value={internalValue.addressPostcode ?? ''}
ref={inputRefs['addressPostcode']}
ref={inputRefs.addressPostcode}
label="Post Code"
fullWidth
onChange={getChangeHandler('addressPostcode')}
@@ -0,0 +1,120 @@
import { useCallback, useState } from 'react';
import { SELECT_AUTOCOMPLETE_LIST_DROPDOWN_ID } from '@/geo-map/constants/selectAutocompleteListDropDownId';
import { useGetPlaceApiData } from '@/geo-map/hooks/useGetPlaceApiData';
import { PlaceAutocompleteResult } from '@/geo-map/types/placeApi';
import { FieldAddressDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown';
import { isDefined } from 'twenty-shared/utils';
import { useDebouncedCallback } from 'use-debounce';
import { useCountryUtils } from './useCountryUtils';
export const useAddressAutocomplete = (
onChange?: (updatedValue: FieldAddressDraftValue) => void,
) => {
const [placeAutocompleteData, setPlaceAutocompleteData] = useState<
PlaceAutocompleteResult[] | null
>([]);
const [tokenForPlaceApi, setTokenForPlaceApi] = useState<string | null>(null);
const [typeOfAddressForAutocomplete, setTypeOfAddressForAutocomplete] =
useState<string | null>(null);
const { getPlaceAutocompleteData, getPlaceDetailsData } =
useGetPlaceApiData();
const { openDropdown } = useOpenDropdown();
const { closeDropdown: closeDropdownHook } = useCloseDropdown();
const { findCountryNameByCountryCode } = useCountryUtils();
const openDropdownOfAutocomplete = useCallback(() => {
openDropdown({
dropdownComponentInstanceIdFromProps:
SELECT_AUTOCOMPLETE_LIST_DROPDOWN_ID,
});
}, [openDropdown]);
const closeDropdownOfAutocomplete = useCallback(() => {
closeDropdownHook(SELECT_AUTOCOMPLETE_LIST_DROPDOWN_ID);
setPlaceAutocompleteData(null);
setTypeOfAddressForAutocomplete(null);
}, [closeDropdownHook]);
const getAutocompletePlaceData = useDebouncedCallback(
async (
address: string,
token: string,
country?: string,
isFieldCity?: boolean,
) => {
const placeAutocompleteData = await getPlaceAutocompleteData(
address,
token,
country,
isFieldCity,
);
const newData = placeAutocompleteData?.map((data) => ({
text: data.text,
placeId: data.placeId,
}));
if (isDefined(newData) && newData?.length > 0) {
openDropdownOfAutocomplete();
setPlaceAutocompleteData(newData);
} else {
closeDropdownOfAutocomplete();
}
},
300,
);
const autoFillInputsFromPlaceDetails = useCallback(
async (
placeId: string,
token: string,
addressStreet1?: string,
internalValue?: FieldAddressDraftValue,
) => {
const placeData = await getPlaceDetailsData(placeId, token);
const countryName = findCountryNameByCountryCode(placeData?.country);
const updatedAddress = {
addressStreet1: addressStreet1 || (internalValue?.addressStreet1 ?? ''),
addressStreet2: internalValue?.addressStreet2 ?? null,
addressCity: placeData?.city || (internalValue?.addressCity ?? null),
addressState: placeData?.state || (internalValue?.addressState ?? null),
addressCountry: countryName || (internalValue?.addressCountry ?? null),
addressPostcode:
placeData?.postcode || (internalValue?.addressPostcode ?? null),
addressLat:
placeData?.location?.lat ?? internalValue?.addressLat ?? null,
addressLng:
placeData?.location?.lng ?? internalValue?.addressLng ?? null,
};
setTokenForPlaceApi(null);
closeDropdownOfAutocomplete();
onChange?.(updatedAddress);
return updatedAddress;
},
[
getPlaceDetailsData,
findCountryNameByCountryCode,
closeDropdownOfAutocomplete,
onChange,
],
);
return {
placeAutocompleteData,
tokenForPlaceApi,
typeOfAddressForAutocomplete,
setTokenForPlaceApi,
setTypeOfAddressForAutocomplete,
getAutocompletePlaceData,
autoFillInputsFromPlaceDetails,
closeDropdownOfAutocomplete,
};
};
@@ -0,0 +1,35 @@
import { useCallback } from 'react';
import { useCountries } from '@/ui/input/components/internal/hooks/useCountries';
import { isDefined } from 'twenty-shared/utils';
export const useCountryUtils = () => {
const countries = useCountries();
const findCountryCodeByCountryName = useCallback(
(countryName?: string): string => {
if (!isDefined(countryName) || countryName === '') return '';
const foundCountry = countries.find(
(country) => country.countryName === countryName,
);
return foundCountry?.countryCode ?? '';
},
[countries],
);
const findCountryNameByCountryCode = useCallback(
(countryCode?: string): string | null => {
if (!isDefined(countryCode) || countryCode === '') return '';
const foundCountry = countries.find(
(country) => country.countryCode === countryCode,
);
return foundCountry?.countryName ?? null;
},
[countries],
);
return { findCountryCodeByCountryName, findCountryNameByCountryCode };
};
@@ -0,0 +1,69 @@
import { RefObject, useCallback, useState } from 'react';
import { FieldAddressDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue';
export const useFocusManagement = (
inputRefs: {
[key in keyof FieldAddressDraftValue]?: RefObject<HTMLInputElement>;
},
internalValue: FieldAddressDraftValue,
onTab?: (newAddress: FieldAddressDraftValue) => void,
onShiftTab?: (newAddress: FieldAddressDraftValue) => void,
) => {
const [focusPosition, setFocusPosition] =
useState<keyof FieldAddressDraftValue>('addressStreet1');
const getFocusHandler = useCallback(
(fieldName: keyof FieldAddressDraftValue) => () => {
setFocusPosition(fieldName);
inputRefs[fieldName]?.current?.focus();
},
[inputRefs],
);
const handleTab = useCallback(() => {
const currentFocusPosition = Object.keys(inputRefs).findIndex(
(key) => key === focusPosition,
);
const maxFocusPosition = Object.keys(inputRefs).length - 1;
const nextFocusPosition = currentFocusPosition + 1;
const isFocusPositionAfterLast = nextFocusPosition > maxFocusPosition;
if (isFocusPositionAfterLast) {
onTab?.(internalValue);
} else {
const nextFocusFieldName = Object.keys(inputRefs)[
nextFocusPosition
] as keyof FieldAddressDraftValue;
setFocusPosition(nextFocusFieldName);
inputRefs[nextFocusFieldName]?.current?.focus();
}
}, [focusPosition, inputRefs, internalValue, onTab]);
const handleShiftTab = useCallback(() => {
const currentFocusPosition = Object.keys(inputRefs).findIndex(
(key) => key === focusPosition,
);
const nextFocusPosition = currentFocusPosition - 1;
const isFocusPositionBeforeFirst = nextFocusPosition < 0;
if (isFocusPositionBeforeFirst) {
onShiftTab?.(internalValue);
} else {
const nextFocusFieldName = Object.keys(inputRefs)[
nextFocusPosition
] as keyof FieldAddressDraftValue;
setFocusPosition(nextFocusFieldName);
inputRefs[nextFocusFieldName]?.current?.focus();
}
}, [focusPosition, inputRefs, internalValue, onShiftTab]);
return {
focusPosition,
getFocusHandler,
handleTab,
handleShiftTab,
};
};
@@ -234,6 +234,7 @@ export type TextInputV2ComponentProps = Omit<
inheritFontStyles?: boolean;
rightAdornment?: string;
leftAdornment?: string;
textClickOutsideId?: string;
};
type TextInputV2WithAutoGrowWrapperProps = TextInputV2ComponentProps;
@@ -273,6 +274,7 @@ const TextInputV2Component = forwardRef<
autoGrow = false,
rightAdornment,
leftAdornment,
textClickOutsideId,
},
ref,
) => {
@@ -300,7 +302,11 @@ const TextInputV2Component = forwardRef<
const instanceId = useId();
return (
<StyledContainer className={className} fullWidth={fullWidth ?? false}>
<StyledContainer
className={className}
fullWidth={fullWidth ?? false}
data-click-outside-id={textClickOutsideId}
>
{label && (
<InputLabel htmlFor={instanceId}>
{label + (required ? '*' : '')}
@@ -0,0 +1 @@
export const TEXT_INPUT_CLICK_OUTSIDE_ID = 'text-input-click-outside-id';
@@ -59,6 +59,7 @@ export type DropdownProps = {
onOpen?: () => void;
excludedClickOutsideIds?: string[];
isDropdownInModal?: boolean;
disableClickForClickableComponent?: boolean;
};
export const Dropdown = ({
@@ -76,6 +77,7 @@ export const Dropdown = ({
clickableComponentWidth = 'auto',
excludedClickOutsideIds,
isDropdownInModal = false,
disableClickForClickableComponent = false,
}: DropdownProps) => {
const isDropdownOpen = useRecoilComponentValueV2(
isDropdownOpenComponentState,
@@ -151,6 +153,7 @@ export const Dropdown = ({
const handleClickableComponentClick = useRecoilCallback(
() => async (event: MouseEvent) => {
if (disableClickForClickableComponent) return;
event.stopPropagation();
event.preventDefault();
@@ -159,7 +162,12 @@ export const Dropdown = ({
globalHotkeysConfig,
});
},
[globalHotkeysConfig, toggleDropdown, dropdownId],
[
globalHotkeysConfig,
toggleDropdown,
dropdownId,
disableClickForClickableComponent,
],
);
return (