diff --git a/packages/core/src/app/address/AddressForm.tsx b/packages/core/src/app/address/AddressForm.tsx index f6d5a2f503..7ccc0312ed 100644 --- a/packages/core/src/app/address/AddressForm.tsx +++ b/packages/core/src/app/address/AddressForm.tsx @@ -61,6 +61,11 @@ const AddressForm: React.FC = ({ 'CHECKOUT-9019.use_new_phone_number_validation', false, ); + const isNewGooglePlacesApiEnabled = isExperimentEnabled( + config?.checkoutSettings, + 'CHECKOUT-10026.new_google_places_api', + false, + ); const countriesWithAutocomplete = ['US', 'CA', 'AU', 'NZ', 'GB']; const containerRef = useRef(null); @@ -174,6 +179,7 @@ const AddressForm: React.FC = ({ countryCode={countryCode} field={field} isFloatingLabelEnabled={isFloatingLabelEnabledValue} + isNewPlacesApiEnabled={isNewGooglePlacesApiEnabled} key={field.id} nextElement={nextElementRef.current || undefined} onChange={handleAutocompleteChange} diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.test.tsx b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.test.tsx new file mode 100644 index 0000000000..bbd593ddc4 --- /dev/null +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.test.tsx @@ -0,0 +1,260 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { render, screen, waitFor } from '@bigcommerce/checkout/test-utils'; + +import GoogleAutocomplete, { + type GoogleAutocompleteProps, + resetNewGooglePlacesApiState, +} from './GoogleAutocomplete'; +import LegacyGoogleAutocompleteService from './GoogleAutocompleteService'; +import { NewGooglePlacesApiService } from './newGooglePlacesApi/NewGooglePlacesApiService'; + +jest.mock('./GoogleAutocompleteService'); +jest.mock('./newGooglePlacesApi/NewGooglePlacesApiService'); + +const MockLegacyService = LegacyGoogleAutocompleteService as jest.MockedClass< + typeof LegacyGoogleAutocompleteService +>; +const MockPlacesApiService = NewGooglePlacesApiService as jest.MockedClass< + typeof NewGooglePlacesApiService +>; + +const legacySuggestions = [ + { + description: '123 Legacy St, New York', + structured_formatting: { main_text: '123 Legacy St' }, + matched_substrings: [], + place_id: 'legacy-place-1', + }, +]; + +const legacyPlaceResult = { + name: '123 Legacy St', + address_components: [], +} as google.maps.places.PlaceResult; + +const newApiSuggestions = [ + { id: 'new-place-1', label: '123 New API Ave', value: '123 New API Ave' }, +]; + +const newApiPlaceResult = { + name: '123 New API Ave', + address_components: [], +} as google.maps.places.PlaceResult; + +const gRpcPermissionDeniedErrorMock = { name: 'RpcError', code: 7 }; +const gRpcSomeOtherErrorMock = { name: 'RpcError', code: 14 }; + +describe('GoogleAutocomplete', () => { + let mockGetLegacyApiSuggestions: jest.Mock; + let mockGetLegacyApiPlaceDetails: jest.Mock; + let mockGetNewApiSuggestions: jest.Mock; + let mockGetNewApiPlaceDetails: jest.Mock; + let defaultProps: GoogleAutocompleteProps; + + beforeEach(() => { + resetNewGooglePlacesApiState(); + + mockGetLegacyApiSuggestions = jest.fn(); + mockGetLegacyApiPlaceDetails = jest.fn(); + mockGetNewApiSuggestions = jest.fn().mockResolvedValue(newApiSuggestions); + mockGetNewApiPlaceDetails = jest.fn().mockResolvedValue(newApiPlaceResult); + + MockLegacyService.mockImplementation( + () => + ({ + getAutocompleteService: jest.fn().mockResolvedValue({ + getPlacePredictions: mockGetLegacyApiSuggestions, + }), + getPlacesServices: jest + .fn() + .mockResolvedValue({ getDetails: mockGetLegacyApiPlaceDetails }), + }) as unknown as LegacyGoogleAutocompleteService, + ); + + MockPlacesApiService.mockImplementation( + () => + ({ + getSuggestions: mockGetNewApiSuggestions, + getPlaceDetails: mockGetNewApiPlaceDetails, + }) as unknown as NewGooglePlacesApiService, + ); + + defaultProps = { + apiKey: 'test-api-key', + isAutocompleteEnabled: true, + isNewPlacesApiEnabled: true, + onSelect: jest.fn(), + onChange: jest.fn(), + }; + }); + + describe('new API available (preferred path)', () => { + it('shows suggestions from the new API', async () => { + render(); + + await userEvent.type(screen.getByRole('textbox'), '123'); + + await screen.findByText('123 New API Ave'); + expect(mockGetLegacyApiSuggestions).not.toHaveBeenCalled(); + }); + + it('calls onSelect with the new API place result when a suggestion is picked', async () => { + render(); + + await userEvent.type(screen.getByRole('textbox'), '123'); + await screen.findByText('123 New API Ave'); + await userEvent.click(screen.getByText('123 New API Ave')); + + await waitFor(() => + expect(defaultProps.onSelect).toHaveBeenCalledWith( + newApiPlaceResult, + expect.objectContaining({ id: 'new-place-1' }), + ), + ); + expect(mockGetLegacyApiPlaceDetails).not.toHaveBeenCalled(); + }); + }); + + describe('new API unavailable (fall back to legacy)', () => { + beforeEach(() => { + mockGetLegacyApiSuggestions.mockImplementation((_req, cb) => + cb(legacySuggestions, 'OK'), + ); + mockGetLegacyApiPlaceDetails.mockImplementation((_req, cb) => + cb(legacyPlaceResult, 'OK'), + ); + }); + + it('falls back to the legacy service for suggestions when the new API is denied', async () => { + mockGetNewApiSuggestions.mockRejectedValue(gRpcPermissionDeniedErrorMock); + + render(); + + await userEvent.type(screen.getByRole('textbox'), '123'); + + await screen.findByText('123 Legacy St, New York'); + expect(mockGetLegacyApiSuggestions).toHaveBeenCalled(); + }); + + it('does not fall back to legacy for a non-permission suggestions failure', async () => { + mockGetNewApiSuggestions.mockRejectedValue(gRpcSomeOtherErrorMock); + + render(); + + await userEvent.type(screen.getByRole('textbox'), '123'); + + await waitFor(() => expect(mockGetNewApiSuggestions).toHaveBeenCalled()); + expect(screen.queryByText('123 Legacy St, New York')).not.toBeInTheDocument(); + expect(mockGetLegacyApiSuggestions).not.toHaveBeenCalled(); + }); + + it('latches onto legacy after a permission denial and stops retrying the new API', async () => { + mockGetNewApiSuggestions.mockRejectedValue(gRpcPermissionDeniedErrorMock); + + render(); + + const input = screen.getByRole('textbox'); + + // First request hits the new API, is denied, and latches the fallback flag. + await userEvent.type(input, '1'); + await screen.findByText('123 Legacy St, New York'); + expect(mockGetNewApiSuggestions).toHaveBeenCalledTimes(1); + + // A later request goes straight to legacy without retrying the new API. + await userEvent.type(input, '2'); + // legacy is called twice for both cases + await waitFor(() => expect(mockGetLegacyApiSuggestions).toHaveBeenCalledTimes(2)); + // new api is called only once for previous input + expect(mockGetNewApiSuggestions).toHaveBeenCalledTimes(1); + }); + + it('keeps retrying the new API on later input after a transient failure, without falling back', async () => { + mockGetNewApiSuggestions.mockRejectedValue(gRpcSomeOtherErrorMock); + + render(); + + const input = screen.getByRole('textbox'); + + // First request fails transiently; no fallback and no latch, since it's not a permission denial. + await userEvent.type(input, '1'); + await waitFor(() => expect(mockGetNewApiSuggestions).toHaveBeenCalledTimes(1)); + expect(mockGetLegacyApiSuggestions).not.toHaveBeenCalled(); + + // The new API is tried again on the next input, since the failure was not permanent. + await userEvent.type(input, '2'); + await waitFor(() => expect(mockGetNewApiSuggestions).toHaveBeenCalledTimes(2)); + }); + + it('falls back to the legacy service for place details when the new API denies permission', async () => { + // Suggestions still come from the new API so the dropdown has an item to click, + // but getPlaceDetails is denied, forcing the legacy getDetails fallback. + mockGetNewApiPlaceDetails.mockRejectedValue(gRpcPermissionDeniedErrorMock); + + render(); + + await userEvent.type(screen.getByRole('textbox'), '1'); + await screen.findByText('123 New API Ave'); + await userEvent.click(screen.getByText('123 New API Ave')); + + await waitFor(() => + expect(defaultProps.onSelect).toHaveBeenCalledWith( + legacyPlaceResult, + expect.objectContaining({ id: 'new-place-1' }), + ), + ); + expect(mockGetLegacyApiPlaceDetails).toHaveBeenCalled(); + }); + + it('does not fall back to legacy for a non-permission place details failure', async () => { + mockGetNewApiPlaceDetails.mockRejectedValue(new Error('new API down')); + + render(); + + await userEvent.type(screen.getByRole('textbox'), '1'); + await screen.findByText('123 New API Ave'); + await userEvent.click(screen.getByText('123 New API Ave')); + + await waitFor(() => expect(mockGetNewApiPlaceDetails).toHaveBeenCalled()); + expect(mockGetLegacyApiPlaceDetails).not.toHaveBeenCalled(); + expect(defaultProps.onSelect).not.toHaveBeenCalled(); + }); + }); + + describe('new API disabled via experiment flag', () => { + beforeEach(() => { + mockGetLegacyApiSuggestions.mockImplementation((_req, cb) => + cb(legacySuggestions, 'OK'), + ); + mockGetLegacyApiPlaceDetails.mockImplementation((_req, cb) => + cb(legacyPlaceResult, 'OK'), + ); + }); + + it('goes straight to the legacy service for suggestions without calling the new API', async () => { + render(); + + await userEvent.type(screen.getByRole('textbox'), '123'); + + await screen.findByText('123 Legacy St, New York'); + expect(mockGetNewApiSuggestions).not.toHaveBeenCalled(); + }); + + it('goes straight to the legacy service for place details without calling the new API', async () => { + render(); + + await userEvent.type(screen.getByRole('textbox'), '1'); + await screen.findByText('123 Legacy St, New York'); + await userEvent.click(screen.getByText('123 Legacy St, New York')); + + await waitFor(() => + expect(defaultProps.onSelect).toHaveBeenCalledWith( + legacyPlaceResult, + expect.objectContaining({ id: 'legacy-place-1' }), + ), + ); + expect(mockGetNewApiPlaceDetails).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx index ebc4c45343..cee7cb3845 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx @@ -1,12 +1,14 @@ import { noop } from 'lodash'; -import React, { useRef, useState } from 'react'; +import React from 'react'; import { Autocomplete, type AutocompleteItem } from '@bigcommerce/checkout/ui'; -import GoogleAutocompleteService from './GoogleAutocompleteService'; import { type GoogleAutocompleteOptionTypes } from './googleAutocompleteTypes'; +import { useGoogleAutocomplete } from './useGoogleAutocomplete'; import './GoogleAutocomplete.scss'; +export { resetNewGooglePlacesApiState } from './useGoogleAutocomplete'; + export interface GoogleAutocompleteProps { initialValue?: string; componentRestrictions?: google.maps.places.ComponentRestrictions; @@ -15,23 +17,13 @@ export interface GoogleAutocompleteProps { nextElement?: HTMLElement; inputProps?: any; isAutocompleteEnabled?: boolean; + isNewPlacesApiEnabled?: boolean; types?: GoogleAutocompleteOptionTypes[]; onSelect?(place: google.maps.places.PlaceResult, item: AutocompleteItem): void; onToggleOpen?(state: { inputValue: string; isOpen: boolean }): void; onChange?(value: string, isOpen: boolean): void; } -const toAutocompleteItems = ( - results?: google.maps.places.AutocompletePrediction[], -): AutocompleteItem[] => { - return (results || []).map((result) => ({ - label: result.description, - value: result.structured_formatting.main_text, - highlightedSlices: result.matched_substrings, - id: result.place_id, - })); -}; - const GoogleAutocomplete: React.FC = ({ initialValue, onToggleOpen = noop, @@ -40,87 +32,23 @@ const GoogleAutocomplete: React.FC = ({ onSelect = noop, nextElement, isAutocompleteEnabled, + isNewPlacesApiEnabled = false, onChange = noop, componentRestrictions, types, apiKey, }) => { - const [items, setItems] = useState([]); - const [autoComplete, setAutoComplete] = useState('off'); - const googleAutocompleteServiceRef = useRef(); - - if (!googleAutocompleteServiceRef.current) { - googleAutocompleteServiceRef.current = new GoogleAutocompleteService(apiKey); - } - - const onSelectHandler = (item: AutocompleteItem) => { - const service = googleAutocompleteServiceRef.current; - - if (!service) return; - - service.getPlacesServices().then((placesService) => { - placesService.getDetails( - { - placeId: item.id, - fields: fields || ['address_components', 'name'], - }, - (result) => { - if (nextElement) { - nextElement.focus(); - } - - onSelect(result, item); - }, - ); - }); - }; - - const resetAutocomplete = (): void => { - setItems([]); - setAutoComplete('off'); - }; - - const setAutocompleteValue = (input: string): void => { - setAutoComplete(input && input.length ? 'nope' : 'off'); - }; - - const setItemsFromInput = (input: string): void => { - if (!input) { - setItems([]); - - return; - } - - const service = googleAutocompleteServiceRef.current; - - if (!service) return; - - service.getAutocompleteService().then((autocompleteService) => { - autocompleteService.getPlacePredictions( - { - input, - types: types || ['geocode'], - componentRestrictions, - }, - (results) => { - const autocompleteItems = toAutocompleteItems(results ?? undefined); - - setItems(autocompleteItems); - }, - ); - }); - }; - - const onChangeHandler = (input: string) => { - onChange(input, false); - - if (!isAutocompleteEnabled) { - return resetAutocomplete(); - } - - setAutocompleteValue(input); - setItemsFromInput(input); - }; + const { items, autoComplete, handleSelect, handleChange } = useGoogleAutocomplete({ + apiKey, + fields, + nextElement, + isAutocompleteEnabled, + isNewPlacesApiEnabled, + types, + componentRestrictions, + onSelect, + onChange, + }); return ( = ({ }} items={items} listTestId="address-autocomplete-suggestions" - onChange={onChangeHandler} - onSelect={onSelectHandler} + onChange={handleChange} + onSelect={handleSelect} onToggleOpen={onToggleOpen} >
diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteFormField.tsx b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteFormField.tsx index 2fec8edc00..3bceeaa78e 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteFormField.tsx +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteFormField.tsx @@ -21,6 +21,7 @@ export interface GoogleAutocompleteFormFieldProps { nextElement?: HTMLElement; parentFieldName?: string; isFloatingLabelEnabled?: boolean; + isNewPlacesApiEnabled?: boolean; onSelect(place: google.maps.places.PlaceResult, item: AutocompleteItem): void; onToggleOpen?(state: { inputValue: string; isOpen: boolean }): void; onChange(value: string, isOpen: boolean): void; @@ -37,6 +38,7 @@ const GoogleAutocompleteFormField: FunctionComponent { const fieldName = parentFieldName ? `${parentFieldName}.${name}` : name; @@ -68,6 +70,7 @@ const GoogleAutocompleteFormField: FunctionComponent([]); + const [autoComplete, setAutoComplete] = useState('off'); + const newGooglePlacesApiServiceRef = useRef(); + const googleAutocompleteServiceRef = useRef(); + const latestNewApiInputRef = useRef(); + + if (!newGooglePlacesApiServiceRef.current) { + newGooglePlacesApiServiceRef.current = new NewGooglePlacesApiService(apiKey); + } + + if (!googleAutocompleteServiceRef.current) { + // When the new Places API is enabled, the legacy service must share the same script-loader instance. + // Otherwise Maps JS API that they depend on will be loaded twice and that breaks both services + googleAutocompleteServiceRef.current = new GoogleAutocompleteService( + apiKey, + isNewPlacesApiEnabled ? getNewGooglePlacesApiScriptLoader() : undefined, + ); + } + + const isUsingLegacyApi = () => !isNewPlacesApiEnabled || newGooglePlacesApiState.isUnavailable; + + const finalizeSelection = ( + place: google.maps.places.PlaceResult | null, + item: AutocompleteItem, + ) => { + if (nextElement) { + nextElement.focus(); + } + + onSelect(place, item); + }; + + const fetchSuggestionsWithLegacyApi = (input: string): void => { + const service = googleAutocompleteServiceRef.current; + + if (!service) return; + + service.getAutocompleteService().then((autocompleteService) => { + autocompleteService.getPlacePredictions( + { input, types: types || ['geocode'], componentRestrictions }, + (results) => { + setItems(toAutocompleteItems(results ?? undefined)); + }, + ); + }); + }; + + const fetchSuggestionsWithNewApi = (input: string): void => { + const service = newGooglePlacesApiServiceRef.current; + + if (!service) return; + + latestNewApiInputRef.current = input; + + service + .getSuggestions(input, types, componentRestrictions) + .then((results) => { + if (latestNewApiInputRef.current === input) { + setItems(results); + } + }) + .catch((error) => { + if (isNewPlacesApiPermissionDenied(error)) { + newGooglePlacesApiState.isUnavailable = true; + + if (latestNewApiInputRef.current === input) { + fetchSuggestionsWithLegacyApi(input); + } + + return; + } + + if (latestNewApiInputRef.current === input) { + setItems([]); + } + }); + }; + + const selectWithLegacyApi = (item: AutocompleteItem): void => { + const service = googleAutocompleteServiceRef.current; + + if (!service) return; + + service.getPlacesServices().then((placesService) => { + placesService.getDetails( + { placeId: item.id, fields: fields || ['address_components', 'name'] }, + (result) => { + finalizeSelection(result, item); + }, + ); + }); + }; + + const selectWithNewApi = (item: AutocompleteItem): void => { + const service = newGooglePlacesApiServiceRef.current; + + if (!service) return; + + service + .getPlaceDetails(item.id, fields) + .then((result) => finalizeSelection(result, item)) + .catch((error) => { + if (isNewPlacesApiPermissionDenied(error)) { + newGooglePlacesApiState.isUnavailable = true; + selectWithLegacyApi(item); + } + }); + }; + + const handleSelect = (item: AutocompleteItem) => { + if (isUsingLegacyApi()) { + selectWithLegacyApi(item); + + return; + } + + selectWithNewApi(item); + }; + + const resetAutocomplete = (): void => { + setItems([]); + setAutoComplete('off'); + }; + + const setAutocompleteValue = (input: string): void => { + setAutoComplete(input && input.length ? 'nope' : 'off'); + }; + + const fetchSuggestions = (input: string): void => { + if (isUsingLegacyApi()) { + fetchSuggestionsWithLegacyApi(input); + + return; + } + + fetchSuggestionsWithNewApi(input); + }; + + const handleChange = (input: string) => { + onChange(input, false); + + if (!isAutocompleteEnabled) { + resetAutocomplete(); + + return; + } + + setAutocompleteValue(input); + + if (!input) { + setItems([]); + + return; + } + + fetchSuggestions(input); + }; + + return { items, autoComplete, handleSelect, handleChange }; +} diff --git a/packages/core/src/app/address/googleAutocomplete/utils.ts b/packages/core/src/app/address/googleAutocomplete/utils.ts new file mode 100644 index 0000000000..ceffbe0f49 --- /dev/null +++ b/packages/core/src/app/address/googleAutocomplete/utils.ts @@ -0,0 +1,12 @@ +import { type AutocompleteItem } from '@bigcommerce/checkout/ui'; + +export const toAutocompleteItems = ( + results?: google.maps.places.AutocompletePrediction[], +): AutocompleteItem[] => { + return (results || []).map((result) => ({ + label: result.description, + value: result.structured_formatting.main_text, + highlightedSlices: result.matched_substrings, + id: result.place_id, + })); +};