Add nonce loading indicator & refresh button (#1021)

* Add InlineSpinner component

* Add 'what-input' module

* Add input style overrides

* Add new refresh icon

* Update footer styles

* Add nonce refresh button & loading indicator

* Center InlineSpinner

* Add types

* Lock version

* prettify package.json

* prettify package.json
This commit is contained in:
James Prado 2018-02-08 12:51:15 -05:00 committed by Daniel Ternyak
parent 7ac546acaf
commit 0ab226ca16
19 changed files with 236 additions and 138 deletions

View File

@ -17,6 +17,7 @@ import { Store } from 'redux';
import { pollOfflineStatus } from 'actions/config'; import { pollOfflineStatus } from 'actions/config';
import { AppState } from 'reducers'; import { AppState } from 'reducers';
import { RouteNotFound } from 'components/RouteNotFound'; import { RouteNotFound } from 'components/RouteNotFound';
import 'what-input';
interface Props { interface Props {
store: Store<AppState>; store: Store<AppState>;

View File

@ -146,8 +146,8 @@
margin: 0 0 $space-md 0; margin: 0 0 $space-md 0;
} }
li, > li,
p { > p {
font-size: 0.8rem; font-size: 0.8rem;
margin: $space-sm 0; margin: $space-sm 0;
} }

View File

@ -0,0 +1,11 @@
@import 'common/sass/variables';
.gaslimit {
&-label-wrapper {
align-items: center;
margin-bottom: $space-xs;
> label {
margin-bottom: 0;
}
}
}

View File

@ -1,66 +1,34 @@
import React from 'react'; import React from 'react';
import { GasLimitFieldFactory } from './GasLimitFieldFactory'; import { GasLimitFieldFactory } from './GasLimitFieldFactory';
import translate from 'translations'; import translate from 'translations';
import { CSSTransition } from 'react-transition-group';
import { Spinner } from 'components/ui';
import { gasLimitValidator } from 'libs/validators'; import { gasLimitValidator } from 'libs/validators';
import { InlineSpinner } from 'components/ui/InlineSpinner';
import './GasLimitField.scss';
interface Props { interface Props {
includeLabel: boolean;
onlyIncludeLoader: boolean;
customLabel?: string; customLabel?: string;
disabled?: boolean; disabled?: boolean;
} }
export const GaslimitLoading: React.SFC<{ export const GasLimitField: React.SFC<Props> = ({ customLabel, disabled }) => (
gasEstimationPending: boolean; <GasLimitFieldFactory
onlyIncludeLoader?: boolean; withProps={({ gasLimit: { raw }, onChange, readOnly, gasEstimationPending }) => (
}> = ({ gasEstimationPending, onlyIncludeLoader }) => ( <React.Fragment>
<CSSTransition in={gasEstimationPending} timeout={300} classNames="fade"> <div className="gaslimit-label-wrapper flex-wrapper">
<div className={`Calculating-limit small ${gasEstimationPending ? 'active' : ''}`}> {customLabel ? <label>{customLabel} </label> : <label>{translate('TRANS_gas')} </label>}
{onlyIncludeLoader ? 'Calculating gas limit' : 'Calculating'} <div className="flex-spacer" />
<Spinner /> <InlineSpinner active={gasEstimationPending} text="Calculating" />
</div> </div>
</CSSTransition> <input
); className={`form-control ${gasLimitValidator(raw) ? 'is-valid' : 'is-invalid'}`}
type="number"
export const GasLimitField: React.SFC<Props> = ({ placeholder="e.g. 21000"
includeLabel, readOnly={!!readOnly}
onlyIncludeLoader, value={raw}
customLabel, onChange={onChange}
disabled disabled={disabled}
}) => ( />
<React.Fragment> </React.Fragment>
<GasLimitFieldFactory )}
withProps={({ gasLimit: { raw }, onChange, readOnly, gasEstimationPending }) => ( />
<React.Fragment>
<div className="flex-wrapper">
{includeLabel ? (
customLabel ? (
<label>{customLabel} </label>
) : (
<label>{translate('TRANS_gas')} </label>
)
) : null}
<div className="flex-spacer" />
<GaslimitLoading
gasEstimationPending={gasEstimationPending}
onlyIncludeLoader={onlyIncludeLoader}
/>
</div>
{onlyIncludeLoader ? null : (
<input
className={`form-control ${gasLimitValidator(raw) ? 'is-valid' : 'is-invalid'}`}
type="number"
placeholder="e.g. 21000"
readOnly={!!readOnly}
value={raw}
onChange={onChange}
disabled={disabled}
/>
)}
</React.Fragment>
)}
/>
</React.Fragment>
); );

View File

@ -0,0 +1,36 @@
@import 'common/sass/variables';
.nonce {
&-label-wrapper {
align-items: center;
margin-bottom: $space-xs;
> label {
margin-bottom: 0;
}
}
&-input-wrapper {
position: relative;
}
&-refresh {
position: absolute;
right: 0;
top: 0;
border: none;
background: transparent;
padding: 0;
margin: 0 1rem;
height: 2.55rem;
opacity: 0.3;
transition: opacity 300ms;
> img {
height: 1.4rem;
}
&:hover {
opacity: 0.54;
}
&:active {
transition: opacity 120ms;
opacity: 1;
}
}
}

View File

@ -1,38 +1,72 @@
import React from 'react'; import React from 'react';
import { NonceFieldFactory } from 'components/NonceFieldFactory'; import { NonceFieldFactory } from 'components/NonceFieldFactory';
import Help from 'components/ui/Help'; import Help from 'components/ui/Help';
import RefreshIcon from 'assets/images/refresh.svg';
import './NonceField.scss';
import { InlineSpinner } from 'components/ui/InlineSpinner';
import { connect } from 'react-redux';
import { getNonceRequested, TGetNonceRequested } from 'actions/transaction';
import { nonceRequestPending } from 'selectors/transaction';
import { AppState } from 'reducers';
interface Props { interface OwnProps {
alwaysDisplay: boolean; alwaysDisplay: boolean;
} }
const nonceHelp = ( interface StateProps {
<Help nonePending: boolean;
size={'x1'} }
link={'https://myetherwallet.github.io/knowledge-base/transactions/what-is-nonce.html'}
/>
);
export const NonceField: React.SFC<Props> = ({ alwaysDisplay }) => ( interface DispatchProps {
<NonceFieldFactory requestNonce: TGetNonceRequested;
withProps={({ nonce: { raw, value }, onChange, readOnly, shouldDisplay }) => { }
const content = (
<div>
<label>Nonce</label>
{nonceHelp}
<input type Props = OwnProps & DispatchProps & StateProps;
className={`form-control ${!!value ? 'is-valid' : 'is-invalid'}`}
type="number"
placeholder="e.g. 7"
value={raw}
readOnly={readOnly}
onChange={onChange}
/>
</div>
);
return alwaysDisplay || shouldDisplay ? content : null; class NonceField extends React.Component<Props> {
}} public render() {
/> const { alwaysDisplay, requestNonce, nonePending } = this.props;
); return (
<NonceFieldFactory
withProps={({ nonce: { raw, value }, onChange, readOnly, shouldDisplay }) => {
return alwaysDisplay || shouldDisplay ? (
<React.Fragment>
<div className="nonce-label-wrapper flex-wrapper">
<label className="nonce-label">Nonce</label>
<Help
size={'x1'}
link={
'https://myetherwallet.github.io/knowledge-base/transactions/what-is-nonce.html'
}
/>
<div className="flex-spacer" />
<InlineSpinner active={nonePending} text="Calculating" />
</div>
<div className="nonce-input-wrapper">
<input
className={`form-control nonce-input ${!!value ? 'is-valid' : 'is-invalid'}`}
type="number"
placeholder="e.g. 7"
value={raw}
readOnly={readOnly}
onChange={onChange}
/>
<button className="nonce-refresh" onClick={requestNonce}>
<img src={RefreshIcon} alt="refresh" />
</button>
</div>
</React.Fragment>
) : null;
}}
/>
);
}
}
const mapStateToProps = (state: AppState) => {
return {
nonePending: nonceRequestPending(state)
};
};
export default connect(mapStateToProps, { requestNonce: getNonceRequested })(NonceField);

View File

@ -12,7 +12,7 @@
.Calculating-limit { .Calculating-limit {
color: rgba(51, 51, 51, 0.7); color: rgba(51, 51, 51, 0.7);
display: flex; display: flex;
align-items: baseline; align-items: center;
font-weight: 400; font-weight: 400;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;

View File

@ -40,10 +40,4 @@
width: 100%; width: 100%;
} }
} }
&-data {
}
&-fee-summary {
}
} }

