diff --git a/.changeset/cold-jeans-yawn.md b/.changeset/cold-jeans-yawn.md new file mode 100644 index 0000000000..0aabaac2fa --- /dev/null +++ b/.changeset/cold-jeans-yawn.md @@ -0,0 +1,5 @@ +--- +"@adyen/adyen-web": patch +--- + +Fix address lookup reseting state field after country change diff --git a/packages/lib/src/components/internal/Address/Address.test.tsx b/packages/lib/src/components/internal/Address/Address.test.tsx index 500d93b482..2fc4121da9 100644 --- a/packages/lib/src/components/internal/Address/Address.test.tsx +++ b/packages/lib/src/components/internal/Address/Address.test.tsx @@ -6,9 +6,31 @@ import { AddressData } from '../../../types'; import { FALLBACK_VALUE } from './constants'; import { render, screen } from '@testing-library/preact'; import { CoreProvider } from '../../../core/Context/CoreProvider'; +import userEvent from '@testing-library/user-event'; jest.mock('../../../core/Services/get-dataset'); -(getDataset as jest.Mock).mockImplementation(jest.fn(() => Promise.resolve([{ id: 'NL', name: 'Netherlands' }]))); +(getDataset as jest.Mock).mockImplementation( + jest.fn(dataset => { + switch (dataset) { + case 'countries': + return Promise.resolve([ + { id: 'US', name: 'United States' }, + { id: 'CA', name: 'Canada' }, + { id: 'NL', name: 'Netherlands' } + ]); + case 'states/US': + return Promise.resolve([ + { id: 'AR', name: 'Arkansas' }, + { id: 'CA', name: 'California' } + ]); + case 'states/CA': + return Promise.resolve([ + { id: 'AB', name: 'Alberta' }, + { id: 'BC', name: 'British Columbia' } + ]); + } + }) +); describe('Address', () => { const addressSpecificationsMock: AddressSpecifications = { @@ -165,7 +187,7 @@ describe('Address', () => { expect(receivedData.country).toBe(data.country); }); - test('should not include fields without a value in the data object', () => { + test('should not include fields without a value in the data object', () => { const data: AddressData = { country: 'NL' }; const onChangeMock = jest.fn(); @@ -203,4 +225,199 @@ describe('Address', () => { const receivedData = lastOnChangeCall[0].data; expect(receivedData.stateOrProvince).toBe(undefined); }); + + describe('Country and State or Province', () => { + const user = userEvent.setup(); + const onChangeMock = jest.fn(); + test('should set the stateOrProvince value to empty and show options when country changes', async () => { + const data: AddressData = { country: 'US' }; + + customRender(
); + + const countrySearch = await screen.findByLabelText('Country/Region'); + await user.click(countrySearch); + // write in the searchbar + await user.keyboard('Canada'); + // select one option + await user.keyboard('[ArrowDown][Enter]'); + const stateSearch = await screen.findByLabelText('Province or Territory'); + expect(stateSearch).toBeInTheDocument(); + // open the state selector + await user.click(stateSearch); + // check if options are avaliable + expect(screen.getByText('Alberta')).toBeVisible(); + }); + + test('should reset the stateOrProvince value when country changes', async () => { + const data: AddressData = { country: 'US', stateOrProvince: 'CA' }; + + customRender(
); + + // check if US values for state or province are set + expect(await screen.findByDisplayValue('United States')).toBeVisible(); + expect(await screen.findByDisplayValue('California')).toBeVisible(); + + // search for CountryLabel and region, choose Canada + const countrySearch = await screen.findByLabelText('Country/Region'); + await user.click(countrySearch); + // write in the searchbar + await user.keyboard('Canada'); + // select one option + await user.keyboard('[ArrowDown][Enter]'); + + // Check if the state has reset to empty value + const stateSearch = await screen.findByLabelText('Province or Territory'); + expect(stateSearch).toBeInTheDocument(); + expect(stateSearch).toHaveValue(''); + }); + + test('should trigger postal code validation on country change and show error message', async () => { + const data: AddressData = { country: 'US', stateOrProvince: 'CA', postalCode: '90000' }; + + customRender(
); + + // search for CountryLabel and region, choose Canada + const countrySearch = await screen.findByLabelText('Country/Region'); + await user.click(countrySearch); + // write in the searchbar + await user.keyboard('Canada'); + // select one option + await user.keyboard('[ArrowDown][Enter]'); + + // Check if the state has reset to empty value + const postalCodeField = await screen.findByRole('textbox', { name: 'Postal code' }); + expect(postalCodeField).toBeInTheDocument(); + expect(postalCodeField).toHaveValue('90000'); + expect(screen.getByText('Invalid format. Expected format: A9A 9A9 or A9A9A9')).toBeVisible(); + }); + }); + + describe('AddressSearch in Address', () => { + // 0. delay the test since it rellies on user input + // there's probably a performance optimisation here, but delay was the simples and most reliable way to fix it + const user = userEvent.setup({ delay: 100 }); + const onChangeMock = jest.fn(); + + test('should fill the stateOrProvince field for countries who support state', async () => { + const data: AddressData = {}; + + // 1. setup the test + // 1a. create mock for this tests + const addressMock = { + id: 1, + name: '1000 Test Road, California', + street: '1000 Test Road', + city: 'Los Santos', + //houseNumberOrName: '', + postalCode: '90000', + country: 'US', + stateOrProvince: 'CA' + }; + + // 1b. pass the mock to the the mock functions so we get it as the search result + const onAdressSearchMock = jest.fn(async (value, { resolve }) => { + await resolve([addressMock]); + }); + const onAddressSelectedMock = jest.fn(async (value, { resolve }) => { + await resolve(addressMock); + }); + + // 2. render and intereact + customRender( +
+ ); + const searchBar = screen.getByRole('combobox'); + await user.click(searchBar); + // write in the searchbar + await user.keyboard('mock'); + // select one option + await user.keyboard('[ArrowDown][Enter]'); + + // 3. check filled values are correct + expect(screen.getByDisplayValue('1000 Test Road')).toBeInTheDocument(); + expect(screen.getByDisplayValue('90000')).toBeInTheDocument(); + expect(screen.getByDisplayValue('California')).toBeInTheDocument(); + }); + + test('should fill the stateOrProvince field for countries who support state', async () => { + const data: AddressData = { country: 'CA' }; + + // 1. setup the test + // 1a. create mock for this tests + const addressMock = { + id: 1, + name: '1000 Test Road, California', + street: '1000 Test Road', + city: 'Los Santos', + //houseNumberOrName: '', + postalCode: '90000', + country: 'US', + stateOrProvince: 'CA' + }; + + // 1b. pass the mock to the the mock functions so we get it as the search result + const onAdressSearchMock = jest.fn(async (value, { resolve }) => { + await resolve([addressMock]); + }); + const onAddressSelectedMock = jest.fn(async (value, { resolve }) => { + await resolve(addressMock); + }); + + // 2. render and intereact + customRender( +
+ ); + const searchBar = screen.getByRole('combobox'); + await user.click(searchBar); + // write in the searchbar + await user.keyboard('mock'); + // select one option + await user.keyboard('[ArrowDown][Enter]'); + + // 3. check filled values are correct + expect(screen.getByDisplayValue('1000 Test Road')).toBeInTheDocument(); + expect(screen.getByDisplayValue('90000')).toBeInTheDocument(); + expect(screen.getByDisplayValue('California')).toBeInTheDocument(); + }); + + test('should trigger field validation after address is selected', async () => { + const data: AddressData = {}; + + // 1. setup the test + // 1a. create mock for this tests + const incorrectPostalCodeAddressMock = { + id: 1, + name: '1000 Test Road, California', + street: '1000 Test Road', + city: 'Los Santos', + //houseNumberOrName: '', + postalCode: '9000 AA', + country: 'US', + stateOrProvince: 'CA' + }; + + // 1b. pass the mock to the the mock functions so we get it as the search result + const onAdressSearchMock = jest.fn(async (value, { resolve }) => { + await resolve([incorrectPostalCodeAddressMock]); + }); + const onAddressSelectedMock = jest.fn(async (value, { resolve }) => { + await resolve(incorrectPostalCodeAddressMock); + }); + + // 2. render and intereact + customRender( +
+ ); + const searchBar = screen.getByRole('combobox'); + await user.click(searchBar); + // write in the searchbar + await user.keyboard('mock'); + // select one option + await user.keyboard('[ArrowDown][Enter]'); + + // 3. check filled values are correct and error state is triggered + expect(screen.getByRole('textbox', { name: 'Zip code' })).toHaveValue('9000 AA'); + expect(screen.getByText('Invalid format. Expected format: 99999 or 99999-9999')).toBeVisible(); + }); + }); }); diff --git a/packages/lib/src/components/internal/Address/Address.tsx b/packages/lib/src/components/internal/Address/Address.tsx index 0b57bcb025..69ac737a91 100644 --- a/packages/lib/src/components/internal/Address/Address.tsx +++ b/packages/lib/src/components/internal/Address/Address.tsx @@ -39,9 +39,11 @@ export default function Address(props: AddressProps) { const showAddressSearch = !!props.onAddressLookup; + const [ignoreCountryChange, setIgnoreCountryChange] = useState(false); + const showAddressFields = props.onAddressLookup ? hasSelectedAddress || useManualAddress : true; - const { data, errors, valid, isValid, handleChangeFor, triggerValidation, setData } = useForm({ + const { data, errors, valid, isValid, handleChangeFor, triggerValidation, setData, mergeData } = useForm({ schema: requiredFieldsSchema, defaultData: props.data, // Ensure any passed validation rules are merged with the default ones @@ -52,13 +54,17 @@ export default function Address(props: AddressProps) { const setSearchData = useCallback( (selectedAddress: AddressData) => { const propsKeysToProcess = ADDRESS_SCHEMA; - propsKeysToProcess.forEach(propKey => { + const newStateData = propsKeysToProcess.reduce((acc: AddressData, 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)); - }); + if (providedValue !== null && providedValue !== undefined) { + // Cast everything to string + acc[propKey] = String(providedValue); + } + return acc; + }, {}); + mergeData(newStateData); + setIgnoreCountryChange(true); triggerValidation(); setHasSelectedAddress(true); }, @@ -93,6 +99,12 @@ export default function Address(props: AddressProps) { * - Applies validation on postalCode field in case it has any value */ useEffect((): void => { + // if the country was set via setSearchData we don't want to trigger this + if (ignoreCountryChange) { + setIgnoreCountryChange(false); + return; + } + const stateOrProvince = specifications.countryHasDataset(data.country) ? '' : FALLBACK_VALUE; const newData = { ...data, stateOrProvince }; diff --git a/packages/lib/src/utils/useForm/reducer.ts b/packages/lib/src/utils/useForm/reducer.ts index 108e37efc2..e1eca0b18f 100644 --- a/packages/lib/src/utils/useForm/reducer.ts +++ b/packages/lib/src/utils/useForm/reducer.ts @@ -54,13 +54,16 @@ export function init({ schema, defaultData, processField, fieldProblems }) { } export function getReducer(processField) { - return function reducer(state, { type, key, value, mode, schema, defaultData, formValue, selectedSchema, fieldProblems }: any) { + return function reducer(state, { type, key, value, mode, schema, defaultData, formValue, selectedSchema, fieldProblems, data }) { const validationSchema: string[] = selectedSchema || state.schema; switch (type) { case 'setData': { return { ...state, data: { ...state['data'], [key]: value } }; } + case 'mergeData': { + return { ...state, data: { ...state['data'], ...data } }; + } case 'setValid': { return { ...state, valid: { ...state['valid'], [key]: value } }; } diff --git a/packages/lib/src/utils/useForm/types.ts b/packages/lib/src/utils/useForm/types.ts index 9f8fae2fb8..69c879146b 100644 --- a/packages/lib/src/utils/useForm/types.ts +++ b/packages/lib/src/utils/useForm/types.ts @@ -35,6 +35,7 @@ export interface Form extends FormState { triggerValidation: (schema?: any) => void; setSchema: (schema: any) => void; setData: (key: string, value: any) => void; + mergeData: (data: FormSchema) => void; setValid: (key: string, value: any) => void; setErrors: (key: string, value: any) => void; mergeForm: (formValue: any) => void; diff --git a/packages/lib/src/utils/useForm/useForm.ts b/packages/lib/src/utils/useForm/useForm.ts index 02fa074c46..8caea4cedb 100644 --- a/packages/lib/src/utils/useForm/useForm.ts +++ b/packages/lib/src/utils/useForm/useForm.ts @@ -55,6 +55,7 @@ function useForm(props: FormProps): Form { const setErrors = useCallback((key, value) => dispatch({ type: 'setErrors', key, value }), []); const setValid = useCallback((key, value) => dispatch({ type: 'setValid', key, value }), []); const setData = useCallback((key, value) => dispatch({ type: 'setData', key, value }), []); + const mergeData = useCallback(data => dispatch({ type: 'mergeData', data }), []); const setSchema = useCallback(schema => dispatch({ type: 'setSchema', schema, defaultData }), [state.schema]); const mergeForm = useCallback(formValue => dispatch({ type: 'mergeForm', formValue }), []); const setFieldProblems = useCallback(fieldProblems => dispatch({ type: 'setFieldProblems', fieldProblems }), [state.schema]); @@ -69,6 +70,7 @@ function useForm(props: FormProps): Form { triggerValidation, setSchema, setData, + mergeData, setValid, setErrors, isValid,