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

@@ -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,46 +80,146 @@ export default class GasPriceEditor extends Component {
return (
<div className={ styles.container }>
<div className={ styles.graphColumn }>
<GasPriceSelector
histogram={ histogram }
onChange={ this.onEditGasPrice }
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.
<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 }
onChange={ this.onEditGasPrice }
price={ price }
/>
<div className={ styles.gasPriceDesc }>
<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>
<div className={ styles.editColumn }>
<div className={ styles.row }>
<Input
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>
<div className={ styles.row }>
<Input
disabled
error={ errorTotal }
hint='the total amount of the transaction'
label='total transaction amount'
value={ `${eth} ETH` }
/>
</div>
<div className={ styles.row }>
{ children }
</div>
</div>
</div>
</div>
);
}
<div className={ styles.editColumn }>
<div className={ styles.row }>
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={ errorGas }
hint='the amount of gas to use for the transaction'
label={ gasLabel }
onChange={ this.onEditGas }
value={ gas }
/>
<Input
error={ errorPrice }
hint='the price of gas to use for the transaction'
label={ priceLabel }
onChange={ this.onEditGasPrice }
value={ price }
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 className={ styles.row }>
<Input
disabled
error={ errorTotal }
hint='the total amount of the transaction'
label='total transaction amount'
value={ `${eth} ETH` }
/>
</div>
<div className={ styles.row }>
{ children }
</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,26 +21,64 @@ import sinon from 'sinon';
import GasPriceEditor from './';
const api = {
util: {
fromWei: (value) => new BigNumber(value)
}
};
let api;
let component;
let store;
const store = {
estimated: '123',
histogram: {},
priceDefault: '456',
totalValue: '789',
setGas: sinon.stub(),
setPrice: sinon.stub()
};
function createApi () {
api = {
eth: {
blockNumber: sinon.stub().resolves(new BigNumber(3))
},
util: {
fromWei: (value) => new BigNumber(value)
}
};
return api;
}
function createStore () {
createApi();
store = {
_api: api,
conditionType: 'none',
estimated: '123',
histogram: {},
priceDefault: '456',
totalValue: '789',
setGas: sinon.stub(),
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,18 +32,30 @@ const HISTOGRAM = {
counts: [3, 4]
};
const api = {
eth: {
gasPrice: sinon.stub().resolves(GASPRICE)
},
parity: {
gasPriceHistogram: sinon.stub().resolves(HISTOGRAM)
}
};
let api;
describe('ui/GasPriceEditor/store', () => {
// TODO: share with gasPriceEditor.spec.js
function createApi () {
api = {
eth: {
blockNumber: sinon.stub().resolves(new BigNumber(2)),
gasPrice: sinon.stub().resolves(GASPRICE)
},
parity: {
gasPriceHistogram: sinon.stub().resolves(HISTOGRAM)
}
};
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;