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
@@ -0,0 +1,71 @@
import { PlaceAutocompleteResult } from '@/geo-map/types/placeApi';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import styled from '@emotion/styled';
import { useMemo, useRef } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { SelectOption } from 'twenty-ui/input';
import { MenuItemSelectTag } from 'twenty-ui/navigation';
const StyledContainer = styled.div<{ fullWidth?: boolean }>`
margin-bottom: 0px !important;
width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')};
`;
export const PlaceAutocompleteSelect = ({
list,
onChange,
dropdownId,
}: {
list: PlaceAutocompleteResult[];
onChange: (placeId: string) => void;
dropdownId: string;
}) => {
const selectContainerRef = useRef<HTMLDivElement>(null);
const options: SelectOption<string>[] = useMemo(() => {
return list?.map<SelectOption<string>>(({ placeId, text }) => ({
label: text,
value: placeId,
}));
}, [list]);
if (!isDefined(options) || options.length <= 0) return null;
const selectableItemIdArray = options.map((option) => option.value);
return (
<StyledContainer tabIndex={0} ref={selectContainerRef} fullWidth={true}>
<SelectableList
selectableListInstanceId={dropdownId}
selectableItemIdArray={selectableItemIdArray}
focusId={dropdownId}
>
<DropdownContent
ref={selectContainerRef}
selectDisabled
widthInPixels={345}
>
<DropdownMenuItemsContainer hasMaxHeight>
{options.map((option) => {
return (
<SelectableListItem
key={option.value}
itemId={option.value}
onEnter={() => onChange(option.value)}
>
<MenuItemSelectTag
key={option.value}
text={option.label}
color={'transparent'}
onClick={() => onChange(option.value)}
/>
</SelectableListItem>
);
})}
</DropdownMenuItemsContainer>
</DropdownContent>
</SelectableList>
</StyledContainer>
);
};
@@ -0,0 +1,181 @@
import { PlaceAutocompleteResult } from '@/geo-map/types/placeApi';
describe('PlaceAutocompleteSelect Component', () => {
describe('component interface', () => {
it('should have correct prop types', () => {
// Test that the component accepts the expected props
const mockProps = {
list: [] as PlaceAutocompleteResult[],
onChange: jest.fn(),
dropdownId: 'test-dropdown',
};
expect(mockProps.list).toEqual([]);
expect(typeof mockProps.onChange).toBe('function');
expect(typeof mockProps.dropdownId).toBe('string');
});
it('should handle empty list prop', () => {
const emptyList: PlaceAutocompleteResult[] = [];
expect(emptyList).toHaveLength(0);
expect(Array.isArray(emptyList)).toBe(true);
});
it('should handle valid place results', () => {
const validList: PlaceAutocompleteResult[] = [
{ placeId: 'place-1', text: 'New York, NY, USA' },
{ placeId: 'place-2', text: 'Los Angeles, CA, USA' },
];
expect(validList).toHaveLength(2);
expect(validList[0]).toHaveProperty('placeId');
expect(validList[0]).toHaveProperty('text');
expect(typeof validList[0].placeId).toBe('string');
expect(typeof validList[0].text).toBe('string');
});
it('should handle onChange callback function', () => {
const mockOnChange = jest.fn();
const testPlaceId = 'test-place-id';
// Simulate calling the onChange function
mockOnChange(testPlaceId);
expect(mockOnChange).toHaveBeenCalledWith(testPlaceId);
expect(mockOnChange).toHaveBeenCalledTimes(1);
});
it('should handle dropdownId string prop', () => {
const dropdownIds = [
'test-dropdown',
'autocomplete-list',
'place-selector-dropdown',
'geo-map-dropdown-123',
];
dropdownIds.forEach((id) => {
expect(typeof id).toBe('string');
expect(id.length).toBeGreaterThan(0);
});
});
});
describe('data validation', () => {
it('should validate place result structure', () => {
const validPlaceResult: PlaceAutocompleteResult = {
placeId: 'ChIJD7fiBh9u5kcRYJSMaMOCCwQ',
text: 'Paris, France',
};
expect(validPlaceResult).toHaveProperty('placeId');
expect(validPlaceResult).toHaveProperty('text');
expect(validPlaceResult.placeId).toBeTruthy();
expect(validPlaceResult.text).toBeTruthy();
});
it('should handle special characters in place text', () => {
const specialCharacterPlaces: PlaceAutocompleteResult[] = [
{ placeId: 'place-1', text: 'São Paulo, Brazil' },
{ placeId: 'place-2', text: 'Москва, Россия' },
{ placeId: 'place-3', text: '東京, 日本' },
{ placeId: 'place-4', text: 'Zürich, Switzerland' },
{ placeId: 'place-5', text: "Café de l'Europe, Paris" },
];
specialCharacterPlaces.forEach((place) => {
expect(typeof place.text).toBe('string');
expect(place.text.length).toBeGreaterThan(0);
expect(typeof place.placeId).toBe('string');
expect(place.placeId.length).toBeGreaterThan(0);
});
});
it('should handle empty text values gracefully', () => {
const placesWithEmptyText: PlaceAutocompleteResult[] = [
{ placeId: 'place-1', text: '' },
{ placeId: 'place-2', text: ' ' },
{ placeId: 'place-3', text: 'Valid Location' },
];
placesWithEmptyText.forEach((place) => {
expect(typeof place.text).toBe('string');
expect(typeof place.placeId).toBe('string');
expect(place.placeId.length).toBeGreaterThan(0);
});
});
it('should handle large datasets', () => {
const largeDataset: PlaceAutocompleteResult[] = Array.from(
{ length: 1000 },
(_, i) => ({
placeId: `place-${i}`,
text: `Location ${i}`,
}),
);
expect(largeDataset).toHaveLength(1000);
expect(largeDataset[0].placeId).toBe('place-0');
expect(largeDataset[999].text).toBe('Location 999');
});
});
describe('callback behavior', () => {
it('should call onChange with correct placeId', () => {
const mockOnChange = jest.fn();
const testPlaceIds = [
'ChIJD7fiBh9u5kcRYJSMaMOCCwQ',
'ChIJOwg_06VPwokRYv534QaPC8g',
'place-with-special-chars-123',
];
testPlaceIds.forEach((placeId) => {
mockOnChange(placeId);
});
expect(mockOnChange).toHaveBeenCalledTimes(3);
testPlaceIds.forEach((placeId) => {
expect(mockOnChange).toHaveBeenCalledWith(placeId);
});
});
it('should handle multiple onChange calls', () => {
const mockOnChange = jest.fn();
const calls = 10;
for (let i = 0; i < calls; i++) {
mockOnChange(`place-${i}`);
}
expect(mockOnChange).toHaveBeenCalledTimes(calls);
});
});
describe('component requirements', () => {
it('should require all mandatory props', () => {
// Test that all required props are defined in the interface
const requiredProps = ['list', 'onChange', 'dropdownId'];
requiredProps.forEach((prop) => {
expect(typeof prop).toBe('string');
expect(prop.length).toBeGreaterThan(0);
});
});
it('should handle dropdown ID formats', () => {
const validDropdownIds = [
'dropdown-1',
'place-autocomplete-dropdown',
'geo-map-selector',
'test_dropdown_123',
'dropdown',
];
validDropdownIds.forEach((id) => {
expect(typeof id).toBe('string');
expect(id.length).toBeGreaterThan(0);
// Should not contain spaces (valid HTML ID)
expect(id).not.toMatch(/\s/);
});
});
});
});