diff --git a/web/cashtab/src/components/Send/SendToken.js b/web/cashtab/src/components/Send/SendToken.js index c72a04931..3bf6eff9b 100644 --- a/web/cashtab/src/components/Send/SendToken.js +++ b/web/cashtab/src/components/Send/SendToken.js @@ -1,372 +1,398 @@ import React, { useState, useEffect } from 'react'; import { WalletContext } from '@utils/context'; import { Form, notification, message, Spin, Row, Col, Alert } from 'antd'; import Paragraph from 'antd/lib/typography/Paragraph'; import PrimaryButton, { SecondaryButton, } from '@components/Common/PrimaryButton'; import { CashLoader, CashLoadingIcon } from '@components/Common/CustomIcons'; import { FormItemWithMaxAddon, FormItemWithQRCodeAddon, } from '@components/Common/EnhancedInputs'; import useBCH from '@hooks/useBCH'; import { BalanceHeader } from './Send'; import { Redirect } from 'react-router-dom'; import useWindowDimensions from '@hooks/useWindowDimensions'; import { isMobile, isIOS, isSafari } from 'react-device-detect'; import { Img } from 'react-image'; import makeBlockie from 'ethereum-blockies-base64'; import BigNumber from 'bignumber.js'; import { currency, parseAddress, isValidTokenPrefix, } from '@components/Common/Ticker.js'; import { Event } from '@utils/GoogleAnalytics'; -import { formatBalance } from '@utils/cashMethods'; +import { formatBalance, isValidStoredWallet } from '@utils/cashMethods'; -const SendToken = ({ tokenId }) => { +const SendToken = ({ tokenId, jestBCH }) => { const { wallet, tokens, slpBalancesAndUtxos, apiError } = React.useContext( WalletContext, ); - const token = tokens.find(token => token.tokenId === tokenId); + // If this wallet has migrated to latest storage structure, get token info from there + // If not, use the tokens object (unless it's undefined, in which case use an empty array) + const liveTokenState = + isValidStoredWallet(wallet) && wallet.state.tokens + ? wallet.state.tokens + : tokens + ? tokens + : []; + const token = liveTokenState.find(token => token.tokenId === tokenId); const [queryStringText, setQueryStringText] = useState(null); const [sendTokenAddressError, setSendTokenAddressError] = useState(false); const [sendTokenAmountError, setSendTokenAmountError] = useState(false); // Get device window width // If this is less than 769, the page will open with QR scanner open const { width } = useWindowDimensions(); // Load with QR code open if device is mobile and NOT iOS + anything but safari const scannerSupported = width < 769 && isMobile && !(isIOS && !isSafari); const [formData, setFormData] = useState({ dirty: true, value: '', address: '', }); const [loading, setLoading] = useState(false); const { getBCH, getRestUrl, sendToken } = useBCH(); - const BCH = getBCH(); + // jestBCH is only ever specified for unit tests, otherwise app will use getBCH(); + const BCH = jestBCH ? jestBCH : getBCH(); // Keep this function around for re-enabling later // eslint-disable-next-line no-unused-vars async function submit() { setFormData({ ...formData, dirty: false, }); if ( !formData.address || !formData.value || Number(formData.value <= 0) || sendTokenAmountError ) { return; } // Event("Category", "Action", "Label") // Track number of SLPA send transactions and // SLPA token IDs Event('SendToken.js', 'Send', tokenId); setLoading(true); const { address, value } = formData; // Clear params from address let cleanAddress = address.split('?')[0]; try { const link = await sendToken(BCH, wallet, slpBalancesAndUtxos, { tokenId: tokenId, tokenReceiverAddress: cleanAddress, amount: value, }); notification.success({ message: 'Success', description: ( Transaction successful. Click or tap here for more details ), duration: 5, }); } catch (e) { setLoading(false); let message; if (!e.error && !e.message) { message = `Transaction failed: no response from ${getRestUrl()}.`; } else if ( /Could not communicate with full node or other external service/.test( e.error, ) ) { message = 'Could not communicate with API. Please try again.'; } else { message = e.message || e.error || JSON.stringify(e); } console.log(e); notification.error({ message: 'Error', description: message, duration: 3, }); console.error(e); } } const handleSlpAmountChange = e => { let error = false; const { value, name } = e.target; // test if exceeds balance using BigNumber let isGreaterThanBalance = false; if (!isNaN(value)) { const bigValue = new BigNumber(value); // Returns 1 if greater, -1 if less, 0 if the same, null if n/a isGreaterThanBalance = bigValue.comparedTo(token.balance); } // Validate value for > 0 if (isNaN(value)) { error = 'Amount must be a number'; } else if (value <= 0) { error = 'Amount must be greater than 0'; } else if (token && token.balance && isGreaterThanBalance === 1) { error = `Amount cannot exceed your ${token.info.tokenTicker} balance of ${token.balance}`; } else if (!isNaN(value) && value.toString().includes('.')) { if (value.toString().split('.')[1].length > token.info.decimals) { error = `This token only supports ${token.info.decimals} decimal places`; } } setSendTokenAmountError(error); - setFormData(p => ({ ...p, [name]: value })); + setFormData(p => ({ + ...p, + [name]: value, + })); }; const handleTokenAddressChange = e => { const { value, name } = e.target; // validate for token address // validate for parameters // show warning that query strings are not supported let error = false; let addressString = value; // parse address const addressInfo = parseAddress(BCH, addressString); /* Model addressInfo = { address: '', isValid: false, queryString: '', amount: null, }; */ const { address, isValid, queryString } = addressInfo; // If query string, // Show an alert that only amount and currency.ticker are supported setQueryStringText(queryString); // Is this valid address? if (!isValid) { error = 'Address is not valid'; // If valid address but token format } else if (!isValidTokenPrefix(address)) { error = `Cashtab only supports sending to ${currency.tokenPrefixes[0]} prefixed addresses`; } setSendTokenAddressError(error); setFormData(p => ({ ...p, [name]: value, })); }; const onMax = async () => { // Clear this error before updating field setSendTokenAmountError(false); try { let value = token.balance; setFormData({ ...formData, value, }); } catch (err) { console.log(`Error in onMax:`); console.log(err); message.error( 'Unable to calculate the max value due to network errors', ); } }; useEffect(() => { // If the balance has changed, unlock the UI // This is redundant, if backend has refreshed in 1.75s timeout below, UI will already be unlocked setLoading(false); }, [token]); return ( <> {!token && } {token && ( <>

Available balance

{formatBalance(token.balance)}{' '} {token.info.tokenTicker}

-
+ handleTokenAddressChange({ target: { name: 'address', value: result, }, }) } inputProps={{ placeholder: `${currency.tokenTicker} Address`, name: 'address', onChange: e => handleTokenAddressChange(e), required: true, value: formData.address, }} /> } /> ) : ( {`identicon ), suffix: token.info.tokenTicker, onChange: e => handleSlpAmountChange(e), required: true, value: formData.value, }} /> -
+
{apiError || sendTokenAmountError || sendTokenAddressError ? ( <> Send {token.info.tokenName} {apiError && } ) : ( submit()} > Send {token.info.tokenName} )}
{queryStringText && ( )} {apiError && ( -

+

An error occured on our end. Reconnecting...

)} )} ); }; export default SendToken; diff --git a/web/cashtab/src/components/Send/__tests__/SendToken.test.js b/web/cashtab/src/components/Send/__tests__/SendToken.test.js new file mode 100644 index 000000000..5485bb5f9 --- /dev/null +++ b/web/cashtab/src/components/Send/__tests__/SendToken.test.js @@ -0,0 +1,120 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import { ThemeProvider } from 'styled-components'; +import { theme } from '@assets/styles/theme'; +import SendToken from '@components/Send/SendToken'; +import BCHJS from '@psf/bch-js'; +import { + walletWithBalancesAndTokens, + walletWithBalancesAndTokensWithCorrectState, + walletWithBalancesAndTokensWithEmptyState, +} from '../../Wallet/__mocks__/walletAndBalancesMock'; +import { BrowserRouter as Router } from 'react-router-dom'; + +let realUseContext; +let useContextMock; + +beforeEach(() => { + realUseContext = React.useContext; + useContextMock = React.useContext = jest.fn(); + + // Mock method not implemented in JSDOM + // See reference at https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // Deprecated + removeListener: jest.fn(), // Deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); +}); + +afterEach(() => { + React.useContext = realUseContext; +}); + +test('Wallet with BCH balances and tokens', () => { + const testBCH = new BCHJS(); + useContextMock.mockReturnValue(walletWithBalancesAndTokens); + const component = renderer.create( + + + + + , + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('Wallet with BCH balances and tokens and state field', () => { + const testBCH = new BCHJS(); + useContextMock.mockReturnValue(walletWithBalancesAndTokensWithCorrectState); + const component = renderer.create( + + + + + , + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('Wallet with BCH balances and tokens and state field, but no params in state', () => { + const testBCH = new BCHJS(); + useContextMock.mockReturnValue(walletWithBalancesAndTokensWithEmptyState); + const component = renderer.create( + + + + + , + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('Without wallet defined', () => { + const testBCH = new BCHJS(); + useContextMock.mockReturnValue({ + wallet: {}, + balances: { totalBalance: 0 }, + loading: false, + }); + const component = renderer.create( + + + + + , + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); diff --git a/web/cashtab/src/components/Send/__tests__/__snapshots__/SendToken.test.js.snap b/web/cashtab/src/components/Send/__tests__/__snapshots__/SendToken.test.js.snap new file mode 100644 index 000000000..ff46a88d4 --- /dev/null +++ b/web/cashtab/src/components/Send/__tests__/__snapshots__/SendToken.test.js.snap @@ -0,0 +1,789 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP @generated + +exports[`Wallet with BCH balances and tokens 1`] = ` +Array [ +
+

+ Available balance +

+

+ 6.001 + + TBS +

+
, +
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+ + + + + identicon of tokenId bd1acc4c986de57af8d6d2a64aecad8c30ee80f37ae9d066d758923732ddc9ba + + + + TBS + + + + + max + + + + +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
, +] +`; + +exports[`Wallet with BCH balances and tokens and state field 1`] = ` +Array [ +
+

+ Available balance +

+

+ 6.001 + + TBS +

+
, +
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+ + + + + identicon of tokenId bd1acc4c986de57af8d6d2a64aecad8c30ee80f37ae9d066d758923732ddc9ba + + + + TBS + + + + + max + + + + +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
, +] +`; + +exports[`Wallet with BCH balances and tokens and state field, but no params in state 1`] = ` +Array [ +
+

+ Available balance +

+

+ 6.001 + + TBS +

+
, +
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+ + + + + identicon of tokenId bd1acc4c986de57af8d6d2a64aecad8c30ee80f37ae9d066d758923732ddc9ba + + + + TBS + + + + + max + + + + +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
, +] +`; + +exports[`Without wallet defined 1`] = `null`;