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:
committed by
GitHub
parent
eaf54ae02f
commit
b194b67ac4
+3
@@ -11,6 +11,9 @@ export class LocationDTO {
|
||||
|
||||
@ObjectType('PlaceDetailsResult')
|
||||
export class PlaceDetailsResultDTO {
|
||||
@Field({ nullable: true })
|
||||
street?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
state?: string;
|
||||
|
||||
|
||||
+10
-14
@@ -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 {};
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export type GeoMapAddressComponent = {
|
||||
long_name: string;
|
||||
short_name: string;
|
||||
types: string[];
|
||||
};
|
||||
+10
@@ -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;
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type GeoMapAutocompleteSanitizedResult = {
|
||||
text: string;
|
||||
placeId: string;
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type GeoMapGooglePrediction = {
|
||||
description: string;
|
||||
place_id: string;
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type GeoMapLocationFields = {
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
};
|
||||
+50
@@ -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');
|
||||
});
|
||||
});
|
||||
+281
@@ -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');
|
||||
});
|
||||
});
|
||||
+7
-12
@@ -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,
|
||||
|
||||
+65
-62
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user