Skip to content

Commit

Permalink
[cashier-v2] Aum / FEQ-1950 / cashier-v2-transfer-amount-input-conver…
Browse files Browse the repository at this point in the history
…ter (binary-com#14282)

* chore: basic setup for TransferModule

* feat: added CryptoFiatConverter and TransferCryptoFiatAmountConverter

* fix: fixed input validation issue with CryptoFiatConverter

* perf: add unit tests for CryptoFiatConverter

* perf: added unit tests for TransferCryptoFiatAmountConverter

* chore: applied suggestions
  • Loading branch information
aum-deriv committed Mar 26, 2024
1 parent 3a70862 commit 99a4e47
Show file tree
Hide file tree
Showing 26 changed files with 641 additions and 5 deletions.
@@ -0,0 +1,33 @@
.container {
display: flex;
gap: 0.8rem;

@include mobile-cashier-v2 {
flex-direction: column;
align-items: center;
}
}

.arrow-container {
height: 3.45rem;
display: flex;
align-items: center;
justify-content: center;
}

.arrow-icon {
transition: all 0.2s ease;
rotate: 90deg;

@include mobile-cashier-v2 {
rotate: 180deg;
}

&--rtl {
rotate: -90deg;

@include mobile-cashier-v2 {
rotate: 0deg;
}
}
}
@@ -0,0 +1,122 @@
import React, { useEffect, useState } from 'react';
import clsx from 'clsx';
import { Field, FieldProps, useFormikContext } from 'formik';
import { InferType } from 'yup';
import { StandaloneArrowDownBoldIcon } from '@deriv/quill-icons';
import { Input, useDevice } from '@deriv-com/ui';
import { getCryptoFiatConverterValidationSchema, TGetCryptoFiatConverterValidationSchema } from './utils';
import styles from './CryptoFiatConverter.module.scss';

type TGetConvertedAmountParams =
| TGetCryptoFiatConverterValidationSchema['fromAccount']
| TGetCryptoFiatConverterValidationSchema['toAccount'];

const getConvertedAmount = (amount: string, source: TGetConvertedAmountParams, target: TGetConvertedAmountParams) => {
const value = Number(amount);

if (!value) return '';

// TODO: replace these temporary values with exchange rate
const fromRate = 1;
const toRate = 0.5;

const convertedValue =
// eslint-disable-next-line sonarjs/prefer-immediate-return
((value * toRate) / fromRate).toFixed(target.fractionalDigits);

return convertedValue;
};

type TContext = InferType<ReturnType<typeof getCryptoFiatConverterValidationSchema>>;

const CryptoFiatConverter: React.FC<TGetCryptoFiatConverterValidationSchema> = ({ fromAccount, toAccount }) => {
const { isMobile } = useDevice();
const [isFromInputActive, setIsFromInputActive] = useState(true);

const { errors, setFieldValue, setValues } = useFormikContext<TContext>();

useEffect(() => {
if (errors.toAmount && !isFromInputActive) {
setFieldValue('fromAmount', '');
}
}, [errors.toAmount, isFromInputActive, setFieldValue]);

useEffect(() => {
if (errors.fromAmount && isFromInputActive) {
setFieldValue('toAmount', '');
}
}, [errors.fromAmount, isFromInputActive, setFieldValue]);

const handleFromAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const convertedValue = getConvertedAmount(e.target.value, fromAccount, toAccount);

setValues(currentValues => ({
...currentValues,
fromAmount: e.target.value,
toAmount: convertedValue,
}));
};

const handleToAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const convertedValue = getConvertedAmount(e.target.value, toAccount, fromAccount);

setValues(currentValues => ({
...currentValues,
fromAmount: convertedValue,
toAmount: e.target.value,
}));
};

