feat: add Google Place Autocomplete for address fields (#13450)
resolve #13253 This PR enables the use of Google Place Autocomplete and Place Details APIs in the backend. It allows users to automatically fill in address fields by typing into the address1 input. The input is debounced, then the Autocomplete API is called. Once the user selects an address, the Place Details API is used to parse and fill in the individual address fields. https://github.com/user-attachments/assets/e04b8474-25b8-48f5-83d0-2074f8d5fc94 --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com> Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
co-authored by
Félix Malfait
Félix Malfait
parent
c186b78f67
commit
4eba13e9fb
@@ -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<Scalars['String']>;
|
||||
@@ -1082,6 +1088,12 @@ export type LinksMetadata = {
|
||||
secondaryLinks?: Maybe<Array<LinkMetadata>>;
|
||||
};
|
||||
|
||||
export type LocationDto = {
|
||||
__typename?: 'LocationDto';
|
||||
lat?: Maybe<Scalars['Float']>;
|
||||
lng?: Maybe<Scalars['Float']>;
|
||||
};
|
||||
|
||||
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<Scalars['String']>;
|
||||
country?: Maybe<Scalars['String']>;
|
||||
location?: Maybe<LocationDto>;
|
||||
postcode?: Maybe<Scalars['String']>;
|
||||
state?: Maybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type PostgresCredentials = {
|
||||
__typename?: 'PostgresCredentials';
|
||||
id: Scalars['UUID'];
|
||||
@@ -1962,7 +1983,9 @@ export type Query = {
|
||||
findOneServerlessFunction: ServerlessFunction;
|
||||
findWorkspaceFromInviteHash: Workspace;
|
||||
findWorkspaceInvitations: Array<WorkspaceInvitation>;
|
||||
getAddressDetails: PlaceDetailsResultDto;
|
||||
getApprovedAccessDomains: Array<ApprovedAccessDomain>;
|
||||
getAutoCompleteAddress: Array<AutocompleteResultDto>;
|
||||
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<Scalars['String']>;
|
||||
isFieldCity?: InputMaybe<Scalars['Boolean']>;
|
||||
token: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
export type QueryGetAvailablePackagesArgs = {
|
||||
input: ServerlessFunctionIdInput;
|
||||
};
|
||||
|
||||
@@ -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<Scalars['String']>;
|
||||
@@ -1039,6 +1045,12 @@ export type LinksMetadata = {
|
||||
secondaryLinks?: Maybe<Array<LinkMetadata>>;
|
||||
};
|
||||
|
||||
export type LocationDto = {
|
||||
__typename?: 'LocationDto';
|
||||
lat?: Maybe<Scalars['Float']>;
|
||||
lng?: Maybe<Scalars['Float']>;
|
||||
};
|
||||
|
||||
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<Scalars['String']>;
|
||||
country?: Maybe<Scalars['String']>;
|
||||
location?: Maybe<LocationDto>;
|
||||
postcode?: Maybe<Scalars['String']>;
|
||||
state?: Maybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type PostgresCredentials = {
|
||||
__typename?: 'PostgresCredentials';
|
||||
id: Scalars['UUID'];
|
||||
@@ -1870,7 +1891,9 @@ export type Query = {
|
||||
findOneServerlessFunction: ServerlessFunction;
|
||||
findWorkspaceFromInviteHash: Workspace;
|
||||
findWorkspaceInvitations: Array<WorkspaceInvitation>;
|
||||
getAddressDetails: PlaceDetailsResultDto;
|
||||
getApprovedAccessDomains: Array<ApprovedAccessDomain>;
|
||||
getAutoCompleteAddress: Array<AutocompleteResultDto>;
|
||||
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<Scalars['String']>;
|
||||
isFieldCity?: InputMaybe<Scalars['Boolean']>;
|
||||
token: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
export type QueryGetAvailablePackagesArgs = {
|
||||
input: ServerlessFunctionIdInput;
|
||||
};
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const options: SelectOption<string>[] = useMemo(() => {
|
||||
return list?.map<SelectOption<string>>(({ placeId, text }) => ({
|
||||
label: text,
|
||||
value: placeId,
|
||||
}));
|
||||
}, [list]);
|
||||
|
||||
if (!isDefined(options) || options.length <= 0) return null;
|
||||
|
||||
const selectableItemIdArray = options.map((option) => option.value);
|
||||
|
||||
return (
|
||||
<StyledContainer tabIndex={0} ref={selectContainerRef} fullWidth={true}>
|
||||
<SelectableList
|
||||
selectableListInstanceId={dropdownId}
|
||||
selectableItemIdArray={selectableItemIdArray}
|
||||
focusId={dropdownId}
|
||||
>
|
||||
<DropdownContent
|
||||
ref={selectContainerRef}
|
||||
selectDisabled
|
||||
widthInPixels={345}
|
||||
>
|
||||
<DropdownMenuItemsContainer hasMaxHeight>
|
||||
{options.map((option) => {
|
||||
return (
|
||||
<SelectableListItem
|
||||
key={option.value}
|
||||
itemId={option.value}
|
||||
onEnter={() => onChange(option.value)}
|
||||
>
|
||||
<MenuItemSelectTag
|
||||
key={option.value}
|
||||
text={option.label}
|
||||
color={'transparent'}
|
||||
onClick={() => onChange(option.value)}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownContent>
|
||||
</SelectableList>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
+181
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+34
@@ -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);
|
||||
});
|
||||
});
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const SELECT_AUTOCOMPLETE_LIST_DROPDOWN_ID =
|
||||
'select-autocomplete-list-dropdown-id';
|
||||
+197
@@ -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]*$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
+322
@@ -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 }) => (
|
||||
<MockedProvider mocks={mocks} addTypename={false}>
|
||||
{children}
|
||||
</MockedProvider>
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<PlaceAutocompleteResult[] | undefined> => {
|
||||
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<PlaceDetailsResult | undefined> => {
|
||||
const { data } = await apolloClient.query({
|
||||
query: GET_PLACE_DETAILS_QUERY,
|
||||
variables: { placeId, token },
|
||||
fetchPolicy: 'no-cache',
|
||||
});
|
||||
return data?.getAddressDetails;
|
||||
};
|
||||
return {
|
||||
getPlaceAutocompleteData,
|
||||
getPlaceDetailsData,
|
||||
};
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
const addressStreet2InputRef = useRef<HTMLInputElement>(null);
|
||||
const addressCityInputRef = useRef<HTMLInputElement>(null);
|
||||
const addressStateInputRef = useRef<HTMLInputElement>(null);
|
||||
const addressPostcodeInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const inputRefs: {
|
||||
[key in keyof FieldAddressDraftValue]?: RefObject<HTMLInputElement>;
|
||||
} = {
|
||||
addressStreet1: addressStreet1InputRef,
|
||||
addressStreet2: addressStreet2InputRef,
|
||||
addressCity: addressCityInputRef,
|
||||
addressState: addressStateInputRef,
|
||||
addressPostcode: addressPostcodeInputRef,
|
||||
};
|
||||
|
||||
const [focusPosition, setFocusPosition] =
|
||||
useState<keyof FieldAddressDraftValue>('addressStreet1');
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement>(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 (
|
||||
<StyledInputWithDropdownContainer>
|
||||
<Dropdown
|
||||
dropdownId={SELECT_AUTOCOMPLETE_LIST_DROPDOWN_ID}
|
||||
dropdownPlacement="bottom-start"
|
||||
excludedClickOutsideIds={[
|
||||
TEXT_INPUT_CLICK_OUTSIDE_ID,
|
||||
SELECT_AUTOCOMPLETE_LIST_DROPDOWN_ID,
|
||||
]}
|
||||
disableClickForClickableComponent={true}
|
||||
onClickOutside={handleClickOutside}
|
||||
clickableComponent={inputElement}
|
||||
dropdownComponents={
|
||||
<PlaceAutocompleteSelect
|
||||
list={validAutocompleteData}
|
||||
onChange={handlePlaceSelection}
|
||||
dropdownId={SELECT_AUTOCOMPLETE_LIST_DROPDOWN_ID}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</StyledInputWithDropdownContainer>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledAddressContainer ref={wrapperRef}>
|
||||
<TextInputV2
|
||||
autoFocus
|
||||
value={internalValue.addressStreet1 ?? ''}
|
||||
ref={inputRefs['addressStreet1']}
|
||||
label="Address 1"
|
||||
fullWidth
|
||||
onChange={getChangeHandler('addressStreet1')}
|
||||
onFocus={getFocusHandler('addressStreet1')}
|
||||
/>
|
||||
{renderInputWithAutocomplete(
|
||||
<TextInputV2
|
||||
autoFocus
|
||||
value={internalValue.addressStreet1 ?? ''}
|
||||
ref={inputRefs.addressStreet1}
|
||||
label="Address 1"
|
||||
fullWidth
|
||||
onChange={getChangeHandler('addressStreet1')}
|
||||
onFocus={getFocusHandler('addressStreet1')}
|
||||
textClickOutsideId={
|
||||
validAutocompleteData &&
|
||||
typeOfAddressForAutocomplete === 'addressStreet1'
|
||||
? TEXT_INPUT_CLICK_OUTSIDE_ID
|
||||
: undefined
|
||||
}
|
||||
/>,
|
||||
'addressStreet1',
|
||||
)}
|
||||
<TextInputV2
|
||||
value={internalValue.addressStreet2 ?? ''}
|
||||
ref={inputRefs['addressStreet2']}
|
||||
ref={inputRefs.addressStreet2}
|
||||
label="Address 2"
|
||||
fullWidth
|
||||
onChange={getChangeHandler('addressStreet2')}
|
||||
onFocus={getFocusHandler('addressStreet2')}
|
||||
/>
|
||||
<StyledHalfRowContainer>
|
||||
<TextInputV2
|
||||
value={internalValue.addressCity ?? ''}
|
||||
ref={inputRefs['addressCity']}
|
||||
label="City"
|
||||
fullWidth
|
||||
onChange={getChangeHandler('addressCity')}
|
||||
onFocus={getFocusHandler('addressCity')}
|
||||
/>
|
||||
{renderInputWithAutocomplete(
|
||||
<TextInputV2
|
||||
value={internalValue.addressCity ?? ''}
|
||||
ref={inputRefs.addressCity}
|
||||
label="City"
|
||||
fullWidth
|
||||
onChange={getChangeHandler('addressCity')}
|
||||
onFocus={getFocusHandler('addressCity')}
|
||||
textClickOutsideId={
|
||||
validAutocompleteData &&
|
||||
typeOfAddressForAutocomplete === 'addressCity'
|
||||
? TEXT_INPUT_CLICK_OUTSIDE_ID
|
||||
: undefined
|
||||
}
|
||||
/>,
|
||||
'addressCity',
|
||||
)}
|
||||
<TextInputV2
|
||||
value={internalValue.addressState ?? ''}
|
||||
ref={inputRefs['addressState']}
|
||||
ref={inputRefs.addressState}
|
||||
label="State"
|
||||
fullWidth
|
||||
onChange={getChangeHandler('addressState')}
|
||||
@@ -242,7 +342,7 @@ export const AddressInput = ({
|
||||
<StyledHalfRowContainer>
|
||||
<TextInputV2
|
||||
value={internalValue.addressPostcode ?? ''}
|
||||
ref={inputRefs['addressPostcode']}
|
||||
ref={inputRefs.addressPostcode}
|
||||
label="Post Code"
|
||||
fullWidth
|
||||
onChange={getChangeHandler('addressPostcode')}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { SELECT_AUTOCOMPLETE_LIST_DROPDOWN_ID } from '@/geo-map/constants/selectAutocompleteListDropDownId';
|
||||
import { useGetPlaceApiData } from '@/geo-map/hooks/useGetPlaceApiData';
|
||||
import { PlaceAutocompleteResult } from '@/geo-map/types/placeApi';
|
||||
import { FieldAddressDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue';
|
||||
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
|
||||
import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import { useCountryUtils } from './useCountryUtils';
|
||||
|
||||
export const useAddressAutocomplete = (
|
||||
onChange?: (updatedValue: FieldAddressDraftValue) => void,
|
||||
) => {
|
||||
const [placeAutocompleteData, setPlaceAutocompleteData] = useState<
|
||||
PlaceAutocompleteResult[] | null
|
||||
>([]);
|
||||
const [tokenForPlaceApi, setTokenForPlaceApi] = useState<string | null>(null);
|
||||
const [typeOfAddressForAutocomplete, setTypeOfAddressForAutocomplete] =
|
||||
useState<string | null>(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,
|
||||
};
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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<HTMLInputElement>;
|
||||
},
|
||||
internalValue: FieldAddressDraftValue,
|
||||
onTab?: (newAddress: FieldAddressDraftValue) => void,
|
||||
onShiftTab?: (newAddress: FieldAddressDraftValue) => void,
|
||||
) => {
|
||||
const [focusPosition, setFocusPosition] =
|
||||
useState<keyof FieldAddressDraftValue>('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,
|
||||
};
|
||||
};
|
||||
@@ -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 (
|
||||
<StyledContainer className={className} fullWidth={fullWidth ?? false}>
|
||||
<StyledContainer
|
||||
className={className}
|
||||
fullWidth={fullWidth ?? false}
|
||||
data-click-outside-id={textClickOutsideId}
|
||||
>
|
||||
{label && (
|
||||
<InputLabel htmlFor={instanceId}>
|
||||
{label + (required ? '*' : '')}
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const TEXT_INPUT_CLICK_OUTSIDE_ID = 'text-input-click-outside-id';
|
||||
@@ -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 (
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class AutocompleteResultDto {
|
||||
@Field()
|
||||
text: string;
|
||||
|
||||
@Field()
|
||||
placeId: string;
|
||||
}
|
||||
+28
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<AutocompleteSanitizedResult[] | undefined> {
|
||||
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<AddressFields | undefined> {
|
||||
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 {};
|
||||
}
|
||||
}
|
||||
+20
@@ -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,
|
||||
}));
|
||||
};
|
||||
+78
@@ -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;
|
||||
};
|
||||
@@ -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<string, unknown>): ConfigVariables => {
|
||||
|
||||
Reference in New Issue
Block a user