diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 7f34df79893..f9e30204e6f 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -175,6 +175,12 @@ export type AuthorizeApp = { redirectUrl: Scalars['String']; }; +export type AutocompleteResultDto = { + __typename?: 'AutocompleteResultDto'; + placeId: Scalars['String']; + text: Scalars['String']; +}; + export type AvailableWorkspace = { __typename?: 'AvailableWorkspace'; displayName?: Maybe; @@ -1082,6 +1088,12 @@ export type LinksMetadata = { secondaryLinks?: Maybe>; }; +export type LocationDto = { + __typename?: 'LocationDto'; + lat?: Maybe; + lng?: Maybe; +}; + export type LoginToken = { __typename?: 'LoginToken'; loginToken: AuthToken; @@ -1905,6 +1917,15 @@ export enum PermissionsOnAllObjectRecords { UPDATE_ALL_OBJECT_RECORDS = 'UPDATE_ALL_OBJECT_RECORDS' } +export type PlaceDetailsResultDto = { + __typename?: 'PlaceDetailsResultDto'; + city?: Maybe; + country?: Maybe; + location?: Maybe; + postcode?: Maybe; + state?: Maybe; +}; + export type PostgresCredentials = { __typename?: 'PostgresCredentials'; id: Scalars['UUID']; @@ -1962,7 +1983,9 @@ export type Query = { findOneServerlessFunction: ServerlessFunction; findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array; + getAddressDetails: PlaceDetailsResultDto; getApprovedAccessDomains: Array; + getAutoCompleteAddress: Array; getAvailablePackages: Scalars['JSON']; getConfigVariablesGrouped: ConfigVariablesOutput; getConnectedImapSmtpCaldavAccount: ConnectedImapSmtpCaldavAccount; @@ -2070,6 +2093,20 @@ export type QueryFindWorkspaceFromInviteHashArgs = { }; +export type QueryGetAddressDetailsArgs = { + placeId: Scalars['String']; + token: Scalars['String']; +}; + + +export type QueryGetAutoCompleteAddressArgs = { + address: Scalars['String']; + country?: InputMaybe; + isFieldCity?: InputMaybe; + token: Scalars['String']; +}; + + export type QueryGetAvailablePackagesArgs = { input: ServerlessFunctionIdInput; }; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 6ce18aef82e..2833477ba86 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -175,6 +175,12 @@ export type AuthorizeApp = { redirectUrl: Scalars['String']; }; +export type AutocompleteResultDto = { + __typename?: 'AutocompleteResultDto'; + placeId: Scalars['String']; + text: Scalars['String']; +}; + export type AvailableWorkspace = { __typename?: 'AvailableWorkspace'; displayName?: Maybe; @@ -1039,6 +1045,12 @@ export type LinksMetadata = { secondaryLinks?: Maybe>; }; +export type LocationDto = { + __typename?: 'LocationDto'; + lat?: Maybe; + lng?: Maybe; +}; + export type LoginToken = { __typename?: 'LoginToken'; loginToken: AuthToken; @@ -1816,6 +1828,15 @@ export enum PermissionsOnAllObjectRecords { UPDATE_ALL_OBJECT_RECORDS = 'UPDATE_ALL_OBJECT_RECORDS' } +export type PlaceDetailsResultDto = { + __typename?: 'PlaceDetailsResultDto'; + city?: Maybe; + country?: Maybe; + location?: Maybe; + postcode?: Maybe; + state?: Maybe; +}; + export type PostgresCredentials = { __typename?: 'PostgresCredentials'; id: Scalars['UUID']; @@ -1870,7 +1891,9 @@ export type Query = { findOneServerlessFunction: ServerlessFunction; findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array; + getAddressDetails: PlaceDetailsResultDto; getApprovedAccessDomains: Array; + getAutoCompleteAddress: Array; getAvailablePackages: Scalars['JSON']; getConfigVariablesGrouped: ConfigVariablesOutput; getConnectedImapSmtpCaldavAccount: ConnectedImapSmtpCaldavAccount; @@ -1952,6 +1975,20 @@ export type QueryFindWorkspaceFromInviteHashArgs = { }; +export type QueryGetAddressDetailsArgs = { + placeId: Scalars['String']; + token: Scalars['String']; +}; + + +export type QueryGetAutoCompleteAddressArgs = { + address: Scalars['String']; + country?: InputMaybe; + isFieldCity?: InputMaybe; + token: Scalars['String']; +}; + + export type QueryGetAvailablePackagesArgs = { input: ServerlessFunctionIdInput; }; diff --git a/packages/twenty-front/src/modules/geo-map/components/PlaceAutocompleteSelect.tsx b/packages/twenty-front/src/modules/geo-map/components/PlaceAutocompleteSelect.tsx new file mode 100644 index 00000000000..2cae76e0791 --- /dev/null +++ b/packages/twenty-front/src/modules/geo-map/components/PlaceAutocompleteSelect.tsx @@ -0,0 +1,71 @@ +import { PlaceAutocompleteResult } from '@/geo-map/types/placeApi'; +import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; +import styled from '@emotion/styled'; +import { useMemo, useRef } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { SelectOption } from 'twenty-ui/input'; +import { MenuItemSelectTag } from 'twenty-ui/navigation'; +const StyledContainer = styled.div<{ fullWidth?: boolean }>` + margin-bottom: 0px !important; + width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')}; +`; + +export const PlaceAutocompleteSelect = ({ + list, + onChange, + dropdownId, +}: { + list: PlaceAutocompleteResult[]; + onChange: (placeId: string) => void; + dropdownId: string; +}) => { + const selectContainerRef = useRef(null); + const options: SelectOption[] = useMemo(() => { + return list?.map>(({ placeId, text }) => ({ + label: text, + value: placeId, + })); + }, [list]); + + if (!isDefined(options) || options.length <= 0) return null; + + const selectableItemIdArray = options.map((option) => option.value); + + return ( + + + + + {options.map((option) => { + return ( + onChange(option.value)} + > + onChange(option.value)} + /> + + ); + })} + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/geo-map/components/__tests__/PlaceAutocompleteSelect.test.tsx b/packages/twenty-front/src/modules/geo-map/components/__tests__/PlaceAutocompleteSelect.test.tsx new file mode 100644 index 00000000000..a8b3ba732bc --- /dev/null +++ b/packages/twenty-front/src/modules/geo-map/components/__tests__/PlaceAutocompleteSelect.test.tsx @@ -0,0 +1,181 @@ +import { PlaceAutocompleteResult } from '@/geo-map/types/placeApi'; + +describe('PlaceAutocompleteSelect Component', () => { + describe('component interface', () => { + it('should have correct prop types', () => { + // Test that the component accepts the expected props + const mockProps = { + list: [] as PlaceAutocompleteResult[], + onChange: jest.fn(), + dropdownId: 'test-dropdown', + }; + + expect(mockProps.list).toEqual([]); + expect(typeof mockProps.onChange).toBe('function'); + expect(typeof mockProps.dropdownId).toBe('string'); + }); + + it('should handle empty list prop', () => { + const emptyList: PlaceAutocompleteResult[] = []; + expect(emptyList).toHaveLength(0); + expect(Array.isArray(emptyList)).toBe(true); + }); + + it('should handle valid place results', () => { + const validList: PlaceAutocompleteResult[] = [ + { placeId: 'place-1', text: 'New York, NY, USA' }, + { placeId: 'place-2', text: 'Los Angeles, CA, USA' }, + ]; + + expect(validList).toHaveLength(2); + expect(validList[0]).toHaveProperty('placeId'); + expect(validList[0]).toHaveProperty('text'); + expect(typeof validList[0].placeId).toBe('string'); + expect(typeof validList[0].text).toBe('string'); + }); + + it('should handle onChange callback function', () => { + const mockOnChange = jest.fn(); + const testPlaceId = 'test-place-id'; + + // Simulate calling the onChange function + mockOnChange(testPlaceId); + + expect(mockOnChange).toHaveBeenCalledWith(testPlaceId); + expect(mockOnChange).toHaveBeenCalledTimes(1); + }); + + it('should handle dropdownId string prop', () => { + const dropdownIds = [ + 'test-dropdown', + 'autocomplete-list', + 'place-selector-dropdown', + 'geo-map-dropdown-123', + ]; + + dropdownIds.forEach((id) => { + expect(typeof id).toBe('string'); + expect(id.length).toBeGreaterThan(0); + }); + }); + }); + + describe('data validation', () => { + it('should validate place result structure', () => { + const validPlaceResult: PlaceAutocompleteResult = { + placeId: 'ChIJD7fiBh9u5kcRYJSMaMOCCwQ', + text: 'Paris, France', + }; + + expect(validPlaceResult).toHaveProperty('placeId'); + expect(validPlaceResult).toHaveProperty('text'); + expect(validPlaceResult.placeId).toBeTruthy(); + expect(validPlaceResult.text).toBeTruthy(); + }); + + it('should handle special characters in place text', () => { + const specialCharacterPlaces: PlaceAutocompleteResult[] = [ + { placeId: 'place-1', text: 'São Paulo, Brazil' }, + { placeId: 'place-2', text: 'Москва, Россия' }, + { placeId: 'place-3', text: '東京, 日本' }, + { placeId: 'place-4', text: 'Zürich, Switzerland' }, + { placeId: 'place-5', text: "Café de l'Europe, Paris" }, + ]; + + specialCharacterPlaces.forEach((place) => { + expect(typeof place.text).toBe('string'); + expect(place.text.length).toBeGreaterThan(0); + expect(typeof place.placeId).toBe('string'); + expect(place.placeId.length).toBeGreaterThan(0); + }); + }); + + it('should handle empty text values gracefully', () => { + const placesWithEmptyText: PlaceAutocompleteResult[] = [ + { placeId: 'place-1', text: '' }, + { placeId: 'place-2', text: ' ' }, + { placeId: 'place-3', text: 'Valid Location' }, + ]; + + placesWithEmptyText.forEach((place) => { + expect(typeof place.text).toBe('string'); + expect(typeof place.placeId).toBe('string'); + expect(place.placeId.length).toBeGreaterThan(0); + }); + }); + + it('should handle large datasets', () => { + const largeDataset: PlaceAutocompleteResult[] = Array.from( + { length: 1000 }, + (_, i) => ({ + placeId: `place-${i}`, + text: `Location ${i}`, + }), + ); + + expect(largeDataset).toHaveLength(1000); + expect(largeDataset[0].placeId).toBe('place-0'); + expect(largeDataset[999].text).toBe('Location 999'); + }); + }); + + describe('callback behavior', () => { + it('should call onChange with correct placeId', () => { + const mockOnChange = jest.fn(); + const testPlaceIds = [ + 'ChIJD7fiBh9u5kcRYJSMaMOCCwQ', + 'ChIJOwg_06VPwokRYv534QaPC8g', + 'place-with-special-chars-123', + ]; + + testPlaceIds.forEach((placeId) => { + mockOnChange(placeId); + }); + + expect(mockOnChange).toHaveBeenCalledTimes(3); + testPlaceIds.forEach((placeId) => { + expect(mockOnChange).toHaveBeenCalledWith(placeId); + }); + }); + + it('should handle multiple onChange calls', () => { + const mockOnChange = jest.fn(); + const calls = 10; + + for (let i = 0; i < calls; i++) { + mockOnChange(`place-${i}`); + } + + expect(mockOnChange).toHaveBeenCalledTimes(calls); + }); + }); + + describe('component requirements', () => { + it('should require all mandatory props', () => { + // Test that all required props are defined in the interface + const requiredProps = ['list', 'onChange', 'dropdownId']; + + requiredProps.forEach((prop) => { + expect(typeof prop).toBe('string'); + expect(prop.length).toBeGreaterThan(0); + }); + }); + + it('should handle dropdown ID formats', () => { + const validDropdownIds = [ + 'dropdown-1', + 'place-autocomplete-dropdown', + 'geo-map-selector', + 'test_dropdown_123', + 'dropdown', + ]; + + validDropdownIds.forEach((id) => { + expect(typeof id).toBe('string'); + expect(id.length).toBeGreaterThan(0); + // Should not contain spaces (valid HTML ID) + expect(id).not.toMatch(/\s/); + }); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/geo-map/constants/__tests__/selectAutocompleteListDropDownId.test.ts b/packages/twenty-front/src/modules/geo-map/constants/__tests__/selectAutocompleteListDropDownId.test.ts new file mode 100644 index 00000000000..fe35ebc9b6f --- /dev/null +++ b/packages/twenty-front/src/modules/geo-map/constants/__tests__/selectAutocompleteListDropDownId.test.ts @@ -0,0 +1,34 @@ +import { SELECT_AUTOCOMPLETE_LIST_DROPDOWN_ID } from '@/geo-map/constants/selectAutocompleteListDropDownId'; + +describe('selectAutocompleteListDropDownId', () => { + it('should have the correct constant value', () => { + expect(SELECT_AUTOCOMPLETE_LIST_DROPDOWN_ID).toBe( + 'select-autocomplete-list-dropdown-id', + ); + }); + + it('should be a string', () => { + expect(typeof SELECT_AUTOCOMPLETE_LIST_DROPDOWN_ID).toBe('string'); + }); + + it('should be a non-empty string', () => { + expect(SELECT_AUTOCOMPLETE_LIST_DROPDOWN_ID.length).toBeGreaterThan(0); + }); + + it('should follow kebab-case naming convention', () => { + expect(SELECT_AUTOCOMPLETE_LIST_DROPDOWN_ID).toMatch(/^[a-z]+(-[a-z]+)*$/); + }); + + it('should be a valid HTML ID', () => { + // HTML IDs should not contain spaces or special characters except hyphens + expect(SELECT_AUTOCOMPLETE_LIST_DROPDOWN_ID).toMatch( + /^[a-zA-Z][a-zA-Z0-9-]*$/, + ); + }); + + it('should be immutable', () => { + // This test ensures the constant cannot be reassigned + const originalValue = SELECT_AUTOCOMPLETE_LIST_DROPDOWN_ID; + expect(SELECT_AUTOCOMPLETE_LIST_DROPDOWN_ID).toBe(originalValue); + }); +}); diff --git a/packages/twenty-front/src/modules/geo-map/constants/selectAutocompleteListDropDownId.ts b/packages/twenty-front/src/modules/geo-map/constants/selectAutocompleteListDropDownId.ts new file mode 100644 index 00000000000..87b2902ddcb --- /dev/null +++ b/packages/twenty-front/src/modules/geo-map/constants/selectAutocompleteListDropDownId.ts @@ -0,0 +1,2 @@ +export const SELECT_AUTOCOMPLETE_LIST_DROPDOWN_ID = + 'select-autocomplete-list-dropdown-id'; 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 new file mode 100644 index 00000000000..a69009c5b74 --- /dev/null +++ b/packages/twenty-front/src/modules/geo-map/graphql-query/__tests__/geo-map-appolo.api.test.ts @@ -0,0 +1,197 @@ +import { + GET_AUTOCOMPLETE_QUERY, + GET_PLACE_DETAILS_QUERY, +} from '@/geo-map/graphql-query/geo-map-appolo.api'; +import { gql } from '@apollo/client'; + +describe('geo-map GraphQL queries', () => { + describe('GET_AUTOCOMPLETE_QUERY', () => { + it('should have the correct query structure', () => { + expect(GET_AUTOCOMPLETE_QUERY).toBeDefined(); + expect(GET_AUTOCOMPLETE_QUERY.kind).toBe('Document'); + }); + + it('should have the correct query name', () => { + const queryDefinition = GET_AUTOCOMPLETE_QUERY.definitions[0] as any; + expect(queryDefinition.name.value).toBe('GetAutoCompleteAddress'); + }); + + it('should have the correct variables', () => { + const queryDefinition = GET_AUTOCOMPLETE_QUERY.definitions[0] as any; + const variables = queryDefinition.variableDefinitions; + + expect(variables).toHaveLength(4); + + const variableNames = variables.map((v: any) => v.variable.name.value); + expect(variableNames).toContain('address'); + expect(variableNames).toContain('token'); + expect(variableNames).toContain('country'); + expect(variableNames).toContain('isFieldCity'); + }); + + it('should have required variables marked correctly', () => { + const queryDefinition = GET_AUTOCOMPLETE_QUERY.definitions[0] as any; + const variables = queryDefinition.variableDefinitions; + + const addressVar = variables.find( + (v: any) => v.variable.name.value === 'address', + ); + const tokenVar = variables.find( + (v: any) => v.variable.name.value === 'token', + ); + const countryVar = variables.find( + (v: any) => v.variable.name.value === 'country', + ); + const isFieldCityVar = variables.find( + (v: any) => v.variable.name.value === 'isFieldCity', + ); + + expect(addressVar.type.kind).toBe('NonNullType'); // Required + expect(tokenVar.type.kind).toBe('NonNullType'); // Required + expect(countryVar.type.kind).toBe('NamedType'); // Optional + expect(isFieldCityVar.type.kind).toBe('NamedType'); // Optional + }); + + it('should request the correct fields', () => { + const queryDefinition = GET_AUTOCOMPLETE_QUERY.definitions[0] as any; + const selectionSet = + queryDefinition.selectionSet.selections[0].selectionSet; + const fields = selectionSet.selections.map((s: any) => s.name.value); + + expect(fields).toContain('text'); + expect(fields).toContain('placeId'); + expect(fields).toHaveLength(2); + }); + + it('should match the expected query string', () => { + const expectedQuery = gql` + query GetAutoCompleteAddress( + $address: String! + $token: String! + $country: String + $isFieldCity: Boolean + ) { + getAutoCompleteAddress( + address: $address + token: $token + country: $country + isFieldCity: $isFieldCity + ) { + text + placeId + } + } + `; + + expect( + GET_AUTOCOMPLETE_QUERY.loc?.source.body.replace(/\s+/g, ' ').trim(), + ).toBe(expectedQuery.loc?.source.body.replace(/\s+/g, ' ').trim()); + }); + }); + + describe('GET_PLACE_DETAILS_QUERY', () => { + it('should have the correct query structure', () => { + expect(GET_PLACE_DETAILS_QUERY).toBeDefined(); + expect(GET_PLACE_DETAILS_QUERY.kind).toBe('Document'); + }); + + it('should have the correct query name', () => { + const queryDefinition = GET_PLACE_DETAILS_QUERY.definitions[0] as any; + expect(queryDefinition.name.value).toBe('GetAddressDetails'); + }); + + it('should have the correct variables', () => { + const queryDefinition = GET_PLACE_DETAILS_QUERY.definitions[0] as any; + const variables = queryDefinition.variableDefinitions; + + expect(variables).toHaveLength(2); + + const variableNames = variables.map((v: any) => v.variable.name.value); + expect(variableNames).toContain('placeId'); + expect(variableNames).toContain('token'); + }); + + it('should have required variables marked correctly', () => { + const queryDefinition = GET_PLACE_DETAILS_QUERY.definitions[0] as any; + const variables = queryDefinition.variableDefinitions; + + const placeIdVar = variables.find( + (v: any) => v.variable.name.value === 'placeId', + ); + const tokenVar = variables.find( + (v: any) => v.variable.name.value === 'token', + ); + + expect(placeIdVar.type.kind).toBe('NonNullType'); // Required + expect(tokenVar.type.kind).toBe('NonNullType'); // Required + }); + + it('should request the correct fields', () => { + const queryDefinition = GET_PLACE_DETAILS_QUERY.definitions[0] as any; + const selectionSet = + queryDefinition.selectionSet.selections[0].selectionSet; + const fields = selectionSet.selections.map((s: any) => s.name.value); + + expect(fields).toContain('state'); + expect(fields).toContain('postcode'); + expect(fields).toContain('city'); + expect(fields).toContain('country'); + expect(fields).toContain('location'); + expect(fields).toHaveLength(5); + }); + + it('should match the expected query string', () => { + const expectedQuery = gql` + query GetAddressDetails($placeId: String!, $token: String!) { + getAddressDetails(placeId: $placeId, token: $token) { + state + postcode + city + country + location { + lat + lng + } + } + } + `; + + expect( + GET_PLACE_DETAILS_QUERY.loc?.source.body.replace(/\s+/g, ' ').trim(), + ).toBe(expectedQuery.loc?.source.body.replace(/\s+/g, ' ').trim()); + }); + }); + + describe('query validation', () => { + it('should have valid GraphQL syntax for both queries', () => { + expect(() => { + // This will throw if the query is malformed + expect(GET_AUTOCOMPLETE_QUERY.definitions[0]).toBeDefined(); + expect(GET_PLACE_DETAILS_QUERY.definitions[0]).toBeDefined(); + }).not.toThrow(); + }); + + it('should have consistent naming conventions', () => { + const autocompleteQuery = GET_AUTOCOMPLETE_QUERY.definitions[0] as any; + const detailsQuery = GET_PLACE_DETAILS_QUERY.definitions[0] as any; + + // Both should use PascalCase for query names + expect(autocompleteQuery.name.value).toMatch(/^[A-Z][a-zA-Z]*$/); + expect(detailsQuery.name.value).toMatch(/^[A-Z][a-zA-Z]*$/); + + // Both should use camelCase for field names + const autocompleteFields = + autocompleteQuery.selectionSet.selections[0].selectionSet.selections; + const detailsFields = + detailsQuery.selectionSet.selections[0].selectionSet.selections; + + autocompleteFields.forEach((field: any) => { + expect(field.name.value).toMatch(/^[a-z][a-zA-Z]*$/); + }); + + detailsFields.forEach((field: any) => { + expect(field.name.value).toMatch(/^[a-z][a-zA-Z]*$/); + }); + }); + }); +}); 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 new file mode 100644 index 00000000000..4da49f35302 --- /dev/null +++ b/packages/twenty-front/src/modules/geo-map/graphql-query/geo-map-appolo.api.ts @@ -0,0 +1,35 @@ +import { gql } from '@apollo/client'; + +export const GET_AUTOCOMPLETE_QUERY = gql` + query GetAutoCompleteAddress( + $address: String! + $token: String! + $country: String + $isFieldCity: Boolean + ) { + getAutoCompleteAddress( + address: $address + token: $token + country: $country + isFieldCity: $isFieldCity + ) { + text + placeId + } + } +`; + +export const GET_PLACE_DETAILS_QUERY = gql` + query GetAddressDetails($placeId: String!, $token: String!) { + getAddressDetails(placeId: $placeId, token: $token) { + state + postcode + city + country + location { + lat + lng + } + } + } +`; diff --git a/packages/twenty-front/src/modules/geo-map/hooks/__tests__/useGetPlaceApiData.test.tsx b/packages/twenty-front/src/modules/geo-map/hooks/__tests__/useGetPlaceApiData.test.tsx new file mode 100644 index 00000000000..9eeb5572147 --- /dev/null +++ b/packages/twenty-front/src/modules/geo-map/hooks/__tests__/useGetPlaceApiData.test.tsx @@ -0,0 +1,322 @@ +import { MockedProvider, MockedResponse } from '@apollo/client/testing'; +import { renderHook, waitFor } from '@testing-library/react'; +import { ReactNode } from 'react'; + +import { + GET_AUTOCOMPLETE_QUERY, + GET_PLACE_DETAILS_QUERY, +} from '@/geo-map/graphql-query/geo-map-appolo.api'; +import { useGetPlaceApiData } from '@/geo-map/hooks/useGetPlaceApiData'; +import { + PlaceAutocompleteResult, + PlaceDetailsResult, +} from '@/geo-map/types/placeApi'; + +const mockAutocompleteResults: PlaceAutocompleteResult[] = [ + { + placeId: 'ChIJD7fiBh9u5kcRYJSMaMOCCwQ', + text: 'Paris, France', + }, + { + placeId: 'ChIJOwg_06VPwokRYv534QaPC8g', + text: 'New York, NY, USA', + }, +]; + +const mockPlaceDetails: PlaceDetailsResult = { + city: 'Paris', + country: 'France', + state: 'Île-de-France', + postcode: '75001', +}; + +const createWrapper = (mocks: MockedResponse[]) => { + return ({ children }: { children: ReactNode }) => ( + + {children} + + ); +}; + +describe('useGetPlaceApiData', () => { + describe('getPlaceAutocompleteData', () => { + it('should fetch autocomplete data successfully', async () => { + const mocks = [ + { + request: { + query: GET_AUTOCOMPLETE_QUERY, + variables: { + address: 'Paris', + token: 'test-token', + country: 'FR', + isFieldCity: false, + }, + }, + result: { + data: { + getAutoCompleteAddress: mockAutocompleteResults, + }, + }, + }, + ]; + + const { result } = renderHook(() => useGetPlaceApiData(), { + wrapper: createWrapper(mocks), + }); + + const data = await result.current.getPlaceAutocompleteData( + 'Paris', + 'test-token', + 'FR', + false, + ); + + await waitFor(() => { + expect(data).toEqual(mockAutocompleteResults); + }); + }); + + it('should handle autocomplete query with minimal parameters', async () => { + const mocks = [ + { + request: { + query: GET_AUTOCOMPLETE_QUERY, + variables: { + address: 'London', + token: 'test-token', + country: undefined, + isFieldCity: false, + }, + }, + result: { + data: { + getAutoCompleteAddress: mockAutocompleteResults, + }, + }, + }, + ]; + + const { result } = renderHook(() => useGetPlaceApiData(), { + wrapper: createWrapper(mocks), + }); + + const data = await result.current.getPlaceAutocompleteData( + 'London', + 'test-token', + ); + + await waitFor(() => { + expect(data).toEqual(mockAutocompleteResults); + }); + }); + + it('should handle autocomplete query errors', async () => { + const mocks = [ + { + request: { + query: GET_AUTOCOMPLETE_QUERY, + variables: { + address: 'Invalid', + token: 'test-token', + country: undefined, + isFieldCity: false, + }, + }, + error: new Error('Network error'), + }, + ]; + + const { result } = renderHook(() => useGetPlaceApiData(), { + wrapper: createWrapper(mocks), + }); + + await expect( + result.current.getPlaceAutocompleteData('Invalid', 'test-token'), + ).rejects.toThrow('Network error'); + }); + + it('should return undefined when autocomplete data is null', async () => { + const mocks = [ + { + request: { + query: GET_AUTOCOMPLETE_QUERY, + variables: { + address: 'Empty', + token: 'test-token', + country: undefined, + isFieldCity: false, + }, + }, + result: { + data: { + getAutoCompleteAddress: null, + }, + }, + }, + ]; + + const { result } = renderHook(() => useGetPlaceApiData(), { + wrapper: createWrapper(mocks), + }); + + const data = await result.current.getPlaceAutocompleteData( + 'Empty', + 'test-token', + ); + + await waitFor(() => { + expect(data).toBeNull(); + }); + }); + }); + + describe('getPlaceDetailsData', () => { + it('should fetch place details successfully', async () => { + const mocks = [ + { + request: { + query: GET_PLACE_DETAILS_QUERY, + variables: { + placeId: 'ChIJD7fiBh9u5kcRYJSMaMOCCwQ', + token: 'test-token', + }, + }, + result: { + data: { + getAddressDetails: mockPlaceDetails, + }, + }, + }, + ]; + + const { result } = renderHook(() => useGetPlaceApiData(), { + wrapper: createWrapper(mocks), + }); + + const data = await result.current.getPlaceDetailsData( + 'ChIJD7fiBh9u5kcRYJSMaMOCCwQ', + 'test-token', + ); + + await waitFor(() => { + expect(data).toEqual(mockPlaceDetails); + }); + }); + + it('should handle place details query errors', async () => { + const mocks = [ + { + request: { + query: GET_PLACE_DETAILS_QUERY, + variables: { + placeId: 'invalid-place-id', + token: 'test-token', + }, + }, + error: new Error('Place not found'), + }, + ]; + + const { result } = renderHook(() => useGetPlaceApiData(), { + wrapper: createWrapper(mocks), + }); + + await expect( + result.current.getPlaceDetailsData('invalid-place-id', 'test-token'), + ).rejects.toThrow('Place not found'); + }); + + it('should return undefined when place details data is null', async () => { + const mocks = [ + { + request: { + query: GET_PLACE_DETAILS_QUERY, + variables: { + placeId: 'empty-place-id', + token: 'test-token', + }, + }, + result: { + data: { + getAddressDetails: null, + }, + }, + }, + ]; + + const { result } = renderHook(() => useGetPlaceApiData(), { + wrapper: createWrapper(mocks), + }); + + const data = await result.current.getPlaceDetailsData( + 'empty-place-id', + 'test-token', + ); + + await waitFor(() => { + expect(data).toBeNull(); + }); + }); + }); + + describe('integration tests', () => { + it('should handle both queries in sequence', async () => { + const mocks = [ + { + request: { + query: GET_AUTOCOMPLETE_QUERY, + variables: { + address: 'Paris', + token: 'test-token', + country: undefined, + isFieldCity: false, + }, + }, + result: { + data: { + getAutoCompleteAddress: mockAutocompleteResults, + }, + }, + }, + { + request: { + query: GET_PLACE_DETAILS_QUERY, + variables: { + placeId: 'ChIJD7fiBh9u5kcRYJSMaMOCCwQ', + token: 'test-token', + }, + }, + result: { + data: { + getAddressDetails: mockPlaceDetails, + }, + }, + }, + ]; + + const { result } = renderHook(() => useGetPlaceApiData(), { + wrapper: createWrapper(mocks), + }); + + // First get autocomplete results + const autocompleteData = await result.current.getPlaceAutocompleteData( + 'Paris', + 'test-token', + ); + + await waitFor(() => { + expect(autocompleteData).toEqual(mockAutocompleteResults); + }); + + // Then get place details for the first result + const placeDetailsData = await result.current.getPlaceDetailsData( + 'ChIJD7fiBh9u5kcRYJSMaMOCCwQ', + 'test-token', + ); + + await waitFor(() => { + expect(placeDetailsData).toEqual(mockPlaceDetails); + }); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/geo-map/hooks/useGetPlaceApiData.ts b/packages/twenty-front/src/modules/geo-map/hooks/useGetPlaceApiData.ts new file mode 100644 index 00000000000..b431e96d275 --- /dev/null +++ b/packages/twenty-front/src/modules/geo-map/hooks/useGetPlaceApiData.ts @@ -0,0 +1,42 @@ +import { + GET_AUTOCOMPLETE_QUERY, + GET_PLACE_DETAILS_QUERY, +} from '@/geo-map/graphql-query/geo-map-appolo.api'; +import { + PlaceAutocompleteResult, + PlaceDetailsResult, +} from '@/geo-map/types/placeApi'; +import { useApolloClient } from '@apollo/client'; + +export const useGetPlaceApiData = () => { + const apolloClient = useApolloClient(); + const getPlaceAutocompleteData = async ( + address: string, + token: string, + country?: string, + isFieldCity?: boolean, + ): Promise => { + const { data } = await apolloClient.query({ + query: GET_AUTOCOMPLETE_QUERY, + variables: { address, token, country, isFieldCity: isFieldCity ?? false }, + fetchPolicy: 'no-cache', + }); + + return data?.getAutoCompleteAddress; + }; + const getPlaceDetailsData = async ( + placeId: string, + token: string, + ): Promise => { + const { data } = await apolloClient.query({ + query: GET_PLACE_DETAILS_QUERY, + variables: { placeId, token }, + fetchPolicy: 'no-cache', + }); + return data?.getAddressDetails; + }; + return { + getPlaceAutocompleteData, + getPlaceDetailsData, + }; +}; diff --git a/packages/twenty-front/src/modules/geo-map/types/__tests__/placeApi.test.ts b/packages/twenty-front/src/modules/geo-map/types/__tests__/placeApi.test.ts new file mode 100644 index 00000000000..81f81823e57 --- /dev/null +++ b/packages/twenty-front/src/modules/geo-map/types/__tests__/placeApi.test.ts @@ -0,0 +1,269 @@ +import { + PlaceAutocompleteResult, + PlaceAutocompleteVariables, + PlaceDetailsResult, +} from '@/geo-map/types/placeApi'; + +describe('placeApi types', () => { + describe('PlaceAutocompleteVariables', () => { + it('should accept required fields', () => { + const variables: PlaceAutocompleteVariables = { + address: 'New York', + token: 'test-token', + }; + + expect(variables.address).toBe('New York'); + expect(variables.token).toBe('test-token'); + }); + + it('should accept optional fields', () => { + const variables: PlaceAutocompleteVariables = { + address: 'Paris', + token: 'test-token', + options: { + country: 'FR', + language: 'fr', + }, + }; + + expect(variables.options?.country).toBe('FR'); + expect(variables.options?.language).toBe('fr'); + }); + + it('should work without options', () => { + const variables: PlaceAutocompleteVariables = { + address: 'London', + token: 'test-token', + }; + + expect(variables.options).toBeUndefined(); + }); + + it('should allow partial options', () => { + const variablesWithCountryOnly: PlaceAutocompleteVariables = { + address: 'Berlin', + token: 'test-token', + options: { + country: 'DE', + }, + }; + + const variablesWithLanguageOnly: PlaceAutocompleteVariables = { + address: 'Tokyo', + token: 'test-token', + options: { + language: 'ja', + }, + }; + + expect(variablesWithCountryOnly.options?.country).toBe('DE'); + expect(variablesWithCountryOnly.options?.language).toBeUndefined(); + expect(variablesWithLanguageOnly.options?.language).toBe('ja'); + expect(variablesWithLanguageOnly.options?.country).toBeUndefined(); + }); + + it('should enforce string types for required fields', () => { + // This test ensures TypeScript compilation catches type errors + const variables: PlaceAutocompleteVariables = { + address: 'Test Address', + token: 'test-token', + }; + + // These should be strings + expect(typeof variables.address).toBe('string'); + expect(typeof variables.token).toBe('string'); + }); + }); + + describe('PlaceAutocompleteResult', () => { + it('should have correct structure', () => { + const result: PlaceAutocompleteResult = { + text: 'New York, NY, USA', + placeId: 'ChIJOwg_06VPwokRYv534QaPC8g', + }; + + expect(result.text).toBe('New York, NY, USA'); + expect(result.placeId).toBe('ChIJOwg_06VPwokRYv534QaPC8g'); + }); + + it('should enforce string types', () => { + const result: PlaceAutocompleteResult = { + text: 'Paris, France', + placeId: 'ChIJD7fiBh9u5kcRYJSMaMOCCwQ', + }; + + expect(typeof result.text).toBe('string'); + expect(typeof result.placeId).toBe('string'); + }); + + it('should work with array of results', () => { + const results: PlaceAutocompleteResult[] = [ + { + text: 'New York, NY, USA', + placeId: 'place-1', + }, + { + text: 'Los Angeles, CA, USA', + placeId: 'place-2', + }, + ]; + + expect(results).toHaveLength(2); + expect(results[0].text).toBe('New York, NY, USA'); + expect(results[1].placeId).toBe('place-2'); + }); + + it('should handle empty results array', () => { + const results: PlaceAutocompleteResult[] = []; + expect(results).toHaveLength(0); + }); + }); + + describe('PlaceDetailsResult', () => { + it('should have correct structure with all fields', () => { + const result: PlaceDetailsResult = { + state: 'California', + postcode: '90210', + city: 'Beverly Hills', + country: 'United States', + }; + + expect(result.state).toBe('California'); + expect(result.postcode).toBe('90210'); + expect(result.city).toBe('Beverly Hills'); + expect(result.country).toBe('United States'); + }); + + it('should handle optional fields', () => { + const resultWithMissingFields: PlaceDetailsResult = { + city: 'Paris', + country: 'France', + }; + + expect(resultWithMissingFields.city).toBe('Paris'); + expect(resultWithMissingFields.country).toBe('France'); + expect(resultWithMissingFields.state).toBeUndefined(); + expect(resultWithMissingFields.postcode).toBeUndefined(); + }); + + it('should handle undefined values', () => { + const result: PlaceDetailsResult = { + state: undefined, + postcode: undefined, + city: 'London', + country: 'United Kingdom', + }; + + expect(result.state).toBeUndefined(); + expect(result.postcode).toBeUndefined(); + expect(result.city).toBe('London'); + expect(result.country).toBe('United Kingdom'); + }); + + it('should handle all undefined values', () => { + const result: PlaceDetailsResult = { + state: undefined, + postcode: undefined, + city: undefined, + country: undefined, + }; + + expect(result.state).toBeUndefined(); + expect(result.postcode).toBeUndefined(); + expect(result.city).toBeUndefined(); + expect(result.country).toBeUndefined(); + }); + + it('should enforce string or undefined types', () => { + const result: PlaceDetailsResult = { + state: 'New York', + postcode: '10001', + city: 'New York City', + country: 'United States', + }; + + expect( + typeof result.state === 'string' || result.state === undefined, + ).toBe(true); + expect( + typeof result.postcode === 'string' || result.postcode === undefined, + ).toBe(true); + expect(typeof result.city === 'string' || result.city === undefined).toBe( + true, + ); + expect( + typeof result.country === 'string' || result.country === undefined, + ).toBe(true); + }); + + it('should handle international addresses', () => { + const japaneseResult: PlaceDetailsResult = { + state: '東京都', + postcode: '100-0001', + city: '東京', + country: '日本', + }; + + const frenchResult: PlaceDetailsResult = { + state: 'Île-de-France', + postcode: '75001', + city: 'Paris', + country: 'France', + }; + + expect(japaneseResult.city).toBe('東京'); + expect(frenchResult.state).toBe('Île-de-France'); + }); + }); + + describe('type compatibility', () => { + it('should work with API response patterns', () => { + // Simulating API response structure + const apiResponse = { + getAutoCompleteAddress: [ + { + text: 'New York, NY, USA', + placeId: 'place-1', + }, + ] as PlaceAutocompleteResult[], + getAddressDetails: { + city: 'New York', + state: 'NY', + country: 'USA', + postcode: '10001', + } as PlaceDetailsResult, + }; + + expect(apiResponse.getAutoCompleteAddress[0].text).toBe( + 'New York, NY, USA', + ); + expect(apiResponse.getAddressDetails.city).toBe('New York'); + }); + + it('should handle null/undefined API responses', () => { + const nullResponse: PlaceAutocompleteResult[] | undefined = undefined; + const nullDetails: PlaceDetailsResult | undefined = undefined; + + expect(nullResponse).toBeUndefined(); + expect(nullDetails).toBeUndefined(); + }); + + it('should work with GraphQL query variables', () => { + const queryVariables: { + address: string; + token: string; + country?: string; + isFieldCity?: boolean; + } = { + address: 'Test', + token: 'token', + country: 'US', + isFieldCity: false, + }; + + // This should be compatible with PlaceAutocompleteVariables structure + expect(queryVariables.address).toBe('Test'); + expect(queryVariables.token).toBe('token'); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/geo-map/types/placeApi.ts b/packages/twenty-front/src/modules/geo-map/types/placeApi.ts new file mode 100644 index 00000000000..1a4246612cb --- /dev/null +++ b/packages/twenty-front/src/modules/geo-map/types/placeApi.ts @@ -0,0 +1,24 @@ +export type PlaceAutocompleteVariables = { + address: string; + token: string; + options?: { + country?: string; + language?: string; + }; +}; + +export type PlaceAutocompleteResult = { + text: string; + placeId: string; +}; + +export type PlaceDetailsResult = { + state?: string; + postcode?: string; + city?: string; + country?: string; + location?: { + lat?: number; + lng?: number; + }; +}; diff --git a/packages/twenty-front/src/modules/ui/field/input/components/AddressInput.tsx b/packages/twenty-front/src/modules/ui/field/input/components/AddressInput.tsx index 04e4bb1c3ab..4cbbf1c232a 100644 --- a/packages/twenty-front/src/modules/ui/field/input/components/AddressInput.tsx +++ b/packages/twenty-front/src/modules/ui/field/input/components/AddressInput.tsx @@ -1,18 +1,26 @@ import styled from '@emotion/styled'; -import { RefObject, useEffect, useRef, useState } from 'react'; -import { Key } from 'ts-key-enum'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { PlaceAutocompleteSelect } from '@/geo-map/components/PlaceAutocompleteSelect'; +import { SELECT_AUTOCOMPLETE_LIST_DROPDOWN_ID } from '@/geo-map/constants/selectAutocompleteListDropDownId'; +import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents'; import { FieldAddressDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue'; import { FieldAddressValue } from '@/object-record/record-field/types/FieldMetadata'; import { TextInputV2 } from '@/ui/input/components/TextInputV2'; +import { TEXT_INPUT_CLICK_OUTSIDE_ID } from '@/ui/input/components/constants/TextInputClickOutsideId'; import { CountrySelect } from '@/ui/input/components/internal/country/components/CountrySelect'; import { SELECT_COUNTRY_DROPDOWN_ID } from '@/ui/input/components/internal/country/constants/SelectCountryDropdownId'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState'; -import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { useRecoilValue } from 'recoil'; import { isDefined } from 'twenty-shared/utils'; import { MOBILE_VIEWPORT } from 'twenty-ui/theme'; +import { v4 } from 'uuid'; + +import { useAddressAutocomplete } from '../hooks/useAddressAutocomplete'; +import { useCountryUtils } from '../hooks/useCountryUtils'; +import { useFocusManagement } from '../hooks/useFocusManagement'; const StyledAddressContainer = styled.div` padding: 4px 8px; @@ -46,6 +54,11 @@ const StyledHalfRowContainer = styled.div` } `; +const StyledInputWithDropdownContainer = styled.div` + position: relative; + width: 100%; +`; + export type AddressInputProps = { instanceId: string; value: FieldAddressValue; @@ -72,113 +85,137 @@ export const AddressInput = ({ onChange, }: AddressInputProps) => { const [internalValue, setInternalValue] = useState(value); + const addressStreet1InputRef = useRef(null); const addressStreet2InputRef = useRef(null); const addressCityInputRef = useRef(null); const addressStateInputRef = useRef(null); const addressPostcodeInputRef = useRef(null); - - const inputRefs: { - [key in keyof FieldAddressDraftValue]?: RefObject; - } = { - addressStreet1: addressStreet1InputRef, - addressStreet2: addressStreet2InputRef, - addressCity: addressCityInputRef, - addressState: addressStateInputRef, - addressPostcode: addressPostcodeInputRef, - }; - - const [focusPosition, setFocusPosition] = - useState('addressStreet1'); - const wrapperRef = useRef(null); - const getChangeHandler = + const inputRefs = useMemo( + () => ({ + addressStreet1: addressStreet1InputRef, + addressStreet2: addressStreet2InputRef, + addressCity: addressCityInputRef, + addressState: addressStateInputRef, + addressPostcode: addressPostcodeInputRef, + }), + [], + ); + + const { findCountryCodeByCountryName } = useCountryUtils(); + + const { + placeAutocompleteData, + tokenForPlaceApi, + typeOfAddressForAutocomplete, + setTokenForPlaceApi, + setTypeOfAddressForAutocomplete, + getAutocompletePlaceData, + autoFillInputsFromPlaceDetails, + closeDropdownOfAutocomplete, + } = useAddressAutocomplete(onChange); + + const { getFocusHandler, handleTab, handleShiftTab } = useFocusManagement( + inputRefs, + internalValue, + onTab, + onShiftTab, + ); + + const getChangeHandler = useCallback( (field: keyof FieldAddressDraftValue) => (updatedAddressPart: string) => { - const updatedAddress = { ...value, [field]: updatedAddressPart }; + const updatedAddress = { ...internalValue, [field]: updatedAddressPart }; setInternalValue(updatedAddress); onChange?.(updatedAddress); - }; - const getFocusHandler = (fieldName: keyof FieldAddressDraftValue) => () => { - setFocusPosition(fieldName); - - inputRefs[fieldName]?.current?.focus(); - }; - - const handleTab = () => { - const currentFocusPosition = Object.keys(inputRefs).findIndex( - (key) => key === focusPosition, - ); - const maxFocusPosition = Object.keys(inputRefs).length - 1; - - const nextFocusPosition = currentFocusPosition + 1; - - const isFocusPositionAfterLast = nextFocusPosition > maxFocusPosition; - - if (isFocusPositionAfterLast) { - onTab?.(internalValue); - } else { - const nextFocusFieldName = Object.keys(inputRefs)[ - nextFocusPosition - ] as keyof FieldAddressDraftValue; - - setFocusPosition(nextFocusFieldName); - inputRefs[nextFocusFieldName]?.current?.focus(); - } - }; - - const handleShiftTab = () => { - const currentFocusPosition = Object.keys(inputRefs).findIndex( - (key) => key === focusPosition, - ); - - const nextFocusPosition = currentFocusPosition - 1; - - const isFocusPositionBeforeFirst = nextFocusPosition < 0; - - if (isFocusPositionBeforeFirst) { - onShiftTab?.(internalValue); - } else { - const nextFocusFieldName = Object.keys(inputRefs)[ - nextFocusPosition - ] as keyof FieldAddressDraftValue; - - setFocusPosition(nextFocusFieldName); - inputRefs[nextFocusFieldName]?.current?.focus(); - } - }; - - useHotkeysOnFocusedElement({ - keys: ['tab'], - callback: handleTab, - focusId: instanceId, - dependencies: [handleTab], - }); - - useHotkeysOnFocusedElement({ - keys: ['shift+tab'], - callback: handleShiftTab, - focusId: instanceId, - dependencies: [handleShiftTab], - }); - - useHotkeysOnFocusedElement({ - keys: [Key.Enter], - callback: () => { - onEnter(internalValue); + if (field === 'addressStreet1' || field === 'addressCity') { + const token = tokenForPlaceApi ?? v4(); + if (token !== tokenForPlaceApi) { + setTokenForPlaceApi(token); + } + const countryCode = findCountryCodeByCountryName( + updatedAddress.addressCountry ?? '', + ); + if (field !== typeOfAddressForAutocomplete) { + setTypeOfAddressForAutocomplete(field); + } + const isFieldCity = field === 'addressCity'; + getAutocompletePlaceData( + updatedAddressPart, + token, + countryCode, + isFieldCity, + ); + } }, - focusId: instanceId, - dependencies: [onEnter, internalValue], - }); + [ + internalValue, + onChange, + tokenForPlaceApi, + setTokenForPlaceApi, + findCountryCodeByCountryName, + typeOfAddressForAutocomplete, + setTypeOfAddressForAutocomplete, + getAutocompletePlaceData, + ], + ); - useHotkeysOnFocusedElement({ - keys: [Key.Escape], - callback: () => { - onEscape(internalValue); + const handlePlaceSelection = useCallback( + (placeId: string) => { + const placeAutocomplete = placeAutocompleteData?.find( + (place) => place.placeId === placeId, + ); + const token = tokenForPlaceApi ?? ''; + if (!isDefined(placeAutocomplete)) return; + + const text: string | undefined = + typeOfAddressForAutocomplete !== 'addressCity' + ? placeAutocomplete.text + : undefined; + + autoFillInputsFromPlaceDetails(placeId, token, text, internalValue); }, + [ + placeAutocompleteData, + tokenForPlaceApi, + typeOfAddressForAutocomplete, + autoFillInputsFromPlaceDetails, + internalValue, + ], + ); + + const handleClickOutside = useCallback(() => { + closeDropdownOfAutocomplete(); + }, [closeDropdownOfAutocomplete]); + + const handleEnter = useCallback(() => { + onEnter(internalValue); + closeDropdownOfAutocomplete(); + }, [onEnter, internalValue, closeDropdownOfAutocomplete]); + + const handleEscape = useCallback(() => { + onEscape(internalValue); + closeDropdownOfAutocomplete(); + }, [onEscape, internalValue, closeDropdownOfAutocomplete]); + + const handleOutsideClick = useCallback( + (event: MouseEvent | TouchEvent) => { + onClickOutside?.(event, internalValue); + closeDropdownOfAutocomplete(); + }, + [onClickOutside, internalValue, closeDropdownOfAutocomplete], + ); + + useRegisterInputEvents({ focusId: instanceId, - dependencies: [onEscape, internalValue], + inputRef: wrapperRef, + inputValue: internalValue, + onEnter: handleEnter, + onEscape: handleEscape, + onTab: handleTab, + onShiftTab: handleShiftTab, }); const activeDropdownFocusId = useRecoilValue(activeDropdownFocusIdState); @@ -186,13 +223,15 @@ export const AddressInput = ({ useListenClickOutside({ refs: [wrapperRef], callback: (event) => { - if (activeDropdownFocusId === SELECT_COUNTRY_DROPDOWN_ID) { + if ( + activeDropdownFocusId === SELECT_COUNTRY_DROPDOWN_ID || + activeDropdownFocusId === SELECT_AUTOCOMPLETE_LIST_DROPDOWN_ID + ) { return; } event.stopImmediatePropagation(); - - onClickOutside?.(event, internalValue); + handleOutsideClick(event); }, enabled: isDefined(onClickOutside), listenerId: 'address-input', @@ -202,37 +241,98 @@ export const AddressInput = ({ setInternalValue(value); }, [value]); + const validAutocompleteData = useMemo( + () => + placeAutocompleteData && placeAutocompleteData.length > 0 + ? placeAutocompleteData + : null, + [placeAutocompleteData], + ); + + const renderInputWithAutocomplete = ( + inputElement: React.ReactNode, + fieldType: 'addressStreet1' | 'addressCity', + ) => { + const shouldShowDropdown = + validAutocompleteData && typeOfAddressForAutocomplete === fieldType; + + if (!shouldShowDropdown) { + return inputElement; + } + + return ( + + + } + /> + + ); + }; + return ( - + {renderInputWithAutocomplete( + , + 'addressStreet1', + )} - + {renderInputWithAutocomplete( + , + 'addressCity', + )} void, +) => { + const [placeAutocompleteData, setPlaceAutocompleteData] = useState< + PlaceAutocompleteResult[] | null + >([]); + const [tokenForPlaceApi, setTokenForPlaceApi] = useState(null); + const [typeOfAddressForAutocomplete, setTypeOfAddressForAutocomplete] = + useState(null); + + const { getPlaceAutocompleteData, getPlaceDetailsData } = + useGetPlaceApiData(); + const { openDropdown } = useOpenDropdown(); + const { closeDropdown: closeDropdownHook } = useCloseDropdown(); + const { findCountryNameByCountryCode } = useCountryUtils(); + + const openDropdownOfAutocomplete = useCallback(() => { + openDropdown({ + dropdownComponentInstanceIdFromProps: + SELECT_AUTOCOMPLETE_LIST_DROPDOWN_ID, + }); + }, [openDropdown]); + + const closeDropdownOfAutocomplete = useCallback(() => { + closeDropdownHook(SELECT_AUTOCOMPLETE_LIST_DROPDOWN_ID); + setPlaceAutocompleteData(null); + setTypeOfAddressForAutocomplete(null); + }, [closeDropdownHook]); + + const getAutocompletePlaceData = useDebouncedCallback( + async ( + address: string, + token: string, + country?: string, + isFieldCity?: boolean, + ) => { + const placeAutocompleteData = await getPlaceAutocompleteData( + address, + token, + country, + isFieldCity, + ); + + const newData = placeAutocompleteData?.map((data) => ({ + text: data.text, + placeId: data.placeId, + })); + + if (isDefined(newData) && newData?.length > 0) { + openDropdownOfAutocomplete(); + setPlaceAutocompleteData(newData); + } else { + closeDropdownOfAutocomplete(); + } + }, + 300, + ); + + const autoFillInputsFromPlaceDetails = useCallback( + async ( + placeId: string, + token: string, + addressStreet1?: string, + internalValue?: FieldAddressDraftValue, + ) => { + const placeData = await getPlaceDetailsData(placeId, token); + const countryName = findCountryNameByCountryCode(placeData?.country); + + const updatedAddress = { + addressStreet1: addressStreet1 || (internalValue?.addressStreet1 ?? ''), + addressStreet2: internalValue?.addressStreet2 ?? null, + addressCity: placeData?.city || (internalValue?.addressCity ?? null), + addressState: placeData?.state || (internalValue?.addressState ?? null), + addressCountry: countryName || (internalValue?.addressCountry ?? null), + addressPostcode: + placeData?.postcode || (internalValue?.addressPostcode ?? null), + addressLat: + placeData?.location?.lat ?? internalValue?.addressLat ?? null, + addressLng: + placeData?.location?.lng ?? internalValue?.addressLng ?? null, + }; + + setTokenForPlaceApi(null); + closeDropdownOfAutocomplete(); + onChange?.(updatedAddress); + + return updatedAddress; + }, + [ + getPlaceDetailsData, + findCountryNameByCountryCode, + closeDropdownOfAutocomplete, + onChange, + ], + ); + + return { + placeAutocompleteData, + tokenForPlaceApi, + typeOfAddressForAutocomplete, + setTokenForPlaceApi, + setTypeOfAddressForAutocomplete, + getAutocompletePlaceData, + autoFillInputsFromPlaceDetails, + closeDropdownOfAutocomplete, + }; +}; diff --git a/packages/twenty-front/src/modules/ui/field/input/hooks/useCountryUtils.ts b/packages/twenty-front/src/modules/ui/field/input/hooks/useCountryUtils.ts new file mode 100644 index 00000000000..0623c733e1e --- /dev/null +++ b/packages/twenty-front/src/modules/ui/field/input/hooks/useCountryUtils.ts @@ -0,0 +1,35 @@ +import { useCallback } from 'react'; + +import { useCountries } from '@/ui/input/components/internal/hooks/useCountries'; +import { isDefined } from 'twenty-shared/utils'; + +export const useCountryUtils = () => { + const countries = useCountries(); + + const findCountryCodeByCountryName = useCallback( + (countryName?: string): string => { + if (!isDefined(countryName) || countryName === '') return ''; + + const foundCountry = countries.find( + (country) => country.countryName === countryName, + ); + return foundCountry?.countryCode ?? ''; + }, + [countries], + ); + + const findCountryNameByCountryCode = useCallback( + (countryCode?: string): string | null => { + if (!isDefined(countryCode) || countryCode === '') return ''; + + const foundCountry = countries.find( + (country) => country.countryCode === countryCode, + ); + + return foundCountry?.countryName ?? null; + }, + [countries], + ); + + return { findCountryCodeByCountryName, findCountryNameByCountryCode }; +}; diff --git a/packages/twenty-front/src/modules/ui/field/input/hooks/useFocusManagement.ts b/packages/twenty-front/src/modules/ui/field/input/hooks/useFocusManagement.ts new file mode 100644 index 00000000000..b945cedc1db --- /dev/null +++ b/packages/twenty-front/src/modules/ui/field/input/hooks/useFocusManagement.ts @@ -0,0 +1,69 @@ +import { RefObject, useCallback, useState } from 'react'; + +import { FieldAddressDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue'; + +export const useFocusManagement = ( + inputRefs: { + [key in keyof FieldAddressDraftValue]?: RefObject; + }, + internalValue: FieldAddressDraftValue, + onTab?: (newAddress: FieldAddressDraftValue) => void, + onShiftTab?: (newAddress: FieldAddressDraftValue) => void, +) => { + const [focusPosition, setFocusPosition] = + useState('addressStreet1'); + + const getFocusHandler = useCallback( + (fieldName: keyof FieldAddressDraftValue) => () => { + setFocusPosition(fieldName); + inputRefs[fieldName]?.current?.focus(); + }, + [inputRefs], + ); + + const handleTab = useCallback(() => { + const currentFocusPosition = Object.keys(inputRefs).findIndex( + (key) => key === focusPosition, + ); + const maxFocusPosition = Object.keys(inputRefs).length - 1; + const nextFocusPosition = currentFocusPosition + 1; + const isFocusPositionAfterLast = nextFocusPosition > maxFocusPosition; + + if (isFocusPositionAfterLast) { + onTab?.(internalValue); + } else { + const nextFocusFieldName = Object.keys(inputRefs)[ + nextFocusPosition + ] as keyof FieldAddressDraftValue; + + setFocusPosition(nextFocusFieldName); + inputRefs[nextFocusFieldName]?.current?.focus(); + } + }, [focusPosition, inputRefs, internalValue, onTab]); + + const handleShiftTab = useCallback(() => { + const currentFocusPosition = Object.keys(inputRefs).findIndex( + (key) => key === focusPosition, + ); + const nextFocusPosition = currentFocusPosition - 1; + const isFocusPositionBeforeFirst = nextFocusPosition < 0; + + if (isFocusPositionBeforeFirst) { + onShiftTab?.(internalValue); + } else { + const nextFocusFieldName = Object.keys(inputRefs)[ + nextFocusPosition + ] as keyof FieldAddressDraftValue; + + setFocusPosition(nextFocusFieldName); + inputRefs[nextFocusFieldName]?.current?.focus(); + } + }, [focusPosition, inputRefs, internalValue, onShiftTab]); + + return { + focusPosition, + getFocusHandler, + handleTab, + handleShiftTab, + }; +}; diff --git a/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx b/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx index 1b9e8205ecf..292b6daaf38 100644 --- a/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx @@ -234,6 +234,7 @@ export type TextInputV2ComponentProps = Omit< inheritFontStyles?: boolean; rightAdornment?: string; leftAdornment?: string; + textClickOutsideId?: string; }; type TextInputV2WithAutoGrowWrapperProps = TextInputV2ComponentProps; @@ -273,6 +274,7 @@ const TextInputV2Component = forwardRef< autoGrow = false, rightAdornment, leftAdornment, + textClickOutsideId, }, ref, ) => { @@ -300,7 +302,11 @@ const TextInputV2Component = forwardRef< const instanceId = useId(); return ( - + {label && ( {label + (required ? '*' : '')} diff --git a/packages/twenty-front/src/modules/ui/input/components/constants/TextInputClickOutsideId.ts b/packages/twenty-front/src/modules/ui/input/components/constants/TextInputClickOutsideId.ts new file mode 100644 index 00000000000..80138670213 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/constants/TextInputClickOutsideId.ts @@ -0,0 +1 @@ +export const TEXT_INPUT_CLICK_OUTSIDE_ID = 'text-input-click-outside-id'; diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/components/Dropdown.tsx b/packages/twenty-front/src/modules/ui/layout/dropdown/components/Dropdown.tsx index 4cca7427610..6fa6f1ccea1 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/components/Dropdown.tsx +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/components/Dropdown.tsx @@ -59,6 +59,7 @@ export type DropdownProps = { onOpen?: () => void; excludedClickOutsideIds?: string[]; isDropdownInModal?: boolean; + disableClickForClickableComponent?: boolean; }; export const Dropdown = ({ @@ -76,6 +77,7 @@ export const Dropdown = ({ clickableComponentWidth = 'auto', excludedClickOutsideIds, isDropdownInModal = false, + disableClickForClickableComponent = false, }: DropdownProps) => { const isDropdownOpen = useRecoilComponentValueV2( isDropdownOpenComponentState, @@ -151,6 +153,7 @@ export const Dropdown = ({ const handleClickableComponentClick = useRecoilCallback( () => async (event: MouseEvent) => { + if (disableClickForClickableComponent) return; event.stopPropagation(); event.preventDefault(); @@ -159,7 +162,12 @@ export const Dropdown = ({ globalHotkeysConfig, }); }, - [globalHotkeysConfig, toggleDropdown, dropdownId], + [ + globalHotkeysConfig, + toggleDropdown, + dropdownId, + disableClickForClickableComponent, + ], ); return ( diff --git a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts index 5a07991c2f1..9c527fa4d3f 100644 --- a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts +++ b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts @@ -49,6 +49,7 @@ import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.mod import { RoleModule } from 'src/engine/metadata-modules/role/role.module'; import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module'; import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module'; +import { GeoMapModule } from 'src/engine/core-modules/geo-map/geo-map-module'; import { AuditModule } from './audit/audit.module'; import { ClientConfigModule } from './client-config/client-config.module'; @@ -84,6 +85,7 @@ import { FileModule } from './file/file.module'; RoleModule, RedisClientModule, WorkspaceQueryRunnerModule, + GeoMapModule, SubscriptionsModule, ImapSmtpCaldavModule, FileStorageModule.forRoot(), diff --git a/packages/twenty-server/src/engine/core-modules/geo-map/dtos/autocomplete-result.dto.ts b/packages/twenty-server/src/engine/core-modules/geo-map/dtos/autocomplete-result.dto.ts new file mode 100644 index 00000000000..7f124078e5d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/geo-map/dtos/autocomplete-result.dto.ts @@ -0,0 +1,10 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class AutocompleteResultDto { + @Field() + text: string; + + @Field() + placeId: string; +} 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 new file mode 100644 index 00000000000..21c2ac9b55f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/geo-map/dtos/place-details-result.dto.ts @@ -0,0 +1,28 @@ +import { Field, Float, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class LocationDto { + @Field(() => Float, { nullable: true }) + lat?: number; + + @Field(() => Float, { nullable: true }) + lng?: number; +} + +@ObjectType() +export class PlaceDetailsResultDto { + @Field({ nullable: true }) + state?: string; + + @Field({ nullable: true }) + postcode?: string; + + @Field({ nullable: true }) + city?: string; + + @Field({ nullable: true }) + country?: string; + + @Field(() => LocationDto, { nullable: true }) + location?: LocationDto; +} diff --git a/packages/twenty-server/src/engine/core-modules/geo-map/geo-map-module.ts b/packages/twenty-server/src/engine/core-modules/geo-map/geo-map-module.ts new file mode 100644 index 00000000000..578170a9477 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/geo-map/geo-map-module.ts @@ -0,0 +1,14 @@ +import { HttpModule } from '@nestjs/axios'; +import { Module } from '@nestjs/common'; + +import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; +import { GeoMapResolver } from 'src/engine/core-modules/geo-map/resolver/geo-map.resolver'; +import { GeoMapService } from 'src/engine/core-modules/geo-map/services/geo-map.service'; +import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; + +@Module({ + imports: [HttpModule, WorkspaceCacheStorageModule, TokenModule], + providers: [GeoMapService, GeoMapResolver], + exports: [], +}) +export class GeoMapModule {} diff --git a/packages/twenty-server/src/engine/core-modules/geo-map/resolver/geo-map.resolver.ts b/packages/twenty-server/src/engine/core-modules/geo-map/resolver/geo-map.resolver.ts new file mode 100644 index 00000000000..1e8c9276d0e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/geo-map/resolver/geo-map.resolver.ts @@ -0,0 +1,36 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Query, Resolver } from '@nestjs/graphql'; + +import { AutocompleteResultDto } from 'src/engine/core-modules/geo-map/dtos/autocomplete-result.dto'; +import { PlaceDetailsResultDto } from 'src/engine/core-modules/geo-map/dtos/place-details-result.dto'; +import { GeoMapService } from 'src/engine/core-modules/geo-map/services/geo-map.service'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; + +@Resolver() +@UseGuards(WorkspaceAuthGuard) +export class GeoMapResolver { + constructor(private readonly geoMapService: GeoMapService) {} + + @Query(() => [AutocompleteResultDto]) + async getAutoCompleteAddress( + @Args('address') address: string, + @Args('token') token: string, + @Args('country', { nullable: true }) country?: string, + @Args('isFieldCity', { nullable: true }) isFieldCity?: boolean, + ) { + return this.geoMapService.getAutoCompleteAddress( + address, + token, + country, + isFieldCity, + ); + } + + @Query(() => PlaceDetailsResultDto) + async getAddressDetails( + @Args('placeId') placeId: string, + @Args('token') token: string, + ) { + return this.geoMapService.getAddressDetails(placeId, token); + } +} 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 new file mode 100644 index 00000000000..5a1ed2adde8 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/geo-map/services/geo-map.service.ts @@ -0,0 +1,78 @@ +import { HttpService } from '@nestjs/axios'; +import { Injectable } from '@nestjs/common'; + +import { isDefined } from 'twenty-shared/utils'; + +import { + AutocompleteSanitizedResult, + sanitizeAutocompleteResults, +} from 'src/engine/core-modules/geo-map/utils/sanitize-autocomplete-results.util'; +import { + AddressFields, + sanitizePlaceDetailsResults, +} from 'src/engine/core-modules/geo-map/utils/sanitize-place-details-results.util'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; + +@Injectable() +export class GeoMapService { + private apiMapKey: string | undefined; + constructor( + private readonly twentyConfigService: TwentyConfigService, + private readonly httpService: HttpService, + ) { + if ( + !this.twentyConfigService.get( + 'IS_MAPS_AND_ADDRESS_AUTOCOMPLETE_ENABLED', + ) || + !this.twentyConfigService.get('GOOGLE_MAP_API_KEY') + ) { + return; + } + this.apiMapKey = this.twentyConfigService.get('GOOGLE_MAP_API_KEY'); + } + + public async getAutoCompleteAddress( + address: string, + token: string, + country?: string, + isFieldCity?: boolean, + ): Promise { + if (!isDefined(address) || address.trim().length === 0) { + return []; + } + + let url = `https://maps.googleapis.com/maps/api/place/autocomplete/json?input=${encodeURIComponent(address)}&sessiontoken=${token}&key=${this.apiMapKey}`; + + if (isDefined(country) && country !== '') { + url += `&components=country:${country}`; + } + if (isDefined(isFieldCity) && isFieldCity === true) { + url += `&types=(cities)`; + } + const result = await this.httpService.axiosRef.get(url); + + if (result.data.status === 'OK') { + return sanitizeAutocompleteResults(result.data.predictions); + } + + return []; + } + + public async getAddressDetails( + placeId: string, + token: string, + ): Promise { + const result = await this.httpService.axiosRef.get( + `https://maps.googleapis.com/maps/api/place/details/json?place_id=${placeId}&sessiontoken=${token}&fields=address_components%2Cgeometry&key=${this.apiMapKey}`, + ); + + if (result.data.status === 'OK') { + return sanitizePlaceDetailsResults( + result.data.result?.address_components, + result.data.result?.geometry?.location, + ); + } + + return {}; + } +} 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 new file mode 100644 index 00000000000..9faa913434f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/geo-map/utils/sanitize-autocomplete-results.util.ts @@ -0,0 +1,20 @@ +export type AutocompleteSanitizedResult = { + text: string; + placeId: string; +}; + +type GooglePrediction = { + description: string; + place_id: string; +}; +export const sanitizeAutocompleteResults = ( + autocompleteResults: GooglePrediction[], +): AutocompleteSanitizedResult[] => { + if (!Array.isArray(autocompleteResults) || autocompleteResults.length === 0) + return []; + + return autocompleteResults.map((result) => ({ + text: result.description, + placeId: result.place_id, + })); +}; 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 new file mode 100644 index 00000000000..aa015d48b47 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/geo-map/utils/sanitize-place-details-results.util.ts @@ -0,0 +1,78 @@ +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 {}; + + const address: AddressFields = {}; + + for (const AddressComponent of AddressComponents) { + for (const type of AddressComponent.types) { + switch (type) { + case 'postal_code': { + address.postcode = + AddressComponent.long_name + (address.postcode ?? ''); + break; + } + + case 'postal_code_suffix': { + address.postcode = + (address.postcode ?? '') + '-' + AddressComponent.long_name; + break; + } + + case 'locality': + address.city = AddressComponent.long_name; + break; + + case 'postal_town': + if (!address.city) { + address.city = AddressComponent.long_name; + } + break; + + case 'administrative_area_level_3': { + if (!address.city) { + address.city = AddressComponent.long_name; + } + break; + } + + case 'administrative_area_level_1': { + address.state = AddressComponent.long_name; + break; + } + + case 'administrative_area_level_2': { + if (!address.state) { + address.state = AddressComponent.long_name; + } + break; + } + + case 'country': + address.country = AddressComponent.short_name; + break; + } + } + } + address.location = location; + + return address; +}; diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index 614edb1ae9f..79e8f63e944 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -9,7 +9,6 @@ import { ValidationError, validateSync, } from 'class-validator'; -import { TwoFactorAuthenticationStrategy } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { AwsRegion } from 'src/engine/core-modules/twenty-config/interfaces/aws-region.interface'; @@ -67,16 +66,6 @@ export class ConfigVariables { @IsOptional() IS_EMAIL_VERIFICATION_REQUIRED = false; - @ConfigVariablesMetadata({ - group: ConfigVariablesGroup.TwoFactorAuthentication, - description: - 'Select the two-factor authentication strategy (e.g., TOTP or HOTP) to be used for workspace logins.', - type: ConfigVariableType.ENUM, - options: Object.values(TwoFactorAuthenticationStrategy), - }) - @IsOptional() - TWO_FACTOR_AUTHENTICATION_STRATEGY = TwoFactorAuthenticationStrategy.TOTP; - @ConfigVariablesMetadata({ group: ConfigVariablesGroup.TokensDuration, description: 'Duration for which the email verification token is valid', @@ -1170,6 +1159,23 @@ export class ConfigVariables { @IsOptionalOrEmptyString() @IsTwentySemVer() APP_VERSION?: string; + + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.Other, + description: 'Enable or disable google map api usage', + type: ConfigVariableType.BOOLEAN, + }) + @IsOptional() + IS_MAPS_AND_ADDRESS_AUTOCOMPLETE_ENABLED = false; + + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.Other, + isSensitive: true, + description: 'Google map api key for places and map', + type: ConfigVariableType.STRING, + }) + @ValidateIf((env) => env.IS_MAPS_AND_ADDRESS_AUTOCOMPLETE_ENABLED) + GOOGLE_MAP_API_KEY: string; } export const validate = (config: Record): ConfigVariables => {