Add block & timestamp conditions to Signer (#4411)

* WIP

* WIP (with lint)

* Update ui/RadioButtons

* transaction.condition

* Date & Time selection in-place

* Swap to condition-only

* Fix tests, align naming

* Pick error properly from validation

* Update tests

* condition: time sent withough ms

* Format numbers as base-10

* override popup styles (zIndex)

* Pass condition to signer

* Update expectation (failing test typo)

* Adjust min/max height for expanded bar

* Fix address display

* Fix name display

* Number inputs for gas/gasPrice/blockNumber

* Default blockNumber to 1 (align with min setting)

* Update tests with min value

* Add Block Number

* Fix failing tests (after blockNumber intro)
This commit is contained in:
Jaco Greeff 2017-02-03 20:01:09 +01:00 committed by GitHub
parent 312aa72747
commit cd4d489b57
31 changed files with 894 additions and 255 deletions

View File

@ -127,6 +127,18 @@ export function inNumber16 (number) {
return inHex(bn.toString(16));
}
export function inOptionsCondition (condition) {
if (condition) {
if (condition.block) {
condition.block = condition.block ? inNumber10(condition.block) : null;
} else if (condition.time) {
condition.time = inNumber10(Math.floor(condition.time.getTime() / 1000));
}
}
return condition;
}
export function inOptions (options) {
if (options) {
Object.keys(options).forEach((key) => {
@ -136,6 +148,10 @@ export function inOptions (options) {
options[key] = inAddress(options[key]);
break;
case 'condition':
options[key] = inOptionsCondition(options[key]);
break;
case 'gas':
case 'gasPrice':
options[key] = inNumber16((new BigNumber(options[key])).round());

View File

@ -221,6 +221,18 @@ export function outSyncing (syncing) {
return syncing;
}
export function outTransactionCondition (condition) {
if (condition) {
if (condition.block) {
condition.block = outNumber(condition.block);
} else if (condition.time) {
condition.time = outDate(condition.time);
}
}
return condition;
}
export function outTransaction (tx) {
if (tx) {
Object.keys(tx).forEach((key) => {
@ -234,8 +246,14 @@ export function outTransaction (tx) {
tx[key] = outNumber(tx[key]);
break;
case 'condition':
tx[key] = outTransactionCondition(tx[key]);
break;
case 'minBlock':
tx[key] = tx[key] ? outNumber(tx[key]) : null;
tx[key] = tx[key]
? outNumber(tx[key])
: null;
break;
case 'creates':

View File

@ -15,46 +15,23 @@
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import React, { Component, PropTypes } from 'react';
import { FormattedMessage } from 'react-intl';
import { Input, GasPriceEditor } from '~/ui';
import { GasPriceEditor } from '~/ui';
import styles from '../executeContract.css';
export default class AdvancedStep extends Component {
static propTypes = {
gasStore: PropTypes.object.isRequired,
minBlock: PropTypes.string,
minBlockError: PropTypes.string,
onMinBlockChange: PropTypes.func
gasStore: PropTypes.object.isRequired
};
render () {
const { gasStore, minBlock, minBlockError, onMinBlockChange } = this.props;
const { gasStore } = this.props;
return (
<div>
<Input
error={ minBlockError }
hint={
<FormattedMessage
id='executeContract.advanced.minBlock.hint'
defaultMessage='Only post the transaction after this block'
/>
}
label={
<FormattedMessage
id='executeContract.advanced.minBlock.label'
defaultMessage='BlockNumber to send from'
/>
}
value={ minBlock }
onSubmit={ onMinBlockChange }
/>
<div className={ styles.gaseditor }>
<GasPriceEditor store={ gasStore } />
</div>
</div>
);
}
}

View File

@ -14,7 +14,6 @@
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import BigNumber from 'bignumber.js';
import { pick } from 'lodash';
import { observer } from 'mobx-react';
import React, { Component, PropTypes } from 'react';
@ -100,8 +99,6 @@ class ExecuteContract extends Component {
fromAddressError: null,
func: null,
funcError: null,
minBlock: '0',
minBlockError: null,
rejected: false,
sending: false,
step: STEP_DETAILS,
@ -167,8 +164,8 @@ class ExecuteContract extends Component {
renderDialogActions () {
const { onClose, fromAddress } = this.props;
const { advancedOptions, sending, step, fromAddressError, minBlockError, valuesError } = this.state;
const hasError = fromAddressError || minBlockError || valuesError.find((error) => error);
const { advancedOptions, sending, step, fromAddressError, valuesError } = this.state;
const hasError = fromAddressError || valuesError.find((error) => error);
const cancelBtn = (
<Button
@ -258,7 +255,7 @@ class ExecuteContract extends Component {
renderStep () {
const { onFromAddressChange } = this.props;
const { advancedOptions, step, busyState, minBlock, minBlockError, txhash, rejected } = this.state;
const { advancedOptions, step, busyState, txhash, rejected } = this.state;
if (rejected) {
return (
@ -305,12 +302,7 @@ class ExecuteContract extends Component {
);
} else if (advancedOptions && (step === STEP_BUSY_OR_ADVANCED)) {
return (
<AdvancedStep
gasStore={ this.gasStore }
minBlock={ minBlock }
minBlockError={ minBlockError }
onMinBlockChange={ this.onMinBlockChange }
/>
<AdvancedStep gasStore={ this.gasStore } />
);
}
@ -339,15 +331,6 @@ class ExecuteContract extends Component {
}, this.estimateGas);
}
onMinBlockChange = (minBlock) => {
const minBlockError = validateUint(minBlock).valueError;
this.setState({
minBlock,
minBlockError
});
}
onValueChange = (event, index, _value) => {
const { func, values, valuesError } = this.state;
const input = func.inputs.find((input, _index) => index === _index);
@ -409,17 +392,14 @@ class ExecuteContract extends Component {
postTransaction = () => {
const { api, store } = this.context;
const { fromAddress } = this.props;
const { advancedOptions, amount, func, minBlock, values } = this.state;
const { advancedOptions, amount, func, values } = this.state;
const steps = advancedOptions ? STAGES_ADVANCED : STAGES_BASIC;
const finalstep = steps.length - 1;
const options = {
gas: this.gasStore.gas,
gasPrice: this.gasStore.price,
const options = this.gasStore.overrideTransaction({
from: fromAddress,
minBlock: new BigNumber(minBlock || 0).gt(0) ? minBlock : null,
value: api.util.toWei(amount || 0)
};
});
this.setState({ sending: true, step: advancedOptions ? STEP_BUSY : STEP_BUSY_OR_ADVANCED });

View File

@ -27,36 +27,17 @@ export default class Extras extends Component {
dataError: PropTypes.string,
gasStore: PropTypes.object.isRequired,
isEth: PropTypes.bool,
minBlock: PropTypes.string,
minBlockError: PropTypes.string,
onChange: PropTypes.func.isRequired,
total: PropTypes.string,
totalError: PropTypes.string
}
render () {
const { gasStore, minBlock, minBlockError, onChange } = this.props;
const { gasStore, onChange } = this.props;
return (
<Form>
{ this.renderData() }
<Input
error={ minBlockError }
hint={
<FormattedMessage
id='transferModal.minBlock.hint'
defaultMessage='Only post the transaction after this block'
/>
}
label={
<FormattedMessage
id='transferModal.minBlock.label'
defaultMessage='BlockNumber to send from'
/>
}
value={ minBlock }
onChange={ this.onEditMinBlock }
/>
<div className={ styles.gaseditor }>
<GasPriceEditor
store={ gasStore }
@ -98,8 +79,4 @@ export default class Extras extends Component {
onEditData = (event) => {
this.props.onChange('data', event.target.value);
}
onEditMinBlock = (event) => {
this.props.onChange('minBlock', event.target.value);
}
}

View File

@ -52,9 +52,6 @@ export default class TransferStore {
@observable data = '';
@observable dataError = null;
@observable minBlock = '0';
@observable minBlockError = null;
@observable recipient = '';
@observable recipientError = ERRORS.requireRecipient;
@ -78,39 +75,6 @@ export default class TransferStore {
gasStore = null;
@computed get steps () {
const steps = [].concat(this.extras ? STAGES_EXTRA : STAGES_BASIC);
if (this.rejected) {
steps[steps.length - 1] = TITLES.rejected;
}
return steps;
}
@computed get isValid () {
const detailsValid = !this.recipientError && !this.valueError && !this.totalError && !this.senderError;
const extrasValid = !this.gasStore.errorGas && !this.gasStore.errorPrice && !this.minBlockError && !this.totalError;
const verifyValid = !this.passwordError;
switch (this.stage) {
case 0:
return detailsValid;
case 1:
return this.extras
? extrasValid
: verifyValid;
case 2:
return verifyValid;
}
}
get token () {
return this.balance.tokens.find((balance) => balance.token.tag === this.tag).token;
}
constructor (api, props) {
this.api = api;
@ -135,6 +99,39 @@ export default class TransferStore {
}
}
@computed get steps () {
const steps = [].concat(this.extras ? STAGES_EXTRA : STAGES_BASIC);
if (this.rejected) {
steps[steps.length - 1] = TITLES.rejected;
}
return steps;
}
@computed get isValid () {
const detailsValid = !this.recipientError && !this.valueError && !this.totalError && !this.senderError;
const extrasValid = !this.gasStore.errorGas && !this.gasStore.errorPrice && !this.gasStore.conditionBlockError && !this.totalError;
const verifyValid = !this.passwordError;
switch (this.stage) {
case 0:
return detailsValid;
case 1:
return this.extras
? extrasValid
: verifyValid;
case 2:
return verifyValid;
}
}
get token () {
return this.balance.tokens.find((balance) => balance.token.tag === this.tag).token;
}
@action onNext = () => {
this.stage += 1;
}
@ -164,9 +161,6 @@ export default class TransferStore {
case 'gasPrice':
return this._onUpdateGasPrice(value);
case 'minBlock':
return this._onUpdateMinBlock(value);
case 'recipient':
return this._onUpdateRecipient(value);
@ -284,14 +278,6 @@ export default class TransferStore {
this.recalculate();
}
@action _onUpdateMinBlock = (minBlock) => {
console.log('minBlock', minBlock);
transaction(() => {
this.minBlock = minBlock;
this.minBlockError = this._validatePositiveNumber(minBlock);
});
}
@action _onUpdateGasPrice = (gasPrice) => {
this.recalculate();
}
@ -590,7 +576,6 @@ export default class TransferStore {
send () {
const { options, values } = this._getTransferParams();
options.minBlock = new BigNumber(this.minBlock || 0).gt(0) ? this.minBlock : null;
log.debug('@send', 'transfer value', options.value && options.value.toFormat());
return this._getTransferMethod().postTransaction(options, values);
@ -639,15 +624,12 @@ export default class TransferStore {
const to = (isEth && !isWallet) ? this.recipient
: (this.isWallet ? this.wallet.address : this.token.address);
const options = {
const options = this.gasStore.overrideTransaction({
from: this.sender || this.account.address,
to
};
});
if (!gas) {
options.gas = this.gasStore.gas;
options.gasPrice = this.gasStore.price;
} else {
if (gas) {
options.gas = MAX_GAS_ESTIMATION;
}

View File

@ -207,7 +207,7 @@ class Transfer extends Component {
return null;
}
const { isEth, data, dataError, minBlock, minBlockError, total, totalError } = this.store;
const { isEth, data, dataError, total, totalError } = this.store;
return (
<Extras
@ -215,8 +215,6 @@ class Transfer extends Component {
dataError={ dataError }
gasStore={ this.store.gasStore }
isEth={ isEth }
minBlock={ minBlock }
minBlockError={ minBlockError }
onChange={ this.store.onUpdateDetails }
total={ total }
totalError={ totalError }

View File

@ -52,7 +52,7 @@ export default class SignerMiddleware {
}
onConfirmStart = (store, action) => {
const { gas = 0, gasPrice = 0, id, password, payload, wallet } = action.payload;
const { condition, gas = 0, gasPrice = 0, id, password, payload, wallet } = action.payload;
const handlePromise = (promise) => {
promise
@ -120,7 +120,7 @@ export default class SignerMiddleware {
});
}
handlePromise(this._api.signer.confirmRequest(id, { gas, gasPrice }, password));
handlePromise(this._api.signer.confirmRequest(id, { gas, gasPrice, condition }, password));
}
onRejectStart = (store, action) => {

View File

@ -78,6 +78,9 @@ class InputAddress extends Component {
props.focused = focused;
}
// FIXME: The is not advisable, fixes the display issue, however the name should come from
// a common component.
// account.name || (value ? 'UNNAMED' : value)
return (
<div className={ containerClasses.join(' ') }>
<Input
@ -96,7 +99,7 @@ class InputAddress extends Component {
tabIndex={ tabIndex }
value={
text && account
? account.name
? (account.name || (value ? 'UNNAMED' : value))
: (nullName || value)
}
{ ...props }

View File

@ -0,0 +1,17 @@
// Copyright 2015-2017 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
export default from './inputDate';

View File

@ -0,0 +1,22 @@
/* Copyright 2015-2017 Parity Technologies (UK) Ltd.
/* This file is part of Parity.
/*
/* Parity is free software: you can redistribute it and/or modify
/* it under the terms of the GNU General Public License as published by
/* the Free Software Foundation, either version 3 of the License, or
/* (at your option) any later version.
/*
/* Parity is distributed in the hope that it will be useful,
/* but WITHOUT ANY WARRANTY; without even the implied warranty of
/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
/* GNU General Public License for more details.
/*
/* You should have received a copy of the GNU General Public License
/* along with Parity. If not, see <http://www.gnu.org/licenses/>.
*/
.container {
.input {
width: 100%;
}
}

View File

@ -0,0 +1,53 @@
// Copyright 2015-2017 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import { DatePicker } from 'material-ui';
import React, { Component, PropTypes } from 'react';
import Label from '../Label';
import styles from './inputDate.css';
// NOTE: Has to be larger than Signer overlay Z, aligns with ../InputTime
const DIALOG_STYLE = { zIndex: 10010 };
export default class InputDate extends Component {
static propTypes = {
className: PropTypes.string,
hint: PropTypes.node,
label: PropTypes.node,
onChange: PropTypes.func,
value: PropTypes.object.isRequired
};
render () {
const { className, hint, label, onChange, value } = this.props;
return (
<div className={ [styles.container, className].join(' ') }>
<Label label={ label } />
<DatePicker
autoOk
className={ styles.input }
dialogContainerStyle={ DIALOG_STYLE }
hintText={ hint }
onChange={ onChange }
value={ value }
/>
</div>
);
}
}

View File

@ -0,0 +1,17 @@
// Copyright 2015-2017 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
export default from './inputTime';

View File

@ -0,0 +1,22 @@
/* Copyright 2015-2017 Parity Technologies (UK) Ltd.
/* This file is part of Parity.
/*
/* Parity is free software: you can redistribute it and/or modify
/* it under the terms of the GNU General Public License as published by
/* the Free Software Foundation, either version 3 of the License, or
/* (at your option) any later version.
/*
/* Parity is distributed in the hope that it will be useful,
/* but WITHOUT ANY WARRANTY; without even the implied warranty of
/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
/* GNU General Public License for more details.
/*
/* You should have received a copy of the GNU General Public License
/* along with Parity. If not, see <http://www.gnu.org/licenses/>.
*/
.container {
.input {
width: 100%;
}
}

View File

@ -0,0 +1,54 @@
// Copyright 2015-2017 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import { TimePicker } from 'material-ui';
import React, { Component, PropTypes } from 'react';
import Label from '../Label';
import styles from './inputTime.css';
// NOTE: Has to be larger than Signer overlay Z, aligns with ../InputDate
const DIALOG_STYLE = { zIndex: 10010 };
export default class InputTime extends Component {
static propTypes = {
className: PropTypes.string,
hint: PropTypes.node,
label: PropTypes.node,
onChange: PropTypes.func,
value: PropTypes.object.isRequired
}
render () {
const { className, hint, label, onChange, value } = this.props;
return (
<div className={ [styles.container, className].join(' ') }>
<Label label={ label } />
<TimePicker
autoOk
className={ styles.input }
dialogStyle={ DIALOG_STYLE }
format='24hr'
hintText={ hint }
onChange={ onChange }
value={ value }
/>
</div>
);
}
}

View File

@ -0,0 +1,17 @@
// Copyright 2015-2017 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
export default from './label';

View File

@ -0,0 +1,24 @@
/* Copyright 2015-2017 Parity Technologies (UK) Ltd.
/* This file is part of Parity.
/*
/* Parity is free software: you can redistribute it and/or modify
/* it under the terms of the GNU General Public License as published by
/* the Free Software Foundation, either version 3 of the License, or
/* (at your option) any later version.
/*
/* Parity is distributed in the hope that it will be useful,
/* but WITHOUT ANY WARRANTY; without even the implied warranty of
/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
/* GNU General Public License for more details.
/*
/* You should have received a copy of the GNU General Public License
/* along with Parity. If not, see <http://www.gnu.org/licenses/>.
*/
$labelColor: rgba(255, 255, 255, 0.5);
$labelFontSize: 0.75rem;
.label {
color: $labelColor;
font-size: $labelFontSize;
}

View File

@ -0,0 +1,40 @@
// Copyright 2015-2017 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import React, { Component, PropTypes } from 'react';
import styles from './label.css';
export default class Label extends Component {
static propTypes = {
className: PropTypes.string,
label: PropTypes.node
}
render () {
const { className, label } = this.props;
if (!label) {
return null;
}
return (
<label className={ [styles.label, className].join(' ') }>
{ label }
</label>
);
}
}

View File

@ -15,18 +15,23 @@
/* along with Parity. If not, see <http://www.gnu.org/licenses/>.
*/
.spaced {
.container {
.label {
}
.radioButton {
margin: 0.25em 0;
}
.typeContainer {
.radioLabel {
display: flex;
flex-direction: column;
.desc {
.description {
font-size: 0.8em;
margin-bottom: 0.5em;
color: #ccc;
z-index: 2;
}
}
}

View File

@ -18,10 +18,14 @@ import { RadioButton, RadioButtonGroup } from 'material-ui/RadioButton';
import React, { Component, PropTypes } from 'react';
import { arrayOrObjectProptype } from '~/util/proptypes';
import Label from '../Label';
import styles from './radioButtons.css';
export default class RadioButtons extends Component {
static propTypes = {
className: PropTypes.string,
label: PropTypes.node,
name: PropTypes.string,
onChange: PropTypes.func.isRequired,
value: PropTypes.any,
@ -34,10 +38,10 @@ export default class RadioButtons extends Component {
};
render () {
const { value, values } = this.props;
const { className, label, value, values } = this.props;
const index = Number.isNaN(parseInt(value))
? values.findIndex((val) => val.key === value)
? values.findIndex((_value) => _value.key === value)
: parseInt(value);
const selectedValue = typeof value !== 'object'
? values[index]
@ -45,6 +49,11 @@ export default class RadioButtons extends Component {
const key = this.getKey(selectedValue, index);
return (
<div className={ [styles.container, className].join(' ') }>
<Label
className={ styles.label }
label={ label }
/>
<RadioButtonGroup
name={ name }
onChange={ this.onChange }
@ -52,6 +61,7 @@ export default class RadioButtons extends Component {
>
{ this.renderContent() }
</RadioButtonGroup>
</div>
);
}
@ -67,14 +77,14 @@ export default class RadioButtons extends Component {
return (
<RadioButton
className={ styles.spaced }
className={ styles.radioButton }
key={ index }
label={
<div className={ styles.typeContainer }>
<div className={ styles.radioLabel }>
<span>{ label }</span>
{
description
? <span className={ styles.desc }>{ description }</span>
? <span className={ styles.description }>{ description }</span>
: null
}
</div>
@ -97,7 +107,7 @@ export default class RadioButtons extends Component {
onChange = (event, index) => {
const { onChange, values } = this.props;
const value = values[index] || values.find((v) => v.key === index);
const value = values[index] || values.find((value) => value.key === index);
onChange(value, index);
}

View File

@ -16,25 +16,31 @@
import AddressSelect from './AddressSelect';
import FormWrap from './FormWrap';
import TypedInput from './TypedInput';
import Input from './Input';
import InputAddress from './InputAddress';
import InputAddressSelect from './InputAddressSelect';
import InputChip from './InputChip';
import InputDate from './InputDate';
import InputInline from './InputInline';
import Select from './Select';
import InputTime from './InputTime';
import Label from './Label';
import RadioButtons from './RadioButtons';
import Select from './Select';
import TypedInput from './TypedInput';
export default from './form';
export {
AddressSelect,
FormWrap,
TypedInput,
Input,
InputAddress,
InputAddressSelect,
InputChip,
InputDate,
InputInline,
InputTime,
Label,
RadioButtons,
Select,
RadioButtons
TypedInput
};

View File

@ -16,6 +16,46 @@
*/
.container {
display: flex;
flex-direction: column;
}
.conditionContainer {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
margin-bottom: 1.5em;
.input {
flex: 0 1 50%;
}
}
.conditionRadio {
display: flex;
flex-direction: column;
margin-bottom: 1em;
&>label {
margin-bottom: 0.5em;
}
&>div {
display: flex;
flex-direction: row;
&>div {
width: auto !important;
label {
padding-right: 1.5em;
white-space: nowrap;
}
}
}
}
.graphContainer {
display: flex;
flex-wrap: wrap;
position: relative;

View File

@ -17,13 +17,44 @@
import BigNumber from 'bignumber.js';
import { observer } from 'mobx-react';
import React, { Component, PropTypes } from 'react';
import { FormattedMessage } from 'react-intl';
import Input from '../Form/Input';
import { Input, InputDate, InputTime, RadioButtons } from '../Form';
import GasPriceSelector from '../GasPriceSelector';
import Store from './store';
import Store, { CONDITIONS } from './store';
import styles from './gasPriceEditor.css';
const CONDITION_VALUES = [
{
label: (
<FormattedMessage
id='txEditor.condition.none'
defaultMessage='No conditions'
/>
),
key: CONDITIONS.NONE
},
{
label: (
<FormattedMessage
id='txEditor.condition.blocknumber'
defaultMessage='Send after BlockNumber'
/>
),
key: CONDITIONS.BLOCK
},
{
label: (
<FormattedMessage
id='txEditor.condition.datetime'
defaultMessage='Send after Date & Time'
/>
),
key: CONDITIONS.TIME
}
];
@observer
export default class GasPriceEditor extends Component {
static contextTypes = {
@ -41,7 +72,7 @@ export default class GasPriceEditor extends Component {
render () {
const { api } = this.context;
const { children, store } = this.props;
const { errorGas, errorPrice, errorTotal, estimated, gas, histogram, price, priceDefault, totalValue } = store;
const { conditionType, errorGas, errorPrice, errorTotal, estimated, gas, histogram, price, priceDefault, totalValue } = store;
const eth = api.util.fromWei(totalValue).toFormat();
const gasLabel = `gas (estimated: ${new BigNumber(estimated).toFormat()})`;
@ -49,6 +80,21 @@ export default class GasPriceEditor extends Component {
return (
<div className={ styles.container }>
<RadioButtons
className={ styles.conditionRadio }
label={
<FormattedMessage
id='txEditor.condition.label'
defaultMessage='Condition where transaction activates'
/>
}
onChange={ this.onChangeConditionType }
value={ conditionType }
values={ CONDITION_VALUES }
/>
{ this.renderConditions() }
<div className={ styles.graphContainer }>
<div className={ styles.graphColumn }>
<GasPriceSelector
histogram={ histogram }
@ -56,7 +102,10 @@ export default class GasPriceEditor extends Component {
price={ price }
/>
<div className={ styles.gasPriceDesc }>
You can choose the gas price based on the distribution of recent included transaction gas prices. The lower the gas price is, the cheaper the transaction will be. The higher the gas price is, the faster it should get mined by the network.
<FormattedMessage
id='txEditor.gas.info'
defaultMessage='You can choose the gas price based on the distribution of recent included transaction gas prices. The lower the gas price is, the cheaper the transaction will be. The higher the gas price is, the faster it should get mined by the network.'
/>
</div>
</div>
@ -66,14 +115,18 @@ export default class GasPriceEditor extends Component {
error={ errorGas }
hint='the amount of gas to use for the transaction'
label={ gasLabel }
min={ 1 }
onChange={ this.onEditGas }
type='number'
value={ gas }
/>
<Input
error={ errorPrice }
hint='the price of gas to use for the transaction'
label={ priceLabel }
min={ 1 }
onChange={ this.onEditGasPrice }
type='number'
value={ price }
/>
</div>
@ -91,6 +144,84 @@ export default class GasPriceEditor extends Component {
</div>
</div>
</div>
</div>
);
}
renderConditions () {
const { conditionType, condition, conditionBlockError } = this.props.store;
if (conditionType === CONDITIONS.NONE) {
return null;
}
if (conditionType === CONDITIONS.BLOCK) {
return (
<div className={ styles.conditionContainer }>
<div className={ styles.input }>
<Input
error={ conditionBlockError }
hint={
<FormattedMessage
id='txEditor.condition.block.hint'
defaultMessage='The minimum block to send from'
/>
}
label={
<FormattedMessage
id='txEditor.condition.block.label'
defaultMessage='Transaction send block'
/>
}
min={ 1 }
onChange={ this.onChangeConditionBlock }
type='number'
value={ condition.block }
/>
</div>
</div>
);
}
return (
<div className={ styles.conditionContainer }>
<div className={ styles.input }>
<InputDate
hint={
<FormattedMessage
id='txEditor.condition.date.hint'
defaultMessage='The minimum date to send from'
/>
}
label={
<FormattedMessage
id='txEditor.condition.date.label'
defaultMessage='Transaction send date'
/>
}
onChange={ this.onChangeConditionDateTime }
value={ condition.time }
/>
</div>
<div className={ styles.input }>
<InputTime
hint={
<FormattedMessage
id='txEditor.condition.time.hint'
defaultMessage='The minimum time to send from'
/>
}
label={
<FormattedMessage
id='txEditor.condition.time.label'
defaultMessage='Transaction send time'
/>
}
onChange={ this.onChangeConditionDateTime }
value={ condition.time }
/>
</div>
</div>
);
}
@ -107,4 +238,16 @@ export default class GasPriceEditor extends Component {
store.setPrice(price);
onChange && onChange('gasPrice', price);
}
onChangeConditionType = (conditionType) => {
this.props.store.setConditionType(conditionType.key);
}
onChangeConditionBlock = (event, blockNumber) => {
this.props.store.setConditionBlockNumber(blockNumber);
}
onChangeConditionDateTime = (event, datetime) => {
this.props.store.setConditionDateTime(datetime);
}
}

View File

@ -21,13 +21,29 @@ import sinon from 'sinon';
import GasPriceEditor from './';
const api = {
let api;
let component;
let store;
function createApi () {
api = {
eth: {
blockNumber: sinon.stub().resolves(new BigNumber(3))
},
util: {
fromWei: (value) => new BigNumber(value)
}
};
const store = {
return api;
}
function createStore () {
createApi();
store = {
_api: api,
conditionType: 'none',
estimated: '123',
histogram: {},
priceDefault: '456',
@ -36,11 +52,33 @@ const store = {
setPrice: sinon.stub()
};
return store;
}
function render (props = {}) {
createStore();
component = shallow(
<GasPriceEditor
store={ store }
{ ...props }
/>,
{
context: {
api
}
}
);
return component;
}
describe('ui/GasPriceEditor', () => {
beforeEach(() => {
render();
});
it('renders', () => {
expect(shallow(
<GasPriceEditor store={ store } />,
{ context: { api } }
)).to.be.ok;
expect(component).to.be.ok;
});
});

View File

@ -20,7 +20,17 @@ import { action, computed, observable, transaction } from 'mobx';
import { ERRORS, validatePositiveNumber } from '~/util/validation';
import { DEFAULT_GAS, DEFAULT_GASPRICE, MAX_GAS_ESTIMATION } from '~/util/constants';
const CONDITIONS = {
NONE: 'none',
BLOCK: 'blockNumber',
TIME: 'timestamp'
};
export default class GasPriceEditor {
@observable blockNumber = 0;
@observable condition = {};
@observable conditionBlockError = null;
@observable conditionType = CONDITIONS.NONE;
@observable errorEstimated = null;
@observable errorGas = null;
@observable errorPrice = null;
@ -34,13 +44,23 @@ export default class GasPriceEditor {
@observable priceDefault;
@observable weiValue = '0';
constructor (api, { gas, gasLimit, gasPrice }) {
constructor (api, { gas, gasLimit, gasPrice, condition = null }) {
this._api = api;
this.gas = gas;
this.gasLimit = gasLimit;
this.price = gasPrice;
if (condition) {
if (condition.block) {
this.condition = { block: condition.block.toFixed(0) };
this.conditionType = CONDITIONS.BLOCK;
} else if (condition.time) {
this.condition = { time: condition.time };
this.conditionType = CONDITIONS.TIME;
}
}
if (api) {
this.loadDefaults();
}
@ -54,6 +74,39 @@ export default class GasPriceEditor {
}
}
@action setConditionType = (conditionType = CONDITIONS.NONE) => {
transaction(() => {
this.conditionBlockError = null;
this.conditionType = conditionType;
switch (conditionType) {
case CONDITIONS.BLOCK:
this.condition = Object.assign({}, this.condition, { block: this.blockNumber || 1 });
break;
case CONDITIONS.TIME:
this.condition = Object.assign({}, this.condition, { time: new Date() });
break;
case CONDITIONS.NONE:
default:
this.condition = {};
break;
}
});
}
@action setConditionBlockNumber = (block) => {
transaction(() => {
this.conditionBlockError = validatePositiveNumber(block).numberError;
this.condition = Object.assign({}, this.condition, { block });
});
}
@action setConditionDateTime = (time) => {
this.condition = Object.assign({}, this.condition, { time });
}
@action setEditing = (isEditing) => {
this.isEditing = isEditing;
}
@ -130,9 +183,10 @@ export default class GasPriceEditor {
bucket_bounds: [],
counts: []
})),
this._api.eth.gasPrice()
this._api.eth.gasPrice(),
this._api.eth.blockNumber()
])
.then(([histogram, _price]) => {
.then(([histogram, _price, blockNumber]) => {
transaction(() => {
const price = _price.toFixed(0);
@ -142,6 +196,7 @@ export default class GasPriceEditor {
this.setHistogram(histogram);
this.priceDefault = price;
this.blockNumber = blockNumber.toNumber();
});
})
.catch((error) => {
@ -150,13 +205,37 @@ export default class GasPriceEditor {
}
overrideTransaction = (transaction) => {
if (this.errorGas || this.errorPrice) {
if (this.errorGas || this.errorPrice || this.conditionBlockError) {
return transaction;
}
return Object.assign({}, transaction, {
const override = {
condition: this.condition,
gas: new BigNumber(this.gas || DEFAULT_GAS),
gasPrice: new BigNumber(this.price || DEFAULT_GASPRICE)
});
};
const result = Object.assign({}, transaction, override);
switch (this.conditionType) {
case CONDITIONS.BLOCK:
result.condition = { block: new BigNumber(this.condition.block || 0) };
break;
case CONDITIONS.TIME:
result.condition = { time: this.condition.time };
break;
case CONDITIONS.NONE:
default:
delete result.condition;
break;
}
return result;
}
}
export {
CONDITIONS
};

View File

@ -21,6 +21,7 @@ import { DEFAULT_GAS, DEFAULT_GASPRICE, MAX_GAS_ESTIMATION } from '~/util/consta
import { ERRORS } from '~/util/validation';
import GasPriceEditor from './gasPriceEditor';
import { CONDITIONS } from './store';
const { Store } = GasPriceEditor;
@ -31,8 +32,13 @@ const HISTOGRAM = {
counts: [3, 4]
};
const api = {
let api;
// TODO: share with gasPriceEditor.spec.js
function createApi () {
api = {
eth: {
blockNumber: sinon.stub().resolves(new BigNumber(2)),
gasPrice: sinon.stub().resolves(GASPRICE)
},
parity: {
@ -40,9 +46,16 @@ const api = {
}
};
describe('ui/GasPriceEditor/store', () => {
return api;
}
describe('ui/GasPriceEditor/Store', () => {
let store = null;
beforeEach(() => {
createApi();
});
it('is available via GasPriceEditor.Store', () => {
expect(new Store(null, {})).to.be.ok;
});
@ -65,6 +78,7 @@ describe('ui/GasPriceEditor/store', () => {
describe('constructor (defaults) when histogram not available', () => {
const api = {
eth: {
blockNumber: sinon.stub().resolves(new BigNumber(2)),
gasPrice: sinon.stub().resolves(GASPRICE)
},
parity: {
@ -92,6 +106,67 @@ describe('ui/GasPriceEditor/store', () => {
store = new Store(null, { gasLimit: GASLIMIT });
});
describe('setConditionType', () => {
it('sets the actual type', () => {
store.setConditionType('testingType');
expect(store.conditionType).to.equal('testingType');
});
it('clears any block error on changing type', () => {
store.setConditionBlockNumber(-1);
expect(store.conditionBlockError).not.to.be.null;
store.setConditionType(CONDITIONS.BLOCK);
expect(store.conditionBlockError).to.be.null;
});
it('sets condition.block when type === CONDITIONS.BLOCK', () => {
store.setConditionType(CONDITIONS.BLOCK);
expect(store.condition.block).to.be.ok;
});
it('clears condition when type === CONDITIONS.NONE', () => {
store.setConditionType(CONDITIONS.BLOCK);
store.setConditionType(CONDITIONS.NONE);
expect(store.condition).to.deep.equal({});
});
it('sets condition.time when type === CONDITIONS.TIME', () => {
store.setConditionType(CONDITIONS.TIME);
expect(store.condition.time).to.be.ok;
});
});
describe('setConditionBlockNumber', () => {
beforeEach(() => {
store.setConditionBlockNumber('testingBlock');
});
it('sets the blockNumber', () => {
expect(store.condition.block).to.equal('testingBlock');
});
it('sets the error on invalid numbers', () => {
expect(store.conditionBlockError).not.to.be.null;
});
it('sets the error on negative numbers', () => {
store.setConditionBlockNumber(-1);
expect(store.conditionBlockError).not.to.be.null;
});
it('clears the error on positive numbers', () => {
store.setConditionBlockNumber(1000);
expect(store.conditionBlockError).to.be.null;
});
});
describe('setConditionDateTime', () => {
it('sets the datatime', () => {
store.setConditionDateTime('testingDateTime');
expect(store.condition.time).to.equal('testingDateTime');
});
});
describe('setEditing', () => {
it('sets the value', () => {
expect(store.isEditing).to.be.false;

View File

@ -14,9 +14,10 @@
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import { CircularProgress } from 'material-ui';
import moment from 'moment';
import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import CircularProgress from 'material-ui/CircularProgress';
import { TypedInput, InputAddress } from '../Form';
import MethodDecodingStore from './methodDecodingStore';
@ -128,17 +129,27 @@ class MethodDecoding extends Component {
renderMinBlock () {
const { historic, transaction } = this.props;
const { minBlock } = transaction;
const { condition } = transaction;
if (!minBlock || minBlock.eq(0)) {
if (!condition) {
return null;
}
if (condition.block && condition.block.gt(0)) {
return (
<span>, { historic ? 'Submitted' : 'Submission' } at block <span className={ styles.highlight }>#{ minBlock.toFormat(0) }</span></span>
<span>, { historic ? 'Submitted' : 'Submission' } at block <span className={ styles.highlight }>#{ condition.block.toFormat(0) }</span></span>
);
}
if (condition.time) {
return (
<span>, { historic ? 'Submitted' : 'Submission' } at <span className={ styles.highlight }>{ moment(condition.time).format('LLLL') }</span></span>
);
}
return null;
}
renderAction () {
const { token } = this.props;
const { methodName, methodInputs, methodSignature, isDeploy, isReceived, isContract } = this.state;

View File

@ -35,7 +35,7 @@ import DappIcon from './DappIcon';
import Editor from './Editor';
import Errors from './Errors';
import Features, { FEATURES, FeaturesStore } from './Features';
import Form, { AddressSelect, FormWrap, TypedInput, Input, InputAddress, InputAddressSelect, InputChip, InputInline, Select, RadioButtons } from './Form';
import Form, { AddressSelect, FormWrap, Input, InputAddress, InputAddressSelect, InputChip, InputDate, InputInline, InputTime, Label, RadioButtons, Select, TypedInput } from './Form';
import GasPriceEditor from './GasPriceEditor';
import GasPriceSelector from './GasPriceSelector';
import Icons from './Icons';
@ -95,9 +95,12 @@ export {
InputAddress,
InputAddressSelect,
InputChip,
InputDate,
InputInline,
InputTime,
IdentityIcon,
IdentityName,
Label,
LanguageSelector,
Loading,
MethodDecoding,

View File

@ -15,6 +15,9 @@
/* along with Parity. If not, see <http://www.gnu.org/licenses/>.
*/
$overlayZ: 10000;
$modalZ: 10001;
.account {
display: flex;
flex: 1;
@ -61,7 +64,7 @@
bottom: 0;
left: 0;
background: rgba(255, 255, 255, 0.5);
z-index: 10000;
z-index: $overlayZ;
user-select: none;
}
@ -70,7 +73,7 @@
position: fixed;
font-size: 16px;
font-family: 'Roboto', sans-serif;
z-index: 10001;
z-index: $modalZ;
user-select: none;
}
@ -109,7 +112,8 @@
border-radius: 4px 4px 0 0;
display: flex;
flex-direction: column;
max-height: 50vh;
min-height: 30vh;
max-height: 80vh;
max-width: calc(100vw - 1em);
.content {

View File

@ -73,14 +73,14 @@ export default class TransactionMainDetails extends Component {
: transaction
}
/>
{ this.renderEditGas() }
{ this.renderEditTx() }
</div>
{ children }
</div>
);
}
renderEditGas () {
renderEditTx () {
const { gasStore } = this.props;
if (!gasStore) {
@ -91,7 +91,7 @@ export default class TransactionMainDetails extends Component {
<div className={ styles.editButtonRow }>
<Button
icon={ <MapsLocalGasStation /> }
label='Edit gas/gasPrice'
label='Edit conditions/gas/gasPrice'
onClick={ this.toggleGasEditor }
/>
</div>

View File

@ -45,6 +45,7 @@ export default class TransactionPending extends Component {
onReject: PropTypes.func.isRequired,
store: PropTypes.object.isRequired,
transaction: PropTypes.shape({
condition: PropTypes.object,
data: PropTypes.string,
from: PropTypes.string.isRequired,
gas: PropTypes.object.isRequired,
@ -59,6 +60,7 @@ export default class TransactionPending extends Component {
};
gasStore = new GasPriceEditor.Store(this.context.api, {
condition: this.props.transaction.condition,
gas: this.props.transaction.gas.toFixed(),
gasLimit: this.props.gasLimit,
gasPrice: this.props.transaction.gasPrice.toFixed()
@ -80,7 +82,7 @@ export default class TransactionPending extends Component {
render () {
return this.gasStore.isEditing
? this.renderGasEditor()
? this.renderTxEditor()
: this.renderTransaction();
}
@ -115,7 +117,7 @@ export default class TransactionPending extends Component {
);
}
renderGasEditor () {
renderTxEditor () {
const { className } = this.props;
return (
@ -133,15 +135,21 @@ export default class TransactionPending extends Component {
onConfirm = (data) => {
const { id, transaction } = this.props;
const { password, wallet } = data;
const { gas, gasPrice } = this.gasStore.overrideTransaction(transaction);
const { condition, gas, gasPrice } = this.gasStore.overrideTransaction(transaction);
this.props.onConfirm({
const options = {
gas,
gasPrice,
id,
password,
wallet
});
};
if (condition && (condition.block || condition.time)) {
options.condition = condition;
}
this.props.onConfirm(options);
}
onReject = () => {