diff --git a/.changeset/fifty-parrots-collect.md b/.changeset/fifty-parrots-collect.md new file mode 100644 index 0000000000..d81b6b64c1 --- /dev/null +++ b/.changeset/fifty-parrots-collect.md @@ -0,0 +1,5 @@ +--- +'@adyen/adyen-web': minor +--- + +feature: adds new onAddressSelected to fill data when an item is selected in AddressSearch diff --git a/packages/lib/src/components/Card/components/CardInput/CardInput.tsx b/packages/lib/src/components/Card/components/CardInput/CardInput.tsx index 0cd0ddbcbb..d65a321d88 100644 --- a/packages/lib/src/components/Card/components/CardInput/CardInput.tsx +++ b/packages/lib/src/components/Card/components/CardInput/CardInput.tsx @@ -493,6 +493,8 @@ const CardInput: FunctionalComponent = props => { partialAddressSchema={partialAddressSchema} handleAddress={handleAddress} onAddressLookup={props.onAddressLookup} + onAddressSelected={props.onAddressSelected} + addressSearchDebounceMs={props.addressSearchDebounceMs} // iOSFocusedField={iOSFocusedField} /> diff --git a/packages/lib/src/components/Card/components/CardInput/components/CardFieldsWrapper.tsx b/packages/lib/src/components/Card/components/CardInput/components/CardFieldsWrapper.tsx index 84904a73e6..d62d4be4df 100644 --- a/packages/lib/src/components/Card/components/CardInput/components/CardFieldsWrapper.tsx +++ b/packages/lib/src/components/Card/components/CardInput/components/CardFieldsWrapper.tsx @@ -45,6 +45,8 @@ export const CardFieldsWrapper = ({ setAddressRef, partialAddressSchema, onAddressLookup, + onAddressSelected, + addressSearchDebounceMs, // For this comp (props passed through from CardInput) amount, billingAddressRequired, @@ -159,6 +161,8 @@ export const CardFieldsWrapper = ({ specifications={partialAddressSchema} iOSFocusedField={iOSFocusedField} onAddressLookup={onAddressLookup} + onAddressSelected={onAddressSelected} + addressSearchDebounceMs={addressSearchDebounceMs} /> )} diff --git a/packages/lib/src/components/Card/components/CardInput/types.ts b/packages/lib/src/components/Card/components/CardInput/types.ts index edb24ac695..71d3e9ed1d 100644 --- a/packages/lib/src/components/Card/components/CardInput/types.ts +++ b/packages/lib/src/components/Card/components/CardInput/types.ts @@ -13,7 +13,7 @@ import Analytics from '../../../../core/Analytics'; import RiskElement from '../../../../core/RiskModule'; import { ComponentMethodsRef } from '../../../types'; import { DisclaimerMsgObject } from '../../../internal/DisclaimerMessage/DisclaimerMessage'; -import { OnAddressLookupType } from '../../../internal/Address/components/AddressSearch'; +import { OnAddressLookupType, OnAddressSelectedType } from '../../../internal/Address/components/AddressSearch'; export interface CardInputValidState { holderName?: boolean; @@ -108,6 +108,8 @@ export interface CardInputProps { onFocus?: (e) => {}; onLoad?: () => {}; onAddressLookup?: OnAddressLookupType; + onAddressSelected?: OnAddressSelectedType; + addressSearchDebounceMs?: number; payButton?: (obj) => {}; placeholders?: Placeholders; positionHolderNameOnTop?: boolean; diff --git a/packages/lib/src/components/internal/Address/Address.tsx b/packages/lib/src/components/internal/Address/Address.tsx index 2ccc87b449..ea23d1861d 100644 --- a/packages/lib/src/components/internal/Address/Address.tsx +++ b/packages/lib/src/components/internal/Address/Address.tsx @@ -1,5 +1,5 @@ import { Fragment, h } from 'preact'; -import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'; import Fieldset from '../FormFields/Fieldset'; import ReadOnlyAddress from './components/ReadOnlyAddress'; import { getAddressValidationRules } from './validate'; @@ -49,22 +49,25 @@ export default function Address(props: AddressProps) { formatters: addressFormatters }); - const setSearchData = selectedAddress => { - const propsKeysToProcess = ADDRESS_SCHEMA; - propsKeysToProcess.forEach(propKey => { - // Make sure the data provided by the merchant is always strings - const providedValue = selectedAddress[propKey]; - if (providedValue === null || providedValue === undefined) return; - // Cast everything to string - setData(propKey, String(providedValue)); + const setSearchData = useCallback( + (selectedAddress: AddressData) => { + const propsKeysToProcess = ADDRESS_SCHEMA; + propsKeysToProcess.forEach(propKey => { + // Make sure the data provided by the merchant is always strings + const providedValue = selectedAddress[propKey]; + if (providedValue === null || providedValue === undefined) return; + // Cast everything to string + setData(propKey, String(providedValue)); + }); triggerValidation(); - }); - setHasSelectedAddress(true); - }; + setHasSelectedAddress(true); + }, + [setHasSelectedAddress, triggerValidation, setData] + ); - const onManualAddress = () => { + const onManualAddress = useCallback(() => { setUseManualAddress(true); - }; + }, []); // Expose method expected by (parent) Address.tsx addressRef.current.showValidation = () => { @@ -172,10 +175,12 @@ export default function Address(props: AddressProps) { {showAddressSearch && ( )} {showAddressFields && ( diff --git a/packages/lib/src/components/internal/Address/components/AddressSearch.test.tsx b/packages/lib/src/components/internal/Address/components/AddressSearch.test.tsx new file mode 100644 index 0000000000..6abc8b74ed --- /dev/null +++ b/packages/lib/src/components/internal/Address/components/AddressSearch.test.tsx @@ -0,0 +1,191 @@ +import { h } from 'preact'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '@testing-library/preact'; +import AddressSearch from './AddressSearch'; + +const ADDRESS_LOOKUP_RESULT = [ + { + id: 1, + name: 'Road 1, 2000, UK', + street: 'Road 1' + }, + { + id: 2, + name: 'Road 2, 2500, UK', + street: 'Road 2' + }, + { + id: 3, + name: 'Road 3, 3000, UK', + street: 'Road 3' + } +]; + +const ADDRESS_SELECT_RESULT = { + id: 1, + name: 'Road 1, 2000, UK', + street: 'Road 1', + city: 'London', + houseNumberOrName: '1', + postalCode: '2000', + country: 'GB', + raw: { + id: 1, + raw: 'RAW_DATA_MOCK' + } +}; +const onAddressLookupMockFn = async (value, { resolve }) => { + resolve(ADDRESS_LOOKUP_RESULT); +}; +const onAddressSelectMockFn = async (value, { resolve }) => { + resolve(ADDRESS_SELECT_RESULT); +}; + +const onAddressSelectMockFnReject = + rejectReason => + async (value, { reject }) => { + reject(rejectReason); + }; + +test('onAddressLookupMock should be triggered when typing', async () => { + const user = userEvent.setup({ delay: 100 }); + + const onAddressLookupMock = jest.fn(onAddressLookupMockFn); + + render( + {}} + onManualAddress={() => {}} + externalErrorMessage={'failed'} + onAddressLookup={onAddressLookupMock} + hideManualButton={true} + addressSearchDebounceMs={0} + /> + ); + + // Helps to make sure all tests ran + expect.assertions(5); + + // Get the input + const searchBar = screen.getByRole('combobox'); + await user.click(searchBar); + expect(searchBar).toHaveFocus(); + + await user.keyboard('Test'); + expect(searchBar).toHaveValue('Test'); + + // Test if onAddressLookup is called with the correct values + await waitFor(() => expect(onAddressLookupMock).toHaveBeenCalledTimes(4)); + await waitFor(() => expect(onAddressLookupMock.mock.lastCall[0]).toBe('Test')); + // Test if the return of the function is displayed + const resultList = screen.getByRole('listbox'); + expect(resultList).toHaveTextContent('Road 1, 2000, UK'); +}); + +test('onSelect is triggered with correct data', async () => { + const user = userEvent.setup({ delay: 100 }); + + const onAddressLookupMock = jest.fn(onAddressLookupMockFn); + const onAddressSelectMock = jest.fn(onAddressSelectMockFn); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const internalSetDataMock = jest.fn(data => {}); + + render( + {}} + externalErrorMessage={'failed'} + onAddressLookup={onAddressLookupMock} + onAddressSelected={onAddressSelectMock} + hideManualButton={true} + addressSearchDebounceMs={0} + /> + ); + const searchBar = screen.getByRole('combobox'); + + await user.click(searchBar); + await user.keyboard('Test'); + + // Move down with the keyboard and select the first option + await user.keyboard('[ArrowDown][Enter]'); + await waitFor(() => expect(onAddressSelectMock).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect(onAddressSelectMock.mock.lastCall[0]).toStrictEqual({ + id: 1, + name: 'Road 1, 2000, UK', + street: 'Road 1' + }) + ); + + // CHeck if the parents select function is called with full data + await waitFor(() => expect(internalSetDataMock.mock.lastCall[0]).toStrictEqual(ADDRESS_SELECT_RESULT)); +}); + +test('rejecting onAddressLookupMock should not trigger error', async () => { + const user = userEvent.setup({ delay: 100 }); + + const onAddressLookupMock = jest.fn(onAddressSelectMockFnReject({})); + + render( + {}} + onManualAddress={() => {}} + externalErrorMessage={'failed'} + onAddressLookup={onAddressLookupMock} + hideManualButton={true} + addressSearchDebounceMs={0} + /> + ); + + // Helps to make sure all tests ran + expect.assertions(3); + + // Get the input + const searchBar = screen.getByRole('combobox'); + await user.click(searchBar); + await user.keyboard('Test'); + + // Still test if correct values are being called + await waitFor(() => expect(onAddressLookupMock).toHaveBeenCalledTimes(4)); + await waitFor(() => expect(onAddressLookupMock.mock.lastCall[0]).toBe('Test')); + + // Test if no options are displayed + const resultList = screen.getByRole('listbox'); + expect(resultList).toHaveTextContent('No options found'); + + // TODO fix this + // const resultError = screen.getByText('failed'); + // expect(resultError).not.toBeVisible(); +}); + +test('rejecting onAddressLookupMock with errorMessage displays error and message', async () => { + const user = userEvent.setup({ delay: 100 }); + + const onAddressLookupMock = jest.fn(onAddressSelectMockFnReject({ errorMessage: 'Refused Mock' })); + + render( + {}} + onManualAddress={() => {}} + externalErrorMessage={'failed'} + onAddressLookup={onAddressLookupMock} + hideManualButton={true} + addressSearchDebounceMs={0} + /> + ); + + // Helps to make sure all tests ran + expect.assertions(3); + + // Get the input + const searchBar = screen.getByRole('combobox'); + await user.click(searchBar); + await user.keyboard('Test'); + + await waitFor(() => expect(onAddressLookupMock).toHaveBeenCalledTimes(4)); + await waitFor(() => expect(onAddressLookupMock.mock.lastCall[0]).toBe('Test')); + + // Test if no options are displayed + const resultError = screen.getByText('Refused Mock'); + expect(resultError).toBeVisible(); +}); diff --git a/packages/lib/src/components/internal/Address/components/AddressSearch.tsx b/packages/lib/src/components/internal/Address/components/AddressSearch.tsx index 6786f67cd6..0538ee1c1b 100644 --- a/packages/lib/src/components/internal/Address/components/AddressSearch.tsx +++ b/packages/lib/src/components/internal/Address/components/AddressSearch.tsx @@ -1,11 +1,12 @@ import Field from '../../FormFields/Field'; -import { Fragment, h } from 'preact'; +import { h } from 'preact'; import { AddressLookupItem } from '../types'; -import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'; +import { useCallback, useEffect, useState, useMemo } from 'preact/hooks'; import './AddressSearch.scss'; import useCoreContext from '../../../../core/Context/useCoreContext'; import { debounce } from '../utils'; import Select from '../../FormFields/Select'; +import { AddressData } from '../../../../types'; export type OnAddressLookupType = ( value: string, @@ -15,15 +16,37 @@ export type OnAddressLookupType = ( } ) => Promise; +export type OnAddressSelectedType = ( + value: string, + actions: { + resolve: (value: AddressLookupItem) => void; + reject: (reason?: any) => void; + } +) => Promise; + interface AddressSearchProps { onAddressLookup?: OnAddressLookupType; - onSelect: any; //TODO + onAddressSelected?: OnAddressSelectedType; + onSelect: (addressItem: AddressData) => void; onManualAddress: any; externalErrorMessage: string; hideManualButton: boolean; + addressSearchDebounceMs?: number; +} + +interface RejectionReason { + errorMessage: string; } -export default function AddressSearch({ onAddressLookup, onSelect, onManualAddress, externalErrorMessage, hideManualButton }: AddressSearchProps) { +export default function AddressSearch({ + onAddressLookup, + onAddressSelected, + onSelect, + onManualAddress, + externalErrorMessage, + hideManualButton, + addressSearchDebounceMs +}: Readonly) { const [formattedData, setFormattedData] = useState([]); const [originalData, setOriginalData] = useState([]); @@ -32,20 +55,23 @@ export default function AddressSearch({ onAddressLookup, onSelect, onManualAddre const { i18n } = useCoreContext(); const mapDataToSelect = data => data.map(({ id, name }) => ({ id, name })); - const onInput = useCallback( - async event => { + const handlePromiseReject = useCallback((reason: RejectionReason) => { + if (reason?.errorMessage) { + setErrorMessage(reason.errorMessage); + } + }, []); + + const onTextInput = useCallback( + async (inputValue: string) => { new Promise>((resolve, reject) => { - onAddressLookup(event, { resolve, reject }); + onAddressLookup(inputValue, { resolve, reject }); }) - .then(data => { - setOriginalData(data); - setFormattedData(mapDataToSelect(data)); + .then(searchArray => { + setOriginalData(searchArray); + setFormattedData(mapDataToSelect(searchArray)); setErrorMessage(''); }) - .catch(reason => { - setErrorMessage(reason); - console.error('error', reason); - }); + .catch(reason => handlePromiseReject(reason)); }, [onAddressLookup] ); @@ -55,44 +81,57 @@ export default function AddressSearch({ onAddressLookup, onSelect, onManualAddre setErrorMessage(externalErrorMessage); }, [externalErrorMessage]); - const onChange = event => { + const onSelectItem = async event => { if (!event.target.value) { setErrorMessage(i18n.get('address.errors.incomplete')); return; } const value = originalData.find(item => item.id === event.target.value); - onSelect(value); - setFormattedData([]); + + // 1. in case we don't get a function just select item + if (typeof onAddressSelected !== 'function') { + onSelect(value); + setFormattedData([]); + return; + } + + // 2. in case callback is provided, create and call onAddressSelected + new Promise((resolve, reject) => { + onAddressSelected(value, { resolve, reject }); + }) + .then(fullData => { + onSelect(fullData); + setFormattedData([]); + }) + .catch(reason => handlePromiseReject(reason)); }; - const debounceInputHandler = useMemo(() => debounce(onInput), []); + const debounceInputHandler = useMemo(() => debounce(onTextInput, addressSearchDebounceMs), []); return ( - -
- - + + {!hideManualButton && ( + + + + )} +
); } diff --git a/packages/lib/src/components/internal/Address/types.ts b/packages/lib/src/components/internal/Address/types.ts index bd530ed6e2..377d1cc94d 100644 --- a/packages/lib/src/components/internal/Address/types.ts +++ b/packages/lib/src/components/internal/Address/types.ts @@ -2,7 +2,7 @@ import { AddressField, AddressData } from '../../../types'; import Specifications from './Specifications'; import { ValidatorRules } from '../../../utils/Validator/types'; import { ValidationRuleResult } from '../../../utils/Validator/ValidationRuleResult'; -import { OnAddressLookupType } from './components/AddressSearch'; +import { OnAddressLookupType, OnAddressSelectedType } from './components/AddressSearch'; // Describes an object with unknown keys whose value is always a string export type StringObject = { @@ -16,6 +16,8 @@ export interface AddressProps { label?: string; onChange: (newState) => void; onAddressLookup?: OnAddressLookupType; + onAddressSelected?: OnAddressSelectedType; + addressSearchDebounceMs?: number; requiredFields?: string[]; ref?: any; specifications?: AddressSpecifications; diff --git a/packages/lib/src/components/internal/FormFields/Select/Select.tsx b/packages/lib/src/components/internal/FormFields/Select/Select.tsx index ada51a00cf..178809e803 100644 --- a/packages/lib/src/components/internal/FormFields/Select/Select.tsx +++ b/packages/lib/src/components/internal/FormFields/Select/Select.tsx @@ -27,7 +27,8 @@ function Select({ uniqueId, disabled, disableTextFilter, - clearOnSelect + clearOnSelect, + blurOnClose }: SelectProps) { const filterInputRef = useRef(null); const selectContainerRef = useRef(null); @@ -75,6 +76,8 @@ function Select({ * Closes the selectList, empties the text filter and focuses the button element */ const closeList = () => { + //blurs the field when the list is closed, makes for a better UX for most users, needs more testing + blurOnClose && filterInputRef.current.blur(); setShowList(false); }; diff --git a/packages/lib/src/components/internal/FormFields/Select/types.ts b/packages/lib/src/components/internal/FormFields/Select/types.ts index bed81faa85..20580da838 100644 --- a/packages/lib/src/components/internal/FormFields/Select/types.ts +++ b/packages/lib/src/components/internal/FormFields/Select/types.ts @@ -32,6 +32,7 @@ export interface SelectProps { disabled?: boolean; disableTextFilter?: boolean; clearOnSelect?: boolean; + blurOnClose?: boolean; } export interface SelectButtonProps {