[FEATURE] Timestamp scheduling

* Scheduling: Basic date and time widget

* Linting fixes

* Moved the datetime field to new tab

* Fixed push errors

* Added missing specs

* Undid unintentional UI change

* Fixed some failing tests

* Ignore datetime parameter when checking if a transaction is full

* Added a date selector widget and renamed ScheduleTimestamp to ScheduleDate

* Marked componentDidMount

* Initialized Pikaday

* Revert "Initialized Pikaday"

This reverts commit 4e5bf5b2b882f236f5977400abf9b7092cbd1592.

* Revert "Marked componentDidMount"

This reverts commit 85d52192ac58f4b6ca9219e702f7390cd27e582f.

* Revert "Added a date selector widget and renamed ScheduleTimestamp to ScheduleDate"

This reverts commit aaad0ac9b565a78d1bfc631754160919fd38a59b.

* Converted the date picker into a datetime picker

* Added decent styling to the datetimepicker

* Added validation to the datetime picker

* Fixed prepush errors for scheduling timestamp

* Adjusted validation logic scheduling timestamp
This commit is contained in:
Joseph Bagaric 2018-03-27 11:35:06 +02:00 committed by Bagaric
parent 9a70d776b4
commit c6ec79a31b
30 changed files with 409 additions and 20 deletions

View File

