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

Arshad/ COJ-481 / Address Fields #13074

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React, { ComponentProps } from 'react';
import { Field, FieldProps } from 'formik';
import * as Yup from 'yup';
import { useBreakpoint } from '@deriv/quill-design';
import { WalletDropdown as DropDown } from '../base/WalletDropdown';

type FormDropDownFieldProps = Omit<
ComponentProps<typeof DropDown>,
'errorMessage' | 'isRequired' | 'onSelect' | 'variant'
> & {
name: string;
validationSchema?: Yup.AnySchema;
};

/**
* FormDropDownField is a wrapper around Dropdown that can be used with Formik.
* @name FormDropDownField
* @param name - Name of the field
* @param [props] - Other props to pass to Input
* @returns ReactNode
*/
const FormDropDownField = ({ name, validationSchema, ...rest }: FormDropDownFieldProps) => {
const { isMobile } = useBreakpoint();

const validateField = (value: unknown) => {
try {
if (validationSchema) {
validationSchema.validateSync(value);
}
} catch (err: unknown) {
return (err as Yup.ValidationError).message;
}
};

return (
<Field name={name} validate={validateField}>
{({ field, form, meta: { error, touched } }: FieldProps<string>) => (
<DropDown
{...field}
{...rest}
errorMessage={error}
isRequired={touched && !!error}
onSelect={(value: string) => form.setFieldValue(name, value)}
variant={isMobile ? 'prompt' : 'comboBox'}
/>
)}
</Field>
);
};

export default FormDropDownField;
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React, { ComponentProps } from 'react';
import { Field, FieldProps } from 'formik';
import * as Yup from 'yup';
import { WalletTextField as TextField } from '../base/WalletTextField';

type FormInputFieldProps = Omit<ComponentProps<typeof TextField>, 'errorMessage' | 'isInvalid' | 'showMessage'> & {
name: string;
validationSchema?: Yup.AnySchema;
};

/**
* FormInputField is a wrapper around Input that can be used with Formik.
* @name FormInputField
* @param name - Name of the field
* @param [validationSchema] - Yup validation schema to use for the field
* @param [props] - Other props to pass to Input
* @returns ReactNode
*/
const FormInputField = ({ name, validationSchema, ...rest }: FormInputFieldProps) => {
const validateField = (value: unknown) => {
try {
if (validationSchema) {
validationSchema.validateSync(value);
}
} catch (err: unknown) {
return (err as Yup.ValidationError).message;
}
};

return (
<Field name={name} validate={validateField}>
{({ field, meta: { error, touched } }: FieldProps<string>) => (
<TextField
{...field}
{...rest}
autoComplete='off'
errorMessage={touched && error}
isInvalid={touched && !!error}
showMessage
type='text'
/>
)}
</Field>
);
};

