Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Adds onAddressSelected to address lookup functionality #2406

Merged
merged 12 commits into from
Dec 4, 2023
5 changes: 5 additions & 0 deletions .changeset/fifty-parrots-collect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@adyen/adyen-web': minor
---

feature: adds new onAddressSelected to fill data when an item is selected in AddressSearch
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,8 @@ const CardInput: FunctionalComponent<CardInputProps> = props => {
partialAddressSchema={partialAddressSchema}
handleAddress={handleAddress}
onAddressLookup={props.onAddressLookup}
onAddressSelected={props.onAddressSelected}
addressSearchDebounceMs={props.addressSearchDebounceMs}
//
iOSFocusedField={iOSFocusedField}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export const CardFieldsWrapper = ({
setAddressRef,
partialAddressSchema,
onAddressLookup,
onAddressSelected,
addressSearchDebounceMs,
// For this comp (props passed through from CardInput)
amount,
billingAddressRequired,
Expand Down Expand Up @@ -159,6 +161,8 @@ export const CardFieldsWrapper = ({
specifications={partialAddressSchema}
iOSFocusedField={iOSFocusedField}
onAddressLookup={onAddressLookup}
onAddressSelected={onAddressSelected}
addressSearchDebounceMs={addressSearchDebounceMs}
/>
)}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -108,6 +108,8 @@ export interface CardInputProps {
onFocus?: (e) => {};
onLoad?: () => {};
onAddressLookup?: OnAddressLookupType;
onAddressSelected?: OnAddressSelectedType;
addressSearchDebounceMs?: number;
payButton?: (obj) => {};
placeholders?: Placeholders;
positionHolderNameOnTop?: boolean;
Expand Down
33 changes: 19 additions & 14 deletions packages/lib/src/components/internal/Address/Address.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -172,10 +175,12 @@ export default function Address(props: AddressProps) {
{showAddressSearch && (
<AddressSearch
onAddressLookup={props.onAddressLookup}
onAddressSelected={props.onAddressSelected}
onSelect={setSearchData}
onManualAddress={onManualAddress}
externalErrorMessage={searchErrorMessage}
hideManualButton={showAddressFields}
addressSearchDebounceMs={props.addressSearchDebounceMs}
/>
)}
{showAddressFields && (
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<AddressSearch
onSelect={() => {}}
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(
<AddressSearch
onSelect={internalSetDataMock}
onManualAddress={() => {}}
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(
<AddressSearch
onSelect={() => {}}
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(
<AddressSearch
onSelect={() => {}}
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();
});
Loading