@ -13,7 +13,8 @@ import {
ResetAction,
SetGasPriceFieldAction,
SetTimeBountyFieldAction,
SetWindowStartFieldAction
SetWindowStartFieldAction,
SetScheduleTimestampFieldAction
} from '../actionTypes';
import { TypeKeys } from 'actions/transaction/constants';
import { InputTimeBountyIntentAction } from 'actions/transaction';
@ -112,6 +113,14 @@ const setWindowStartField = (
payload
});
type TSetScheduleTimestampField = typeof setScheduleTimestampField;
const setScheduleTimestampField = (
payload: SetScheduleTimestampFieldAction['payload']
): SetScheduleTimestampFieldAction => ({
type: TypeKeys.SCHEDULE_TIMESTAMP_FIELD_SET,
payload
});
type TReset = typeof reset;
const reset = (payload: ResetAction['payload'] = { include: {}, exclude: {} }): ResetAction => ({
type: TypeKeys.RESET,
@ -134,6 +143,7 @@ export {
TSetGasPriceField,
TSetWindowStartField,
TSetTimeBountyField,
TSetScheduleTimestampField,
TReset,
inputGasLimit,
inputGasPrice,
@ -150,5 +160,6 @@ export {
setValueField,
setGasPriceField,
setWindowStartField,
setScheduleTimestampField,
reset
};

View File

@ -5,4 +5,5 @@ export * from './sign';
export * from './broadcast';
export * from './current';
export * from './windowStart';
export * from './scheduleTimestamp';
export * from './sendEverything';

View File

@ -0,0 +1,12 @@
import { SetCurrentScheduleTimestampAction } from '../actionTypes/scheduleTimestamp';
import { TypeKeys } from '../';
type TSetCurrentScheduleTimestamp = typeof setCurrentScheduleTimestamp;
const setCurrentScheduleTimestamp = (
payload: SetCurrentScheduleTimestampAction['payload']
): SetCurrentScheduleTimestampAction => ({
type: TypeKeys.CURRENT_SCHEDULE_TIMESTAMP_SET,
payload
});
export { setCurrentScheduleTimestamp, TSetCurrentScheduleTimestamp };

View File

@ -99,6 +99,14 @@ interface SetWindowStartFieldAction {
};
}
interface SetScheduleTimestampFieldAction {
type: TypeKeys.SCHEDULE_TIMESTAMP_FIELD_SET;
payload: {
raw: string;
value: Date | null;
};
}
type InputFieldAction = InputNonceAction | InputGasLimitAction | InputDataAction;
type FieldAction =
@ -109,7 +117,8 @@ type FieldAction =
| SetValueFieldAction
| SetGasPriceFieldAction
| SetTimeBountyFieldAction
| SetWindowStartFieldAction;
| SetWindowStartFieldAction
| SetScheduleTimestampFieldAction;
export {
InputGasLimitAction,
@ -128,5 +137,6 @@ export {
InputFieldAction,
SetGasPriceFieldAction,
SetTimeBountyFieldAction,
SetWindowStartFieldAction
SetWindowStartFieldAction,
SetScheduleTimestampFieldAction
};

View File

@ -0,0 +1,12 @@
import { TypeKeys } from '../constants';
/* user input */
interface SetCurrentScheduleTimestampAction {
type: TypeKeys.CURRENT_SCHEDULE_TIMESTAMP_SET;
payload: string;
}
type CurrentAction = SetCurrentScheduleTimestampAction;
export { SetCurrentScheduleTimestampAction, CurrentAction };

View File

@ -26,6 +26,7 @@ export enum TypeKeys {
CURRENT_VALUE_SET = 'CURRENT_VALUE_SET',
CURRENT_TO_SET = 'CURRENT_TO_SET',
CURRENT_WINDOW_START_SET = 'CURRENT_WINDOW_START_SET',
CURRENT_SCHEDULE_TIMESTAMP_SET = 'CURRENT_SCHEDULE_TIMESTAMP_SET',
DATA_FIELD_INPUT = 'DATA_FIELD_INPUT',
GAS_LIMIT_INPUT = 'GAS_LIMIT_INPUT',
@ -43,6 +44,7 @@ export enum TypeKeys {
GAS_PRICE_FIELD_SET = 'GAS_PRICE_FIELD_SET',
TIME_BOUNTY_FIELD_SET = 'TIME_BOUNTY_FIELD_SET',
WINDOW_START_FIELD_SET = 'WINDOW_START_FIELD_SET',
SCHEDULE_TIMESTAMP_FIELD_SET = 'SCHEDULE_TIMESTAMP_FIELD_SET',
TOKEN_TO_META_SET = 'TOKEN_TO_META_SET',
UNIT_META_SET = 'UNIT_META_SET',

View File

@ -0,0 +1,28 @@
import React from 'react';
import translate from 'translations';
import { ScheduleTimestampFieldFactory } from './ScheduleTimestampFieldFactory';
interface Props {
isReadOnly?: boolean;
}
export const ScheduleTimestampField: React.SFC<Props> = ({ isReadOnly }) => (
<ScheduleTimestampFieldFactory
withProps={({ currentScheduleTimestamp, isValid, onChange, readOnly }) => (
<div className="input-group-wrapper">
<label className="input-group">
<div className="input-group-header">{translate('SCHEDULE_timestamp')}</div>
<input
className={`input-group-input ${isValid ? '' : 'invalid'}`}
type="text"
value={currentScheduleTimestamp.raw}
readOnly={!!(isReadOnly || readOnly)}
spellCheck={false}
onChange={onChange}
id="datepicker"
/>
</label>
</div>
)}
/>
);

View File

@ -0,0 +1,72 @@
import { Query } from 'components/renderCbs';
import { setCurrentScheduleTimestamp, TSetCurrentScheduleTimestamp } from 'actions/transaction';
import { ScheduleTimestampInputFactory } from './ScheduleTimestampInputFactory';
import React from 'react';
import { connect } from 'react-redux';
import { ICurrentScheduleTimestamp } from 'selectors/transaction';
import moment from 'moment';
import { EAC_SCHEDULING_CONFIG } from 'libs/scheduling';
interface DispatchProps {
setCurrentScheduleTimestamp: TSetCurrentScheduleTimestamp;
}
interface OwnProps {
scheduleTimestamp: string | null;
withProps(props: CallbackProps): React.ReactElement<any> | null;
}
export interface CallbackProps {
isValid: boolean;
readOnly: boolean;
currentScheduleTimestamp: ICurrentScheduleTimestamp;
onChange(ev: React.FormEvent<HTMLInputElement>): void;
}
type Props = DispatchProps & OwnProps;
class ScheduleTimestampFieldFactoryClass extends React.Component<Props> {
public componentDidMount() {
const { scheduleTimestamp } = this.props;
if (scheduleTimestamp) {
this.props.setCurrentScheduleTimestamp(scheduleTimestamp);
}
}
public render() {
return (
<ScheduleTimestampInputFactory
onChange={this.setScheduleTimestamp}
withProps={this.props.withProps}
/>
);
}
private setScheduleTimestamp = (ev: any) => {
const value = ev.currentTarget
? ev.currentTarget.value
: moment(ev).format(EAC_SCHEDULING_CONFIG.SCHEDULE_TIMESTAMP_FORMAT);
this.props.setCurrentScheduleTimestamp(value);
};
}
const ScheduleTimestampFieldFactory = connect(null, { setCurrentScheduleTimestamp })(
ScheduleTimestampFieldFactoryClass
);
interface DefaultScheduleTimestampFieldProps {
withProps(props: CallbackProps): React.ReactElement<any> | null;
}
const DefaultScheduleTimestampField: React.SFC<DefaultScheduleTimestampFieldProps> = ({
withProps
}) => (
<Query
params={['scheduleTimestamp']}
withQuery={({ scheduleTimestamp }) => (
<ScheduleTimestampFieldFactory scheduleTimestamp={scheduleTimestamp} withProps={withProps} />
)}
/>
);
export { DefaultScheduleTimestampField as ScheduleTimestampFieldFactory };

View File

@ -0,0 +1,76 @@
import React, { Component } from 'react';
import { Query } from 'components/renderCbs';
import {
getCurrentScheduleTimestamp,
isValidCurrentScheduleTimestamp,
ICurrentScheduleTimestamp
} from 'selectors/transaction';
import { connect } from 'react-redux';
import { AppState } from 'reducers';
import { CallbackProps } from 'components/ScheduleTimestampFieldFactory';
import { getResolvingDomain } from 'selectors/ens';
import Pikaday from 'pikaday-time';
import { EAC_SCHEDULING_CONFIG } from 'libs/scheduling';
interface StateProps {
currentScheduleTimestamp: ICurrentScheduleTimestamp;
isValid: boolean;
isResolving: boolean;
}
interface OwnProps {
onChange(ev: React.FormEvent<HTMLInputElement>): void;
withProps(props: CallbackProps): React.ReactElement<any> | null;
}
type Props = OwnProps & StateProps;
class ScheduleTimestampInputFactoryClass extends Component<Props> {
public componentDidMount() {
const now = new Date();
const picker = new Pikaday({
field: document.getElementById('datepicker'),
format: EAC_SCHEDULING_CONFIG.SCHEDULE_TIMESTAMP_FORMAT,
minDate: now,
defaultDate: now,
setDefaultDate: true,
yearRange: [2016, 2100],
showTime: true,
showMinutes: true,
showSeconds: false,
use24hour: false,
incrementMinuteBy: 5,
onSelect: this.props.onChange
});
picker.setDate(Date.now());
}
public render() {
const { currentScheduleTimestamp, onChange, isValid, withProps } = this.props;
return (
<div className="row form-group">
<div className="col-xs-11">
<Query
params={['readOnly']}
withQuery={({ readOnly }) =>
withProps({
currentScheduleTimestamp,
isValid,
onChange,
readOnly: !!readOnly || this.props.isResolving
})
}
/>
</div>
</div>
);
}
}
export const ScheduleTimestampInputFactory = connect((state: AppState) => ({
currentScheduleTimestamp: getCurrentScheduleTimestamp(state),
isResolving: getResolvingDomain(state),
isValid: isValidCurrentScheduleTimestamp(state)
}))(ScheduleTimestampInputFactoryClass);

View File

@ -0,0 +1 @@
export * from './ScheduleTimestampFieldFactory';

View File

@ -9,6 +9,7 @@ export * from './GenerateTransaction';
export * from './SendButton';
export * from './SigningStatus';
export * from './WindowStartField';
export * from './ScheduleTimestampField';
export { default as NonceField } from './NonceField';
export { default as Header } from './Header';
export { default as Footer } from './Footer';

View File

@ -21,7 +21,8 @@ export type Param =
| 'value'
| 'gaslimit'
| 'limit'
| 'windowStart';
| 'windowStart'
| 'scheduleTimestamp';
interface Props extends RouteComponentProps<{}> {
params: Param[];

View File

@ -39,7 +39,7 @@ class Schedule extends React.Component<Props> {
{wallet && (
<div className="SubTabs row">
<div className="col-sm-8">
{wallet.isReadOnly ? <Redirect to="schedule/info" /> : <ScheduleMain />};
{wallet.isReadOnly ? <Redirect to="schedule/info" /> : <ScheduleMain />}
</div>
<SideBar />
</div>

View File

@ -6,7 +6,8 @@ import {
AmountField,
TXMetaDataPanel,
CurrentCustomMessage,
WindowStartField
WindowStartField,
ScheduleTimestampField
} from 'components';
import { OnlyUnlocked, WhenQueryExists } from 'components/renderCbs';
import translate from 'translations';
@ -70,6 +71,12 @@ class SchedulingFieldsClass extends Component<StateProps> {
</div>
</div>
<div className="row form-group">
<div className="col-xs-12">
<ScheduleTimestampField />
</div>
</div>
<div className="row form-group">
<div className="col-xs-12">
<TXMetaDataPanel scheduling={true} />

View File

@ -12,12 +12,14 @@ export const EAC_SCHEDULING_CONFIG = {
TIME_BOUNTY_DEFAULT: 10, // $1
TIME_BOUNTY_MAX: 100, // $10
TIME_BOUNTY_TO_WEI_MULTIPLIER: new BN('100000000000000'),
WINDOW_SIZE_IN_BLOCKS: 90
WINDOW_SIZE_IN_BLOCKS: 90,
SCHEDULE_TIMESTAMP_FORMAT: 'YYYY-MM-DD HH:mm:ss'
};
export const EAC_ADDRESSES = {
KOVAN: {
blockScheduler: '0x1afc19a7e642761ba2b55d2a45b32c7ef08269d1'
blockScheduler: '0x1afc19a7e642761ba2b55d2a45b32c7ef08269d1',
timestampScheduler: '0xc6370807f0164bdf10a66c08d0dab1028dbe80a3'
}
};
@ -49,9 +51,10 @@ export const getScheduleData = (
windowStart: any,
gasPrice: BN | null,
timeBounty: any,
requiredDeposit: any
requiredDeposit: any,
scheduleTimestamp: Date | null
) => {
if (!callValue || !gasPrice || !windowStart) {
if (!callValue || !gasPrice || !windowStart || !scheduleTimestamp) {
return;
}
@ -63,7 +66,8 @@ export const getScheduleData = (
gasPrice,
EAC_SCHEDULING_CONFIG.FEE,
timeBounty,
requiredDeposit
requiredDeposit,
scheduleTimestamp
]);
};

View File

@ -20,6 +20,7 @@ const INITIAL_STATE: State = {
nonce: { raw: '', value: null },
value: { raw: '', value: null },
windowStart: { raw: '', value: null },
scheduleTimestamp: { raw: '', value: null },
gasLimit: { raw: '21000', value: new BN(21000) },
gasPrice: { raw: '20', value: gasPriceToBase(20) },
timeBounty: {
@ -79,6 +80,8 @@ export const fields = (
return updateField('timeBounty')(state, action);
case TK.WINDOW_START_FIELD_SET:
return updateField('windowStart')(state, action);
case TK.SCHEDULE_TIMESTAMP_FIELD_SET:
return updateField('scheduleTimestamp')(state, action);
case TK.TOKEN_TO_ETHER_SWAP:
return tokenToEther(state, action);
case TK.ETHER_TO_TOKEN_SWAP:

View File

@ -3,7 +3,8 @@ import {
SetDataFieldAction,
SetNonceFieldAction,
SetGasLimitFieldAction,
SetWindowStartFieldAction
SetWindowStartFieldAction,
SetScheduleTimestampFieldAction
} from 'actions/transaction';
import { Wei } from 'libs/units';
@ -16,4 +17,5 @@ export interface State {
gasPrice: { raw: string; value: Wei };
timeBounty: { raw: string; value: Wei };
windowStart: SetWindowStartFieldAction['payload'];
scheduleTimestamp: SetScheduleTimestampFieldAction['payload'];
}

View File

@ -0,0 +1,25 @@
import { setScheduleTimestampField } from 'actions/transaction/actionCreators/fields';
import { call, put, takeLatest } from 'redux-saga/effects';
import { SagaIterator } from 'redux-saga';
import { TypeKeys } from 'actions/transaction/constants';
import { SetScheduleTimestampFieldAction } from 'actions/transaction';
import { SetCurrentScheduleTimestampAction } from 'actions/transaction/actionTypes/scheduleTimestamp';
export function* setCurrentScheduleTimestamp({
payload: raw
}: SetCurrentScheduleTimestampAction): SagaIterator {
let value: Date | null = null;
value = new Date(raw);
yield call(setField, { value, raw });
}
export function* setField(payload: SetScheduleTimestampFieldAction['payload']) {
yield put(setScheduleTimestampField(payload));
}
export const currentScheduleTimestamp = takeLatest(
[TypeKeys.CURRENT_SCHEDULE_TIMESTAMP_SET],
setCurrentScheduleTimestamp
);

View File

@ -1,5 +1,6 @@
import { currentTo } from './currentTo';
import { currentValue } from './currentValue';
import { currentWindowStart } from './currentWindowStart';
import { currentScheduleTimestamp } from './currentScheduleTimestamp';
export const current = [currentTo, ...currentValue, currentWindowStart];
export const current = [currentTo, ...currentValue, currentWindowStart, currentScheduleTimestamp];

View File

@ -35,6 +35,7 @@
@import './styles/tab';
@import './styles/flexbox';
@import './styles/helpers';
@import './styles/datetimepicker';
@import './styles/code';
@import './fonts';

View File

@ -0,0 +1,51 @@
$border-color: #e5ecf3;
$datetimepicker-border: 2px solid $border-color;
.pika-single {
background-color: #fff;
border: $datetimepicker-border;
padding: 20px;
}
.pika-single.is-hidden {
display: none;
}
.pika-next {
@extend .pull-right;
}
.pika-button, .pika-prev, .pika-next {
@extend .btn;
@extend .btn-sm;
}
:not(.is-disabled) > .pika-button:hover, .pika-prev:hover, .pika-next:hover {
@extend .btn-primary;
}
.is-disabled > .pika-button:hover {
color: $border-color;
}
.pika-time-container {
margin-top: 20px;
}
.pika-label {
margin-bottom: 20px;
}
.pika-label select {
@extend .pull-right;
}
.pika-title {
margin-bottom: 20px;
border-bottom: $datetimepicker-border;
}
.pika-lendar {
border-bottom: $datetimepicker-border;
}
tr.pika-row > td.is-disabled {
color: $border-color;
}

View File

@ -12,6 +12,7 @@ const getValue = (state: AppState) => getFields(state).value;
const getNonce = (state: AppState) => getFields(state).nonce;
const getTimeBounty = (state: AppState) => getFields(state).timeBounty;
const getWindowStart = (state: AppState) => getFields(state).windowStart;
const getScheduleTimestamp = (state: AppState) => getFields(state).scheduleTimestamp;
const getDataExists = (state: AppState) => {
const { value } = getData(state);
@ -39,5 +40,6 @@ export {
getDataExists,
getValidGasCost,
getTimeBounty,
getWindowStart
getWindowStart,
getScheduleTimestamp
};

View File

@ -30,6 +30,7 @@ export const isFullTx = (
const partialParamsToCheck = { ...rest };
delete partialParamsToCheck.windowStart;
delete partialParamsToCheck.scheduleTimestamp;
const validPartialParams = Object.values(partialParamsToCheck).reduce<boolean>(
(isValid, v: AppState['transaction']['fields'] & ICurrentTo & ICurrentValue) =>
@ -68,3 +69,10 @@ export const isWindowStartValid = (
return Boolean(windowStart && windowStart.value && windowStart.value > parseInt(latestBlock, 10));
};
export const isScheduleTimestampValid = (transactionFields: AppState['transaction']['fields']) => {
const { scheduleTimestamp } = transactionFields;
const now = new Date();
return Boolean(scheduleTimestamp && scheduleTimestamp.value && scheduleTimestamp.value > now);
};

View File

@ -5,4 +5,5 @@ export * from './meta';
export * from './sign';
export * from './current';
export * from './windowStart';
export * from './scheduleTimestamp';
export * from './network';

View File

@ -0,0 +1,30 @@
import { AppState } from 'reducers';
import { getScheduleTimestamp } from './fields';
import moment from 'moment';
import { EAC_SCHEDULING_CONFIG } from 'libs/scheduling';
interface ICurrentScheduleTimestamp {
raw: string;
value: Date | null;
}
const isValidCurrentScheduleTimestamp = (state: AppState) => {
const currentScheduleTimestamp = getScheduleTimestamp(state);
const raw = currentScheduleTimestamp.raw;
const value = currentScheduleTimestamp.value;
const fiveMinFromNow = moment()
.add(5, 'm')
.toDate();
return value && value > fiveMinFromNow && isCorrectFormat(raw);
};
const isCorrectFormat = (dateString: string) => {
return moment(dateString, EAC_SCHEDULING_CONFIG.SCHEDULE_TIMESTAMP_FORMAT, true).isValid();
};
const getCurrentScheduleTimestamp = (state: AppState): ICurrentScheduleTimestamp =>
getScheduleTimestamp(state);
export { getCurrentScheduleTimestamp, ICurrentScheduleTimestamp, isValidCurrentScheduleTimestamp };

View File

@ -1,10 +1,22 @@
import { AppState } from 'reducers';
import { getCurrentTo, getCurrentValue } from './current';
import { getFields, getData, getWindowStart, getNonce, getTimeBounty } from './fields';
import {
getFields,
getData,
getWindowStart,
getNonce,
getTimeBounty,
getScheduleTimestamp
} from './fields';
import { makeTransaction, IHexStrTransaction } from 'libs/transaction';
import EthTx from 'ethereumjs-tx';
import { getUnit } from 'selectors/transaction/meta';
import { reduceToValues, isFullTx, isWindowStartValid } from 'selectors/transaction/helpers';
import {
reduceToValues,
isFullTx,
isWindowStartValid,
isScheduleTimestampValid
} from 'selectors/transaction/helpers';
import {
getGasPrice,
getGasLimit,
@ -62,15 +74,18 @@ const getSchedulingTransaction = (state: AppState): IGetTransaction => {
const callData = getData(state);
const validGasCost = getValidGasCost(state);
const windowStart = getWindowStart(state);
const scheduleTimestamp = getScheduleTimestamp(state);
const gasLimit = getGasLimit(state);
const nonce = getNonce(state);
const gasPrice = getGasPrice(state);
const timeBounty = getTimeBounty(state);
const windowStartValid = isWindowStartValid(transactionFields, getLatestBlock(state));
const scheduleTimestampValid = isScheduleTimestampValid(transactionFields);
const isFullTransaction =
isFullTx(state, transactionFields, currentTo, currentValue, dataExists, validGasCost, unit) &&
windowStartValid;
windowStartValid &&
scheduleTimestampValid;
const transactionData = getScheduleData(
currentTo.raw,
@ -81,7 +96,8 @@ const getSchedulingTransaction = (state: AppState): IGetTransaction => {
windowStart.value,
gasPrice.value,
timeBounty.value,
EAC_SCHEDULING_CONFIG.REQUIRED_DEPOSIT
EAC_SCHEDULING_CONFIG.REQUIRED_DEPOSIT,
scheduleTimestamp.value
);
const endowment = calcEACEndowment(

1
common/typescript/pikaday-time.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module 'pikaday-time';

View File

@ -17,7 +17,8 @@ describe('fields reducer', () => {
raw: EAC_SCHEDULING_CONFIG.TIME_BOUNTY_DEFAULT.toString(),
value: timeBountyRawToValue(EAC_SCHEDULING_CONFIG.TIME_BOUNTY_DEFAULT)
},
windowStart: { raw: '', value: null }
windowStart: { raw: '', value: null },
scheduleTimestamp: { raw: '', value: null }
};
const testPayload = { raw: 'test', value: null };

View File

@ -47,6 +47,10 @@ describe('fields selector', () => {
windowStart: {
raw: '',
value: null
},
scheduleTimestamp: {
raw: '',
value: null
}
};

View File

@ -51,6 +51,10 @@ describe('helpers selector', () => {
windowStart: {
raw: '',
value: null
},
scheduleTimestamp: {
raw: '',
value: null
}
}
};
@ -64,7 +68,8 @@ describe('helpers selector', () => {
to: new Buffer([0, 1, 2, 3]),
value: Wei('1000000000'),
timeBounty: Wei('1500'),
windowStart: null
windowStart: null,
scheduleTimestamp: null
};
expect(reduceToValues(state.transaction.fields)).toEqual(values);
});