View File

@ -87,11 +87,7 @@ class AdvancedGas extends React.Component<Props, State> {
{gasLimitField && ( {gasLimitField && (
<div className="AdvancedGas-gas-limit"> <div className="AdvancedGas-gas-limit">
<GasLimitField <GasLimitField customLabel={translateRaw('OFFLINE_Step2_Label_4')} />
includeLabel={true}
customLabel={translateRaw('OFFLINE_Step2_Label_4')}
onlyIncludeLoader={false}
/>
</div> </div>
)} )}
{nonceField && ( {nonceField && (

View File

@ -53,26 +53,3 @@
} }
} }
} }
.fade {
&-enter,
&-exit {
transition: opacity 300ms;
}
&-enter {
opacity: 0;
&-active {
opacity: 1;
}
}
&-exit {
opacity: 1;
&-active {
opacity: 0;
}
}
}

View File

@ -5,15 +5,21 @@ import { gasPriceDefaults } from 'config';
import FeeSummary from './FeeSummary'; import FeeSummary from './FeeSummary';
import './SimpleGas.scss'; import './SimpleGas.scss';
import { AppState } from 'reducers'; import { AppState } from 'reducers';
import { getGasLimitEstimationTimedOut } from 'selectors/transaction'; import {
getGasLimitEstimationTimedOut,
getGasEstimationPending,
nonceRequestPending
} from 'selectors/transaction';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { GasLimitField } from 'components/GasLimitField';
import { getIsWeb3Node } from 'selectors/config'; import { getIsWeb3Node } from 'selectors/config';
import { Wei, fromWei } from 'libs/units'; import { Wei, fromWei } from 'libs/units';
import { InlineSpinner } from 'components/ui/InlineSpinner';
const SliderWithTooltip = Slider.createSliderWithTooltip(Slider); const SliderWithTooltip = Slider.createSliderWithTooltip(Slider);
interface OwnProps { interface OwnProps {
gasPrice: AppState['transaction']['fields']['gasPrice']; gasPrice: AppState['transaction']['fields']['gasPrice'];
noncePending: boolean;
gasLimitPending: boolean;
inputGasPrice(rawGas: string); inputGasPrice(rawGas: string);
setGasPrice(rawGas: string); setGasPrice(rawGas: string);
} }
@ -31,16 +37,22 @@ class SimpleGas extends React.Component<Props> {
} }
public render() { public render() {
const { gasPrice, gasLimitEstimationTimedOut, isWeb3Node } = this.props; const {
gasPrice,
gasLimitEstimationTimedOut,
isWeb3Node,
noncePending,
gasLimitPending
} = this.props;
return ( return (
<div className="SimpleGas row form-group"> <div className="SimpleGas row form-group">
<div className="SimpleGas-title"> <div className="SimpleGas-title">
<GasLimitField <div className="flex-wrapper">
includeLabel={true} <label>{translateRaw('Transaction Fee')} </label>
customLabel={translateRaw('Transaction Fee')} <div className="flex-spacer" />
onlyIncludeLoader={true} <InlineSpinner active={noncePending || gasLimitPending} text="Calculating" />
/> </div>
</div> </div>
{gasLimitEstimationTimedOut && ( {gasLimitEstimationTimedOut && (
@ -101,6 +113,8 @@ class SimpleGas extends React.Component<Props> {
} }
export default connect((state: AppState) => ({ export default connect((state: AppState) => ({
noncePending: nonceRequestPending(state),
gasLimitPending: getGasEstimationPending(state),
gasLimitEstimationTimedOut: getGasLimitEstimationTimedOut(state), gasLimitEstimationTimedOut: getGasLimitEstimationTimedOut(state),
isWeb3Node: getIsWeb3Node(state) isWeb3Node: getIsWeb3Node(state)
}))(SimpleGas); }))(SimpleGas);

View File

@ -1,7 +1,6 @@
export * from './AddressField'; export * from './AddressField';
export * from './DataField'; export * from './DataField';
export * from './GasLimitField'; export * from './GasLimitField';
export * from './NonceField';
export * from './AmountField'; export * from './AmountField';
export * from './SendEverything'; export * from './SendEverything';
export * from './UnitDropDown'; export * from './UnitDropDown';
@ -9,6 +8,7 @@ export * from './CurrentCustomMessage';
export * from './GenerateTransaction'; export * from './GenerateTransaction';
export * from './SendButton'; export * from './SendButton';
export * from './SigningStatus'; export * from './SigningStatus';
export { default as NonceField } from './NonceField';
export { default as Header } from './Header'; export { default as Header } from './Header';
export { default as Footer } from './Footer'; export { default as Footer } from './Footer';
export { default as BalanceSidebar } from './BalanceSidebar'; export { default as BalanceSidebar } from './BalanceSidebar';

View File

@ -0,0 +1,24 @@
.inline-spinner {
&--fade {
&-enter,
&-exit {
transition: opacity 300ms;
}
&-enter {
opacity: 0;
&-active {
opacity: 1;
}
}
&-exit {
opacity: 1;
&-active {
opacity: 0;
}
}
}
}

View File

@ -0,0 +1,17 @@
import React from 'react';
import { CSSTransition } from 'react-transition-group';
import { Spinner } from 'components/ui';
import './InlineSpinner.scss';
export const InlineSpinner: React.SFC<{
active: boolean;
text?: string;
}> = ({ active, text }) => (
<CSSTransition in={active} timeout={300} classNames="inline-spinner--fade">
{/* TODO: when react-transition-group v2.3 releases, use '-done' classes instead of conditional 'active' class https://github.com/reactjs/react-transition-group/issues/274 */}
<div className={`Calculating-limit small ${active ? 'active' : ''}`}>
{text}
<Spinner />
</div>
</CSSTransition>
);

View File

@ -35,3 +35,7 @@
@import './styles/tab'; @import './styles/tab';
@import './styles/flexbox'; @import './styles/flexbox';
@import './fonts'; @import './fonts';
[data-whatintent='mouse'] *:focus {
outline: none;
}

View File

@ -9,6 +9,7 @@
@import './overrides/input-groups'; @import './overrides/input-groups';
@import './overrides/type'; @import './overrides/type';
@import './overrides/tables'; @import './overrides/tables';
@import './overrides/inputs';
// Other overrides // Other overrides
@import './overrides/react-select'; @import './overrides/react-select';

View File

@ -0,0 +1,9 @@
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type='number'] {
-moz-appearance: textfield;
}

View File

@ -4,6 +4,9 @@ import { RequestStatus } from 'reducers/transaction/network';
export const getNetworkStatus = (state: AppState) => getTransactionState(state).network; export const getNetworkStatus = (state: AppState) => getTransactionState(state).network;
export const nonceRequestPending = (state: AppState) =>
getNetworkStatus(state).getNonceStatus === RequestStatus.REQUESTED;
export const nonceRequestFailed = (state: AppState) => export const nonceRequestFailed = (state: AppState) =>
getNetworkStatus(state).getNonceStatus === RequestStatus.FAILED; getNetworkStatus(state).getNonceStatus === RequestStatus.FAILED;

View File

@ -130,7 +130,8 @@
"webpack-hot-middleware": "2.21.0", "webpack-hot-middleware": "2.21.0",
"webpack-sources": "1.0.1", "webpack-sources": "1.0.1",
"webpack-subresource-integrity": "1.0.3", "webpack-subresource-integrity": "1.0.3",
"worker-loader": "1.1.0" "worker-loader": "1.1.0",
"what-input": "5.0.5"
}, },
"scripts": { "scripts": {
"freezer": "webpack --config=./webpack_config/webpack.freezer.js && node ./dist/freezer.js", "freezer": "webpack --config=./webpack_config/webpack.freezer.js && node ./dist/freezer.js",
@ -140,10 +141,14 @@
"prebuild": "check-node-version --package", "prebuild": "check-node-version --package",
"build:downloadable": "webpack --config webpack_config/webpack.html.js", "build:downloadable": "webpack --config webpack_config/webpack.html.js",
"prebuild:downloadable": "check-node-version --package", "prebuild:downloadable": "check-node-version --package",
"build:electron": "webpack --config webpack_config/webpack.electron-prod.js && node webpack_config/buildElectron.js", "build:electron":
"build:electron:osx": "webpack --config webpack_config/webpack.electron-prod.js && ELECTRON_OS=osx node webpack_config/buildElectron.js", "webpack --config webpack_config/webpack.electron-prod.js && node webpack_config/buildElectron.js",
"build:electron:windows": "webpack --config webpack_config/webpack.electron-prod.js && ELECTRON_OS=windows node webpack_config/buildElectron.js", "build:electron:osx":
"build:electron:linux": "webpack --config webpack_config/webpack.electron-prod.js && ELECTRON_OS=linux node webpack_config/buildElectron.js", "webpack --config webpack_config/webpack.electron-prod.js && ELECTRON_OS=osx node webpack_config/buildElectron.js",
"build:electron:windows":
"webpack --config webpack_config/webpack.electron-prod.js && ELECTRON_OS=windows node webpack_config/buildElectron.js",
"build:electron:linux":
"webpack --config webpack_config/webpack.electron-prod.js && ELECTRON_OS=linux node webpack_config/buildElectron.js",
"prebuild:electron": "check-node-version --package", "prebuild:electron": "check-node-version --package",
"test:coverage": "jest --config=jest_config/jest.config.json --coverage", "test:coverage": "jest --config=jest_config/jest.config.json --coverage",
"test": "jest --config=jest_config/jest.config.json", "test": "jest --config=jest_config/jest.config.json",
@ -155,14 +160,18 @@
"predev": "check-node-version --package", "predev": "check-node-version --package",
"dev:https": "HTTPS=true node webpack_config/devServer.js", "dev:https": "HTTPS=true node webpack_config/devServer.js",
"predev:https": "check-node-version --package", "predev:https": "check-node-version --package",
"dev:electron": "concurrently --kill-others --names 'webpack,electron' 'BUILD_ELECTRON=true node webpack_config/devServer.js' 'webpack --config webpack_config/webpack.electron-dev.js && electron dist/electron-js/main.js'", "dev:electron":
"dev:electron:https": "concurrently --kill-others --names 'webpack,electron' 'BUILD_ELECTRON=true HTTPS=true node webpack_config/devServer.js' 'HTTPS=true webpack --config webpack_config/webpack.electron-dev.js && electron dist/electron-js/main.js'", "concurrently --kill-others --names 'webpack,electron' 'BUILD_ELECTRON=true node webpack_config/devServer.js' 'webpack --config webpack_config/webpack.electron-dev.js && electron dist/electron-js/main.js'",
"dev:electron:https":
"concurrently --kill-others --names 'webpack,electron' 'BUILD_ELECTRON=true HTTPS=true node webpack_config/devServer.js' 'HTTPS=true webpack --config webpack_config/webpack.electron-dev.js && electron dist/electron-js/main.js'",
"tslint": "tslint --project . --exclude common/vendor/**/*", "tslint": "tslint --project . --exclude common/vendor/**/*",
"tscheck": "tsc --noEmit", "tscheck": "tsc --noEmit",
"start": "npm run dev", "start": "npm run dev",
"precommit": "lint-staged", "precommit": "lint-staged",
"formatAll": "find ./common/ -name '*.ts*' | xargs prettier --write --config ./.prettierrc --config-precedence file-override", "formatAll":
"prettier:diff": "prettier --write --config ./.prettierrc --list-different \"common/**/*.ts\" \"common/**/*.tsx\"", "find ./common/ -name '*.ts*' | xargs prettier --write --config ./.prettierrc --config-precedence file-override",
"prettier:diff":
"prettier --write --config ./.prettierrc --list-different \"common/**/*.ts\" \"common/**/*.tsx\"",
"prepush": "npm run tslint && npm run tscheck" "prepush": "npm run tslint && npm run tscheck"
}, },
"lint-staged": { "lint-staged": {