Several fixes to the Wallet in general (#4504)

* Fix address non-ellipsis on Wallet view

* Add warning if daily limit has been reached

* Add OOG warnings to TxHash

* Fixes to the Warning

* Added stringified version of the changes

* Add wallet link for notifications

* Fix tests

* Use Formatted Messages

* React Intl

* s/ui.walletSettings/walletSettings in React Intl
This commit is contained in:
Nicolas Gotchac 2017-02-10 18:27:18 +01:00 committed by Jaco Greeff
parent da2e28dad1
commit ace5c27211
10 changed files with 384 additions and 48 deletions

View File

@ -20,6 +20,7 @@ import { uniq } from 'lodash';
import { wallet as walletAbi } from '~/contracts/abi';
import { bytesToHex } from '~/api/util/format';
import { fromWei } from '~/api/util/wei';
import Contract from '~/api/contract';
import ERRORS from './errors';
import { ERROR_CODES } from '~/api/transport/error';
@ -39,6 +40,8 @@ const TITLES = {
const STAGES_BASIC = [TITLES.transfer, TITLES.sending, TITLES.complete];
const STAGES_EXTRA = [TITLES.transfer, TITLES.extras, TITLES.sending, TITLES.complete];
export const WALLET_WARNING_SPENT_TODAY_LIMIT = 'WALLET_WARNING_SPENT_TODAY_LIMIT';
export default class TransferStore {
@observable stage = 0;
@observable extras = false;
@ -65,6 +68,8 @@ export default class TransferStore {
@observable value = '0.0';
@observable valueError = null;
@observable walletWarning = null;
account = null;
balance = null;
onClose = null;
@ -332,6 +337,21 @@ export default class TransferStore {
valueError = this._validateDecimals(value);
}
if (this.isWallet && !valueError) {
const { last, limit, spent } = this.wallet.dailylimit;
const remains = fromWei(limit.minus(spent));
const today = Math.round(Date.now() / (24 * 3600 * 1000));
const isResetable = last.lt(today);
if ((!isResetable && remains.lt(value)) || fromWei(limit).lt(value)) {
// already spent too much today
this.walletWarning = WALLET_WARNING_SPENT_TODAY_LIMIT;
} else if (this.walletWarning) {
// all ok
this.walletWarning = null;
}
}
transaction(() => {
this.value = value;
this.valueError = valueError;

View File

@ -15,6 +15,7 @@
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import React, { Component, PropTypes } from 'react';
import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { observer } from 'mobx-react';
@ -28,7 +29,7 @@ import { nullableProptype } from '~/util/proptypes';
import Details from './Details';
import Extras from './Extras';
import TransferStore from './store';
import TransferStore, { WALLET_WARNING_SPENT_TODAY_LIMIT } from './store';
import styles from './transfer.css';
const STEP_DETAILS = 0;
@ -71,6 +72,7 @@ class Transfer extends Component {
visible
>
{ this.renderExceptionWarning() }
{ this.renderWalletWarning() }
{ this.renderPage() }
</Modal>
);
@ -89,6 +91,29 @@ class Transfer extends Component {
);
}
renderWalletWarning () {
const { walletWarning } = this.store;
if (!walletWarning) {
return null;
}
if (walletWarning === WALLET_WARNING_SPENT_TODAY_LIMIT) {
const warning = (
<FormattedMessage
id='transfer.warning.wallet_spent_limit'
defaultMessage='This transaction value is above the remaining daily limit. It will need to be confirmed by other owners.'
/>
);
return (
<Warning warning={ warning } />
);
}
return null;
}
renderAccount () {
const { account } = this.props;

View File

@ -61,3 +61,8 @@
margin-left: 0.125em;
}
.modifications {
color: white;
margin: 0;
}

View File

@ -15,6 +15,7 @@
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import React, { Component, PropTypes } from 'react';
import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { observer } from 'mobx-react';
import { pick } from 'lodash';
@ -23,7 +24,7 @@ import ActionDone from 'material-ui/svg-icons/action/done';
import ContentClear from 'material-ui/svg-icons/content/clear';
import NavigationArrowForward from 'material-ui/svg-icons/navigation/arrow-forward';
import { Button, Modal, TxHash, BusyStep, Form, TypedInput, InputAddress, AddressSelect } from '~/ui';
import { Button, Modal, TxHash, BusyStep, Form, TypedInput, Input, InputAddress, AddressSelect } from '~/ui';
import { fromWei } from '~/api/util/wei';
import WalletSettingsStore from './walletSettingsStore.js';
@ -51,12 +52,27 @@ class WalletSettings extends Component {
return (
<Modal
visible
title='rejected'
title={
<FormattedMessage
id='walletSettings.rejected.title'
defaultMessage='rejected'
/>
}
actions={ this.renderDialogActions() }
>
<BusyStep
title='The modifications have been rejected'
state='The wallet settings will not be modified. You can safely close this window.'
title={
<FormattedMessage
id='walletSettings.rejected.busyStep.title'
defaultMessage='The modifications have been rejected'
/>
}
state={
<FormattedMessage
id='walletSettings.rejected.busyStep.state'
defaultMessage='The wallet settings will not be modified. You can safely close this window.'
/>
}
/>
</Modal>
);
@ -112,57 +128,124 @@ class WalletSettings extends Component {
default:
case 'EDIT':
const { wallet, errors } = this.store;
const { errors, fromString, wallet } = this.store;
const { accountsInfo, senders } = this.props;
return (
<Form>
<p>
In order to edit this contract's settings, at
least { this.store.initialWallet.require.toNumber() } owners have to
send the very same modifications.
Otherwise, no modification will be taken into account...
<FormattedMessage
id='walletSettings.edit.message'
defaultMessage={
`In order to edit this contract's settings, at
least { owners, number } { owners, plural,
one { owner }
other { owners }
} have to
send the very same modifications. You can paste a stringified version
of the modifications here.`
}
values={ {
owners: this.store.initialWallet.require.toNumber()
} }
/>
</p>
<Input
hint='[ ... ]'
label={
<FormattedMessage
id='walletSettings.modifications.fromString.label'
defaultMessage='modifications'
/>
}
onChange={ this.store.onModificationsStringChange }
/>
<AddressSelect
label='from account (wallet owner)'
hint='send modifications as this owner'
label={
<FormattedMessage
id='walletSettings.modifications.sender.label'
defaultMessage='from account (wallet owner)'
/>
}
hint={
<FormattedMessage
id='walletSettings.modifications.sender.hint'
defaultMessage='send modifications as this owner'
/>
}
value={ wallet.sender }
error={ errors.sender }
onChange={ this.store.onSenderChange }
accounts={ senders }
/>
<TypedInput
label='other wallet owners'
value={ wallet.owners.slice() }
onChange={ this.store.onOwnersChange }
accounts={ accountsInfo }
param='address[]'
/>
<br />
<div className={ styles.splitInput }>
<TypedInput
label='required owners'
hint='number of required owners to accept a transaction'
error={ errors.require }
min={ 1 }
onChange={ this.store.onRequireChange }
max={ wallet.owners.length }
param='uint'
value={ wallet.require }
/>
{
fromString
? null
: (
<div>
<TypedInput
label={
<FormattedMessage
id='walletSettings.modifications.owners.label'
defaultMessage='other wallet owners'
/>
}
value={ wallet.owners.slice() }
onChange={ this.store.onOwnersChange }
accounts={ accountsInfo }
param='address[]'
/>
<TypedInput
label='wallet day limit'
hint='amount of ETH spendable without confirmations'
value={ wallet.dailylimit }
error={ errors.dailylimit }
onChange={ this.store.onDailylimitChange }
param='uint'
isEth
/>
</div>
<div className={ styles.splitInput }>
<TypedInput
label={
<FormattedMessage
id='walletSettings.modifications.required.label'
defaultMessage='required owners'
/>
}
hint={
<FormattedMessage
id='walletSettings.modifications.required.hint'
defaultMessage='number of required owners to accept a transaction'
/>
}
error={ errors.require }
min={ 1 }
onChange={ this.store.onRequireChange }
max={ wallet.owners.length }
param='uint'
value={ wallet.require }
/>
<TypedInput
label={
<FormattedMessage
id='walletSettings.modifications.daylimit.label'
defaultMessage='wallet day limit'
/>
}
hint={
<FormattedMessage
id='walletSettings.modifications.daylimit.hint'
defaultMessage='amount of ETH spendable without confirmations'
/>
}
value={ wallet.dailylimit }
error={ errors.dailylimit }
onChange={ this.store.onDailylimitChange }
param='uint'
isEth
/>
</div>
</div>
)
}
</Form>
);
}
@ -171,7 +254,12 @@ class WalletSettings extends Component {
renderChanges (changes) {
if (changes.length === 0) {
return (
<p>No modifications have been made to the Wallet settings.</p>
<p>
<FormattedMessage
id='walletSettings.changes.none'
defaultMessage='No modifications have been made to the Wallet settings.'
/>
</p>
);
}
@ -183,7 +271,31 @@ class WalletSettings extends Component {
return (
<div>
<p>You are about to make the following modifications</p>
<p className={ styles.modifications }>
<FormattedMessage
id='walletSettings.changes.modificationString'
defaultMessage={
`For your modifications to be taken into account,
other owners have to send the same modifications. They can paste
this string to make it easier:`
}
/>
</p>
<Input
allowCopy
label='modifications'
readOnly
value={ this.store.changesToString() }
/>
<p>
<FormattedMessage
id='walletSettings.changes.overview'
defaultMessage={
`You are about to make the following modifications`
}
/>
</p>
{ modifications }
</div>
);

View File

@ -30,10 +30,11 @@ const STEPS = {
export default class WalletSettingsStore {
accounts = {};
@observable step = null;
@observable requests = [];
@observable deployState = '';
@observable done = false;
@observable fromString = false;
@observable requests = [];
@observable step = null;
@observable wallet = {
owners: null,
@ -79,6 +80,51 @@ export default class WalletSettingsStore {
.map((s) => s.idx);
}
@action
changesFromString (json) {
try {
const data = JSON.parse(json);
const changes = data.map((datum) => {
const [ type, valueStr ] = datum.split(';');
let value = valueStr;
// Only addresses start with `0x`, the others
// are BigNumbers
if (!/^0x/.test(valueStr)) {
value = new BigNumber(valueStr, 16);
}
return { type, value };
});
this.changes = changes;
} catch (error) {
if (!(error instanceof SyntaxError)) {
console.error('changes from string', error);
}
this.changes = [];
}
}
changesToString () {
const changes = this.changes.map((change) => {
const { type, value } = change;
const valueStr = (value && typeof value.plus === 'function')
? value.toString(16)
: value;
return [
type,
valueStr
].join(';');
});
return JSON.stringify(changes);
}
get changes () {
const changes = [];
@ -127,6 +173,36 @@ export default class WalletSettingsStore {
return changes;
}
set changes (changes) {
transaction(() => {
this.wallet.dailylimit = this.initialWallet.dailylimit;
this.wallet.require = this.initialWallet.require;
this.wallet.owners = this.initialWallet.owners.slice();
changes.forEach((change) => {
const { type, value } = change;
switch (type) {
case 'dailylimit':
this.wallet.dailylimit = value;
break;
case 'require':
this.wallet.require = value;
break;
case 'remove_owner':
this.wallet.owners = this.wallet.owners.filter((owner) => owner !== value);
break;
case 'add_owner':
this.wallet.owners.push(value);
break;
}
});
});
}
constructor (api, wallet) {
this.api = api;
this.step = this.stepsKeys[0];
@ -177,6 +253,16 @@ export default class WalletSettingsStore {
this.onChange({ dailylimit });
}
@action onModificationsStringChange = (event, value) => {
this.changesFromString(value);
if (this.changes && this.changes.length > 0) {
this.fromString = true;
} else {
this.fromString = false;
}
}
@action send = () => {
const changes = this.changes;
const walletInstance = this.walletInstance;

View File

@ -90,7 +90,11 @@ function setBalances (_balances, skipNotifications = false) {
const txValue = value.minus(prevValue);
const redirectToAccount = () => {
const route = `/accounts/${account.address}`;
const basePath = account.wallet
? 'wallet'
: 'accounts';
const route = `/${basePath}/${account.address}`;
dispatch(push(route));
};

View File

@ -21,8 +21,9 @@ import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { txLink } from '~/3rdparty/etherscan/links';
import ShortenedHash from '../ShortenedHash';
import Warning from '~/ui/Warning';
import ShortenedHash from '../ShortenedHash';
import styles from './txHash.css';
class TxHash extends Component {
@ -43,10 +44,44 @@ class TxHash extends Component {
state = {
blockNumber: new BigNumber(0),
gas: {},
subscriptionId: null,
transaction: null
}
componentWillMount () {
this.fetchTransactionGas();
}
componentWillReceiveProps (nextProps) {
const prevHash = this.props.hash;
const nextHash = nextProps.hash;
if (prevHash !== nextHash) {
this.fetchTransactionGas(nextProps);
}
}
/**
* Get the gas send for the current transaction
* and save the value in the state
*/
fetchTransactionGas (props = this.props) {
const { hash } = props;
if (!hash) {
return;
}
this.context.api.eth
.getTransactionByHash(hash)
.then((transaction = {}) => {
const { gas = new BigNumber(0) } = transaction;
this.setState({ gas: { hash, value: gas } });
});
}
componentDidMount () {
const { api } = this.context;
@ -73,6 +108,7 @@ class TxHash extends Component {
return (
<div>
{ this.renderWarning() }
<p>{
summary
? hashLink
@ -87,6 +123,32 @@ class TxHash extends Component {
);
}
renderWarning () {
const { gas, transaction } = this.state;
if (!(transaction && transaction.blockNumber && transaction.blockNumber.gt(0))) {
return null;
}
const { gasUsed = new BigNumber(0) } = transaction;
const isOog = transaction.transactionHash === gas.hash && gasUsed.gte(gas.value);
if (!isOog) {
return null;
}
return (
<Warning
warning={
<FormattedMessage
id='ui.txHash.oog'
defaultMessage='The transaction might have gone out of gas. Try again with more gas.'
/>
}
/>
);
}
renderConfirmations () {
const { maxConfirmations } = this.props;
const { blockNumber, transaction } = this.state;

View File

@ -33,10 +33,17 @@ function createApi () {
blockNumber = new BigNumber(100);
api = {
eth: {
getTransactionByHash: (hash) => {
return Promise.resolve({
blockNumber: new BigNumber(100),
gas: new BigNumber(42000)
});
},
getTransactionReceipt: (hash) => {
return Promise.resolve({
blockNumber: new BigNumber(100),
hash
transactionHash: hash,
gasUsed: new BigNumber(42000)
});
}
},
@ -129,6 +136,14 @@ describe('ui/TxHash', () => {
it('renders confirmation text', () => {
expect(child.find('FormattedMessage').props().id).to.equal('ui.txHash.confirmations');
});
it('renders with warnings', () => {
expect(component.find('Warning')).to.have.length.gte(1);
});
it('renders with oog warning', () => {
expect(component.find('Warning').shallow().find('FormattedMessage').prop('id')).to.match(/oog/);
});
});
});
});

View File

@ -59,9 +59,15 @@
display: inline-block;
}
.addressline {
display: flex;
}
.address {
display: inline-block;
margin-left: .5em;
margin-left: 0.5em;
overflow: hidden;
text-overflow: ellipsis;
}
.tags {

View File

@ -55,6 +55,7 @@
.header {
flex: 1;
margin-right: 0.25em;
overflow: hidden;
}
.details {