Compare commits

...
Author SHA1 Message Date
Charles Bochet c537cb6934 chore(geo-map): fix prettier formatting in test files
Made-with: Cursor
2026-04-14 13:28:17 +02:00
Charles Bochet 9cb3c7e67a fix(address): coerce lat/lng to numbers before persist validation
Lat/lng values from the record store can arrive as strings, causing the
Zod addressSchema validation to reject them on manual address edits.
Use existing castAsNumberOrNull utility to ensure proper number types.

Made-with: Cursor
2026-04-14 13:19:24 +02:00
Charles Bochet 4e1d0ef1a2 refactor(geo-map): simplify address parsing and add unit tests
- Replace nested for+switch with flat if+continue using includes
- Extract types into separate files with GeoMap prefix
- Use isNonEmptyArray type guard instead of manual checks
- Convert sanitizePlaceDetailsResults to named object params
- Rename AddressComponents to camelCase
- Add unit tests for both sanitize utils with real Google API data

Made-with: Cursor
2026-04-14 11:57:48 +02:00
Charles Bochet ed48fd96e7 Merge branch 'main' into fix/18860-address-autocomplete-street-format 2026-04-14 11:18:31 +02:00
jeevan6996 71abe38520 chore(address): normalize generated file EOF formatting
Signed-off-by: jeevan6996 <jeevanpawar5890@gmail.com>
2026-04-04 12:35:20 +01:00
jeevan6996 984dc3cf6a chore(address): update generated metadata artifacts
Signed-off-by: jeevan6996 <jeevanpawar5890@gmail.com>
2026-04-04 00:55:49 +01:00
jeevan6996 8b128d7646 fix(address): keep street line clean after autocomplete
Signed-off-by: jeevan6996 <jeevanpawar5890@gmail.com>
2026-04-04 00:31:23 +01:00
21 changed files with 501 additions and 92 deletions
@@ -2580,6 +2580,7 @@ type Location {
}
type PlaceDetailsResult {
street: String
state: String
postcode: String
city: String
@@ -2293,6 +2293,7 @@ export interface Location {
}
export interface PlaceDetailsResult {
street?: Scalars['String']
state?: Scalars['String']
postcode?: Scalars['String']
city?: Scalars['String']
@@ -5608,6 +5609,7 @@ export interface LocationGenqlSelection{
}
export interface PlaceDetailsResultGenqlSelection{
street?: boolean | number
state?: boolean | number
postcode?: boolean | number
city?: boolean | number
@@ -5158,6 +5158,9 @@ export default {
]
},
"PlaceDetailsResult": {
"street": [
1
],
"state": [
1
],
@@ -4298,6 +4298,7 @@ export type PlaceDetailsResult = {
location?: Maybe<Location>;
postcode?: Maybe<Scalars['String']>;
state?: Maybe<Scalars['String']>;
street?: Maybe<Scalars['String']>;
};
export type PostgresCredentials = {
@@ -132,18 +132,20 @@ describe('geo-map GraphQL queries', () => {
queryDefinition.selectionSet.selections[0].selectionSet;
const fields = selectionSet.selections.map((s: any) => s.name.value);
expect(fields).toContain('street');
expect(fields).toContain('state');
expect(fields).toContain('postcode');
expect(fields).toContain('city');
expect(fields).toContain('country');
expect(fields).toContain('location');
expect(fields).toHaveLength(5);
expect(fields).toHaveLength(6);
});
it('should match the expected query string', () => {
const expectedQuery = gql`
query GetAddressDetails($placeId: String!, $token: String!) {
getAddressDetails(placeId: $placeId, token: $token) {
street
state
postcode
city
@@ -22,6 +22,7 @@ export const GET_AUTOCOMPLETE_QUERY = gql`
export const GET_PLACE_DETAILS_QUERY = gql`
query GetAddressDetails($placeId: String!, $token: String!) {
getAddressDetails(placeId: $placeId, token: $token) {
street
state
postcode
city
@@ -13,6 +13,7 @@ export type PlaceAutocompleteResult = {
};
export type PlaceDetailsResult = {
street?: string;
state?: string;
postcode?: string;
city?: string;
@@ -7,6 +7,7 @@ import { RecordFieldComponentInstanceContext } from '@/object-record/record-fiel
import { FieldInputEventContext } from '@/object-record/record-field/ui/contexts/FieldInputEventContext';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useContext } from 'react';
import { castAsNumberOrNull } from '~/utils/cast-as-number-or-null';
export const AddressFieldInput = () => {
const { draftValue, setDraftValue, fieldDefinition } = useAddressField();
@@ -25,8 +26,8 @@ export const AddressFieldInput = () => {
addressState: newAddress?.addressState ?? null,
addressCountry: newAddress?.addressCountry ?? null,
addressPostcode: newAddress?.addressPostcode ?? null,
addressLat: newAddress?.addressLat ?? null,
addressLng: newAddress?.addressLng ?? null,
addressLat: castAsNumberOrNull(newAddress?.addressLat),
addressLng: castAsNumberOrNull(newAddress?.addressLng),
};
};
const settings = fieldDefinition.metadata.settings;
@@ -140,6 +140,45 @@ describe('useAddressAutocomplete', () => {
});
});
it('should use place street over full autocomplete text', async () => {
const mockOnChange = jest.fn();
mockGetPlaceDetailsData.mockResolvedValue({
street: '123 Main St',
city: 'Springfield',
state: 'IL',
country: 'US',
postcode: '62704',
});
mockFindCountryNameByCountryCode.mockReturnValue('United States');
const { result } = renderHook(() => useAddressAutocomplete(mockOnChange));
await act(async () => {
await result.current.autoFillInputsFromPlaceDetails(
'place123',
'token123',
'123 Main St, Springfield, IL 62704, USA',
{
addressStreet1: '',
addressStreet2: null,
addressCity: null,
addressState: null,
addressCountry: null,
addressPostcode: null,
addressLat: null,
addressLng: null,
},
);
});
expect(mockOnChange).toHaveBeenCalledWith(
expect.objectContaining({
addressStreet1: '123 Main St',
}),
);
});
it('should preserve existing values when place data is missing', async () => {
const mockOnChange = jest.fn();
const mockPlaceData = {
@@ -80,7 +80,10 @@ export const useAddressAutocomplete = (
const countryName = findCountryNameByCountryCode(placeData?.country);
const updatedAddress = {
addressStreet1: addressStreet1 || (internalValue?.addressStreet1 ?? ''),
addressStreet1:
placeData?.street ||
addressStreet1 ||
(internalValue?.addressStreet1 ?? ''),
addressStreet2: internalValue?.addressStreet2 ?? null,
addressCity: placeData?.city || (internalValue?.addressCity ?? null),
addressState: placeData?.state || (internalValue?.addressState ?? null),
@@ -11,6 +11,9 @@ export class LocationDTO {
@ObjectType('PlaceDetailsResult')
export class PlaceDetailsResultDTO {
@Field({ nullable: true })
street?: string;
@Field({ nullable: true })
state?: string;
@@ -3,14 +3,10 @@ import { Injectable } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import {
type AutocompleteSanitizedResult,
sanitizeAutocompleteResults,
} from 'src/engine/core-modules/geo-map/utils/sanitize-autocomplete-results.util';
import {
type AddressFields,
sanitizePlaceDetailsResults,
} from 'src/engine/core-modules/geo-map/utils/sanitize-place-details-results.util';
import { type GeoMapAddressFields } from 'src/engine/core-modules/geo-map/types/geo-map-address-fields.type';
import { type GeoMapAutocompleteSanitizedResult } from 'src/engine/core-modules/geo-map/types/geo-map-autocomplete-sanitized-result.type';
import { sanitizeAutocompleteResults } from 'src/engine/core-modules/geo-map/utils/sanitize-autocomplete-results.util';
import { sanitizePlaceDetailsResults } from 'src/engine/core-modules/geo-map/utils/sanitize-place-details-results.util';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -37,7 +33,7 @@ export class GeoMapService {
token: string,
country?: string,
isFieldCity?: boolean,
): Promise<AutocompleteSanitizedResult[] | undefined> {
): Promise<GeoMapAutocompleteSanitizedResult[] | undefined> {
if (!isNonEmptyString(address?.trim())) {
return [];
}
@@ -64,7 +60,7 @@ export class GeoMapService {
public async getAddressDetails(
placeId: string,
token: string,
): Promise<AddressFields | undefined> {
): Promise<GeoMapAddressFields | undefined> {
const httpClient = this.secureHttpClientService.getHttpClient();
const result = await httpClient.get(
@@ -72,10 +68,10 @@ export class GeoMapService {
);
if (result.data.status === 'OK') {
return sanitizePlaceDetailsResults(
result.data.result?.address_components,
result.data.result?.geometry?.location,
);
return sanitizePlaceDetailsResults({
addressComponents: result.data.result?.address_components,
location: result.data.result?.geometry?.location,
});
}
return {};
@@ -0,0 +1,5 @@
export type GeoMapAddressComponent = {
long_name: string;
short_name: string;
types: string[];
};
@@ -0,0 +1,10 @@
import { type GeoMapLocationFields } from 'src/engine/core-modules/geo-map/types/geo-map-location-fields.type';
export type GeoMapAddressFields = {
street?: string;
state?: string;
postcode?: string;
city?: string;
country?: string;
location?: GeoMapLocationFields;
};
@@ -0,0 +1,4 @@
export type GeoMapAutocompleteSanitizedResult = {
text: string;
placeId: string;
};
@@ -0,0 +1,4 @@
export type GeoMapGooglePrediction = {
description: string;
place_id: string;
};
@@ -0,0 +1,4 @@
export type GeoMapLocationFields = {
lat?: number;
lng?: number;
};
@@ -0,0 +1,50 @@
import { sanitizeAutocompleteResults } from 'src/engine/core-modules/geo-map/utils/sanitize-autocomplete-results.util';
// Real Google Places Autocomplete API prediction format
const GOOGLE_PREDICTIONS = [
{
description: '48 Pirrama Road, Pyrmont NSW 2009, Australia',
place_id: 'ChIJN1t_tDeuEmsRUsoyG83frY4',
},
{
description: '111 8th Avenue, New York, NY 10011, USA',
place_id: 'ChIJaXQRs6lZwokRY6EFpJnhNNE',
},
{
description: 'Eiffel Tower, Avenue Anatole France, Paris, France',
place_id: 'ChIJLU7jZClu5kcR4PcOOO6p3I0',
},
];
describe('sanitizeAutocompleteResults', () => {
it('should return empty array for empty input', () => {
expect(sanitizeAutocompleteResults([])).toEqual([]);
});
it('should map predictions to text and placeId', () => {
const result = sanitizeAutocompleteResults(GOOGLE_PREDICTIONS);
expect(result).toEqual([
{
text: '48 Pirrama Road, Pyrmont NSW 2009, Australia',
placeId: 'ChIJN1t_tDeuEmsRUsoyG83frY4',
},
{
text: '111 8th Avenue, New York, NY 10011, USA',
placeId: 'ChIJaXQRs6lZwokRY6EFpJnhNNE',
},
{
text: 'Eiffel Tower, Avenue Anatole France, Paris, France',
placeId: 'ChIJLU7jZClu5kcR4PcOOO6p3I0',
},
]);
});
it('should map a single prediction', () => {
const result = sanitizeAutocompleteResults([GOOGLE_PREDICTIONS[0]]);
expect(result).toHaveLength(1);
expect(result[0].text).toBe('48 Pirrama Road, Pyrmont NSW 2009, Australia');
expect(result[0].placeId).toBe('ChIJN1t_tDeuEmsRUsoyG83frY4');
});
});
@@ -0,0 +1,281 @@
import { type GeoMapAddressComponent } from 'src/engine/core-modules/geo-map/types/geo-map-address-component.type';
import { sanitizePlaceDetailsResults } from 'src/engine/core-modules/geo-map/utils/sanitize-place-details-results.util';
// Real Google Places API response for "48 Pirrama Road, Pyrmont NSW 2009, Australia"
const GOOGLE_OFFICE_SYDNEY: GeoMapAddressComponent[] = [
{ long_name: '48', short_name: '48', types: ['street_number'] },
{
long_name: 'Pirrama Road',
short_name: 'Pirrama Rd',
types: ['route'],
},
{
long_name: 'Pyrmont',
short_name: 'Pyrmont',
types: ['locality', 'political'],
},
{
long_name: 'City of Sydney',
short_name: 'City of Sydney',
types: ['administrative_area_level_2', 'political'],
},
{
long_name: 'New South Wales',
short_name: 'NSW',
types: ['administrative_area_level_1', 'political'],
},
{
long_name: 'Australia',
short_name: 'AU',
types: ['country', 'political'],
},
{ long_name: '2009', short_name: '2009', types: ['postal_code'] },
];
// Real Google Places API response for "111 8th Avenue, New York, NY 10011, USA"
const NYC_ADDRESS: GeoMapAddressComponent[] = [
{ long_name: '111', short_name: '111', types: ['street_number'] },
{
long_name: '8th Avenue',
short_name: '8th Ave',
types: ['route'],
},
{
long_name: 'New York',
short_name: 'New York',
types: ['locality', 'political'],
},
{
long_name: 'New York County',
short_name: 'New York County',
types: ['administrative_area_level_2', 'political'],
},
{
long_name: 'New York',
short_name: 'NY',
types: ['administrative_area_level_1', 'political'],
},
{
long_name: 'United States',
short_name: 'US',
types: ['country', 'political'],
},
{ long_name: '10011', short_name: '10011', types: ['postal_code'] },
{
long_name: '1305',
short_name: '1305',
types: ['postal_code_suffix'],
},
];
// UK address using postal_town (London addresses often lack locality)
const UK_ADDRESS: GeoMapAddressComponent[] = [
{ long_name: '221B', short_name: '221B', types: ['street_number'] },
{
long_name: 'Baker Street',
short_name: 'Baker St',
types: ['route'],
},
{
long_name: 'London',
short_name: 'London',
types: ['postal_town'],
},
{
long_name: 'Greater London',
short_name: 'Greater London',
types: ['administrative_area_level_2', 'political'],
},
{
long_name: 'England',
short_name: 'England',
types: ['administrative_area_level_1', 'political'],
},
{
long_name: 'United Kingdom',
short_name: 'GB',
types: ['country', 'political'],
},
{ long_name: 'NW1 6XE', short_name: 'NW1 6XE', types: ['postal_code'] },
];
// Landmark without street_number or route
const LANDMARK: GeoMapAddressComponent[] = [
{
long_name: 'Paris',
short_name: 'Paris',
types: ['locality', 'political'],
},
{
long_name: 'Paris',
short_name: 'Paris',
types: ['administrative_area_level_2', 'political'],
},
{
long_name: 'Île-de-France',
short_name: 'IDF',
types: ['administrative_area_level_1', 'political'],
},
{
long_name: 'France',
short_name: 'FR',
types: ['country', 'political'],
},
{ long_name: '75007', short_name: '75007', types: ['postal_code'] },
];
describe('sanitizePlaceDetailsResults', () => {
it('should return empty object for empty array', () => {
expect(sanitizePlaceDetailsResults({ addressComponents: [] })).toEqual({});
});
it('should parse a full US address (Google Sydney office)', () => {
const result = sanitizePlaceDetailsResults({
addressComponents: GOOGLE_OFFICE_SYDNEY,
location: { lat: -33.866489, lng: 151.1958561 },
});
expect(result).toEqual({
street: '48 Pirrama Road',
city: 'Pyrmont',
state: 'New South Wales',
country: 'AU',
postcode: '2009',
location: { lat: -33.866489, lng: 151.1958561 },
});
});
it('should parse US address with postal_code_suffix', () => {
const result = sanitizePlaceDetailsResults({
addressComponents: NYC_ADDRESS,
});
expect(result).toEqual({
street: '111 8th Avenue',
city: 'New York',
state: 'New York',
country: 'US',
postcode: '10011-1305',
location: undefined,
});
});
it('should use postal_town as city fallback (UK address)', () => {
const result = sanitizePlaceDetailsResults({
addressComponents: UK_ADDRESS,
});
expect(result).toEqual({
street: '221B Baker Street',
city: 'London',
state: 'England',
country: 'GB',
postcode: 'NW1 6XE',
location: undefined,
});
});
it('should not set street when no street_number or route', () => {
const result = sanitizePlaceDetailsResults({
addressComponents: LANDMARK,
});
expect(result.street).toBeUndefined();
expect(result.city).toBe('Paris');
expect(result.country).toBe('FR');
});
it('should set street with only route (no street_number)', () => {
const result = sanitizePlaceDetailsResults({
addressComponents: [
{
long_name: 'Broadway',
short_name: 'Broadway',
types: ['route'],
},
],
});
expect(result.street).toBe('Broadway');
});
it('should use administrative_area_level_2 as state fallback', () => {
const result = sanitizePlaceDetailsResults({
addressComponents: [
{
long_name: 'Some County',
short_name: 'Some County',
types: ['administrative_area_level_2', 'political'],
},
],
});
expect(result.state).toBe('Some County');
});
it('should prefer administrative_area_level_1 over level_2 for state', () => {
const result = sanitizePlaceDetailsResults({
addressComponents: [
{
long_name: 'Some County',
short_name: 'Some County',
types: ['administrative_area_level_2', 'political'],
},
{
long_name: 'California',
short_name: 'CA',
types: ['administrative_area_level_1', 'political'],
},
],
});
expect(result.state).toBe('California');
});
it('should prefer locality over postal_town for city', () => {
const result = sanitizePlaceDetailsResults({
addressComponents: [
{
long_name: 'Westminster',
short_name: 'Westminster',
types: ['locality', 'political'],
},
{
long_name: 'London',
short_name: 'London',
types: ['postal_town'],
},
],
});
expect(result.city).toBe('Westminster');
});
it('should use administrative_area_level_3 as city fallback', () => {
const result = sanitizePlaceDetailsResults({
addressComponents: [
{
long_name: 'Small Town',
short_name: 'Small Town',
types: ['administrative_area_level_3', 'political'],
},
],
});
expect(result.city).toBe('Small Town');
});
it('should use short_name for country', () => {
const result = sanitizePlaceDetailsResults({
addressComponents: [
{
long_name: 'United States',
short_name: 'US',
types: ['country', 'political'],
},
],
});
expect(result.country).toBe('US');
});
});
@@ -1,17 +1,12 @@
export type AutocompleteSanitizedResult = {
text: string;
placeId: string;
};
import { isNonEmptyArray } from 'twenty-shared/utils';
import { type GeoMapAutocompleteSanitizedResult } from 'src/engine/core-modules/geo-map/types/geo-map-autocomplete-sanitized-result.type';
import { type GeoMapGooglePrediction } from 'src/engine/core-modules/geo-map/types/geo-map-google-prediction.type';
type GooglePrediction = {
description: string;
place_id: string;
};
export const sanitizeAutocompleteResults = (
autocompleteResults: GooglePrediction[],
): AutocompleteSanitizedResult[] => {
if (!Array.isArray(autocompleteResults) || autocompleteResults.length === 0)
return [];
autocompleteResults: GeoMapGooglePrediction[],
): GeoMapAutocompleteSanitizedResult[] => {
if (!isNonEmptyArray(autocompleteResults)) return [];
return autocompleteResults.map((result) => ({
text: result.description,
@@ -1,77 +1,80 @@
export type AddressComponent = {
long_name: string;
short_name: string;
types: string[];
};
export type AddressFields = {
state?: string;
postcode?: string;
city?: string;
country?: string;
location?: locationFields;
};
export type locationFields = {
lat?: number;
lng?: number;
};
export const sanitizePlaceDetailsResults = (
AddressComponents: AddressComponent[],
location?: locationFields,
): AddressFields => {
if (!AddressComponents || AddressComponents.length === 0) return {};
import { isNonEmptyArray } from 'twenty-shared/utils';
const address: AddressFields = {};
import { type GeoMapAddressComponent } from 'src/engine/core-modules/geo-map/types/geo-map-address-component.type';
import { type GeoMapAddressFields } from 'src/engine/core-modules/geo-map/types/geo-map-address-fields.type';
import { type GeoMapLocationFields } from 'src/engine/core-modules/geo-map/types/geo-map-location-fields.type';
for (const AddressComponent of AddressComponents) {
for (const type of AddressComponent.types) {
switch (type) {
case 'postal_code': {
address.postcode =
AddressComponent.long_name + (address.postcode ?? '');
break;
}
const hasType = (
addressComponent: GeoMapAddressComponent,
type: string,
): boolean => addressComponent.types.includes(type);
case 'postal_code_suffix': {
address.postcode =
(address.postcode ?? '') + '-' + AddressComponent.long_name;
break;
}
export const sanitizePlaceDetailsResults = ({
addressComponents,
location,
}: {
addressComponents: GeoMapAddressComponent[];
location?: GeoMapLocationFields;
}): GeoMapAddressFields => {
if (!isNonEmptyArray(addressComponents)) return {};
case 'locality':
address.city = AddressComponent.long_name;
break;
const address: GeoMapAddressFields = {};
case 'postal_town':
if (!address.city) {
address.city = AddressComponent.long_name;
}
break;
for (const addressComponent of addressComponents) {
if (hasType(addressComponent, 'street_number')) {
address.street =
addressComponent.long_name + ' ' + (address.street ?? '');
continue;
}
case 'administrative_area_level_3': {
if (!address.city) {
address.city = AddressComponent.long_name;
}
break;
}
if (hasType(addressComponent, 'route')) {
address.street = (address.street ?? '') + addressComponent.long_name;
continue;
}
case 'administrative_area_level_1': {
address.state = AddressComponent.long_name;
break;
}
if (hasType(addressComponent, 'postal_code')) {
address.postcode = addressComponent.long_name + (address.postcode ?? '');
continue;
}
case 'administrative_area_level_2': {
if (!address.state) {
address.state = AddressComponent.long_name;
}
break;
}
if (hasType(addressComponent, 'postal_code_suffix')) {
address.postcode =
(address.postcode ?? '') + '-' + addressComponent.long_name;
continue;
}
case 'country':
address.country = AddressComponent.short_name;
break;
if (hasType(addressComponent, 'locality')) {
address.city = addressComponent.long_name;
continue;
}
if (
hasType(addressComponent, 'postal_town') ||
hasType(addressComponent, 'administrative_area_level_3')
) {
if (!address.city) {
address.city = addressComponent.long_name;
}
continue;
}
if (hasType(addressComponent, 'administrative_area_level_1')) {
address.state = addressComponent.long_name;
continue;
}
if (hasType(addressComponent, 'administrative_area_level_2')) {
if (!address.state) {
address.state = addressComponent.long_name;
}
continue;
}
if (hasType(addressComponent, 'country')) {
address.country = addressComponent.short_name;
}
}
address.location = location;
return address;