export default FormInputField;
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
label,
&__field:focus ~ &__label {
position: absolute;
top: -0.5rem;
top: 0;
display: block;
transition: 0.2s;
font-size: 1rem;
Expand Down
7 changes: 7 additions & 0 deletions packages/account-v2/src/constants/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const LANDING_COMPANY = {
BVI: 'bvi',
LABUAN: 'labuan',
MALTAINVEST: 'maltainvest',
SVG: 'svg',
VANUATU: 'vanuatu',
} as const;
72 changes: 72 additions & 0 deletions packages/account-v2/src/modules/AddressFields/AddressFields.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import React from 'react';
import { useAuthorize, useSettings, useStatesList } from '@deriv/api';
import FormDropDownField from '../../components/FormFields/FormDropDownField';
import FormInputField from '../../components/FormFields/FormInputField';
import { LANDING_COMPANY } from '../../constants/constants';
import { addressDetailValidations } from './validations';

export const AddressFields = () => {
const { data: activeAccount } = useAuthorize();
const { data: settings } = useSettings();

const { landing_company_name: landingCompanyName, upgradeable_landing_companies: upgradableLandingCompanies } =
activeAccount;

const isSvg =
landingCompanyName === LANDING_COMPANY.SVG || !!upgradableLandingCompanies?.includes(LANDING_COMPANY.SVG);
const { data: statesList, isFetched: statesListFetched } = useStatesList(settings.country_code || '', {
enabled: !!settings.country_code,
});

const {
addressCity: addressCitySchema,
addressLine1: addressLine1Schema,
addressLine2: addressLine2Schema,
addressPostcode: addressPostcodeSchema,
addressState: addressStateSchema,
} = addressDetailValidations(settings.country_code ?? '', isSvg);

return (
<div className='space-y-600'>
<FormInputField
label='First line of address*'
name='addressLine1'
placeholder='First line of address'
validationSchema={addressLine1Schema}
/>
<FormInputField
label='Second line of address'
name='addressLine2'
placeholder='Second line of address'
validationSchema={addressLine2Schema}
/>
<FormInputField
label='Town/City*'
name='addressCity'
placeholder='Town/City'
validationSchema={addressCitySchema}
/>
{statesListFetched && statesList.length ? (
<FormDropDownField
label='State/Province'
list={statesList}
name='addressState'
validationSchema={addressStateSchema}
/>
) : (
<FormInputField
label='State/Province'
name='addressState'
placeholder='State/Province'
validationSchema={addressStateSchema}
/>
)}
<FormInputField
label='Postal/ZIP Code'
name='addressPostcode'
placeholder='Postal/ZIP Code'
validationSchema={addressPostcodeSchema}
/>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { addressDetailValidations as validations, addressPermittedSpecialCharactersMessage } from '../validations';

describe('validations', () => {
const maxCharsMessage = 'Only 70 characters, please.';
it('validates addressLine1 correctly without svg flag', async () => {
const { addressLine1 } = validations('id', false);

await expect(addressLine1.validate('123 Main St')).resolves.toBe('123 Main St');
await expect(addressLine1.validate('')).rejects.toThrow('First line of address is required');
await expect(addressLine1.validate('a$^&')).rejects.toThrow(
`Use only the following special characters: ${addressPermittedSpecialCharactersMessage}`
);
await expect(addressLine1.validate('a'.repeat(71))).rejects.toThrow(maxCharsMessage);
await expect(addressLine1.validate('P.O. Box 121')).resolves.toBe('P.O. Box 121');
});

it('validates addressLine1 correctly with svg flag', async () => {
const { addressLine1 } = validations('id', true);

await expect(addressLine1.validate('123 Main St1')).resolves.toBe('123 Main St1');
await expect(addressLine1.validate('')).rejects.toThrow('First line of address is required');
await expect(addressLine1.validate('a'.repeat(71))).rejects.toThrow(maxCharsMessage);
await expect(addressLine1.validate('P.O. Box 123')).rejects.toThrow('P.O. Box is not accepted in address');
});

it('validates addressLine2 correctly', async () => {
const { addressLine2 } = validations('id', false);

await expect(addressLine2.validate('Apt 4B')).resolves.toBe('Apt 4B');
await expect(addressLine2.validate('a$^&')).rejects.toThrow(
`Use only the following special characters: ${addressPermittedSpecialCharactersMessage}`
);
await expect(addressLine2.validate('a'.repeat(71))).rejects.toThrow(maxCharsMessage);
await expect(addressLine2.validate('P.O. Box 120')).resolves.toBe('P.O. Box 120');
});

it('validates addressLine2 correctly with svg flag', async () => {
const { addressLine2 } = validations('id', true);

await expect(addressLine2.validate('Apt 4B')).resolves.toBe('Apt 4B');
await expect(addressLine2.validate('a'.repeat(71))).rejects.toThrow(maxCharsMessage);
await expect(addressLine2.validate('P.O. Box 122')).rejects.toThrow('P.O. Box is not accepted in address');
});

it('validates addressPostcode correctly with country gb', async () => {
const { addressPostcode } = validations('gb', false);

await expect(addressPostcode.validate('12345')).resolves.toBe('12345');
await expect(addressPostcode.validate('$%&')).rejects.toThrow('Letters, numbers, spaces, hyphens only');
await expect(addressPostcode.validate('a'.repeat(21))).rejects.toThrow(
'Please enter a postal/ZIP code under 20 characters.'
);
await expect(addressPostcode.validate('JE1 1AA')).rejects.toThrow(
'Our accounts and services are unavailable for the Jersey postal code.'
);
});

it('validates addressPostcode correctly with country id', async () => {
const { addressPostcode } = validations('id', false);

await expect(addressPostcode.validate('12345')).resolves.toBe('12345');
await expect(addressPostcode.validate('$%&')).rejects.toThrow('Letters, numbers, spaces, hyphens only');
await expect(addressPostcode.validate('a'.repeat(21))).rejects.toThrow(
'Please enter a postal/ZIP code under 20 characters.'
);
await expect(addressPostcode.validate('JE1 1AA')).resolves.toBe('JE1 1AA');
});

it('validates addressState correctly', async () => {
const { addressState } = validations('id', false);

await expect(addressState.validate('NY')).resolves.toBe('NY');
await expect(addressState.validate('')).rejects.toThrow('State is required');
await expect(addressState.validate('a'.repeat(100))).rejects.toThrow('State is not in a proper format');
await expect(addressState.validate('%_ASD')).rejects.toThrow('State is not in a proper format');
});

it('validates addressCity correctly', async () => {
const { addressCity } = validations('id', false);

await expect(addressCity.validate('New York')).resolves.toBe('New York');
await expect(addressCity.validate('')).rejects.toThrow('City is required');
await expect(addressCity.validate('a'.repeat(100))).rejects.toThrow('Only 99 characters, please.');
await expect(addressCity.validate('%_ASD')).rejects.toThrow(
'Only letters, periods, hyphens, apostrophes, and spaces, please.'
);
});
});
1 change: 1 addition & 0 deletions packages/account-v2/src/modules/AddressFields/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AddressFields } from './AddressFields';
66 changes: 66 additions & 0 deletions packages/account-v2/src/modules/AddressFields/validations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import * as Yup from 'yup';

const regexChecks = {
address_details: {
address_city: /^\p{L}[\p{L}\s'.-]{0,99}$/u,
address_line_1: /^[\p{L}\p{Nd}\s'.,:;()\u00b0@#/-]{1,70}$/u,
address_line_2: /^[\p{L}\p{Nd}\s'.,:;()\u00b0@#/-]{0,70}$/u,
address_postcode: /^[a-zA-Z0-9\s-]{0,20}$/,
address_state: /^[\w\s'.;,-]{0,99}$/,
non_jersey_postcode: /^(?!\s*je.*)[a-z0-9\s-]*/i,
},
};

export const addressPermittedSpecialCharactersMessage = ". , ' : ; ( ) ° @ # / -";

export const addressDetailValidations = (countryCode: string, isSvg: boolean) => ({
addressCity: Yup.string()
.required('City is required')
.max(99, 'Only 99 characters, please.')
.matches(
regexChecks.address_details.address_city,
'Only letters, periods, hyphens, apostrophes, and spaces, please.'
),
addressLine1: Yup.string()
.required('First line of address is required')
.max(70, 'Only 70 characters, please.')
.matches(
regexChecks.address_details.address_line_1,
`Use only the following special characters: ${addressPermittedSpecialCharactersMessage}`
)
.when({
is: () => isSvg,
then: Yup.string().test(
'po_box',
'P.O. Box is not accepted in address',
value => !/p[.\s]+o[.\s]+box/i.test(value ?? '')
),
}),
addressLine2: Yup.string()
.max(70, 'Only 70 characters, please.')
.matches(
regexChecks.address_details.address_line_2,
`Use only the following special characters: ${addressPermittedSpecialCharactersMessage}`
)
.when({
is: () => isSvg,
then: Yup.string().test(
'po_box',
'P.O. Box is not accepted in address',
value => !/p[.\s]+o[.\s]+box/i.test(value ?? '')
),
}),
addressPostcode: Yup.string()
.max(20, 'Please enter a postal/ZIP code under 20 characters.')
.matches(regexChecks.address_details.address_postcode, 'Letters, numbers, spaces, hyphens only')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we dont have localize in v2 packages for now @suisin-deriv

.when({
is: () => countryCode === 'gb',
then: Yup.string().matches(
regexChecks.address_details.non_jersey_postcode,
'Our accounts and services are unavailable for the Jersey postal code.'
),
}),
addressState: Yup.string()
.required('State is required')
.matches(regexChecks.address_details.address_state, 'State is not in a proper format'),
});
2 changes: 2 additions & 0 deletions packages/account-v2/src/modules/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { AddressFields } from './AddressFields';
export { IDVForm } from './IDVForm';
44 changes: 44 additions & 0 deletions packages/api/src/hooks/__tests__/useStatesList.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { renderHook } from '@testing-library/react-hooks';
import useQuery from '../../useQuery';
import useStatesList from '../useStatesList';

jest.mock('../../useQuery');

const mockUseQuery = useQuery as jest.MockedFunction<typeof useQuery<'states_list'>>;

describe('useStatesList', () => {
it('should return an empty array when the store is not ready', () => {
// @ts-expect-error need to come up with a way to mock the return type of useFetch
mockUseQuery.mockReturnValue({
data: {
states_list: [],
},
});
const { result } = renderHook(() => useStatesList('in'));

expect(result.current.data).toHaveLength(0);
});

it('should return data fetched along with correct status', () => {
// @ts-expect-error need to come up with a way to mock the return type of useFetch
mockUseQuery.mockReturnValue({
data: {
states_list: [
{ text: 'state 1', value: 's1' },
{ text: 'state 2', value: 's2' },
],
},
isFetched: true,
});
const { result } = renderHook(() => useStatesList('in'));
expect(result.current.isFetched).toBeTruthy();
});

it('should call the useQuery with options if passed', () => {
renderHook(() => useStatesList('in', { enabled: false }));
expect(mockUseQuery).toHaveBeenCalledWith('states_list', {
payload: { states_list: 'in' },
options: { enabled: false },
});
});
});
Loading
Loading