fix(address): populate street line from place details (#19326)

## Summary
- extract and expose `street` from Google place details (`street_number`
+ `route`) on the server DTO
- request and type `street` in front-end geo-map place details query
- use `placeData.street` as the preferred value for `addressStreet1` in
address autofill
- add regression coverage for query fields and street-line precedence
behavior

## Why
Address autocomplete selection currently writes full place text
(including city/state/postcode/country) into `addressStreet1`,
duplicating values already mapped to dedicated fields.

Fixes #18860

---------

Signed-off-by: jeevan6996 <jeevanpawar5890@gmail.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Jeevan Mohan Pawar
2026-04-14 12:44:36 +01:00
committed by GitHub
parent eaf54ae02f
commit b194b67ac4
21 changed files with 501 additions and 92 deletions
@@ -2584,6 +2584,7 @@ type Location {
}
type PlaceDetailsResult {
street: String
state: String
postcode: String
city: String
@@ -2297,6 +2297,7 @@ export interface Location {
}
export interface PlaceDetailsResult {
street?: Scalars['String']
state?: Scalars['String']
postcode?: Scalars['String']
city?: Scalars['String']
@@ -5616,6 +5617,7 @@ export interface LocationGenqlSelection{
}
export interface PlaceDetailsResultGenqlSelection{
street?: boolean | number
state?: boolean | number
postcode?: boolean | number
city?: boolean | number
@@ -5170,6 +5170,9 @@ export default {
]
},
"PlaceDetailsResult": {
"street": [
1
],
"state": [
1
],
@@ -4300,6 +4300,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;