Skip to content

Commit 102085f

Browse files
authored
[cashier-v2] aum / FEQ-1951 / cashier-v2-transfer-percentage-selector (deriv-com#14361)
* feat: added TransferAmountPercentageSelector * refactor: added PercentageSelector to CryptoFiatConverter * refactor: refactor useExtendedTransferAccounts for getting displayBalance * perf: added unit tests for PercentageSelector * fix: fix sonarCloud * chore: apply suggestions * chore: remove unused imports * chore: remove unused import in spec file
1 parent 55397a6 commit 102085f

File tree

7 files changed

+279
-138
lines changed

7 files changed

+279
-138
lines changed

packages/cashier-v2/src/components/CryptoFiatConverter/CryptoFiatConverter.module.scss

+16
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,20 @@
11
.container {
2+
width: 100%;
3+
display: flex;
4+
flex-direction: column;
5+
align-items: center;
6+
gap: 1.6rem;
7+
}
8+
9+
.percentage-selector-container {
10+
width: 100%;
11+
display: flex;
12+
flex-direction: column;
13+
align-items: center;
14+
gap: 0.4rem;
15+
}
16+
17+
.input-container {
218
display: flex;
319
gap: 0.8rem;
420

packages/cashier-v2/src/components/CryptoFiatConverter/CryptoFiatConverter.tsx

+93-54
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1-
import React, { useEffect, useState } from 'react';
1+
import React, { useCallback, useEffect, useState } from 'react';
22
import clsx from 'clsx';
33
import { Field, FieldProps, useFormikContext } from 'formik';
44
import { InferType } from 'yup';
55
import { StandaloneArrowDownBoldIcon } from '@deriv/quill-icons';
6-
import { Input, useDevice } from '@deriv-com/ui';
6+
import { Input, Text, useDevice } from '@deriv-com/ui';
7+
import { PercentageSelector } from '../PercentageSelector';
78
import { getCryptoFiatConverterValidationSchema, TGetCryptoFiatConverterValidationSchema } from './utils';
89
import styles from './CryptoFiatConverter.module.scss';
910

11+
type TContext = InferType<ReturnType<typeof getCryptoFiatConverterValidationSchema>>;
12+
1013
type TGetConvertedAmountParams =
1114
| TGetCryptoFiatConverterValidationSchema['fromAccount']
1215
| TGetCryptoFiatConverterValidationSchema['toAccount'];
@@ -27,29 +30,46 @@ const getConvertedAmount = (amount: string, source: TGetConvertedAmountParams, t
2730
return convertedValue;
2831
};
2932

30-
type TContext = InferType<ReturnType<typeof getCryptoFiatConverterValidationSchema>>;
31-
3233
const CryptoFiatConverter: React.FC<TGetCryptoFiatConverterValidationSchema> = ({ fromAccount, toAccount }) => {
3334
const { isMobile } = useDevice();
34-
const [isFromInputActive, setIsFromInputActive] = useState(true);
35+
const [isFromInputField, setIsFromInputField] = useState<boolean>(true);
36+
const { errors, setFieldValue, setValues, values } = useFormikContext<TContext>();
37+
const percentage =
38+
fromAccount.balance && Number(values.fromAmount) && !errors.fromAmount
39+
? Math.round((Number(values.fromAmount) * 100) / fromAccount.balance)
40+
: 0;
3541

36-
const { errors, setFieldValue, setValues } = useFormikContext<TContext>();
42+
const isDifferentCurrency = fromAccount.currency !== toAccount.currency;
3743

3844
useEffect(() => {
39-
if (errors.toAmount && !isFromInputActive) {
45+
if (isDifferentCurrency && errors.toAmount && !isFromInputField) {
4046
setFieldValue('fromAmount', '');
4147
}
42-
}, [errors.toAmount, isFromInputActive, setFieldValue]);
48+
}, [isDifferentCurrency, errors.toAmount, isFromInputField, setFieldValue]);
4349

4450
useEffect(() => {
45-
if (errors.fromAmount && isFromInputActive) {
51+
if (isDifferentCurrency && errors.fromAmount && isFromInputField) {
4652
setFieldValue('toAmount', '');
4753
}
48-
}, [errors.fromAmount, isFromInputActive, setFieldValue]);
54+
}, [isDifferentCurrency, errors.fromAmount, isFromInputField, setFieldValue]);
55+
56+
const handlePercentageChange = useCallback(
57+
(per: number) => {
58+
const computedAmount =
59+
((Number(fromAccount.balance) * per) / 100).toFixed(fromAccount.fractionalDigits) ?? 0;
60+
const convertedAmount = getConvertedAmount(computedAmount, fromAccount, toAccount);
61+
62+
setValues(currentValues => ({
63+
...currentValues,
64+
fromAmount: computedAmount,
65+
toAmount: convertedAmount,
66+
}));
67+
},
68+
[fromAccount, setValues, toAccount]
69+
);
4970

5071
const handleFromAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
5172
const convertedValue = getConvertedAmount(e.target.value, fromAccount, toAccount);
52-
5373
setValues(currentValues => ({
5474
...currentValues,
5575
fromAmount: e.target.value,
@@ -69,52 +89,71 @@ const CryptoFiatConverter: React.FC<TGetCryptoFiatConverterValidationSchema> = (
6989

7090
return (
7191
<div className={styles.container}>
72-
<Field name='fromAmount'>
73-
{({ field }: FieldProps) => (
74-
<Input
75-
{...field}
76-
autoComplete='off'
77-
data-testid='dt_crypto_fiat_converter_from_amount_field'
78-
error={Boolean(errors.fromAmount)}
79-
isFullWidth={fromAccount.currency !== toAccount.currency}
80-
label={`Amount (${fromAccount.currency})`}
81-
message={errors.fromAmount}
82-
onChange={handleFromAmountChange}
83-
onFocus={() => {
84-
setIsFromInputActive(true);
85-
}}
86-
type='text'
92+
{isDifferentCurrency && (
93+
<div
94+
className={styles['percentage-selector-container']}
95+
data-testid='dt_crypto_fiat_converter_percentage_selector'
96+
>
97+
<PercentageSelector
98+
amount={!errors.fromAmount ? Number(values.fromAmount) : 0}
99+
balance={Number(fromAccount.balance)}
100+
onChangePercentage={handlePercentageChange}
87101
/>
88-
)}
89-
</Field>
90-
{fromAccount.currency !== toAccount.currency && (
91-
<>
92-
<div className={styles['arrow-container']}>
93-
<StandaloneArrowDownBoldIcon
94-
className={clsx(styles['arrow-icon'], { [styles['arrow-icon--rtl']]: isFromInputActive })}
95-
iconSize={isMobile ? 'sm' : 'md'}
102+
<Text as='div' color='less-prominent' size='xs'>
103+
{`${percentage}% of available balance (${fromAccount.displayBalance})`}
104+
</Text>
105+
</div>
106+
)}
107+
<div className={styles['input-container']}>
108+
<Field name='fromAmount'>
109+
{({ field }: FieldProps) => (
110+
<Input
111+
{...field}
112+
autoComplete='off'
113+
data-testid='dt_crypto_fiat_converter_from_amount_field'
114+
error={Boolean(errors.fromAmount)}
115+
isFullWidth={fromAccount.currency !== toAccount.currency}
116+
label={`Amount (${fromAccount.currency})`}
117+
message={errors.fromAmount}
118+
onChange={handleFromAmountChange}
119+
onFocus={() => {
120+
setIsFromInputField(true);
121+
}}
122+
type='text'
96123
/>
97-
</div>
98-
<Field name='toAmount'>
99-
{({ field }: FieldProps) => (
100-
<Input
101-
{...field}
102-
autoComplete='off'
103-
data-testid='dt_crypto_fiat_converter_to_amount_field'
104-
error={Boolean(errors.toAmount)}
105-
isFullWidth
106-
label={`Amount (${toAccount.currency})`}
107-
message={errors.toAmount}
108-
onChange={handleToAmountChange}
109-
onFocus={() => {
110-
setIsFromInputActive(false);
111-
}}
112-
type='text'
124+
)}
125+
</Field>
126+
{isDifferentCurrency && (
127+
<>
128+
<div className={styles['arrow-container']} data-testid='dt_crypto_fiat_converter_arrow_icon'>
129+
<StandaloneArrowDownBoldIcon
130+
className={clsx(styles['arrow-icon'], {
131+
[styles['arrow-icon--rtl']]: isFromInputField,
132+
})}
133+
iconSize={isMobile ? 'sm' : 'md'}
113134
/>
114-
)}
115-
</Field>
116-
</>
117-
)}
135+
</div>
136+
<Field name='toAmount'>
137+
{({ field }: FieldProps) => (
138+
<Input
139+
{...field}
140+
autoComplete='off'
141+
data-testid='dt_crypto_fiat_converter_to_amount_field'
142+
error={Boolean(errors.toAmount)}
143+
isFullWidth
144+
label={`Amount (${toAccount.currency})`}
145+
message={errors.toAmount}
146+
onChange={handleToAmountChange}
147+
onFocus={() => {
148+
setIsFromInputField(false);
149+
}}
150+
type='text'
151+
/>
152+
)}
153+
</Field>
154+
</>
155+
)}
156+
</div>
118157
</div>
119158
);
120159
};

packages/cashier-v2/src/components/CryptoFiatConverter/__tests__/CryptoFiatConverter.spec.tsx

+94-10
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from 'react';
2-
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
2+
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
33
import { useDevice } from '@deriv-com/ui';
44
import CryptoFiatConverter from '../CryptoFiatConverter';
55
import { TCurrency } from '../../../types';
@@ -10,9 +10,20 @@ jest.mock('@deriv-com/ui', () => ({
1010
useDevice: jest.fn(),
1111
}));
1212

13+
jest.mock('../../PercentageSelector', () => ({
14+
...jest.requireActual('../../PercentageSelector'),
15+
PercentageSelector: jest.fn(({ amount, balance, onChangePercentage }) => (
16+
<>
17+
<button onClick={() => onChangePercentage(25)}>percentageSelector</button>
18+
<div>{`percentage=${(amount * 100) / balance}`}</div>
19+
</>
20+
)),
21+
}));
22+
1323
const mockFromAccount = {
1424
balance: 1000,
1525
currency: 'USD' as TCurrency,
26+
displayBalance: '1000.00 USD',
1627
fractionalDigits: 2,
1728
limits: {
1829
max: 100,
@@ -42,12 +53,39 @@ const wrapper: React.FC<React.PropsWithChildren> = ({ children }) => {
4253
describe('CryptoFiatConverter', () => {
4354
beforeEach(() => {
4455
(useDevice as jest.Mock).mockReturnValue({
45-
isMobile: true,
56+
isMobile: false,
4657
});
4758
});
4859

4960
afterEach(cleanup);
5061

62+
it('should check if the percentage selector field is hidden when the fromAccount and toAccount have same currency', () => {
63+
render(
64+
<CryptoFiatConverter fromAccount={mockFromAccount} toAccount={{ ...mockToAccount, currency: 'USD' }} />,
65+
{ wrapper }
66+
);
67+
68+
expect(screen.queryByTestId('dt_crypto_fiat_converter_percentage_selector')).not.toBeInTheDocument();
69+
});
70+
71+
it('should check if the toAmount field is hidden when the fromAccount and toAccount have same currency', () => {
72+
render(
73+
<CryptoFiatConverter fromAccount={mockFromAccount} toAccount={{ ...mockToAccount, currency: 'USD' }} />,
74+
{ wrapper }
75+
);
76+
77+
expect(screen.queryByTestId('dt_crypto_fiat_converter_to_amount_field')).not.toBeInTheDocument();
78+
});
79+
80+
it('should check if the arrow icon is hidden when the fromAccount and toAccount have same currency', () => {
81+
render(
82+
<CryptoFiatConverter fromAccount={mockFromAccount} toAccount={{ ...mockToAccount, currency: 'USD' }} />,
83+
{ wrapper }
84+
);
85+
86+
expect(screen.queryByTestId('dt_crypto_fiat_converter_arrow_icon')).not.toBeInTheDocument();
87+
});
88+
5189
it('should check if the toAmount field is empty when there is an input error in the fromAmount field', async () => {
5290
render(<CryptoFiatConverter fromAccount={mockFromAccount} toAccount={mockToAccount} />, { wrapper });
5391

@@ -75,10 +113,6 @@ describe('CryptoFiatConverter', () => {
75113
});
76114

77115
it('should test for properly converted toAmount when valid amount is given in fromAmount', async () => {
78-
(useDevice as jest.Mock).mockReturnValue({
79-
isMobile: true,
80-
});
81-
82116
render(<CryptoFiatConverter fromAccount={mockFromAccount} toAccount={mockToAccount} />, { wrapper });
83117

84118
const fromAmountField = screen.getByTestId('dt_crypto_fiat_converter_from_amount_field');
@@ -92,10 +126,6 @@ describe('CryptoFiatConverter', () => {
92126
});
93127

94128
it('should test for properly converted fromAmount when valid amount is given in toAmount', async () => {
95-
(useDevice as jest.Mock).mockReturnValue({
96-
isMobile: true,
97-
});
98-
99129
render(<CryptoFiatConverter fromAccount={mockFromAccount} toAccount={mockToAccount} />, { wrapper });
100130

101131
const fromAmountField = screen.getByTestId('dt_crypto_fiat_converter_from_amount_field');
@@ -107,4 +137,58 @@ describe('CryptoFiatConverter', () => {
107137

108138
expect(fromAmountField).toHaveValue('0.50');
109139
});
140+
141+
it('should check if correct percentage is calculated when fromAmount is updated', async () => {
142+
render(<CryptoFiatConverter fromAccount={mockFromAccount} toAccount={mockToAccount} />, { wrapper });
143+
144+
const fromAmountField = screen.getByTestId('dt_crypto_fiat_converter_from_amount_field');
145+
146+
await act(async () => {
147+
await fireEvent.change(fromAmountField, { target: { value: '10' } });
148+
});
149+
150+
expect(screen.getByText('1% of available balance (1000.00 USD)')).toBeInTheDocument();
151+
expect(screen.getByText('percentage=1')).toBeInTheDocument();
152+
});
153+
154+
it('should check if correct percentage is calculated when toAmount is updated', async () => {
155+
render(<CryptoFiatConverter fromAccount={mockFromAccount} toAccount={mockToAccount} />, { wrapper });
156+
157+
const fromAmountField = screen.getByTestId('dt_crypto_fiat_converter_from_amount_field');
158+
159+
await act(async () => {
160+
await fireEvent.change(fromAmountField, { target: { value: '50.00' } });
161+
});
162+
163+
expect(screen.getByText('5% of available balance (1000.00 USD)')).toBeInTheDocument();
164+
expect(screen.getByText('percentage=5')).toBeInTheDocument();
165+
});
166+
167+
it('should check if correct percentage is calculated when toAmount is updated', async () => {
168+
render(<CryptoFiatConverter fromAccount={mockFromAccount} toAccount={mockToAccount} />, { wrapper });
169+
170+
const toAmountField = screen.getByTestId('dt_crypto_fiat_converter_to_amount_field');
171+
172+
await act(async () => {
173+
await fireEvent.change(toAmountField, { target: { value: '100.00' } });
174+
});
175+
176+
expect(screen.getByText('5% of available balance (1000.00 USD)')).toBeInTheDocument();
177+
expect(screen.getByText('percentage=5')).toBeInTheDocument();
178+
});
179+
180+
it('should update the correct value for fromAmount an toAmount on selecting 25% on the percentage selector', async () => {
181+
render(<CryptoFiatConverter fromAccount={mockFromAccount} toAccount={mockToAccount} />, { wrapper });
182+
183+
const fromAmountField = screen.getByTestId('dt_crypto_fiat_converter_from_amount_field');
184+
const toAmountField = screen.getByTestId('dt_crypto_fiat_converter_to_amount_field');
185+
const percentageSelector = screen.getByText('percentageSelector');
186+
187+
await act(async () => {
188+
await fireEvent.click(percentageSelector);
189+
});
190+
191+
expect(fromAmountField).toHaveValue('250.00');
192+
expect(toAmountField).toHaveValue('125.00000000');
193+
});
110194
});

packages/cashier-v2/src/components/CryptoFiatConverter/utils/cryptoFiatAmountConverterValidator.ts

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type TGetCryptoFiatConverterValidationSchema = {
1111
fromAccount: {
1212
balance: number;
1313
currency: TCurrency;
14+
displayBalance?: string;
1415
fractionalDigits?: number;
1516
limits: {
1617
max: number;

packages/cashier-v2/src/lib/Transfer/components/TransferForm/components/TransferCryptoFiatAmountConverter/TransferCryptoFiatAmountConverter.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const TransferCryptoFiatAmountConverter = () => {
1010
const modifiedFromAccount = {
1111
balance: parseFloat(values.fromAccount.balance),
1212
currency: values.fromAccount.currency as TCurrency,
13+
displayBalance: values.fromAccount.displayBalance,
1314
fractionalDigits: values.fromAccount.currencyConfig?.fractional_digits,
1415
limits: {
1516
max: 100,

0 commit comments

Comments
 (0)