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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user