return (
<div className={styles.container}>
<Field name='fromAmount'>
{({ field }: FieldProps) => (
<Input
{...field}
autoComplete='off'
data-testid='dt_crypto_fiat_converter_from_amount_field'
error={Boolean(errors.fromAmount)}
isFullWidth={fromAccount.currency !== toAccount.currency}
label={`Amount (${fromAccount.currency})`}
message={errors.fromAmount}
onChange={handleFromAmountChange}
onFocus={() => {
setIsFromInputActive(true);
}}
type='text'
/>
)}
</Field>
{fromAccount.currency !== toAccount.currency && (
<>
<div className={styles['arrow-container']}>
<StandaloneArrowDownBoldIcon
className={clsx(styles['arrow-icon'], { [styles['arrow-icon--rtl']]: isFromInputActive })}
iconSize={isMobile ? 'sm' : 'md'}
/>
</div>
<Field name='toAmount'>
{({ field }: FieldProps) => (
<Input
{...field}
autoComplete='off'
data-testid='dt_crypto_fiat_converter_to_amount_field'
error={Boolean(errors.toAmount)}
isFullWidth
label={`Amount (${toAccount.currency})`}
message={errors.toAmount}
onChange={handleToAmountChange}
onFocus={() => {
setIsFromInputActive(false);
}}
type='text'
/>
)}
</Field>
</>
)}
</div>
);
};

export default CryptoFiatConverter;
@@ -0,0 +1,110 @@
import React from 'react';
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { useDevice } from '@deriv-com/ui';
import CryptoFiatConverter from '../CryptoFiatConverter';
import { TCurrency } from '../../../types';
import { Formik } from 'formik';

jest.mock('@deriv-com/ui', () => ({
...jest.requireActual('@deriv-com/ui'),
useDevice: jest.fn(),
}));

const mockFromAccount = {
balance: 1000,
currency: 'USD' as TCurrency,
fractionalDigits: 2,
limits: {
max: 100,
min: 1,
},
};

const mockToAccount = {
currency: 'BTC' as TCurrency,
fractionalDigits: 8,
};

const wrapper: React.FC<React.PropsWithChildren> = ({ children }) => {
return (
<Formik
initialValues={{
fromAmount: '',
toAmount: '',
}}
onSubmit={jest.fn()}
>
{children}
</Formik>
);
};

describe('CryptoFiatConverter', () => {
beforeEach(() => {
(useDevice as jest.Mock).mockReturnValue({
isMobile: true,
});
});

afterEach(cleanup);

it('should check if the toAmount field is empty when there is an input error in the fromAmount field', async () => {
render(<CryptoFiatConverter fromAccount={mockFromAccount} toAccount={mockToAccount} />, { wrapper });

const fromAmountField = screen.getByTestId('dt_crypto_fiat_converter_from_amount_field');
const toAmountField = screen.getByTestId('dt_crypto_fiat_converter_to_amount_field');

await act(async () => {
await fireEvent.change(fromAmountField, { target: { value: '1.0.' } });
});

expect(toAmountField).toHaveValue('');
});

it('should check if the fromAmount field is empty when there is an input error in the toAmount field', async () => {
render(<CryptoFiatConverter fromAccount={mockFromAccount} toAccount={mockToAccount} />, { wrapper });

const fromAmountField = screen.getByTestId('dt_crypto_fiat_converter_from_amount_field');
const toAmountField = screen.getByTestId('dt_crypto_fiat_converter_to_amount_field');

await act(async () => {
await fireEvent.change(toAmountField, { target: { value: '1.0.' } });
});

expect(fromAmountField).toHaveValue('');
});

it('should test for properly converted toAmount when valid amount is given in fromAmount', async () => {
(useDevice as jest.Mock).mockReturnValue({
isMobile: true,
});

render(<CryptoFiatConverter fromAccount={mockFromAccount} toAccount={mockToAccount} />, { wrapper });

const fromAmountField = screen.getByTestId('dt_crypto_fiat_converter_from_amount_field');
const toAmountField = screen.getByTestId('dt_crypto_fiat_converter_to_amount_field');

await act(async () => {
await fireEvent.change(fromAmountField, { target: { value: '1.0' } });
});

expect(toAmountField).toHaveValue('0.50000000');
});

it('should test for properly converted fromAmount when valid amount is given in toAmount', async () => {
(useDevice as jest.Mock).mockReturnValue({
isMobile: true,
});

render(<CryptoFiatConverter fromAccount={mockFromAccount} toAccount={mockToAccount} />, { wrapper });

const fromAmountField = screen.getByTestId('dt_crypto_fiat_converter_from_amount_field');
const toAmountField = screen.getByTestId('dt_crypto_fiat_converter_to_amount_field');

await act(async () => {
await fireEvent.change(toAmountField, { target: { value: '1' } });
});

expect(fromAmountField).toHaveValue('0.50');
});
});
@@ -0,0 +1,2 @@
export { default as CryptoFiatConverter } from './CryptoFiatConverter';
export { getCryptoFiatConverterValidationSchema } from './utils';
@@ -0,0 +1,63 @@
import * as Yup from 'yup';
import { TCurrency } from '../../../types';
import {
betweenMinAndMaxValidator,
decimalsValidator,
insufficientBalanceValidator,
numberValidator,
} from '../../../utils';

