diff --git a/libs/accounts/src/lib/transfer-form.spec.tsx b/libs/accounts/src/lib/transfer-form.spec.tsx index 601274c5a..131c51fee 100644 --- a/libs/accounts/src/lib/transfer-form.spec.tsx +++ b/libs/accounts/src/lib/transfer-form.spec.tsx @@ -1,4 +1,10 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import BigNumber from 'bignumber.js'; import { AddressField, TransferFee, TransferForm } from './transfer-form'; import { AccountType } from '@vegaprotocol/types'; @@ -27,6 +33,34 @@ describe('TransferForm', () => { submitTransfer: jest.fn(), }; + it('validates a manually entered address', async () => { + render(); + submit(); + expect(await screen.findAllByText('Required')).toHaveLength(3); + const toggle = screen.getByText('Enter manually'); + fireEvent.click(toggle); + // has switched to input + expect(toggle).toHaveTextContent('Select from wallet'); + expect(screen.getByLabelText('Vega key')).toHaveAttribute('type', 'text'); + fireEvent.change(screen.getByLabelText('Vega key'), { + target: { value: 'invalid-address' }, + }); + await waitFor(() => { + const errors = screen.getAllByTestId('input-error-text'); + expect(errors[0]).toHaveTextContent('Invalid Vega key'); + }); + + // same pubkey + fireEvent.change(screen.getByLabelText('Vega key'), { + target: { value: pubKey }, + }); + + await waitFor(() => { + const errors = screen.getAllByTestId('input-error-text'); + expect(errors[0]).toHaveTextContent('Vega key is the same'); + }); + }); + it('validates fields and submits', async () => { render(); @@ -62,15 +96,17 @@ describe('TransferForm', () => { formatNumber(asset.balance, asset.decimals) ); + const amountInput = screen.getByLabelText('Amount'); + // Test amount validation - fireEvent.change(screen.getByLabelText('Amount'), { + fireEvent.change(amountInput, { target: { value: '0.00000001' }, }); expect( await screen.findByText('Value is below minimum') ).toBeInTheDocument(); - fireEvent.change(screen.getByLabelText('Amount'), { + fireEvent.change(amountInput, { target: { value: '9999999' }, }); expect( @@ -78,7 +114,7 @@ describe('TransferForm', () => { ).toBeInTheDocument(); // set valid amount - fireEvent.change(screen.getByLabelText('Amount'), { + fireEvent.change(amountInput, { target: { value: amount }, }); expect(screen.getByTestId('transfer-fee')).toHaveTextContent( @@ -100,78 +136,191 @@ describe('TransferForm', () => { }); }); - it('validates a manually entered address', async () => { - render(); - submit(); - expect(await screen.findAllByText('Required')).toHaveLength(3); - const toggle = screen.getByText('Enter manually'); - fireEvent.click(toggle); - // has switched to input - expect(toggle).toHaveTextContent('Select from wallet'); - expect(screen.getByLabelText('Vega key')).toHaveAttribute('type', 'text'); - fireEvent.change(screen.getByLabelText('Vega key'), { - target: { value: 'invalid-address' }, - }); - await waitFor(() => { - const errors = screen.getAllByTestId('input-error-text'); - expect(errors[0]).toHaveTextContent('Invalid Vega key'); + describe('IncludeFeesCheckbox', () => { + it('validates fields and submits when checkbox is checked', async () => { + render(); + + // check current pubkey not shown + const keySelect: HTMLSelectElement = screen.getByLabelText('Vega key'); + expect(keySelect.children).toHaveLength(2); + expect(Array.from(keySelect.options).map((o) => o.value)).toEqual([ + '', + props.pubKeys[1], + ]); + + submit(); + expect(await screen.findAllByText('Required')).toHaveLength(3); + + // Select a pubkey + fireEvent.change(screen.getByLabelText('Vega key'), { + target: { value: props.pubKeys[1] }, + }); + + // Select asset + fireEvent.change( + // Bypass RichSelect and target hidden native select + // eslint-disable-next-line + document.querySelector('select[name="asset"]')!, + { target: { value: asset.id } } + ); + + // assert rich select as updated + expect(await screen.findByTestId('select-asset')).toHaveTextContent( + asset.name + ); + expect(screen.getByTestId('asset-balance')).toHaveTextContent( + formatNumber(asset.balance, asset.decimals) + ); + + const amountInput = screen.getByLabelText('Amount'); + const checkbox = screen.getByTestId('include-transfer-fee'); + expect(checkbox).not.toBeChecked(); + act(() => { + /* fire events that update state */ + // set valid amount + fireEvent.change(amountInput, { + target: { value: amount }, + }); + // check include fees checkbox + fireEvent.click(checkbox); + }); + + expect(checkbox).toBeChecked(); + const expectedFee = new BigNumber(amount) + .times(props.feeFactor) + .toFixed(); + const expectedAmount = new BigNumber(amount).minus(expectedFee).toFixed(); + expect(screen.getByTestId('transfer-fee')).toHaveTextContent(expectedFee); + expect(screen.getByTestId('transfer-amount')).toHaveTextContent( + expectedAmount + ); + expect(screen.getByTestId('total-transfer-fee')).toHaveTextContent( + amount + ); + + submit(); + + await waitFor(() => { + expect(props.submitTransfer).toHaveBeenCalledTimes(1); + expect(props.submitTransfer).toHaveBeenCalledWith({ + fromAccountType: AccountType.ACCOUNT_TYPE_GENERAL, + toAccountType: AccountType.ACCOUNT_TYPE_GENERAL, + to: props.pubKeys[1], + asset: asset.id, + amount: removeDecimal(amount, asset.decimals), + oneOff: {}, + }); + }); }); - // same pubkey - fireEvent.change(screen.getByLabelText('Vega key'), { - target: { value: pubKey }, + it('validates fields when checkbox is not checked', async () => { + render(); + + // check current pubkey not shown + const keySelect: HTMLSelectElement = screen.getByLabelText('Vega key'); + expect(keySelect.children).toHaveLength(2); + expect(Array.from(keySelect.options).map((o) => o.value)).toEqual([ + '', + props.pubKeys[1], + ]); + + submit(); + expect(await screen.findAllByText('Required')).toHaveLength(3); + + // Select a pubkey + fireEvent.change(screen.getByLabelText('Vega key'), { + target: { value: props.pubKeys[1] }, + }); + + // Select asset + fireEvent.change( + // Bypass RichSelect and target hidden native select + // eslint-disable-next-line + document.querySelector('select[name="asset"]')!, + { target: { value: asset.id } } + ); + + // assert rich select as updated + expect(await screen.findByTestId('select-asset')).toHaveTextContent( + asset.name + ); + expect(screen.getByTestId('asset-balance')).toHaveTextContent( + formatNumber(asset.balance, asset.decimals) + ); + + const amountInput = screen.getByLabelText('Amount'); + const checkbox = screen.getByTestId('include-transfer-fee'); + expect(checkbox).not.toBeChecked(); + act(() => { + /* fire events that update state */ + // set valid amount + fireEvent.change(amountInput, { + target: { value: amount }, + }); + }); + expect(checkbox).not.toBeChecked(); + const expectedFee = new BigNumber(amount) + .times(props.feeFactor) + .toFixed(); + const total = new BigNumber(amount).plus(expectedFee).toFixed(); + expect(screen.getByTestId('transfer-fee')).toHaveTextContent(expectedFee); + expect(screen.getByTestId('transfer-amount')).toHaveTextContent(amount); + expect(screen.getByTestId('total-transfer-fee')).toHaveTextContent(total); + }); + }); + + describe('AddressField', () => { + const props = { + pubKeys: ['pubkey-1', 'pubkey-2'], + select:
select
, + input:
input
, + onChange: jest.fn(), + }; + + it('toggles content and calls onChange', async () => { + const mockOnChange = jest.fn(); + render(); + + // select should be shown as multiple pubkeys provided + expect(screen.getByText('select')).toBeInTheDocument(); + expect(screen.queryByText('input')).not.toBeInTheDocument(); + fireEvent.click(screen.getByText('Enter manually')); + expect(screen.queryByText('select')).not.toBeInTheDocument(); + expect(screen.getByText('input')).toBeInTheDocument(); + expect(mockOnChange).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByText('Select from wallet')); + expect(screen.getByText('select')).toBeInTheDocument(); + expect(screen.queryByText('input')).not.toBeInTheDocument(); + expect(mockOnChange).toHaveBeenCalledTimes(2); }); - await waitFor(() => { - const errors = screen.getAllByTestId('input-error-text'); - expect(errors[0]).toHaveTextContent('Vega key is the same'); + it('Does not provide select option if there is only a single key', () => { + render(); + expect(screen.getByText('input')).toBeInTheDocument(); + expect(screen.queryByText('Select from wallet')).not.toBeInTheDocument(); + }); + }); + + describe('TransferFee', () => { + const props = { + amount: '200', + feeFactor: '0.001', + fee: '0.2', + transferAmount: '200', + decimals: 8, + }; + it('calculates and renders the transfer fee', () => { + render(); + + const expected = new BigNumber(props.amount) + .times(props.feeFactor) + .toFixed(); + const total = new BigNumber(props.amount).plus(expected).toFixed(); + expect(screen.getByTestId('transfer-fee')).toHaveTextContent(expected); + expect(screen.getByTestId('transfer-amount')).toHaveTextContent( + props.amount + ); + expect(screen.getByTestId('total-transfer-fee')).toHaveTextContent(total); }); }); }); - -describe('AddressField', () => { - const props = { - pubKeys: ['pubkey-1', 'pubkey-2'], - select:
select
, - input:
input
, - onChange: jest.fn(), - }; - - it('toggles content and calls onChange', async () => { - const mockOnChange = jest.fn(); - render(); - - // select should be shown as multiple pubkeys provided - expect(screen.getByText('select')).toBeInTheDocument(); - expect(screen.queryByText('input')).not.toBeInTheDocument(); - fireEvent.click(screen.getByText('Enter manually')); - expect(screen.queryByText('select')).not.toBeInTheDocument(); - expect(screen.getByText('input')).toBeInTheDocument(); - expect(mockOnChange).toHaveBeenCalledTimes(1); - fireEvent.click(screen.getByText('Select from wallet')); - expect(screen.getByText('select')).toBeInTheDocument(); - expect(screen.queryByText('input')).not.toBeInTheDocument(); - expect(mockOnChange).toHaveBeenCalledTimes(2); - }); - - it('Does not provide select option if there is only a single key', () => { - render(); - expect(screen.getByText('input')).toBeInTheDocument(); - expect(screen.queryByText('Select from wallet')).not.toBeInTheDocument(); - }); -}); - -describe('TransferFee', () => { - const props = { - amount: '200', - feeFactor: '0.001', - }; - it('calculates and renders the transfer fee', () => { - render(); - - const expected = new BigNumber(props.amount) - .times(props.feeFactor) - .toFixed(); - expect(screen.getByTestId('transfer-fee')).toHaveTextContent(expected); - }); -}); diff --git a/libs/accounts/src/lib/transfer-form.tsx b/libs/accounts/src/lib/transfer-form.tsx index 417d17ede..7a56c9342 100644 --- a/libs/accounts/src/lib/transfer-form.tsx +++ b/libs/accounts/src/lib/transfer-form.tsx @@ -16,6 +16,7 @@ import { RichSelect, Select, Tooltip, + Checkbox, } from '@vegaprotocol/ui-toolkit'; import type { Transfer } from '@vegaprotocol/wallet'; import { normalizeTransfer } from '@vegaprotocol/wallet'; @@ -63,6 +64,26 @@ export const TransferForm = ({ const amount = watch('amount'); const assetId = watch('asset'); + const [includeFee, setIncludeFee] = useState(false); + + const transferAmount = useMemo(() => { + if (!amount) return undefined; + if (includeFee && feeFactor) { + return new BigNumber(1).minus(feeFactor).times(amount).toString(); + } + return amount; + }, [amount, includeFee, feeFactor]); + + const fee = useMemo(() => { + if (!transferAmount) return undefined; + if (includeFee) { + return new BigNumber(amount).minus(transferAmount).toString(); + } + return ( + feeFactor && new BigNumber(feeFactor).times(transferAmount).toString() + ); + }, [amount, includeFee, transferAmount, feeFactor]); + const asset = useMemo(() => { return assets.find((a) => a.id === assetId); }, [assets, assetId]); @@ -72,13 +93,16 @@ export const TransferForm = ({ if (!asset) { throw new Error('Submitted transfer with no asset selected'); } - const transfer = normalizeTransfer(fields.toAddress, fields.amount, { + if (!transferAmount) { + throw new Error('Submitted transfer with no amount selected'); + } + const transfer = normalizeTransfer(fields.toAddress, transferAmount, { id: asset.id, decimals: asset.decimals, }); submitTransfer(transfer); }, - [asset, submitTransfer] + [asset, submitTransfer, transferAmount] ); const min = useMemo(() => { @@ -213,7 +237,32 @@ export const TransferForm = ({ {errors.amount.message} )} - +
+ +
{t('Include transfer fee')}
+ + } + checked={includeFee} + onCheckedChange={() => setIncludeFee(!includeFee)} + /> +
+ {transferAmount && fee && ( + + )} @@ -223,34 +272,71 @@ export const TransferForm = ({ export const TransferFee = ({ amount, + transferAmount, feeFactor, + fee, + decimals, }: { amount: string; + transferAmount: string; feeFactor: string | null; + fee?: string; + decimals?: number; }) => { - if (!feeFactor || !amount) return null; + if (!feeFactor || !amount || !transferAmount || !fee) return null; - // using toFixed without an argument will always return a - // number in normal notation without rounding, formatting functions - // arent working in a way which won't round the decimal places - const value = new BigNumber(amount).times(feeFactor).toFixed(); + const totalValue = new BigNumber(transferAmount).plus(fee).toString(); return ( -
-
+
+
{t('Transfer fee')}
+ +
+ {formatNumber(fee, decimals)} +
-
- {value} +
+ +
{t('Amount to be transferred')}
+
+ +
+ {formatNumber(amount, decimals)} +
+
+
+ +
{t('Total amount (with fee)')}
+
+ +
+ {formatNumber(totalValue, decimals)} +
); diff --git a/libs/accounts/src/setup-tests.ts b/libs/accounts/src/setup-tests.ts index 7b0828bfa..880268538 100644 --- a/libs/accounts/src/setup-tests.ts +++ b/libs/accounts/src/setup-tests.ts @@ -1 +1,6 @@ import '@testing-library/jest-dom'; +import ResizeObserver from 'resize-observer-polyfill'; +import { defaultFallbackInView } from 'react-intersection-observer'; + +defaultFallbackInView(true); +global.ResizeObserver = ResizeObserver;