Unverified Commit 739f1eff authored by Amaury Martiny's avatar Amaury Martiny Committed by GitHub

Merge pull request #135 from paritytech/am-form-validation

Form validation
parents 254ecab9 dba232c0
......@@ -38,8 +38,10 @@
"@parity/light.js": "https://github.com/parity-js/light.js#9646ce15d9dd9c4cf11776ddd613d5bd86016f94",
"@parity/shared": "^3.0.2",
"bignumber.js": "^4.1.0",
"debounce-promise": "^3.1.0",
"debug": "^3.1.0",
"fether-ui": "^0.1.0",
"final-form": "^4.8.3",
"is-electron": "^2.1.0",
"light-hoc": "^0.1.0",
"lodash": "^4.17.10",
......@@ -48,10 +50,12 @@
"react": "^16.3.2",
"react-blockies": "^1.3.0",
"react-dom": "^16.3.2",
"react-final-form": "^3.6.4",
"react-markdown": "^3.3.4",
"react-router-dom": "^4.2.2",
"react-scripts": "1.1.4",
"react-tooltip": "^3.6.1",
"recompose": "^0.27.1",
"rxjs": "^6.2.0"
},
"devDependencies": {
......
......@@ -29,6 +29,13 @@ const Router =
@inject('healthStore', 'onboardingStore')
@observer
class App extends Component {
componentDidCatch () {
if (process.env.NODE_ENV !== 'development') {
// Redirect to '/' on errors
window.location.href = '/';
}
}
render () {
return (
<Router>
......
......@@ -4,32 +4,20 @@
// SPDX-License-Identifier: BSD-3-Clause
import React, { Component } from 'react';
import { inject, observer } from 'mobx-react';
import { Route, Redirect, Switch } from 'react-router-dom';
import Sent from './Sent';
import Signer from './Signer';
import TxForm from './TxForm';
@inject('sendStore')
@observer
class Send extends Component {
render () {
const {
sendStore: { tokenAddress }
} = this.props;
// We only show then Send components if we have already selected a token to
// send.
if (!tokenAddress) {
return <Redirect to='/' />;
}
return (
<Switch>
<Route exact path='/send' component={TxForm} />
<Route path='/send/signer' component={Signer} />
<Route path='/send/sent' component={Sent} />
<Route exact path='/send/:tokenAddress' component={TxForm} />
<Route path='/send/:tokenAddress/signer' component={Signer} />
<Route path='/send/:tokenAddress/sent' component={Sent} />
<Redirect to='/' />
</Switch>
);
}
......
......@@ -4,65 +4,41 @@
// SPDX-License-Identifier: BSD-3-Clause
import React, { Component } from 'react';
import { findDOMNode } from 'react-dom';
import { FormField, Header } from 'fether-ui';
import { Field, Form } from 'react-final-form';
import { Form as FetherForm, Header } from 'fether-ui';
import { inject, observer } from 'mobx-react';
import { Link } from 'react-router-dom';
import ReactTooltip from 'react-tooltip';
import { Link, Redirect } from 'react-router-dom';
import { withProps } from 'recompose';
import TokenBalance from '../../Tokens/TokensList/TokenBalance';
@inject('sendStore', 'tokensStore')
@withProps(({ match: { params: { tokenAddress } }, tokensStore }) => ({
token: tokensStore.tokens[tokenAddress]
}))
@observer
class Signer extends Component {
state = {
error: null,
isSending: false,
password: ''
};
handleAccept = event => {
const { history, sendStore } = this.props;
const { password } = this.state;
event.preventDefault();
this.setState({ isSending: true }, () => {
sendStore
.send(password)
.then(() => history.push('/send/sent'))
.catch(error => {
this.setState({ error, isSending: false }, () =>
ReactTooltip.show(findDOMNode(this.tooltip))
);
});
});
};
handleCancel = () => {
const { history } = this.props;
history.goBack();
};
handleChangePassword = ({ target: { value } }) => {
this.setState({ error: null, password: value });
};
/**
* TODO All this tooltips refs etc should go inside a React validation
* library.
*/
handleTooltipRef = ref => {
this.tooltip = ref;
handleAccept = values => {
const { history, sendStore, token } = this.props;
return sendStore
.send(token, values.password)
.then(() => history.push(`/send/${token.address}/sent`))
.catch(error => ({
password: error.text
}));
};
render () {
const {
sendStore: { tokenAddress, tx },
tokensStore
history,
sendStore: { tx },
token
} = this.props;
const { error, isSending, password } = this.state;
const token = tokensStore.tokens[tokenAddress];
if (!tx || !token) {
return <Redirect to='/' />;
}
return (
<div>
......@@ -72,7 +48,7 @@ class Signer extends Component {
Close
</Link>
}
title={<h1>Send {token.name}</h1>}
title={token && <h1>Send {token.name}</h1>}
/>
<div className='window_content'>
......@@ -80,64 +56,63 @@ class Signer extends Component {
<TokenBalance
drawers={[
<div key='txForm'>
<div className='form_field'>
<label>Amount</label>
<div className='form_field_value'>
{tx.amount} {token.symbol}
</div>
</div>
<div className='form_field'>
<label>To</label>
<div className='form_field_value'>{tx.to}</div>
</div>
<FetherForm.Field
className='form_field_value'
disabled
defaultValue={`${tx.amount} ${token.symbol}`}
label='Amount'
/>
<FetherForm.Field
as='textarea'
className='form_field_value'
disabled
defaultValue={tx.to}
label='To'
/>
</div>,
<form key='signerForm' onSubmit={this.handleAccept}>
<div className='text'>
<p>Enter your password to confirm this transaction.</p>
</div>
<div
data-tip={error ? error.text : ''}
ref={this.handleTooltipRef}
>
<FormField
label='Password'
onChange={this.handleChangePassword}
required
type='password'
value={password}
/>
</div>
<nav className='form-nav -binary'>
<button
className='button -cancel'
onClick={this.handleCancel}
type='button'
>
Cancel
</button>
<button
className='button -submit'
disabled={!password.length || isSending || error}
>
Confirm transaction
</button>
</nav>
</form>
<Form
key='signerForm'
onSubmit={this.handleAccept}
render={({ handleSubmit, pristine, submitting }) => (
<form onSubmit={handleSubmit}>
<div className='text'>
<p>Enter your password to confirm this transaction.</p>
</div>
<Field
label='Password'
name='password'
render={FetherForm.Field}
required
type='password'
/>
<nav className='form-nav -binary'>
<button
className='button -cancel'
onClick={history.goBack}
type='button'
>
Cancel
</button>
<button
className='button -submit'
disabled={pristine || submitting}
>
Confirm transaction
</button>
</nav>
</form>
)}
/>
]}
onClick={null}
token={token}
/>
</div>
</div>
<ReactTooltip
effect='solid'
event='mouseover'
eventOff='keydown mouseout'
place='top'
/>
</div>
);
}
......
......@@ -11,12 +11,12 @@ import { withRouter } from 'react-router-dom';
import withBalance from '../../../utils/withBalance';
@withBalance()
@withBalance
@inject('sendStore')
@withRouter
class TokenBalance extends Component {
static propTypes = {
token: PropTypes.object.isRequired
token: PropTypes.object
};
handleClick = () => {
......@@ -24,8 +24,8 @@ class TokenBalance extends Component {
if (!token.address) {
return;
}
sendStore.setTokenAddress(token.address);
history.push('/send');
sendStore.clear();
history.push(`/send/${token.address}`);
};
render () {
......
......@@ -14,6 +14,8 @@
}
.form_field_value {
background-color: transparent;
border: none;
font-size: ms(-1);
font-weight: 400;
font-family: $mono;
......@@ -21,6 +23,8 @@
opacity: 0.75;
margin: 0.25rem 0.5rem 0;
padding-bottom: 0.5rem;
padding-left: 0;
padding-right: 0;
overflow: hidden;
word-wrap: break-word;
}
......
......@@ -27,6 +27,7 @@ export class ParityStore {
// Retrieve token from localStorage
const token = store.get(LS_KEY);
if (token) {
debug('Got token from localStorage.');
this.setToken(token);
}
......@@ -99,6 +100,7 @@ export class ParityStore {
}
// If `parity signer new-token` has successfully given us a token back,
// then we submit it
debug('Successfully received new token.');
this.setToken(token);
});
};
......@@ -137,7 +139,6 @@ export class ParityStore {
return;
}
debug(`Setting token in localStorage.`);
this.token = token;
// If we receive a new token, then we try to connect to the Api with this
......
......@@ -3,26 +3,17 @@
//
// SPDX-License-Identifier: BSD-3-Clause
import abi from '@parity/shared/lib/contracts/abi/eip20';
import { action, computed, observable } from 'mobx';
import { BigNumber } from 'bignumber.js';
import { blockNumber$, makeContract$, post$ } from '@parity/light.js';
import memoize from 'lodash/memoize';
import noop from 'lodash/noop';
import { toWei } from '@parity/api/lib/util/wei';
import { blockNumber$, post$ } from '@parity/light.js';
import { contractForToken, txForErc20, txForEth } from '../utils/estimateGas';
import Debug from '../utils/debug';
import parityStore from './parityStore';
import tokensStore from './tokensStore';
const debug = Debug('sendStore');
const GAS_MULT_FACTOR = 1.25; // Since estimateGas is not always accurate, we add a 33% factor for buffer.
const DEFAULT_GAS = new BigNumber(21000 * GAS_MULT_FACTOR); // Default gas amount
export class SendStore {
@observable blockNumber; // Current block number, used to calculate tx confirmations.
@observable estimated = DEFAULT_GAS; // Estimated gas amount for this transaction.
@observable tokenAddress; // 'ETH', or the token contract address
tx = {}; // The actual tx we are sending. No need to be observable.
@observable txStatus; // Status of the tx, see wiki for details.
......@@ -55,74 +46,18 @@ export class SendStore {
return this.blockNumber - +this.txStatus.confirmed.blockNumber;
}
/**
* If it's a token, then return the makeContract$ object.
*/
@computed
get contract () {
if (this.tokenAddress === 'ETH') {
return null;
}
return makeContract$(this.tokenAddress, abi);
}
/**
* Estimate the amount of gas for our transaction.
*/
estimateGas = () => {
if (!this.tx || !Object.keys(this.tx).length) {
return Promise.reject(new Error('Tx not set in sendStore.'));
}
if (this.tokenAddress === 'ETH') {
return this.estimateGasForEth(this.txForEth);
} else {
return this.estimateGasForErc20(this.txForErc20);
}
};
/**
* Estimate gas to transfer in ERC20 contract. Expensive function, so we
* memoize it.
*/
estimateGasForErc20 = memoize(
txForErc20 =>
this.contract.contractObject.instance.transfer
.estimateGas(txForErc20.options, txForErc20.args)
.then(this.setEstimated)
.catch(noop),
JSON.stringify
);
/**
* Estimate gas to transfer to an ETH address. Expensive function, so we
* memoize it.
*/
estimateGasForEth = memoize(
txForEth =>
parityStore.api.eth
.estimateGas(txForEth)
.then(this.setEstimated)
.catch(noop),
JSON.stringify
);
/**
* Create a transaction.
*/
send = password => {
send = (token, password) => {
const tx =
token.address === 'ETH' ? txForEth(this.tx) : txForErc20(this.tx, token);
const send$ =
this.tokenAddress === 'ETH'
? post$(this.txForEth)
: this.contract.transfer$(
...this.txForErc20.args,
this.txForErc20.options
);
token.address === 'ETH'
? post$(tx)
: contractForToken(token.address).transfer$(...tx.args, tx.options);
debug(
'Sending tx.',
this.tokenAddress === 'ETH' ? this.txForEth : this.txForErc20
);
debug('Sending tx.', tx);
return new Promise((resolve, reject) => {
send$.subscribe(txStatus => {
......@@ -138,54 +73,11 @@ export class SendStore {
});
};
/**
* This.tx is a user-friendly tx object. We convert it now as it can be
* passed to makeContract$(...).
*/
@computed
get txForErc20 () {
return {
args: [
this.tx.to,
new BigNumber(this.tx.amount).mul(
new BigNumber(10).pow(tokensStore.tokens[this.tokenAddress].decimals)
)
],
options: {
gasPrice: toWei(this.tx.gasPrice, 'shannon') // shannon == gwei
}
};
}
/**
* This.tx is a user-friendly tx object. We convert it now as it can be
* passed to post$(tx).
*/
@computed
get txForEth () {
return {
gasPrice: toWei(this.tx.gasPrice, 'shannon'), // shannon == gwei
to: this.tx.to,
value: toWei(this.tx.amount.toString())
};
}
@action
setBlockNumber = blockNumber => {
this.blockNumber = blockNumber;
};
@action
setEstimated = estimated => {
this.estimated = estimated.mul(GAS_MULT_FACTOR);
debug('Estimated gas,', +estimated, ', with buffer,', +this.estimated);
};
@action
setTokenAddress = tokenAddress => {
this.tokenAddress = tokenAddress;
};
@action
setTx = tx => {
this.tx = tx;
......
......@@ -5,8 +5,6 @@
/* eslint-env jest */
import abi from '@parity/shared/lib/contracts/abi/eip20';
import BigNumber from 'bignumber.js';
import lightJs from '@parity/light.js'; // Mocked
import parityStore from './parityStore';
......@@ -21,11 +19,6 @@ jest.mock('@parity/light.js', () => ({
}))
})),
makeContract$: jest.fn(() => ({
contractObject: {
instance: {
transfer: { estimateGas: jest.fn(() => Promise.resolve(123)) }
}
},
transfer$: jest.fn(() => ({ subscribe: jest.fn() }))
})),
post$: jest.fn(() => ({
......@@ -38,9 +31,6 @@ jest.mock('@parity/light.js', () => ({
jest.mock('./parityStore', () => ({
api: {
eth: {
estimateGas: jest.fn(() => Promise.resolve(123))
},
signer: {
confirmRequest: jest.fn(() => Promise.resolve(true))
}
......@@ -60,6 +50,15 @@ const mockTx = {
to: '0x123'
};
const mockErc20Token = {
address: 'foo',
decimals: 18
};
const mockEthToken = {
address: 'ETH'
};
let sendStore; // Will hold the newly created instance of SendStore in each test
beforeEach(() => {
sendStore = new SendStore();
......@@ -113,149 +112,34 @@ describe('@computed confirmations', () => {
});
});
describe('@computed contract', () => {
test('should create a contract with correct token address if the current token Erc20', () => {
sendStore.setTokenAddress('foo');
sendStore.contract; // eslint-disable-line
expect(lightJs.makeContract$).toHaveBeenCalledWith('foo', abi);
});
test('should return null if the current token is ETH', () => {
sendStore.setTokenAddress('ETH');
expect(sendStore.contract).toBe(null);
});
});
describe('method estimateGas', () => {
test('should reject and not estimate if no tx is set', () => {
sendStore.estimateGasForErc20 = jest.fn();
sendStore.estimateGasForEth = jest.fn();
expect(sendStore.estimateGas()).rejects.toHaveProperty(
'message',
'Tx not set in sendStore.'
);
expect(sendStore.estimateGasForErc20).not.toHaveBeenCalled();
expect(sendStore.estimateGasForEth).not.toHaveBeenCalled();
});
test('should call estimateGasForErc20 if the current token is Erc20', () => {
sendStore.estimateGasForErc20 = jest.fn(() => 'estimateGasForErc20');
sendStore.setTokenAddress('foo');
sendStore.setTx(mockTx);
expect(sendStore.estimateGas()).toBe('estimateGasForErc20');
expect(sendStore.estimateGasForErc20).toHaveBeenCalled();
});
test('should call estimateGasForEth if the current token is ETH', () => {
sendStore.estimateGasForEth = jest