export type TGetCryptoFiatConverterValidationSchema = {
fromAccount: {
balance: number;
currency: TCurrency;
fractionalDigits?: number;
limits: {
max: number;
min: number;
};
};
toAccount: {
currency: TCurrency;
fractionalDigits?: number;
};
};

export const getCryptoFiatConverterValidationSchema = ({
fromAccount,
toAccount,
}: TGetCryptoFiatConverterValidationSchema) => {
return Yup.object({
fromAmount: Yup.string()
.required('This field is required.')
.test({
name: 'test-valid-number',
test: (value = '', context) => numberValidator(value, context),
})
.test({
name: 'test-decimals',
test: (value = '', context) => decimalsValidator(value, context, fromAccount.fractionalDigits ?? 2),
})
.test({
name: 'test-insufficient-funds',
test: (value = '', context) => insufficientBalanceValidator(value, context, fromAccount.balance),
})
.test({
name: 'test-between-min-max',
test: (value = '', context) =>
betweenMinAndMaxValidator(value, context, {
currency: fromAccount.currency,
limits: fromAccount.limits,
}),
}),
toAmount: Yup.string()
.test({
name: 'test-valid-number',
test: (value = '', context) => numberValidator(value, context),
})
.test({
name: 'test-decimals',
test: (value = '', context) => decimalsValidator(value, context, toAccount.fractionalDigits ?? 2),
}),
});
};
@@ -0,0 +1 @@
export * from './cryptoFiatAmountConverterValidator';
1 change: 1 addition & 0 deletions packages/cashier-v2/src/components/index.ts
@@ -1,6 +1,7 @@
export { Breadcrumb } from './Breadcrumb';
export { CashierBreadcrumb } from './CashierBreadcrumb';
export { Clipboard } from './Clipboard';
export { CryptoFiatConverter, getCryptoFiatConverterValidationSchema } from './CryptoFiatConverter';
export { CurrencyIcon } from './CurrencyIcon';
export { DummyComponent } from './DummyComponent';
export { ErrorScreen } from './ErrorScreen';
Expand Down
13 changes: 13 additions & 0 deletions packages/cashier-v2/src/flows/AccountTransfer/AccountTransfer.tsx
@@ -0,0 +1,13 @@
import React from 'react';
import { PageContainer } from '../../components';
import { TransferModule } from '../../lib';

const AccountTransfer = () => {
return (
<PageContainer>
<TransferModule />
</PageContainer>
);
};

export default AccountTransfer;
1 change: 1 addition & 0 deletions packages/cashier-v2/src/flows/AccountTransfer/index.ts
@@ -0,0 +1 @@
export { default as AccountTransfer } from './AccountTransfer';
1 change: 1 addition & 0 deletions packages/cashier-v2/src/flows/index.ts
@@ -1,3 +1,4 @@
export { AccountTransfer } from './AccountTransfer';
export { Deposit } from './Deposit';
export { PaymentAgentTransfer } from './PaymentAgentTransfer';
export { Withdrawal } from './Withdrawal';

0 comments on commit 99a4e47

Please sign in to comment.