@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.
- package/CHANGELOG.md +2 -1
- package/app/components/address-suggestion-dropdown/index.jsx +189 -0
- package/app/components/address-suggestion-dropdown/index.test.jsx +332 -0
- package/app/components/forms/address-fields.jsx +18 -2
- package/app/components/forms/useAddressFields.jsx +139 -1
- package/app/components/forms/useAddressFields.test.js +310 -0
- package/app/hooks/useAutocompleteSuggestions.js +131 -0
- package/app/hooks/useAutocompleteSuggestions.test.js +296 -0
- package/app/mocks/mock-address-suggestions.js +445 -0
- package/app/pages/checkout/index.jsx +4 -2
- package/app/pages/checkout/index.test.js +206 -0
- package/app/pages/checkout/util/checkout-context.js +4 -1
- package/app/pages/checkout/util/google-api-provider.js +45 -0
- package/app/pages/checkout/util/google-api-provider.test.js +395 -0
- package/app/ssr.js +5 -1
- package/app/static/img/GoogleMaps_Logo_Gray_4x.png +0 -0
- package/app/static/translations/compiled/en-GB.json +6 -0
- package/app/static/translations/compiled/en-US.json +6 -0
- package/app/static/translations/compiled/en-XA.json +15 -1
- package/app/theme/components/project/swatch-group.js +1 -1
- package/app/utils/address-suggestions.js +237 -0
- package/config/default.js +4 -1
- package/package.json +9 -8
- package/translations/en-GB.json +3 -0
- package/translations/en-US.json +3 -0
|
@@ -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
|
+
}
|