@salesforce/retail-react-app 8.3.0-nightly-20251208080231 → 8.3.0-preview.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,12 +5,18 @@
5
5
  * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6
6
  */
7
7
  import {useIntl, defineMessages} from 'react-intl'
8
+ import {useState, useCallback, useEffect} from 'react'
8
9
  import {formatPhoneNumber} from '@salesforce/retail-react-app/app/utils/phone-utils'
9
10
  import {
10
11
  stateOptions,
11
12
  provinceOptions
12
13
  } from '@salesforce/retail-react-app/app/components/forms/state-province-options'
13
14
  import {SHIPPING_COUNTRY_CODES} from '@salesforce/retail-react-app/app/constants'
15
+ import {
16
+ processAddressSuggestion,
17
+ setAddressFieldValues
18
+ } from '@salesforce/retail-react-app/app/utils/address-suggestions'
19
+ import {useAutocompleteSuggestions} from '@salesforce/retail-react-app/app/hooks/useAutocompleteSuggestions'
14
20
 
15
21
  const messages = defineMessages({
16
22
  required: {defaultMessage: 'Required', id: 'use_address_fields.error.required'},
@@ -42,14 +48,128 @@ export default function useAddressFields({
42
48
  form: {
43
49
  watch,
44
50
  control,
51
+ setValue,
45
52
  formState: {errors}
46
53
  },
47
54
  prefix = ''
48
55
  }) {
49
56
  const {formatMessage} = useIntl()
50
57
 
58
+ const [showDropdown, setShowDropdown] = useState(false)
59
+ const [isDismissed, setIsDismissed] = useState(false)
60
+ const [currentInput, setCurrentInput] = useState('')
61
+ const [isAutocompleted, setIsAutocompleted] = useState(false)
62
+ const [previousCountry, setPreviousCountry] = useState(undefined)
63
+
51
64
  const countryCode = watch('countryCode')
52
65
 
66
+ const {suggestions, isLoading, resetSession} = useAutocompleteSuggestions(
67
+ currentInput,
68
+ countryCode
69
+ )
70
+
71
+ const clearAddressFields = useCallback(() => {
72
+ setValue(`${prefix}address1`, '')
73
+ setValue(`${prefix}city`, '')
74
+ setValue(`${prefix}stateCode`, '')
75
+ setValue(`${prefix}postalCode`, '')
76
+ setCurrentInput('')
77
+ setShowDropdown(false)
78
+ setIsDismissed(false)
79
+ resetSession()
80
+ }, [prefix, setValue, resetSession])
81
+
82
+ useEffect(() => {
83
+ if (isAutocompleted) {
84
+ return
85
+ }
86
+
87
+ // Only clear fields if the country actually changed from a previous value (not initial load)
88
+ if (countryCode && previousCountry !== undefined && countryCode !== previousCountry) {
89
+ clearAddressFields()
90
+ }
91
+
92
+ setPreviousCountry(countryCode)
93
+ }, [countryCode, clearAddressFields, isAutocompleted, previousCountry])
94
+
95
+ const handleAddressInputChange = useCallback((value) => {
96
+ setCurrentInput(value)
97
+
98
+ if (!value || value.length < 3) {
99
+ setShowDropdown(false)
100
+ } else {
101
+ setShowDropdown(true)
102
+ setIsDismissed(false)
103
+ }
104
+ }, [])
105
+
106
+ const handleAddressFocus = useCallback(() => {
107
+ setIsDismissed(false) // Reset dismissal on new focus
108
+ }, [])
109
+
110
+ const handleAddressCut = useCallback(
111
+ (e) => {
112
+ const newValue = e.target.value
113
+ handleAddressInputChange(newValue)
114
+ },
115
+ [handleAddressInputChange]
116
+ )
117
+
118
+ useEffect(() => {
119
+ const handleClickOutside = (event) => {
120
+ const addressInput = document.querySelector(`input[name="${prefix}address1"]`)
121
+ const dropdown = document.querySelector('[data-testid="address-suggestion-dropdown"]')
122
+
123
+ if (
124
+ addressInput &&
125
+ dropdown &&
126
+ !addressInput.contains(event.target) &&
127
+ !dropdown.contains(event.target)
128
+ ) {
129
+ setShowDropdown(false)
130
+ setIsDismissed(true)
131
+ resetSession()
132
+ }
133
+ }
134
+
135
+ document.addEventListener('mousedown', handleClickOutside)
136
+
137
+ return () => {
138
+ document.removeEventListener('mousedown', handleClickOutside)
139
+ }
140
+ }, [prefix, setShowDropdown, setIsDismissed, resetSession])
141
+
142
+ const handleDropdownClose = useCallback(() => {
143
+ setShowDropdown(false)
144
+ setIsDismissed(true)
145
+ resetSession()
146
+ }, [setShowDropdown, setIsDismissed, resetSession])
147
+
148
+ const handleSuggestionSelect = useCallback(
149
+ async (suggestion) => {
150
+ try {
151
+ setIsAutocompleted(true)
152
+
153
+ const addressFields = await processAddressSuggestion(suggestion)
154
+
155
+ setAddressFieldValues(setValue, prefix, addressFields)
156
+
157
+ resetSession()
158
+ setShowDropdown(false)
159
+ setIsDismissed(true)
160
+ setCurrentInput('')
161
+
162
+ setTimeout(() => {
163
+ setIsAutocompleted(false)
164
+ }, 100)
165
+ } catch (error) {
166
+ console.error('Error parsing address suggestion:', error)
167
+ setIsAutocompleted(false)
168
+ }
169
+ },
170
+ [prefix, setValue, resetSession, setIsAutocompleted]
171
+ )
172
+
53
173
  const fields = {
54
174
  firstName: {
55
175
  name: `${prefix}firstName`,
@@ -130,7 +250,25 @@ export default function useAddressFields({
130
250
  })
131
251
  },
132
252
  error: errors[`${prefix}address1`],
133
- control
253
+ control,
254
+ inputProps: ({onChange}) => ({
255
+ onChange(evt) {
256
+ onChange(evt.target.value)
257
+ handleAddressInputChange(evt.target.value)
258
+ },
259
+ onFocus: handleAddressFocus,
260
+ onCut: handleAddressCut
261
+ }),
262
+ autocomplete: {
263
+ suggestions,
264
+ showDropdown,
265
+ isLoading,
266
+ isDismissed,
267
+ onInputChange: handleAddressInputChange,
268
+ onFocus: handleAddressFocus,
269
+ onClose: handleDropdownClose,
270
+ onSelectSuggestion: handleSuggestionSelect
271
+ }
134
272
  },
135
273
  city: {
136
274
  name: `${prefix}city`,
@@ -0,0 +1,310 @@
1
+ /*
2
+ * Copyright (c) 2025, salesforce.com, inc.
3
+ * All rights reserved.
4
+ * SPDX-License-Identifier: BSD-3-Clause
5
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6
+ */
7
+
8
+ import {renderHook, act} from '@testing-library/react'
9
+ import useAddressFields from '../forms/useAddressFields'
10
+ import {
11
+ processAddressSuggestion,
12
+ setAddressFieldValues
13
+ } from '@salesforce/retail-react-app/app/utils/address-suggestions'
14
+ import {useAutocompleteSuggestions} from '@salesforce/retail-react-app/app/hooks/useAutocompleteSuggestions'
15
+
16
+ jest.mock('@salesforce/retail-react-app/app/utils/address-suggestions')
17
+
18
+ jest.mock('@salesforce/retail-react-app/app/hooks/useAutocompleteSuggestions', () => ({
19
+ useAutocompleteSuggestions: jest.fn()
20
+ }))
21
+
22
+ jest.mock('react-intl', () => ({
23
+ useIntl: () => ({
24
+ formatMessage: jest.fn((message) => message.defaultMessage || message.id)
25
+ }),
26
+ defineMessages: jest.fn((messages) => messages)
27
+ }))
28
+
29
+ jest.mock('@salesforce/retail-react-app/app/utils/phone-utils', () => ({
30
+ formatPhoneNumber: jest.fn((value) => value)
31
+ }))
32
+
33
+ jest.mock('@salesforce/retail-react-app/app/components/forms/state-province-options', () => ({
34
+ stateOptions: [
35
+ {value: 'NY', label: 'New York'},
36
+ {value: 'CA', label: 'California'}
37
+ ],
38
+ provinceOptions: [
39
+ {value: 'ON', label: 'Ontario'},
40
+ {value: 'BC', label: 'British Columbia'}
41
+ ]
42
+ }))
43
+
44
+ jest.mock('@salesforce/retail-react-app/app/constants', () => ({
45
+ SHIPPING_COUNTRY_CODES: [
46
+ {value: 'US', label: 'United States'},
47
+ {value: 'CA', label: 'Canada'}
48
+ ]
49
+ }))
50
+
51
+ describe('useAddressFields', () => {
52
+ let mockForm
53
+ let mockSetValue
54
+ let mockWatch
55
+ let mockUseAutocompleteSuggestions
56
+
57
+ beforeEach(() => {
58
+ jest.clearAllMocks()
59
+
60
+ mockSetValue = jest.fn()
61
+ mockWatch = jest.fn()
62
+
63
+ mockForm = {
64
+ watch: mockWatch,
65
+ control: {},
66
+ setValue: mockSetValue,
67
+ formState: {errors: {}}
68
+ }
69
+
70
+ mockUseAutocompleteSuggestions = {
71
+ suggestions: [],
72
+ isLoading: false,
73
+ resetSession: jest.fn()
74
+ }
75
+
76
+ useAutocompleteSuggestions.mockReturnValue(mockUseAutocompleteSuggestions)
77
+
78
+ processAddressSuggestion.mockResolvedValue({
79
+ address1: '123 Main Street',
80
+ city: 'New York',
81
+ stateCode: 'NY',
82
+ postalCode: '10001',
83
+ countryCode: 'US'
84
+ })
85
+
86
+ setAddressFieldValues.mockImplementation((setValue, prefix, addressFields) => {
87
+ setValue(`${prefix}address1`, addressFields.address1)
88
+ if (addressFields.city) {
89
+ setValue(`${prefix}city`, addressFields.city)
90
+ }
91
+ if (addressFields.stateCode) {
92
+ setValue(`${prefix}stateCode`, addressFields.stateCode)
93
+ }
94
+ if (addressFields.postalCode) {
95
+ setValue(`${prefix}postalCode`, addressFields.postalCode)
96
+ }
97
+ if (addressFields.countryCode) {
98
+ setValue(`${prefix}countryCode`, addressFields.countryCode)
99
+ }
100
+ })
101
+ })
102
+
103
+ it('should return all required address fields', () => {
104
+ const {result} = renderHook(() => useAddressFields({form: mockForm}))
105
+
106
+ expect(result.current).toHaveProperty('firstName')
107
+ expect(result.current).toHaveProperty('lastName')
108
+ expect(result.current).toHaveProperty('phone')
109
+ expect(result.current).toHaveProperty('countryCode')
110
+ expect(result.current).toHaveProperty('address1')
111
+ expect(result.current).toHaveProperty('city')
112
+ expect(result.current).toHaveProperty('stateCode')
113
+ expect(result.current).toHaveProperty('postalCode')
114
+ expect(result.current).toHaveProperty('preferred')
115
+ })
116
+
117
+ it('should set default country to US', () => {
118
+ const {result} = renderHook(() => useAddressFields({form: mockForm}))
119
+
120
+ expect(result.current.countryCode.defaultValue).toBe('US')
121
+ })
122
+
123
+ it('should handle address input changes', () => {
124
+ const {result} = renderHook(() => useAddressFields({form: mockForm}))
125
+
126
+ act(() => {
127
+ const inputProps = result.current.address1.inputProps({onChange: jest.fn()})
128
+ inputProps.onChange({
129
+ target: {value: '123 Main'}
130
+ })
131
+ })
132
+
133
+ expect(result.current.address1.autocomplete).toBeDefined()
134
+ })
135
+
136
+ it('should handle address input changes for short input', () => {
137
+ const {result} = renderHook(() => useAddressFields({form: mockForm}))
138
+
139
+ act(() => {
140
+ const inputProps = result.current.address1.inputProps({onChange: jest.fn()})
141
+ inputProps.onChange({
142
+ target: {value: '12'}
143
+ })
144
+ })
145
+
146
+ expect(result.current.address1.autocomplete).toBeDefined()
147
+ })
148
+
149
+ it('should populate all address fields when suggestion is selected', async () => {
150
+ const {result} = renderHook(() => useAddressFields({form: mockForm}))
151
+
152
+ const suggestion = {
153
+ mainText: '123 Main Street',
154
+ secondaryText: 'New York, NY 10001, USA',
155
+ country: 'US'
156
+ }
157
+
158
+ await act(async () => {
159
+ await result.current.address1.autocomplete.onSelectSuggestion(suggestion)
160
+ })
161
+
162
+ expect(processAddressSuggestion).toHaveBeenCalledWith(suggestion)
163
+ expect(setAddressFieldValues).toHaveBeenCalledWith(mockSetValue, '', {
164
+ address1: '123 Main Street',
165
+ city: 'New York',
166
+ stateCode: 'NY',
167
+ postalCode: '10001',
168
+ countryCode: 'US'
169
+ })
170
+ })
171
+
172
+ it('should handle partial address data when some fields are missing', async () => {
173
+ const {result} = renderHook(() => useAddressFields({form: mockForm}))
174
+
175
+ processAddressSuggestion.mockResolvedValue({
176
+ address1: '456 Oak Avenue',
177
+ city: 'Toronto'
178
+ })
179
+
180
+ const suggestion = {
181
+ mainText: '456 Oak Avenue',
182
+ secondaryText: 'Toronto, Canada',
183
+ country: 'CA'
184
+ }
185
+
186
+ await act(async () => {
187
+ await result.current.address1.autocomplete.onSelectSuggestion(suggestion)
188
+ })
189
+
190
+ expect(processAddressSuggestion).toHaveBeenCalledWith(suggestion)
191
+ expect(setAddressFieldValues).toHaveBeenCalledWith(mockSetValue, '', {
192
+ address1: '456 Oak Avenue',
193
+ city: 'Toronto'
194
+ })
195
+ })
196
+
197
+ it('should handle address focus correctly', () => {
198
+ const {result} = renderHook(() => useAddressFields({form: mockForm}))
199
+
200
+ act(() => {
201
+ const inputProps = result.current.address1.inputProps({onChange: jest.fn()})
202
+ inputProps.onFocus()
203
+ })
204
+
205
+ expect(result.current.address1.autocomplete).toBeDefined()
206
+ })
207
+
208
+ it('should handle address cut event', () => {
209
+ const {result} = renderHook(() => useAddressFields({form: mockForm}))
210
+
211
+ act(() => {
212
+ const inputProps = result.current.address1.inputProps({onChange: jest.fn()})
213
+ inputProps.onCut({
214
+ target: {value: '123 Main'}
215
+ })
216
+ })
217
+
218
+ expect(result.current.address1.autocomplete).toBeDefined()
219
+ })
220
+
221
+ it('should close dropdown when onClose is called', () => {
222
+ const {result} = renderHook(() => useAddressFields({form: mockForm}))
223
+
224
+ act(() => {
225
+ result.current.address1.autocomplete.onClose()
226
+ })
227
+
228
+ expect(result.current.address1.autocomplete).toBeDefined()
229
+ expect(result.current.address1.autocomplete.onClose).toBeDefined()
230
+ })
231
+
232
+ it('should handle country change and reset address fields', () => {
233
+ let callCount = 0
234
+ mockWatch.mockImplementation(() => {
235
+ callCount++
236
+ return callCount === 1 ? 'US' : 'CA'
237
+ })
238
+
239
+ const {rerender} = renderHook(() => useAddressFields({form: mockForm}))
240
+
241
+ rerender()
242
+
243
+ expect(mockSetValue).toHaveBeenCalledWith('address1', '')
244
+ expect(mockSetValue).toHaveBeenCalledWith('city', '')
245
+ expect(mockSetValue).toHaveBeenCalledWith('stateCode', '')
246
+ expect(mockSetValue).toHaveBeenCalledWith('postalCode', '')
247
+
248
+ expect(mockUseAutocompleteSuggestions.resetSession).toHaveBeenCalled()
249
+ })
250
+
251
+ it('should use prefix for field names when provided', () => {
252
+ const {result} = renderHook(() =>
253
+ useAddressFields({
254
+ form: mockForm,
255
+ prefix: 'shipping'
256
+ })
257
+ )
258
+
259
+ expect(result.current.firstName.name).toBe('shippingfirstName')
260
+ expect(result.current.lastName.name).toBe('shippinglastName')
261
+ expect(result.current.address1.name).toBe('shippingaddress1')
262
+ })
263
+
264
+ it('should handle phone number formatting', () => {
265
+ const {result} = renderHook(() => useAddressFields({form: mockForm}))
266
+
267
+ const mockOnChange = jest.fn()
268
+
269
+ act(() => {
270
+ result.current.phone.inputProps({onChange: mockOnChange}).onChange({
271
+ target: {value: '1234567890'}
272
+ })
273
+ })
274
+
275
+ expect(mockOnChange).toHaveBeenCalledWith('1234567890')
276
+ })
277
+
278
+ it('should handle errors correctly', () => {
279
+ const mockFormWithErrors = {
280
+ ...mockForm,
281
+ formState: {
282
+ errors: {
283
+ firstName: {message: 'First name is required'},
284
+ address1: {message: 'Address is required'}
285
+ }
286
+ }
287
+ }
288
+
289
+ const {result} = renderHook(() => useAddressFields({form: mockFormWithErrors}))
290
+
291
+ expect(result.current.firstName.error).toEqual({message: 'First name is required'})
292
+ expect(result.current.address1.error).toEqual({message: 'Address is required'})
293
+ })
294
+
295
+ it('should call useAutocompleteSuggestions with correct parameters', () => {
296
+ mockWatch.mockReturnValue('US')
297
+
298
+ renderHook(() => useAddressFields({form: mockForm}))
299
+
300
+ expect(useAutocompleteSuggestions).toHaveBeenCalledWith('', 'US')
301
+ })
302
+
303
+ it('should call useAutocompleteSuggestions with prefix when provided', () => {
304
+ mockWatch.mockReturnValue('CA')
305
+
306
+ renderHook(() => useAddressFields({form: mockForm, prefix: 'shipping'}))
307
+
308
+ expect(useAutocompleteSuggestions).toHaveBeenCalledWith('', 'CA')
309
+ })
310
+ })
@@ -0,0 +1,131 @@
1
+ /*
2
+ * Copyright (c) 2025, salesforce.com, inc.
3
+ * All rights reserved.
4
+ * SPDX-License-Identifier: BSD-3-Clause
5
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6
+ */
7
+
8
+ import {useState, useRef, useCallback, useEffect} from 'react'
9
+ import {useMapsLibrary} from '@vis.gl/react-google-maps'
10
+ import {convertGoogleMapsSuggestions} from '@salesforce/retail-react-app/app/utils/address-suggestions'
11
+
12
+ const DEBOUNCE_DELAY = 300
13
+
14
+ /**
15
+ * Custom hook for Google Maps Places autocomplete suggestions
16
+ * @param {string} inputString - The input string to search for
17
+ * @param {string} countryCode - Country code to filter results (e.g., 'US', 'CA')
18
+ * @param {Object} requestOptions - Additional request options for the API
19
+ * @returns {Object} Object containing suggestions, loading state, and reset function
20
+ */
21
+ export const useAutocompleteSuggestions = (
22
+ inputString = '',
23
+ countryCode = '',
24
+ requestOptions = {}
25
+ ) => {
26
+ const places = useMapsLibrary('places')
27
+
28
+ const sessionTokenRef = useRef(null)
29
+ const debounceTimeoutRef = useRef(null)
30
+
31
+ const cacheRef = useRef(new Map())
32
+
33
+ const [suggestions, setSuggestions] = useState([])
34
+ const [isLoading, setIsLoading] = useState(false)
35
+
36
+ // Key format: `${inputString.toLowerCase().trim()}_${countryCode}`
37
+ const getCacheKey = useCallback((input, country) => {
38
+ return `${input.toLowerCase().trim()}_${country || ''}`
39
+ }, [])
40
+
41
+ const fetchSuggestions = useCallback(
42
+ async (input) => {
43
+ if (!places || !input || input.length < 3) {
44
+ setSuggestions([])
45
+ return
46
+ }
47
+
48
+ const cacheKey = getCacheKey(input, countryCode)
49
+
50
+ // Check cache first
51
+ if (cacheRef.current.has(cacheKey)) {
52
+ const cachedSuggestions = cacheRef.current.get(cacheKey)
53
+ setSuggestions(cachedSuggestions)
54
+ setIsLoading(false)
55
+ return
56
+ }
57
+
58
+ setIsLoading(true)
59
+
60
+ try {
61
+ const {AutocompleteSessionToken, AutocompleteSuggestion} = places
62
+
63
+ if (!sessionTokenRef.current) {
64
+ sessionTokenRef.current = new AutocompleteSessionToken()
65
+ }
66
+
67
+ const request = {
68
+ ...requestOptions,
69
+ input: input,
70
+ includedPrimaryTypes: ['street_address'],
71
+ sessionToken: sessionTokenRef.current
72
+ }
73
+
74
+ if (countryCode) {
75
+ request.includedRegionCodes = [countryCode]
76
+ }
77
+
78
+ const response = await AutocompleteSuggestion.fetchAutocompleteSuggestions(request)
79
+
80
+ const googleSuggestions = convertGoogleMapsSuggestions(response.suggestions)
81
+
82
+ // Store in cache for future use
83
+ cacheRef.current.set(cacheKey, googleSuggestions)
84
+
85
+ setSuggestions(googleSuggestions)
86
+ } catch (error) {
87
+ setSuggestions([])
88
+ } finally {
89
+ setIsLoading(false)
90
+ }
91
+ },
92
+ [places, countryCode, getCacheKey]
93
+ )
94
+
95
+ const resetSession = useCallback(() => {
96
+ sessionTokenRef.current = null
97
+ setSuggestions([])
98
+ setIsLoading(false)
99
+ if (debounceTimeoutRef.current) {
100
+ clearTimeout(debounceTimeoutRef.current)
101
+ }
102
+ }, [])
103
+
104
+ useEffect(() => {
105
+ if (debounceTimeoutRef.current) {
106
+ clearTimeout(debounceTimeoutRef.current)
107
+ }
108
+
109
+ if (!inputString || inputString.length < 3) {
110
+ setSuggestions([])
111
+ return
112
+ }
113
+
114
+ debounceTimeoutRef.current = setTimeout(() => {
115
+ fetchSuggestions(inputString)
116
+ }, DEBOUNCE_DELAY)
117
+
118
+ return () => {
119
+ if (debounceTimeoutRef.current) {
120
+ clearTimeout(debounceTimeoutRef.current)
121
+ }
122
+ }
123
+ }, [inputString, fetchSuggestions])
124
+
125
+ return {
126
+ suggestions,
127
+ isLoading,
128
+ resetSession,
129
+ fetchSuggestions
130
+ }
131
+ }