From b194b67ac458ced9f6d0c919c8173da76b813405 Mon Sep 17 00:00:00 2001 From: Jeevan Mohan Pawar <35501212+jeevan6996@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:44:36 +0100 Subject: [PATCH] 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 Co-authored-by: Charles Bochet --- .../src/metadata/generated/schema.graphql | 1 + .../src/metadata/generated/schema.ts | 2 + .../src/metadata/generated/types.ts | 3 + .../src/generated-metadata/graphql.ts | 1 + .../__tests__/geo-map-appolo.api.test.ts | 4 +- .../graphql-query/geo-map-appolo.api.ts | 1 + .../src/modules/geo-map/types/placeApi.ts | 1 + .../input/components/AddressFieldInput.tsx | 5 +- .../__tests__/useAddressAutocomplete.test.tsx | 39 +++ .../input/hooks/useAddressAutocomplete.ts | 5 +- .../geo-map/dtos/place-details-result.dto.ts | 3 + .../geo-map/services/geo-map.service.ts | 24 +- .../types/geo-map-address-component.type.ts | 5 + .../types/geo-map-address-fields.type.ts | 10 + ...-map-autocomplete-sanitized-result.type.ts | 4 + .../types/geo-map-google-prediction.type.ts | 4 + .../types/geo-map-location-fields.type.ts | 4 + ...sanitize-autocomplete-results.util.spec.ts | 50 ++++ ...anitize-place-details-results.util.spec.ts | 281 ++++++++++++++++++ .../sanitize-autocomplete-results.util.ts | 19 +- .../sanitize-place-details-results.util.ts | 127 ++++---- 21 files changed, 501 insertions(+), 92 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/geo-map/types/geo-map-address-component.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/geo-map/types/geo-map-address-fields.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/geo-map/types/geo-map-autocomplete-sanitized-result.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/geo-map/types/geo-map-google-prediction.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/geo-map/types/geo-map-location-fields.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/geo-map/utils/__tests__/sanitize-autocomplete-results.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/geo-map/utils/__tests__/sanitize-place-details-results.util.spec.ts diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 3cb57698c5..79cfddc3c8 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -2584,6 +2584,7 @@ type Location { } type PlaceDetailsResult { + street: String state: String postcode: String city: String diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 9049f0ec43..2348a16643 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -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 diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index de5b2a6edf..6f69716393 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -5170,6 +5170,9 @@ export default { ] }, "PlaceDetailsResult": { + "street": [ + 1 + ], "state": [ 1 ], diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index a44cf7d40c..d8ee1bab81 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -4300,6 +4300,7 @@ export type PlaceDetailsResult = { location?: Maybe; postcode?: Maybe; state?: Maybe; + street?: Maybe; }; export type PostgresCredentials = { diff --git a/packages/twenty-front/src/modules/geo-map/graphql-query/__tests__/geo-map-appolo.api.test.ts b/packages/twenty-front/src/modules/geo-map/graphql-query/__tests__/geo-map-appolo.api.test.ts index a69009c5b7..a3e0392e47 100644 --- a/packages/twenty-front/src/modules/geo-map/graphql-query/__tests__/geo-map-appolo.api.test.ts +++ b/packages/twenty-front/src/modules/geo-map/graphql-query/__tests__/geo-map-appolo.api.test.ts @@ -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 diff --git a/packages/twenty-front/src/modules/geo-map/graphql-query/geo-map-appolo.api.ts b/packages/twenty-front/src/modules/geo-map/graphql-query/geo-map-appolo.api.ts index 4da49f3530..9533d36f2d 100644 --- a/packages/twenty-front/src/modules/geo-map/graphql-query/geo-map-appolo.api.ts +++ b/packages/twenty-front/src/modules/geo-map/graphql-query/geo-map-appolo.api.ts @@ -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 diff --git a/packages/twenty-front/src/modules/geo-map/types/placeApi.ts b/packages/twenty-front/src/modules/geo-map/types/placeApi.ts index 1a4246612c..8c6200c80e 100644 --- a/packages/twenty-front/src/modules/geo-map/types/placeApi.ts +++ b/packages/twenty-front/src/modules/geo-map/types/placeApi.ts @@ -13,6 +13,7 @@ export type PlaceAutocompleteResult = { }; export type PlaceDetailsResult = { + street?: string; state?: string; postcode?: string; city?: string; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/AddressFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/AddressFieldInput.tsx index 98b836895f..625f0a54b4 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/AddressFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/AddressFieldInput.tsx @@ -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; diff --git a/packages/twenty-front/src/modules/ui/field/input/hooks/__tests__/useAddressAutocomplete.test.tsx b/packages/twenty-front/src/modules/ui/field/input/hooks/__tests__/useAddressAutocomplete.test.tsx index cef677a24d..4af4b8dbbb 100644 --- a/packages/twenty-front/src/modules/ui/field/input/hooks/__tests__/useAddressAutocomplete.test.tsx +++ b/packages/twenty-front/src/modules/ui/field/input/hooks/__tests__/useAddressAutocomplete.test.tsx @@ -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 = { diff --git a/packages/twenty-front/src/modules/ui/field/input/hooks/useAddressAutocomplete.ts b/packages/twenty-front/src/modules/ui/field/input/hooks/useAddressAutocomplete.ts index 380ab5cbd6..187bdff295 100644 --- a/packages/twenty-front/src/modules/ui/field/input/hooks/useAddressAutocomplete.ts +++ b/packages/twenty-front/src/modules/ui/field/input/hooks/useAddressAutocomplete.ts @@ -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), diff --git a/packages/twenty-server/src/engine/core-modules/geo-map/dtos/place-details-result.dto.ts b/packages/twenty-server/src/engine/core-modules/geo-map/dtos/place-details-result.dto.ts index a23918df6a..8f67cc5c99 100644 --- a/packages/twenty-server/src/engine/core-modules/geo-map/dtos/place-details-result.dto.ts +++ b/packages/twenty-server/src/engine/core-modules/geo-map/dtos/place-details-result.dto.ts @@ -11,6 +11,9 @@ export class LocationDTO { @ObjectType('PlaceDetailsResult') export class PlaceDetailsResultDTO { + @Field({ nullable: true }) + street?: string; + @Field({ nullable: true }) state?: string; diff --git a/packages/twenty-server/src/engine/core-modules/geo-map/services/geo-map.service.ts b/packages/twenty-server/src/engine/core-modules/geo-map/services/geo-map.service.ts index 50c0b4a5d1..d921cb22ec 100644 --- a/packages/twenty-server/src/engine/core-modules/geo-map/services/geo-map.service.ts +++ b/packages/twenty-server/src/engine/core-modules/geo-map/services/geo-map.service.ts @@ -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 { + ): Promise { if (!isNonEmptyString(address?.trim())) { return []; } @@ -64,7 +60,7 @@ export class GeoMapService { public async getAddressDetails( placeId: string, token: string, - ): Promise { + ): Promise { 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 {}; diff --git a/packages/twenty-server/src/engine/core-modules/geo-map/types/geo-map-address-component.type.ts b/packages/twenty-server/src/engine/core-modules/geo-map/types/geo-map-address-component.type.ts new file mode 100644 index 0000000000..69436e6c7f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/geo-map/types/geo-map-address-component.type.ts @@ -0,0 +1,5 @@ +export type GeoMapAddressComponent = { + long_name: string; + short_name: string; + types: string[]; +}; diff --git a/packages/twenty-server/src/engine/core-modules/geo-map/types/geo-map-address-fields.type.ts b/packages/twenty-server/src/engine/core-modules/geo-map/types/geo-map-address-fields.type.ts new file mode 100644 index 0000000000..1249152a4d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/geo-map/types/geo-map-address-fields.type.ts @@ -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; +}; diff --git a/packages/twenty-server/src/engine/core-modules/geo-map/types/geo-map-autocomplete-sanitized-result.type.ts b/packages/twenty-server/src/engine/core-modules/geo-map/types/geo-map-autocomplete-sanitized-result.type.ts new file mode 100644 index 0000000000..ab1a286b02 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/geo-map/types/geo-map-autocomplete-sanitized-result.type.ts @@ -0,0 +1,4 @@ +export type GeoMapAutocompleteSanitizedResult = { + text: string; + placeId: string; +}; diff --git a/packages/twenty-server/src/engine/core-modules/geo-map/types/geo-map-google-prediction.type.ts b/packages/twenty-server/src/engine/core-modules/geo-map/types/geo-map-google-prediction.type.ts new file mode 100644 index 0000000000..ade50b1d62 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/geo-map/types/geo-map-google-prediction.type.ts @@ -0,0 +1,4 @@ +export type GeoMapGooglePrediction = { + description: string; + place_id: string; +}; diff --git a/packages/twenty-server/src/engine/core-modules/geo-map/types/geo-map-location-fields.type.ts b/packages/twenty-server/src/engine/core-modules/geo-map/types/geo-map-location-fields.type.ts new file mode 100644 index 0000000000..8783804b6e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/geo-map/types/geo-map-location-fields.type.ts @@ -0,0 +1,4 @@ +export type GeoMapLocationFields = { + lat?: number; + lng?: number; +}; diff --git a/packages/twenty-server/src/engine/core-modules/geo-map/utils/__tests__/sanitize-autocomplete-results.util.spec.ts b/packages/twenty-server/src/engine/core-modules/geo-map/utils/__tests__/sanitize-autocomplete-results.util.spec.ts new file mode 100644 index 0000000000..a8d3b8a6e9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/geo-map/utils/__tests__/sanitize-autocomplete-results.util.spec.ts @@ -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'); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/geo-map/utils/__tests__/sanitize-place-details-results.util.spec.ts b/packages/twenty-server/src/engine/core-modules/geo-map/utils/__tests__/sanitize-place-details-results.util.spec.ts new file mode 100644 index 0000000000..142de6d9b9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/geo-map/utils/__tests__/sanitize-place-details-results.util.spec.ts @@ -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'); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/geo-map/utils/sanitize-autocomplete-results.util.ts b/packages/twenty-server/src/engine/core-modules/geo-map/utils/sanitize-autocomplete-results.util.ts index 9faa913434..09b9fcabcf 100644 --- a/packages/twenty-server/src/engine/core-modules/geo-map/utils/sanitize-autocomplete-results.util.ts +++ b/packages/twenty-server/src/engine/core-modules/geo-map/utils/sanitize-autocomplete-results.util.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/geo-map/utils/sanitize-place-details-results.util.ts b/packages/twenty-server/src/engine/core-modules/geo-map/utils/sanitize-place-details-results.util.ts index aa015d48b4..ee8ebabc72 100644 --- a/packages/twenty-server/src/engine/core-modules/geo-map/utils/sanitize-place-details-results.util.ts +++ b/packages/twenty-server/src/engine/core-modules/geo-map/utils/sanitize-place-details-results.util.ts @@ -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;