Better Offline UX (#785)

* Check offline status immediately.

* If they start the page offline, show a less severe error message.

* Get rid of offline aware header. Disable wallet options when offline.

* Add online indicator to the header.

* Prevent some components from render, some requests from firing when offline.

* Allow for array of elements with typing.

* Dont show dollars in fee summary when offline.

* Fix up saga tests.

* Fix sidebar component offline styles.

* Remove force offline.

* Dont request rates if offline.

* Nonce in advanced, show even of online.

* Show invalid advanced props.

* Fix up offline poll tests.
This commit is contained in:
William O'Beirne 2018-01-11 13:04:11 -05:00 committed by Daniel Ternyak
parent 659f218b1c
commit 4f6e83acf4
48 changed files with 441 additions and 319 deletions

View File

@ -15,6 +15,7 @@ import PageNotFound from 'components/PageNotFound';
import LogOutPrompt from 'components/LogOutPrompt'; import LogOutPrompt from 'components/LogOutPrompt';
import { Aux } from 'components/ui'; import { Aux } from 'components/ui';
import { Store } from 'redux'; import { Store } from 'redux';
import { pollOfflineStatus } from 'actions/config';
import { AppState } from 'reducers'; import { AppState } from 'reducers';
interface Props { interface Props {
@ -30,6 +31,10 @@ export default class Root extends Component<Props, State> {
error: null error: null
}; };
public componentDidMount() {
this.props.store.dispatch(pollOfflineStatus());
}
public componentDidCatch(error: Error) { public componentDidCatch(error: Error) {
this.setState({ error }); this.setState({ error });
} }

View File

@ -2,13 +2,6 @@ import * as interfaces from './actionTypes';
import { TypeKeys } from './constants'; import { TypeKeys } from './constants';
import { NodeConfig, CustomNodeConfig, NetworkConfig, CustomNetworkConfig } from 'config/data'; import { NodeConfig, CustomNodeConfig, NetworkConfig, CustomNetworkConfig } from 'config/data';
export type TForceOfflineConfig = typeof forceOfflineConfig;
export function forceOfflineConfig(): interfaces.ForceOfflineAction {
return {
type: TypeKeys.CONFIG_FORCE_OFFLINE
};
}
export type TToggleOfflineConfig = typeof toggleOfflineConfig; export type TToggleOfflineConfig = typeof toggleOfflineConfig;
export function toggleOfflineConfig(): interfaces.ToggleOfflineAction { export function toggleOfflineConfig(): interfaces.ToggleOfflineAction {
return { return {

View File

@ -6,11 +6,6 @@ export interface ToggleOfflineAction {
type: TypeKeys.CONFIG_TOGGLE_OFFLINE; type: TypeKeys.CONFIG_TOGGLE_OFFLINE;
} }
/*** Force Offline ***/
export interface ForceOfflineAction {
type: TypeKeys.CONFIG_FORCE_OFFLINE;
}
/*** Change Language ***/ /*** Change Language ***/
export interface ChangeLanguageAction { export interface ChangeLanguageAction {
type: TypeKeys.CONFIG_LANGUAGE_CHANGE; type: TypeKeys.CONFIG_LANGUAGE_CHANGE;
@ -80,7 +75,6 @@ export type ConfigAction =
| ChangeLanguageAction | ChangeLanguageAction
| ToggleOfflineAction | ToggleOfflineAction
| PollOfflineStatus | PollOfflineStatus
| ForceOfflineAction
| ChangeNodeIntentAction | ChangeNodeIntentAction
| AddCustomNodeAction | AddCustomNodeAction
| RemoveCustomNodeAction | RemoveCustomNodeAction

View File

@ -3,7 +3,6 @@ export enum TypeKeys {
CONFIG_NODE_CHANGE = 'CONFIG_NODE_CHANGE', CONFIG_NODE_CHANGE = 'CONFIG_NODE_CHANGE',
CONFIG_NODE_CHANGE_INTENT = 'CONFIG_NODE_CHANGE_INTENT', CONFIG_NODE_CHANGE_INTENT = 'CONFIG_NODE_CHANGE_INTENT',
CONFIG_TOGGLE_OFFLINE = 'CONFIG_TOGGLE_OFFLINE', CONFIG_TOGGLE_OFFLINE = 'CONFIG_TOGGLE_OFFLINE',
CONFIG_FORCE_OFFLINE = 'CONFIG_FORCE_OFFLINE',
CONFIG_POLL_OFFLINE_STATUS = 'CONFIG_POLL_OFFLINE_STATUS', CONFIG_POLL_OFFLINE_STATUS = 'CONFIG_POLL_OFFLINE_STATUS',
CONFIG_ADD_CUSTOM_NODE = 'CONFIG_ADD_CUSTOM_NODE', CONFIG_ADD_CUSTOM_NODE = 'CONFIG_ADD_CUSTOM_NODE',
CONFIG_REMOVE_CUSTOM_NODE = 'CONFIG_REMOVE_CUSTOM_NODE', CONFIG_REMOVE_CUSTOM_NODE = 'CONFIG_REMOVE_CUSTOM_NODE',

View File

@ -40,4 +40,9 @@
text-align: center; text-align: center;
} }
} }
&-offline {
margin-bottom: 0;
text-align: center;
}
} }

View File

@ -20,6 +20,7 @@ interface Props {
ratesError?: State['ratesError']; ratesError?: State['ratesError'];
fetchCCRates: TFetchCCRates; fetchCCRates: TFetchCCRates;
network: NetworkConfig; network: NetworkConfig;
isOffline: boolean;
} }
interface CmpState { interface CmpState {
@ -44,15 +45,19 @@ export default class EquivalentValues extends React.Component<Props, CmpState> {
} }
public componentWillReceiveProps(nextProps: Props) { public componentWillReceiveProps(nextProps: Props) {
const { balance, tokenBalances } = this.props; const { balance, tokenBalances, isOffline } = this.props;
if (nextProps.balance !== balance || nextProps.tokenBalances !== tokenBalances) { if (
nextProps.balance !== balance ||
nextProps.tokenBalances !== tokenBalances ||
nextProps.isOffline !== isOffline
) {
this.makeBalanceLookup(nextProps); this.makeBalanceLookup(nextProps);
this.fetchRates(nextProps); this.fetchRates(nextProps);
} }
} }
public render() { public render() {
const { balance, tokenBalances, rates, ratesError, network } = this.props; const { balance, tokenBalances, rates, ratesError, isOffline, network } = this.props;
const { currency } = this.state; const { currency } = this.state;
// There are a bunch of reasons why the incorrect balances might be rendered // There are a bunch of reasons why the incorrect balances might be rendered
@ -130,7 +135,13 @@ export default class EquivalentValues extends React.Component<Props, CmpState> {
</select> </select>
</h5> </h5>
{isOffline ? (
<div className="EquivalentValues-offline well well-sm">
Equivalent values are unavailable offline
</div>
) : (
<ul className="EquivalentValues-values">{valuesEl}</ul> <ul className="EquivalentValues-values">{valuesEl}</ul>
)}
</div> </div>
); );
} }
@ -154,8 +165,8 @@ export default class EquivalentValues extends React.Component<Props, CmpState> {
} }
private fetchRates(props: Props) { private fetchRates(props: Props) {
// Duck out if we haven't gotten balances yet // Duck out if we haven't gotten balances yet, or we're not going to
if (!props.balance || !props.tokenBalances) { if (!props.balance || !props.tokenBalances || props.isOffline) {
return; return;
} }

View File

@ -41,4 +41,9 @@
color: $gray; color: $gray;
} }
} }
&-offline {
margin-bottom: 0;
text-align: center;
}
} }

View File

@ -29,6 +29,7 @@ interface StateProps {
tokensError: AppState['wallet']['tokensError']; tokensError: AppState['wallet']['tokensError'];
isTokensLoading: AppState['wallet']['isTokensLoading']; isTokensLoading: AppState['wallet']['isTokensLoading'];
hasSavedWalletTokens: AppState['wallet']['hasSavedWalletTokens']; hasSavedWalletTokens: AppState['wallet']['hasSavedWalletTokens'];
isOffline: AppState['config']['offline'];
} }
interface ActionProps { interface ActionProps {
addCustomToken: TAddCustomToken; addCustomToken: TAddCustomToken;
@ -46,13 +47,20 @@ class TokenBalances extends React.Component<Props> {
tokenBalances, tokenBalances,
hasSavedWalletTokens, hasSavedWalletTokens,
isTokensLoading, isTokensLoading,
tokensError tokensError,
isOffline
} = this.props; } = this.props;
const walletTokens = walletConfig ? walletConfig.tokens : []; const walletTokens = walletConfig ? walletConfig.tokens : [];
let content; let content;
if (tokensError) { if (isOffline) {
content = (
<div className="TokenBalances-offline well well-sm">
Token balances are unavailable offline
</div>
);
} else if (tokensError) {
content = <h5>{tokensError}</h5>; content = <h5>{tokensError}</h5>;
} else if (isTokensLoading) { } else if (isTokensLoading) {
content = ( content = (
@ -109,7 +117,8 @@ function mapStateToProps(state: AppState): StateProps {
tokenBalances: getTokenBalances(state), tokenBalances: getTokenBalances(state),
tokensError: state.wallet.tokensError, tokensError: state.wallet.tokensError,
isTokensLoading: state.wallet.isTokensLoading, isTokensLoading: state.wallet.isTokensLoading,
hasSavedWalletTokens: state.wallet.hasSavedWalletTokens hasSavedWalletTokens: state.wallet.hasSavedWalletTokens,
isOffline: state.config.offline
}; };
} }

View File

@ -19,6 +19,7 @@ interface Props {
rates: AppState['rates']['rates']; rates: AppState['rates']['rates'];
ratesError: AppState['rates']['ratesError']; ratesError: AppState['rates']['ratesError'];
fetchCCRates: TFetchCCRates; fetchCCRates: TFetchCCRates;
isOffline: AppState['config']['offline'];
} }
interface Block { interface Block {
@ -29,7 +30,7 @@ interface Block {
export class BalanceSidebar extends React.Component<Props, {}> { export class BalanceSidebar extends React.Component<Props, {}> {
public render() { public render() {
const { wallet, balance, network, tokenBalances, rates, ratesError } = this.props; const { wallet, balance, network, tokenBalances, rates, ratesError, isOffline } = this.props;
if (!wallet) { if (!wallet) {
return null; return null;
@ -59,6 +60,7 @@ export class BalanceSidebar extends React.Component<Props, {}> {
rates={rates} rates={rates}
ratesError={ratesError} ratesError={ratesError}
fetchCCRates={this.props.fetchCCRates} fetchCCRates={this.props.fetchCCRates}
isOffline={isOffline}
/> />
) )
} }
@ -83,7 +85,8 @@ function mapStateToProps(state: AppState) {
tokenBalances: getShownTokenBalances(state, true), tokenBalances: getShownTokenBalances(state, true),
network: getNetworkConfig(state), network: getNetworkConfig(state),
rates: state.rates.rates, rates: state.rates.rates,
ratesError: state.rates.ratesError ratesError: state.rates.ratesError,
isOffline: state.config.offline
}; };
} }

View File

@ -22,6 +22,7 @@ interface Props {
// Data // Data
gasPrice: AppState['transaction']['fields']['gasPrice']; gasPrice: AppState['transaction']['fields']['gasPrice'];
gasLimit: AppState['transaction']['fields']['gasLimit']; gasLimit: AppState['transaction']['fields']['gasLimit'];
nonce: AppState['transaction']['fields']['nonce'];
offline: AppState['config']['offline']; offline: AppState['config']['offline'];
network: AppState['config']['network']; network: AppState['config']['network'];
// Actions // Actions
@ -41,11 +42,19 @@ class GasSlider extends React.Component<Props, State> {
}; };
public componentDidMount() { public componentDidMount() {
if (!this.props.offline) {
this.props.fetchCCRates([this.props.network.unit]); this.props.fetchCCRates([this.props.network.unit]);
} }
}
public componentWillReceiveProps(nextProps: Props) {
if (this.props.offline && !nextProps.offline) {
this.props.fetchCCRates([this.props.network.unit]);
}
}
public render() { public render() {
const { gasPrice, gasLimit, offline, disableAdvanced } = this.props; const { gasPrice, gasLimit, nonce, offline, disableAdvanced } = this.props;
const showAdvanced = (this.state.showAdvanced || offline) && !disableAdvanced; const showAdvanced = (this.state.showAdvanced || offline) && !disableAdvanced;
return ( return (
@ -54,8 +63,10 @@ class GasSlider extends React.Component<Props, State> {
<AdvancedGas <AdvancedGas
gasPrice={gasPrice.raw} gasPrice={gasPrice.raw}
gasLimit={gasLimit.raw} gasLimit={gasLimit.raw}
nonce={nonce.raw}
changeGasPrice={this.props.inputGasPrice} changeGasPrice={this.props.inputGasPrice}
changeGasLimit={this.props.inputGasLimit} changeGasLimit={this.props.inputGasLimit}
changeNonce={this.props.inputNonce}
/> />
) : ( ) : (
<SimpleGas gasPrice={gasPrice.raw} changeGasPrice={this.props.inputGasPrice} /> <SimpleGas gasPrice={gasPrice.raw} changeGasPrice={this.props.inputGasPrice} />
@ -86,6 +97,7 @@ function mapStateToProps(state: AppState) {
return { return {
gasPrice: state.transaction.fields.gasPrice, gasPrice: state.transaction.fields.gasPrice,
gasLimit: state.transaction.fields.gasLimit, gasLimit: state.transaction.fields.gasLimit,
nonce: state.transaction.fields.nonce,
offline: state.config.offline, offline: state.config.offline,
network: getNetworkConfig(state) network: getNetworkConfig(state)
}; };

View File

@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import classnames from 'classnames';
import translate from 'translations'; import translate from 'translations';
import { DataFieldFactory } from 'components/DataFieldFactory'; import { DataFieldFactory } from 'components/DataFieldFactory';
import FeeSummary from './FeeSummary'; import FeeSummary from './FeeSummary';
@ -7,35 +8,53 @@ import './AdvancedGas.scss';
interface Props { interface Props {
gasPrice: string; gasPrice: string;
gasLimit: string; gasLimit: string;
nonce: string;
changeGasPrice(gwei: string): void; changeGasPrice(gwei: string): void;
changeGasLimit(wei: string): void; changeGasLimit(wei: string): void;
changeNonce(nonce: string): void;
} }
export default class AdvancedGas extends React.Component<Props> { export default class AdvancedGas extends React.Component<Props> {
public render() { public render() {
// Can't shadow var names for data & fee summary
const vals = this.props;
return ( return (
<div className="AdvancedGas row form-group"> <div className="AdvancedGas row form-group">
<div className="col-md-3 col-sm-6 col-xs-12"> <div className="col-md-4 col-sm-6 col-xs-12">
<label>{translate('OFFLINE_Step2_Label_3')} (gwei)</label> <label>{translate('OFFLINE_Step2_Label_3')} (gwei)</label>
<input <input
className="form-control" className={classnames('form-control', !vals.gasPrice && 'is-invalid')}
type="number" type="number"
value={this.props.gasPrice} placeholder="e.g. 40"
value={vals.gasPrice}
onChange={this.handleGasPriceChange} onChange={this.handleGasPriceChange}
/> />
</div> </div>
<div className="col-md-3 col-sm-6 col-xs-12"> <div className="col-md-4 col-sm-6 col-xs-12">
<label>{translate('OFFLINE_Step2_Label_4')}</label> <label>{translate('OFFLINE_Step2_Label_4')}</label>
<input <input
className="form-control" className={classnames('form-control', !vals.gasLimit && 'is-invalid')}
type="number" type="number"
value={this.props.gasLimit} placeholder="e.g. 21000"
value={vals.gasLimit}
onChange={this.handleGasLimitChange} onChange={this.handleGasLimitChange}
/> />
</div> </div>
<div className="col-md-6 col-sm-12"> <div className="col-md-4 col-sm-12">
<label>{translate('OFFLINE_Step2_Label_5')}</label>
<input
className={classnames('form-control', !vals.nonce && 'is-invalid')}
type="number"
placeholder="e.g. 7"
value={vals.nonce}
onChange={this.handleNonceChange}
/>
</div>
<div className="col-md-12">
<label>{translate('OFFLINE_Step2_Label_6')}</label> <label>{translate('OFFLINE_Step2_Label_6')}</label>
<DataFieldFactory <DataFieldFactory
withProps={({ data, onChange }) => ( withProps={({ data, onChange }) => (
@ -69,4 +88,8 @@ export default class AdvancedGas extends React.Component<Props> {
private handleGasLimitChange = (ev: React.FormEvent<HTMLInputElement>) => { private handleGasLimitChange = (ev: React.FormEvent<HTMLInputElement>) => {
this.props.changeGasLimit(ev.currentTarget.value); this.props.changeGasLimit(ev.currentTarget.value);
}; };
private handleNonceChange = (ev: React.FormEvent<HTMLInputElement>) => {
this.props.changeNonce(ev.currentTarget.value);
};
} }

View File

@ -20,13 +20,14 @@ interface Props {
gasLimit: AppState['transaction']['fields']['gasLimit']; gasLimit: AppState['transaction']['fields']['gasLimit'];
rates: AppState['rates']['rates']; rates: AppState['rates']['rates'];
network: AppState['config']['network']; network: AppState['config']['network'];
isOffline: AppState['config']['offline'];
// Component props // Component props
render(data: RenderData): React.ReactElement<string> | string; render(data: RenderData): React.ReactElement<string> | string;
} }
class FeeSummary extends React.Component<Props> { class FeeSummary extends React.Component<Props> {
public render() { public render() {
const { gasPrice, gasLimit, rates, network } = this.props; const { gasPrice, gasLimit, rates, network, isOffline } = this.props;
const feeBig = gasPrice.value && gasLimit.value && gasPrice.value.mul(gasLimit.value); const feeBig = gasPrice.value && gasLimit.value && gasPrice.value.mul(gasLimit.value);
const fee = ( const fee = (
@ -42,7 +43,7 @@ class FeeSummary extends React.Component<Props> {
const usdBig = network.isTestnet const usdBig = network.isTestnet
? new BN(0) ? new BN(0)
: feeBig && rates[network.unit] && feeBig.muln(rates[network.unit].USD); : feeBig && rates[network.unit] && feeBig.muln(rates[network.unit].USD);
const usd = ( const usd = isOffline ? null : (
<UnitDisplay <UnitDisplay
value={usdBig} value={usdBig}
unit="ether" unit="ether"
@ -71,7 +72,8 @@ function mapStateToProps(state: AppState) {
gasPrice: state.transaction.fields.gasPrice, gasPrice: state.transaction.fields.gasPrice,
gasLimit: state.transaction.fields.gasLimit, gasLimit: state.transaction.fields.gasLimit,
rates: state.rates.rates, rates: state.rates.rates,
network: getNetworkConfig(state) network: getNetworkConfig(state),
isOffline: state.config.offline
}; };
} }

View File

@ -0,0 +1,36 @@
@import 'common/sass/variables';
@import 'common/sass/mixins';
@keyframes online-pulse {
0%, 100% {
opacity: 0.8;
}
50% {
opacity: 0.7;
}
}
.OnlineStatus {
position: relative;
top: -2px;
width: 12px;
height: 12px;
text-align: center;
transition: $transition;
border-radius: 100%;
transition: background-color 300ms ease;
@include show-tooltip-on-hover;
&.is-online {
background: lighten($brand-success, 10%);
}
&.is-offline {
background: lighten($brand-danger, 5%);
}
&.is-connecting {
background: #FFF;
animation: online-pulse 800ms ease infinite;
}
}

View File

@ -0,0 +1,15 @@
import React from 'react';
import { Tooltip } from 'components/ui';
import './OnlineStatus.scss';
interface Props {
isOffline: boolean;
}
const OnlineStatus: React.SFC<Props> = ({ isOffline }) => (
<div className={`OnlineStatus fa-stack ${isOffline ? 'is-offline' : 'is-online'}`}>
<Tooltip>{isOffline ? 'Offline' : 'Online'}</Tooltip>
</div>
);
export default OnlineStatus;

View File

@ -130,6 +130,10 @@ $small-size: 900px;
margin-right: 10px; margin-right: 10px;
} }
&-online {
margin-right: 6px;
}
&-dropdown { &-dropdown {
margin-left: 6px; margin-left: 6px;

View File

@ -24,6 +24,7 @@ import {
import GasPriceDropdown from './components/GasPriceDropdown'; import GasPriceDropdown from './components/GasPriceDropdown';
import Navigation from './components/Navigation'; import Navigation from './components/Navigation';
import CustomNodeModal from './components/CustomNodeModal'; import CustomNodeModal from './components/CustomNodeModal';
import OnlineStatus from './components/OnlineStatus';
import { getKeyByValue } from 'utils/helpers'; import { getKeyByValue } from 'utils/helpers';
import { makeCustomNodeId } from 'utils/node'; import { makeCustomNodeId } from 'utils/node';
import { getNetworkConfigFromId } from 'utils/network'; import { getNetworkConfigFromId } from 'utils/network';
@ -35,6 +36,7 @@ interface Props {
node: NodeConfig; node: NodeConfig;
nodeSelection: string; nodeSelection: string;
isChangingNode: boolean; isChangingNode: boolean;
isOffline: boolean;
gasPrice: AppState['transaction']['fields']['gasPrice']; gasPrice: AppState['transaction']['fields']['gasPrice'];
customNodes: CustomNodeConfig[]; customNodes: CustomNodeConfig[];
customNetworks: CustomNetworkConfig[]; customNetworks: CustomNetworkConfig[];
@ -62,6 +64,7 @@ export default class Header extends Component<Props, State> {
node, node,
nodeSelection, nodeSelection,
isChangingNode, isChangingNode,
isOffline,
customNodes, customNodes,
customNetworks customNetworks
} = this.props; } = this.props;
@ -127,6 +130,10 @@ export default class Header extends Component<Props, State> {
<div className="Header-branding-right"> <div className="Header-branding-right">
<span className="Header-branding-right-version hidden-xs">v{VERSION}</span> <span className="Header-branding-right-version hidden-xs">v{VERSION}</span>
<div className="Header-branding-right-online">
<OnlineStatus isOffline={isOffline} />
</div>
<div className="Header-branding-right-dropdown"> <div className="Header-branding-right-dropdown">
<GasPriceDropdown <GasPriceDropdown
value={this.props.gasPrice.raw} value={this.props.gasPrice.raw}

View File

@ -3,7 +3,7 @@ import { Aux } from 'components/ui';
import { Query } from 'components/renderCbs'; import { Query } from 'components/renderCbs';
import Help from 'components/ui/Help'; import Help from 'components/ui/Help';
import { getNonce, nonceRequestFailed } from 'selectors/transaction'; import { getNonce, nonceRequestFailed } from 'selectors/transaction';
import { isAnyOffline } from 'selectors/config'; import { getOffline } from 'selectors/config';
import { AppState } from 'reducers'; import { AppState } from 'reducers';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
const nonceHelp = ( const nonceHelp = (
@ -50,6 +50,6 @@ class NonceInputClass extends Component<Props> {
} }
export const NonceInput = connect((state: AppState) => ({ export const NonceInput = connect((state: AppState) => ({
shouldDisplay: isAnyOffline(state) || nonceRequestFailed(state), shouldDisplay: getOffline(state) || nonceRequestFailed(state),
nonce: getNonce(state) nonce: getNonce(state)
}))(NonceInputClass); }))(NonceInputClass);

View File

@ -1,36 +0,0 @@
import { UnlockHeader } from 'components/ui';
import React, { Component } from 'react';
import translate from 'translations';
import { isAnyOffline } from 'selectors/config';
import { connect } from 'react-redux';
import { AppState } from 'reducers';
interface Props {
disabledWallets?: string[];
}
export const OfflineAwareUnlockHeader: React.SFC<Props> = ({ disabledWallets }) => (
<UnlockHeader title={<Title />} disabledWallets={disabledWallets} />
);
interface StateProps {
shouldDisplayOffline: boolean;
}
class TitleClass extends Component<StateProps> {
public render() {
const { shouldDisplayOffline } = this.props;
const offlineTitle = shouldDisplayOffline ? (
<span style={{ color: 'red' }}> (Offline)</span>
) : null;
return (
<div>
{translate('Account')}
{offlineTitle}
</div>
);
}
}
const Title = connect((state: AppState) => ({
shouldDisplayOffline: isAnyOffline(state)
}))(TitleClass);

View File

@ -33,14 +33,15 @@ import {
import { AppState } from 'reducers'; import { AppState } from 'reducers';
import { knowledgeBaseURL, isWeb3NodeAvailable } from 'config/data'; import { knowledgeBaseURL, isWeb3NodeAvailable } from 'config/data';
import { IWallet } from 'libs/wallet'; import { IWallet } from 'libs/wallet';
import DISABLES from './disables.json';
import { showNotification, TShowNotification } from 'actions/notifications'; import { showNotification, TShowNotification } from 'actions/notifications';
import DigitalBitboxIcon from 'assets/images/wallets/digital-bitbox.svg'; import DigitalBitboxIcon from 'assets/images/wallets/digital-bitbox.svg';
import LedgerIcon from 'assets/images/wallets/ledger.svg'; import LedgerIcon from 'assets/images/wallets/ledger.svg';
import MetamaskIcon from 'assets/images/wallets/metamask.svg'; import MetamaskIcon from 'assets/images/wallets/metamask.svg';
import MistIcon from 'assets/images/wallets/mist.svg'; import MistIcon from 'assets/images/wallets/mist.svg';
import TrezorIcon from 'assets/images/wallets/trezor.svg'; import TrezorIcon from 'assets/images/wallets/trezor.svg';
import './WalletDecrypt.scss'; import './WalletDecrypt.scss';
type UnlockParams = {} | PrivateKeyValue;
interface Props { interface Props {
resetTransactionState: TReset; resetTransactionState: TReset;
@ -59,6 +60,7 @@ interface Props {
isPasswordPending: AppState['wallet']['isPasswordPending']; isPasswordPending: AppState['wallet']['isPasswordPending'];
} }
type UnlockParams = {} | PrivateKeyValue;
interface State { interface State {
selectedWalletKey: string | null; selectedWalletKey: string | null;
value: UnlockParams | null; value: UnlockParams | null;
@ -227,11 +229,6 @@ export class WalletDecrypt extends Component<Props, State> {
); );
} }
public isOnlineRequiredWalletAndOffline(selectedWalletKey) {
const onlineRequiredWallets = ['trezor', 'ledger-nano-s'];
return this.props.offline && onlineRequiredWallets.includes(selectedWalletKey);
}
public buildWalletOptions() { public buildWalletOptions() {
const viewOnly = this.WALLETS['view-only'] as InsecureWalletInfo; const viewOnly = this.WALLETS['view-only'] as InsecureWalletInfo;
@ -379,6 +376,10 @@ export class WalletDecrypt extends Component<Props, State> {
}; };
private isWalletDisabled = (walletKey: string) => { private isWalletDisabled = (walletKey: string) => {
if (this.props.offline && DISABLES.ONLINE_ONLY.includes(walletKey)) {
return true;
}
if (!this.props.disabledWallets) { if (!this.props.disabledWallets) {
return false; return false;
} }

View File

@ -1,4 +1,5 @@
{ {
"READ_ONLY": ["view-only"], "READ_ONLY": ["view-only"],
"UNABLE_TO_SIGN": ["trezor", "view-only"] "UNABLE_TO_SIGN": ["trezor", "view-only"],
"ONLINE_ONLY": ["web3", "trezor"]
} }

View File

@ -9,7 +9,6 @@ export * from './CurrentCustomMessage';
export * from './GenerateTransaction'; export * from './GenerateTransaction';
export * from './SendButton'; export * from './SendButton';
export * from './SigningStatus'; export * from './SigningStatus';
export * from './OfflineAwareUnlockHeader';
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

@ -7,7 +7,7 @@ import { IWallet } from 'libs/wallet/IWallet';
import './UnlockHeader.scss'; import './UnlockHeader.scss';
interface Props { interface Props {
title: React.ReactElement<any>; title: React.ReactElement<string> | string;
wallet: IWallet; wallet: IWallet;
disabledWallets?: string[]; disabledWallets?: string[];
} }

View File

@ -0,0 +1,26 @@
@import 'common/sass/variables';
@keyframes ban-wifi {
0% {
opacity: 0;
transform: scale(1.3);
}
100% {
opacity: 1;
transform: scale(1.1);
}
}
.OfflineTab {
text-align: center;
&-icon {
opacity: 0.8;
.fa-ban {
color: $brand-danger;
animation: ban-wifi 500ms ease 200ms 1;
animation-fill-mode: both;
}
}
}

View File

@ -0,0 +1,16 @@
import React from 'react';
import './OfflineTab.scss';
const OfflineTab: React.SFC<{}> = () => (
<section className="OfflineTab Tab-content swap-tab">
<div className="Tab-content-pane">
<div className="OfflineTab-icon fa-stack fa-4x">
<i className="fa fa-wifi fa-stack-1x" />
<i className="fa fa-ban fa-stack-2x" />
</div>
<h1 className="OfflineTab-message">This feature is unavailable while offline</h1>
</div>
</section>
);
export default OfflineTab;

View File

@ -16,6 +16,7 @@ import { TSetGasPriceField, setGasPriceField as dSetGasPriceField } from 'action
import { AlphaAgreement, Footer, Header } from 'components'; import { AlphaAgreement, Footer, Header } from 'components';
import { AppState } from 'reducers'; import { AppState } from 'reducers';
import Notifications from './Notifications'; import Notifications from './Notifications';
import OfflineTab from './OfflineTab';
import { getGasPrice } from 'selectors/transaction'; import { getGasPrice } from 'selectors/transaction';
interface ReduxProps { interface ReduxProps {
@ -23,6 +24,7 @@ interface ReduxProps {
node: AppState['config']['node']; node: AppState['config']['node'];
nodeSelection: AppState['config']['nodeSelection']; nodeSelection: AppState['config']['nodeSelection'];
isChangingNode: AppState['config']['isChangingNode']; isChangingNode: AppState['config']['isChangingNode'];
isOffline: AppState['config']['offline'];
customNodes: AppState['config']['customNodes']; customNodes: AppState['config']['customNodes'];
customNetworks: AppState['config']['customNetworks']; customNetworks: AppState['config']['customNetworks'];
latestBlock: AppState['config']['latestBlock']; latestBlock: AppState['config']['latestBlock'];
@ -39,19 +41,21 @@ interface ActionProps {
} }
type Props = { type Props = {
// FIXME isUnavailableOffline?: boolean;
children: any; children: string | React.ReactElement<string> | React.ReactElement<string>[];
} & ReduxProps & } & ReduxProps &
ActionProps; ActionProps;
class TabSection extends Component<Props, {}> { class TabSection extends Component<Props, {}> {
public render() { public render() {
const { const {
isUnavailableOffline,
children, children,
// APP // APP
node, node,
nodeSelection, nodeSelection,
isChangingNode, isChangingNode,
isOffline,
languageSelection, languageSelection,
customNodes, customNodes,
customNetworks, customNetworks,
@ -70,6 +74,7 @@ class TabSection extends Component<Props, {}> {
node, node,
nodeSelection, nodeSelection,
isChangingNode, isChangingNode,
isOffline,
gasPrice, gasPrice,
customNodes, customNodes,
customNetworks, customNetworks,
@ -85,7 +90,9 @@ class TabSection extends Component<Props, {}> {
<div className="page-layout"> <div className="page-layout">
<main> <main>
<Header {...headerProps} /> <Header {...headerProps} />
<div className="Tab container">{children}</div> <div className="Tab container">
{isUnavailableOffline && isOffline ? <OfflineTab /> : children}
</div>
<Footer latestBlock={latestBlock} /> <Footer latestBlock={latestBlock} />
</main> </main>
<Notifications /> <Notifications />
@ -100,6 +107,7 @@ function mapStateToProps(state: AppState): ReduxProps {
node: state.config.node, node: state.config.node,
nodeSelection: state.config.nodeSelection, nodeSelection: state.config.nodeSelection,
isChangingNode: state.config.isChangingNode, isChangingNode: state.config.isChangingNode,
isOffline: state.config.offline,
languageSelection: state.config.languageSelection, languageSelection: state.config.languageSelection,
gasPrice: getGasPrice(state), gasPrice: getGasPrice(state),
customNodes: state.config.customNodes, customNodes: state.config.customNodes,

View File

@ -43,7 +43,7 @@ class BroadcastTx extends Component<DispatchProps & StateProps> {
}); });
return ( return (
<TabSection> <TabSection isUnavailableOffline={true}>
<div className="Tab-content-pane row block text-center"> <div className="Tab-content-pane row block text-center">
<div className="BroadcastTx"> <div className="BroadcastTx">
<h1 className="BroadcastTx-title">Broadcast Signed Transaction</h1> <h1 className="BroadcastTx-title">Broadcast Signed Transaction</h1>

View File

@ -43,7 +43,7 @@ class Contracts extends Component<Props, State> {
} }
return ( return (
<TabSection> <TabSection isUnavailableOffline={true}>
<section className="Tab-content Contracts"> <section className="Tab-content Contracts">
<div className="Tab-content-pane"> <div className="Tab-content-pane">
<h1 className="Contracts-header"> <h1 className="Contracts-header">

View File

@ -9,7 +9,7 @@ interface ContainerTabPaneActiveProps {
} }
const ContainerTabPaneActive = ({ children }: ContainerTabPaneActiveProps) => ( const ContainerTabPaneActive = ({ children }: ContainerTabPaneActiveProps) => (
<TabSection> <TabSection isUnavailableOffline={true}>
<section className="container"> <section className="container">
<div className="tab-content"> <div className="tab-content">
<main className="tab-pane active"> <main className="tab-pane active">

View File

@ -2,7 +2,6 @@ import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { isAnyOfflineWithWeb3 } from 'selectors/derived'; import { isAnyOfflineWithWeb3 } from 'selectors/derived';
import { import {
NonceField,
AddressField, AddressField,
AmountField, AmountField,
GasSlider, GasSlider,
@ -33,11 +32,6 @@ const content = (
<GasSlider /> <GasSlider />
</div> </div>
</div> </div>
<div className="row form-group">
<div className="col-xs-12">
<NonceField />
</div>
</div>
<CurrentCustomMessage /> <CurrentCustomMessage />
<NonStandardTransaction /> <NonStandardTransaction />

View File

@ -1,7 +1,8 @@
import TabSection from 'containers/TabSection';
import { OfflineAwareUnlockHeader } from 'components';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import translate from 'translations';
import TabSection from 'containers/TabSection';
import { UnlockHeader } from 'components/ui';
import { SideBar } from './components/index'; import { SideBar } from './components/index';
import { IReadOnlyWallet, IFullWallet } from 'libs/wallet'; import { IReadOnlyWallet, IFullWallet } from 'libs/wallet';
import { getWalletInst } from 'selectors/wallet'; import { getWalletInst } from 'selectors/wallet';
@ -52,7 +53,7 @@ class SendTransaction extends React.Component<Props> {
return ( return (
<TabSection> <TabSection>
<section className="Tab-content"> <section className="Tab-content">
<OfflineAwareUnlockHeader /> <UnlockHeader title={translate('Account')} />
{wallet && <WalletTabs {...tabProps} />} {wallet && <WalletTabs {...tabProps} />}
</section> </section>
</TabSection> </TabSection>

View File

@ -73,6 +73,7 @@ interface ReduxStateProps {
bityOrderStatus: string | null; bityOrderStatus: string | null;
shapeshiftOrderStatus: string | null; shapeshiftOrderStatus: string | null;
paymentAddress: string | null; paymentAddress: string | null;
isOffline: boolean;
} }
interface ReduxActionProps { interface ReduxActionProps {
@ -98,8 +99,15 @@ interface ReduxActionProps {
class Swap extends Component<ReduxActionProps & ReduxStateProps, {}> { class Swap extends Component<ReduxActionProps & ReduxStateProps, {}> {
public componentDidMount() { public componentDidMount() {
this.props.loadBityRatesRequestedSwap(); if (!this.props.isOffline) {
this.props.loadShapeshiftRatesRequestedSwap(); this.loadRates();
}
}
public componentWillReceiveProps(nextProps: ReduxStateProps) {
if (this.props.isOffline && !nextProps.isOffline) {
this.loadRates();
}
} }
public componentWillUnmount() { public componentWillUnmount() {
@ -107,6 +115,11 @@ class Swap extends Component<ReduxActionProps & ReduxStateProps, {}> {
this.props.stopLoadShapeshiftRatesSwap(); this.props.stopLoadShapeshiftRatesSwap();
} }
public loadRates() {
this.props.loadBityRatesRequestedSwap();
this.props.loadShapeshiftRatesRequestedSwap();
}
public render() { public render() {
const { const {
// STATE // STATE
@ -222,7 +235,7 @@ class Swap extends Component<ReduxActionProps & ReduxStateProps, {}> {
const CurrentRatesProps = { provider, bityRates, shapeshiftRates }; const CurrentRatesProps = { provider, bityRates, shapeshiftRates };
return ( return (
<TabSection> <TabSection isUnavailableOffline={true}>
<section className="Tab-content swap-tab"> <section className="Tab-content swap-tab">
{step === 1 && <CurrentRates {...CurrentRatesProps} />} {step === 1 && <CurrentRates {...CurrentRatesProps} />}
{step === 1 && <ShapeshiftBanner />} {step === 1 && <ShapeshiftBanner />}
@ -257,7 +270,8 @@ function mapStateToProps(state: AppState) {
isPostingOrder: state.swap.isPostingOrder, isPostingOrder: state.swap.isPostingOrder,
bityOrderStatus: state.swap.bityOrderStatus, bityOrderStatus: state.swap.bityOrderStatus,
shapeshiftOrderStatus: state.swap.shapeshiftOrderStatus, shapeshiftOrderStatus: state.swap.shapeshiftOrderStatus,
paymentAddress: state.swap.paymentAddress paymentAddress: state.swap.paymentAddress,
isOffline: state.config.offline
}; };
} }

View File

@ -28,7 +28,6 @@ export interface State {
network: NetworkConfig; network: NetworkConfig;
isChangingNode: boolean; isChangingNode: boolean;
offline: boolean; offline: boolean;
forceOffline: boolean;
customNodes: CustomNodeConfig[]; customNodes: CustomNodeConfig[];
customNetworks: CustomNetworkConfig[]; customNetworks: CustomNetworkConfig[];
latestBlock: string; latestBlock: string;
@ -42,7 +41,6 @@ export const INITIAL_STATE: State = {
network: NETWORKS[NODES[defaultNode].network], network: NETWORKS[NODES[defaultNode].network],
isChangingNode: false, isChangingNode: false,
offline: false, offline: false,
forceOffline: false,
customNodes: [], customNodes: [],
customNetworks: [], customNetworks: [],
latestBlock: '???' latestBlock: '???'
@ -79,13 +77,6 @@ function toggleOffline(state: State): State {
}; };
} }
function forceOffline(state: State): State {
return {
...state,
forceOffline: !state.forceOffline
};
}
function addCustomNode(state: State, action: AddCustomNodeAction): State { function addCustomNode(state: State, action: AddCustomNodeAction): State {
const newId = makeCustomNodeId(action.payload); const newId = makeCustomNodeId(action.payload);
return { return {
@ -141,8 +132,6 @@ export function config(state: State = INITIAL_STATE, action: ConfigAction): Stat
return changeNodeIntent(state); return changeNodeIntent(state);
case TypeKeys.CONFIG_TOGGLE_OFFLINE: case TypeKeys.CONFIG_TOGGLE_OFFLINE:
return toggleOffline(state); return toggleOffline(state);
case TypeKeys.CONFIG_FORCE_OFFLINE:
return forceOffline(state);
case TypeKeys.CONFIG_ADD_CUSTOM_NODE: case TypeKeys.CONFIG_ADD_CUSTOM_NODE:
return addCustomNode(state, action); return addCustomNode(state, action);
case TypeKeys.CONFIG_REMOVE_CUSTOM_NODE: case TypeKeys.CONFIG_REMOVE_CUSTOM_NODE:

View File

@ -22,8 +22,7 @@ import {
getNodeConfig, getNodeConfig,
getCustomNodeConfigs, getCustomNodeConfigs,
getCustomNetworkConfigs, getCustomNetworkConfigs,
getOffline, getOffline
getForceOffline
} from 'selectors/config'; } from 'selectors/config';
import { AppState } from 'reducers'; import { AppState } from 'reducers';
import { TypeKeys } from 'actions/config/constants'; import { TypeKeys } from 'actions/config/constants';
@ -50,19 +49,11 @@ export function* pollOfflineStatus(): SagaIterator {
while (true) { while (true) {
const node: NodeConfig = yield select(getNodeConfig); const node: NodeConfig = yield select(getNodeConfig);
const isOffline: boolean = yield select(getOffline); const isOffline: boolean = yield select(getOffline);
const isForcedOffline: boolean = yield select(getForceOffline);
// If they're forcing themselves offline, exit the loop. It will be
// kicked off again if they toggle it in handleTogglePollOfflineStatus.
if (isForcedOffline) {
return;
}
// If our offline state disagrees with the browser, run a check // If our offline state disagrees with the browser, run a check
// Don't check if the user is in another tab or window // Don't check if the user is in another tab or window
const shouldPing = !hasCheckedOnline || navigator.onLine === isOffline; const shouldPing = !hasCheckedOnline || navigator.onLine === isOffline;
if (shouldPing && !document.hidden) { if (shouldPing && !document.hidden) {
hasCheckedOnline = true;
const { pingSucceeded } = yield race({ const { pingSucceeded } = yield race({
pingSucceeded: call(node.lib.ping.bind(node.lib)), pingSucceeded: call(node.lib.ping.bind(node.lib)),
timeout: call(delay, 5000) timeout: call(delay, 5000)
@ -76,6 +67,9 @@ export function* pollOfflineStatus(): SagaIterator {
yield put(toggleOfflineConfig()); yield put(toggleOfflineConfig());
} else if (!pingSucceeded && !isOffline) { } else if (!pingSucceeded && !isOffline) {
// If we were unable to ping but redux says we're online, mark offline // If we were unable to ping but redux says we're online, mark offline
// If they had been online, show an error.
// If they hadn't been online, just inform them with a warning.
if (hasCheckedOnline) {
yield put( yield put(
showNotification( showNotification(
'danger', 'danger',
@ -85,11 +79,21 @@ export function* pollOfflineStatus(): SagaIterator {
Infinity Infinity
) )
); );
} else {
yield put(
showNotification(
'info',
'You are currently offline. Some features will be unavailable.',
5000
)
);
}
yield put(toggleOfflineConfig()); yield put(toggleOfflineConfig());
} else { } else {
// If neither case was true, try again in 5s // If neither case was true, try again in 5s
yield call(delay, 5000); yield call(delay, 5000);
} }
hasCheckedOnline = true;
} else { } else {
yield call(delay, 1000); yield call(delay, 1000);
} }
@ -103,15 +107,6 @@ export function* handlePollOfflineStatus(): SagaIterator {
yield cancel(pollOfflineStatusTask); yield cancel(pollOfflineStatusTask);
} }
export function* handleTogglePollOfflineStatus(): SagaIterator {
const isForcedOffline: boolean = yield select(getForceOffline);
if (isForcedOffline) {
yield fork(handlePollOfflineStatus);
} else {
yield call(handlePollOfflineStatus);
}
}
// @HACK For now we reload the app when doing a language swap to force non-connected // @HACK For now we reload the app when doing a language swap to force non-connected
// data to reload. Also the use of timeout to avoid using additional actions for now. // data to reload. Also the use of timeout to avoid using additional actions for now.
export function* reload(): SagaIterator { export function* reload(): SagaIterator {
@ -251,7 +246,6 @@ export const equivalentNodeOrDefault = (nodeConfig: NodeConfig) => {
export default function* configSaga(): SagaIterator { export default function* configSaga(): SagaIterator {
yield takeLatest(TypeKeys.CONFIG_POLL_OFFLINE_STATUS, handlePollOfflineStatus); yield takeLatest(TypeKeys.CONFIG_POLL_OFFLINE_STATUS, handlePollOfflineStatus);
yield takeEvery(TypeKeys.CONFIG_FORCE_OFFLINE, handleTogglePollOfflineStatus);
yield takeEvery(TypeKeys.CONFIG_NODE_CHANGE_INTENT, handleNodeChangeIntent); yield takeEvery(TypeKeys.CONFIG_NODE_CHANGE_INTENT, handleNodeChangeIntent);
yield takeEvery(TypeKeys.CONFIG_LANGUAGE_CHANGE, reload); yield takeEvery(TypeKeys.CONFIG_LANGUAGE_CHANGE, reload);
yield takeEvery(TypeKeys.CONFIG_ADD_CUSTOM_NODE, switchToNewNode); yield takeEvery(TypeKeys.CONFIG_ADD_CUSTOM_NODE, switchToNewNode);

View File

@ -1,7 +1,7 @@
import { SagaIterator, buffers, delay } from 'redux-saga'; import { SagaIterator, buffers, delay } from 'redux-saga';
import { apply, put, select, take, actionChannel, call, fork } from 'redux-saga/effects'; import { apply, put, select, take, actionChannel, call, fork } from 'redux-saga/effects';
import { INode } from 'libs/nodes/INode'; import { INode } from 'libs/nodes/INode';
import { getNodeLib } from 'selectors/config'; import { getNodeLib, getOffline } from 'selectors/config';
import { getWalletInst } from 'selectors/wallet'; import { getWalletInst } from 'selectors/wallet';
import { getTransaction, IGetTransaction } from 'selectors/transaction'; import { getTransaction, IGetTransaction } from 'selectors/transaction';
import { import {
@ -22,6 +22,11 @@ import { makeTransaction, getTransactionFields, IHexStrTransaction } from 'libs/
export function* shouldEstimateGas(): SagaIterator { export function* shouldEstimateGas(): SagaIterator {
while (true) { while (true) {
const isOffline = yield select(getOffline);
if (isOffline) {
continue;
}
const action: const action:
| SetToFieldAction | SetToFieldAction
| SetDataFieldAction | SetDataFieldAction
@ -59,6 +64,11 @@ export function* estimateGas(): SagaIterator {
const requestChan = yield actionChannel(TypeKeys.ESTIMATE_GAS_REQUESTED, buffers.sliding(1)); const requestChan = yield actionChannel(TypeKeys.ESTIMATE_GAS_REQUESTED, buffers.sliding(1));
while (true) { while (true) {
const isOffline = yield select(getOffline);
if (isOffline) {
continue;
}
const { payload }: EstimateGasRequestedAction = yield take(requestChan); const { payload }: EstimateGasRequestedAction = yield take(requestChan);
// debounce 250 ms // debounce 250 ms
yield call(delay, 250); yield call(delay, 250);

View File

@ -12,9 +12,13 @@ import { Nonce } from 'libs/units';
export function* handleNonceRequest(): SagaIterator { export function* handleNonceRequest(): SagaIterator {
const nodeLib: INode = yield select(getNodeLib); const nodeLib: INode = yield select(getNodeLib);
const walletInst: AppState['wallet']['inst'] = yield select(getWalletInst); const walletInst: AppState['wallet']['inst'] = yield select(getWalletInst);
const offline: boolean = yield select(getOffline); const isOffline: boolean = yield select(getOffline);
try { try {
if (!walletInst || offline) { if (isOffline) {
return;
}
if (!walletInst) {
throw Error(); throw Error();
} }
const fromAddress: string = yield apply(walletInst, walletInst.getAddressString); const fromAddress: string = yield apply(walletInst, walletInst.getAddressString);

View File

@ -39,7 +39,7 @@ import {
import { NODES, initWeb3Node, Token } from 'config/data'; import { NODES, initWeb3Node, Token } from 'config/data';
import { SagaIterator, delay, Task } from 'redux-saga'; import { SagaIterator, delay, Task } from 'redux-saga';
import { apply, call, fork, put, select, takeEvery, take, cancel } from 'redux-saga/effects'; import { apply, call, fork, put, select, takeEvery, take, cancel } from 'redux-saga/effects';
import { getNodeLib, getAllTokens } from 'selectors/config'; import { getNodeLib, getAllTokens, getOffline } from 'selectors/config';
import { import {
getTokens, getTokens,
getWalletInst, getWalletInst,
@ -58,6 +58,11 @@ export interface TokenBalanceLookup {
export function* updateAccountBalance(): SagaIterator { export function* updateAccountBalance(): SagaIterator {
try { try {
const isOffline = yield select(getOffline);
if (isOffline) {
return;
}
yield put(setBalancePending()); yield put(setBalancePending());
const wallet: null | IWallet = yield select(getWalletInst); const wallet: null | IWallet = yield select(getWalletInst);
if (!wallet) { if (!wallet) {
@ -75,6 +80,11 @@ export function* updateAccountBalance(): SagaIterator {
export function* updateTokenBalances(): SagaIterator { export function* updateTokenBalances(): SagaIterator {
try { try {
const isOffline = yield select(getOffline);
if (isOffline) {
return;
}
const wallet: null | IWallet = yield select(getWalletInst); const wallet: null | IWallet = yield select(getWalletInst);
const tokens: MergedToken[] = yield select(getWalletConfigTokens); const tokens: MergedToken[] = yield select(getWalletConfigTokens);
if (!wallet || !tokens.length) { if (!wallet || !tokens.length) {
@ -91,6 +101,11 @@ export function* updateTokenBalances(): SagaIterator {
export function* updateTokenBalance(action: SetTokenBalancePendingAction): SagaIterator { export function* updateTokenBalance(action: SetTokenBalancePendingAction): SagaIterator {
try { try {
const isOffline = yield select(getOffline);
if (isOffline) {
return;
}
const wallet: null | IWallet = yield select(getWalletInst); const wallet: null | IWallet = yield select(getWalletInst);
const { tokenSymbol } = action.payload; const { tokenSymbol } = action.payload;
const allTokens: Token[] = yield select(getAllTokens); const allTokens: Token[] = yield select(getAllTokens);
@ -115,6 +130,11 @@ export function* updateTokenBalance(action: SetTokenBalancePendingAction): SagaI
export function* scanWalletForTokens(action: ScanWalletForTokensAction): SagaIterator { export function* scanWalletForTokens(action: ScanWalletForTokensAction): SagaIterator {
try { try {
const isOffline = yield select(getOffline);
if (isOffline) {
return;
}
const wallet = action.payload; const wallet = action.payload;
const tokens: MergedToken[] = yield select(getTokens); const tokens: MergedToken[] = yield select(getTokens);
yield put(setTokenBalancesPending()); yield put(setTokenBalancesPending());
@ -288,7 +308,9 @@ export default function* walletSaga(): SagaIterator {
takeEvery(TypeKeys.WALLET_SET, handleNewWallet), takeEvery(TypeKeys.WALLET_SET, handleNewWallet),
takeEvery(TypeKeys.WALLET_SCAN_WALLET_FOR_TOKENS, scanWalletForTokens), takeEvery(TypeKeys.WALLET_SCAN_WALLET_FOR_TOKENS, scanWalletForTokens),
takeEvery(TypeKeys.WALLET_SET_WALLET_TOKENS, handleSetWalletTokens), takeEvery(TypeKeys.WALLET_SET_WALLET_TOKENS, handleSetWalletTokens),
takeEvery(CustomTokenTypeKeys.CUSTOM_TOKEN_ADD, handleCustomTokenAdd), takeEvery(TypeKeys.WALLET_SET_TOKEN_BALANCE_PENDING, updateTokenBalance),
takeEvery(TypeKeys.WALLET_SET_TOKEN_BALANCE_PENDING, updateTokenBalance) // Foreign actions
takeEvery(ConfigTypeKeys.CONFIG_TOGGLE_OFFLINE, updateBalances),
takeEvery(CustomTokenTypeKeys.CUSTOM_TOKEN_ADD, handleCustomTokenAdd)
]; ];
} }

View File

@ -86,12 +86,6 @@ export function getOffline(state: AppState): boolean {
return state.config.offline; return state.config.offline;
} }
export function getForceOffline(state: AppState): boolean {
return state.config.forceOffline;
}
export const isAnyOffline = (state: AppState) => getOffline(state) || getForceOffline(state);
export function isSupportedUnit(state: AppState, unit: string) { export function isSupportedUnit(state: AppState, unit: string) {
const isToken: boolean = tokenExists(state, unit); const isToken: boolean = tokenExists(state, unit);
const isEther: boolean = isEtherUnit(unit); const isEther: boolean = isEtherUnit(unit);

View File

@ -1,12 +1,9 @@
import { AppState } from 'reducers'; import { AppState } from 'reducers';
import { getWalletType } from 'selectors/wallet'; import { getWalletType } from 'selectors/wallet';
import { getOffline, getForceOffline } from 'selectors/config'; import { getOffline } from 'selectors/config';
export const isAnyOfflineWithWeb3 = (state: AppState): boolean => { export const isAnyOfflineWithWeb3 = (state: AppState): boolean => {
const { isWeb3Wallet } = getWalletType(state); const { isWeb3Wallet } = getWalletType(state);
const offline = getOffline(state); const offline = getOffline(state);
const forceOffline = getForceOffline(state); return offline && isWeb3Wallet;
const anyOffline = offline || forceOffline;
const anyOfflineAndWeb3 = anyOffline && isWeb3Wallet;
return anyOfflineAndWeb3;
}; };

View File

@ -143,10 +143,8 @@
"tscheck": "tsc --noEmit", "tscheck": "tsc --noEmit",
"start": "npm run dev", "start": "npm run dev",
"precommit": "lint-staged", "precommit": "lint-staged",
"formatAll": "formatAll": "find ./common/ -name '*.ts*' | xargs prettier --write --config ./.prettierrc --config-precedence file-override",
"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\"",
"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": {

View File

@ -17,8 +17,7 @@ it('render snapshot', () => {
nodeSelection: testNode, nodeSelection: testNode,
node: NODES[testNode], node: NODES[testNode],
gasPriceGwei: 21, gasPriceGwei: 21,
offline: false, offline: false
forceOffline: false
}; };
const testState = { const testState = {
wallet: {}, wallet: {},
@ -31,7 +30,6 @@ it('render snapshot', () => {
gasPrice: {}, gasPrice: {},
transactions: {}, transactions: {},
offline: {}, offline: {},
forceOffline: {},
config: testStateConfig, config: testStateConfig,
customTokens: [] customTokens: []
}; };

View File

@ -4,12 +4,13 @@ import Adapter from 'enzyme-adapter-react-16';
import Swap from 'containers/Tabs/Swap'; import Swap from 'containers/Tabs/Swap';
import shallowWithStore from '../utils/shallowWithStore'; import shallowWithStore from '../utils/shallowWithStore';
import { createMockStore } from 'redux-test-utils'; import { createMockStore } from 'redux-test-utils';
import { INITIAL_STATE } from 'reducers/swap'; import { INITIAL_STATE as swap } from 'reducers/swap';
import { INITIAL_STATE as config } from 'reducers/config';
Enzyme.configure({ adapter: new Adapter() }); Enzyme.configure({ adapter: new Adapter() });
it('render snapshot', () => { it('render snapshot', () => {
const store = createMockStore({ swap: INITIAL_STATE }); const store = createMockStore({ swap, config });
const component = shallowWithStore(<Swap />, store); const component = shallowWithStore(<Swap />, store);
expect(component).toMatchSnapshot(); expect(component).toMatchSnapshot();

View File

@ -22,6 +22,7 @@ exports[`render snapshot 1`] = `
destinationAddressSwap={[Function]} destinationAddressSwap={[Function]}
initSwap={[Function]} initSwap={[Function]}
isFetchingRates={null} isFetchingRates={null}
isOffline={false}
isPostingOrder={false} isPostingOrder={false}
loadBityRatesRequestedSwap={[Function]} loadBityRatesRequestedSwap={[Function]}
loadShapeshiftRatesRequestedSwap={[Function]} loadShapeshiftRatesRequestedSwap={[Function]}

View File

@ -53,28 +53,6 @@ describe('config reducer', () => {
}); });
}); });
it('should handle CONFIG_FORCE_OFFLINE', () => {
const forceOfflineTrue = {
...INITIAL_STATE,
forceOffline: true
};
const forceOfflineFalse = {
...INITIAL_STATE,
forceOffline: false
};
expect(config(forceOfflineTrue, configActions.forceOfflineConfig())).toEqual({
...forceOfflineTrue,
forceOffline: false
});
expect(config(forceOfflineFalse, configActions.forceOfflineConfig())).toEqual({
...forceOfflineFalse,
forceOffline: true
});
});
it('should handle CONFIG_ADD_CUSTOM_NODE', () => { it('should handle CONFIG_ADD_CUSTOM_NODE', () => {
expect(config(undefined, configActions.addCustomNode(custNode))).toEqual({ expect(config(undefined, configActions.addCustomNode(custNode))).toEqual({
...INITIAL_STATE, ...INITIAL_STATE,

View File

@ -52,26 +52,6 @@ Object {
} }
`; `;
exports[`pollOfflineStatus* should put showNotification and put toggleOfflineConfig if !pingSucceeded && !isOffline 1`] = `
Object {
"@@redux-saga/IO": true,
"PUT": Object {
"action": Object {
"payload": Object {
"duration": Infinity,
"id": 0.001,
"level": "danger",
"msg": "Youve lost your connection to the network, check your internet
connection or try changing networks from the dropdown at the
top right of the page.",
},
"type": "SHOW_NOTIFICATION",
},
"channel": null,
},
}
`;
exports[`pollOfflineStatus* should race pingSucceeded and timeout 1`] = ` exports[`pollOfflineStatus* should race pingSucceeded and timeout 1`] = `
Object { Object {
"@@redux-saga/IO": true, "@@redux-saga/IO": true,
@ -97,3 +77,21 @@ Object {
}, },
} }
`; `;
exports[`pollOfflineStatus* should toggle offline and show notification if navigator agrees with isOffline and ping fails 1`] = `
Object {
"@@redux-saga/IO": true,
"PUT": Object {
"action": Object {
"payload": Object {
"duration": 5000,
"id": 0.001,
"level": "info",
"msg": "You are currently offline. Some features will be unavailable.",
},
"type": "SHOW_NOTIFICATION",
},
"channel": null,
},
}
`;

View File

@ -7,7 +7,6 @@ import {
pollOfflineStatus, pollOfflineStatus,
handlePollOfflineStatus, handlePollOfflineStatus,
handleNodeChangeIntent, handleNodeChangeIntent,
handleTogglePollOfflineStatus,
reload, reload,
unsetWeb3Node, unsetWeb3Node,
unsetWeb3NodeOnWalletEvent, unsetWeb3NodeOnWalletEvent,
@ -18,7 +17,6 @@ import {
getNode, getNode,
getNodeConfig, getNodeConfig,
getOffline, getOffline,
getForceOffline,
getCustomNodeConfigs, getCustomNodeConfigs,
getCustomNetworkConfigs getCustomNetworkConfigs
} from 'selectors/config'; } from 'selectors/config';
@ -43,12 +41,13 @@ describe('pollOfflineStatus*', () => {
} }
}; };
const isOffline = true; const isOffline = true;
const isForcedOffline = true;
const raceSuccess = { const raceSuccess = {
pingSucceeded: true pingSucceeded: true,
timeout: false
}; };
const raceFailure = { const raceFailure = {
pingSucceeded: false pingSucceeded: false,
timeout: true
}; };
let originalHidden; let originalHidden;
@ -88,49 +87,32 @@ describe('pollOfflineStatus*', () => {
expect(data.gen.next(node).value).toEqual(select(getOffline)); expect(data.gen.next(node).value).toEqual(select(getOffline));
}); });
it('should select getForceOffline', () => {
data.isOfflineClone = data.gen.clone();
expect(data.gen.next(isOffline).value).toEqual(select(getForceOffline));
});
it('should be done if isForcedOffline', () => {
data.clone1 = data.gen.clone();
expect(data.clone1.next(isForcedOffline).done).toEqual(true);
});
it('should call delay if document is hidden', () => { it('should call delay if document is hidden', () => {
data.clone2 = data.gen.clone(); data.hiddenDoc = data.gen.clone();
doc.hidden = true; doc.hidden = true;
expect(data.hiddenDoc.next(!isOffline).value).toEqual(call(delay, 1000));
expect(data.clone2.next(!isForcedOffline).value).toEqual(call(delay, 1000)); doc.hidden = false;
}); });
it('should race pingSucceeded and timeout', () => { it('should race pingSucceeded and timeout', () => {
doc.hidden = false; data.isOfflineClone = data.gen.clone();
expect(data.gen.next(!isForcedOffline).value).toMatchSnapshot(); data.shouldDelayClone = data.gen.clone();
expect(data.gen.next(isOffline).value).toMatchSnapshot();
}); });
it('should put showNotification and put toggleOfflineConfig if pingSucceeded && isOffline', () => { it('should toggle offline and show notification if navigator disagrees with isOffline and ping succeeds', () => {
expect(data.gen.next(raceSuccess).value).toEqual( expect(data.gen.next(raceSuccess).value).toEqual(
put(showNotification('success', 'Your connection to the network has been restored!', 3000)) put(showNotification('success', 'Your connection to the network has been restored!', 3000))
); );
expect(data.gen.next().value).toEqual(put(toggleOfflineConfig())); expect(data.gen.next().value).toEqual(put(toggleOfflineConfig()));
}); });
it('should put showNotification and put toggleOfflineConfig if !pingSucceeded && !isOffline', () => { it('should toggle offline and show notification if navigator agrees with isOffline and ping fails', () => {
nav.onLine = !isOffline; nav.onLine = isOffline;
expect(data.isOfflineClone.next(!isOffline));
data.isOfflineClone.next(!isOffline);
data.isOfflineClone.next(!isForcedOffline);
data.clone3 = data.isOfflineClone.clone();
expect(data.isOfflineClone.next(raceFailure).value).toMatchSnapshot(); expect(data.isOfflineClone.next(raceFailure).value).toMatchSnapshot();
expect(data.isOfflineClone.next().value).toEqual(put(toggleOfflineConfig())); expect(data.isOfflineClone.next().value).toEqual(put(toggleOfflineConfig()));
}); nav.onLine = !isOffline;
it('should call delay when neither case is true', () => {
expect(data.clone3.next(raceSuccess).value).toEqual(call(delay, 5000));
}); });
}); });
@ -152,30 +134,6 @@ describe('handlePollOfflineStatus*', () => {
}); });
}); });
describe('handleTogglePollOfflineStatus*', () => {
const data = {} as any;
data.gen = cloneableGenerator(handleTogglePollOfflineStatus)();
const isForcedOffline = true;
it('should select getForceOffline', () => {
expect(data.gen.next().value).toEqual(select(getForceOffline));
});
it('should fork handlePollOfflineStatus when isForcedOffline', () => {
data.clone = data.gen.clone();
expect(data.gen.next(isForcedOffline).value).toEqual(fork(handlePollOfflineStatus));
});
it('should call handlePollOfflineStatus when !isForcedOffline', () => {
expect(data.clone.next(!isForcedOffline).value).toEqual(call(handlePollOfflineStatus));
});
it('should be done', () => {
expect(data.gen.next().done).toEqual(true);
expect(data.clone.next().done).toEqual(true);
});
});
describe('handleNodeChangeIntent*', () => { describe('handleNodeChangeIntent*', () => {
let originalRandom; let originalRandom;

View File

@ -1,6 +1,6 @@
import { buffers, delay } from 'redux-saga'; import { buffers, delay } from 'redux-saga';
import { apply, put, select, take, actionChannel, call } from 'redux-saga/effects'; import { apply, put, select, take, actionChannel, call } from 'redux-saga/effects';
import { getNodeLib } from 'selectors/config'; import { getNodeLib, getOffline } from 'selectors/config';
import { getWalletInst } from 'selectors/wallet'; import { getWalletInst } from 'selectors/wallet';
import { getTransaction } from 'selectors/transaction'; import { getTransaction } from 'selectors/transaction';
import { import {
@ -16,6 +16,7 @@ import { cloneableGenerator } from 'redux-saga/utils';
import { Wei } from 'libs/units'; import { Wei } from 'libs/units';
describe('shouldEstimateGas*', () => { describe('shouldEstimateGas*', () => {
const offline = false;
const transaction: any = 'transaction'; const transaction: any = 'transaction';
const tx = { transaction }; const tx = { transaction };
const rest: any = { const rest: any = {
@ -39,8 +40,12 @@ describe('shouldEstimateGas*', () => {
const gen = shouldEstimateGas(); const gen = shouldEstimateGas();
it('should select getOffline', () => {
expect(gen.next().value).toEqual(select(getOffline));
});
it('should take expected types', () => { it('should take expected types', () => {
expect(gen.next().value).toEqual( expect(gen.next(offline).value).toEqual(
take([ take([
TypeKeys.TO_FIELD_SET, TypeKeys.TO_FIELD_SET,
TypeKeys.DATA_FIELD_SET, TypeKeys.DATA_FIELD_SET,
@ -65,6 +70,7 @@ describe('shouldEstimateGas*', () => {
}); });
describe('estimateGas*', () => { describe('estimateGas*', () => {
const offline = false;
const requestChan = 'requestChan'; const requestChan = 'requestChan';
const payload: any = { const payload: any = {
mock1: 'mock1', mock1: 'mock1',
@ -102,8 +108,12 @@ describe('estimateGas*', () => {
expect(expected).toEqual(result); expect(expected).toEqual(result);
}); });
it('should select getOffline', () => {
expect(gens.gen.next(requestChan).value).toEqual(select(getOffline));
});
it('should take requestChan', () => { it('should take requestChan', () => {
expect(gens.gen.next(requestChan).value).toEqual(take(requestChan)); expect(gens.gen.next(offline).value).toEqual(take(requestChan));
}); });
it('should call delay', () => { it('should call delay', () => {

View File

@ -40,18 +40,23 @@ describe('handleNonceRequest*', () => {
expect(gens.gen.next(nodeLib).value).toEqual(select(getWalletInst)); expect(gens.gen.next(nodeLib).value).toEqual(select(getWalletInst));
}); });
it('should handle being called without wallet inst correctly', () => {
gens.noWallet = gens.gen.clone();
gens.noWallet.next();
expect(gens.noWallet.next(offline).value).toEqual(
put(showNotification('warning', 'Your addresses nonce could not be fetched'))
);
expect(gens.noWallet.next().value).toEqual(put(getNonceFailed()));
expect(gens.noWallet.next().done).toEqual(true);
});
it('should select getOffline', () => { it('should select getOffline', () => {
gens.clone = gens.gen.clone();
expect(gens.gen.next(walletInst).value).toEqual(select(getOffline)); expect(gens.gen.next(walletInst).value).toEqual(select(getOffline));
}); });
it('should handle errors correctly', () => { it('should exit if being called while offline', () => {
gens.clone.next(); gens.offline = gens.gen.clone();
expect(gens.clone.next().value).toEqual( expect(gens.offline.next(true).done).toEqual(true);
put(showNotification('warning', 'Your addresses nonce could not be fetched'))
);
expect(gens.clone.next().value).toEqual(put(getNonceFailed()));
expect(gens.clone.next().done).toEqual(true);
}); });
it('should apply walletInst.getAddressString', () => { it('should apply walletInst.getAddressString', () => {

View File

@ -15,7 +15,7 @@ import { changeNodeIntent, web3UnsetNode } from 'actions/config';
import { INode } from 'libs/nodes/INode'; import { INode } from 'libs/nodes/INode';
import { initWeb3Node, Token, N_FACTOR } from 'config/data'; import { initWeb3Node, Token, N_FACTOR } from 'config/data';
import { apply, call, fork, put, select, take } from 'redux-saga/effects'; import { apply, call, fork, put, select, take } from 'redux-saga/effects';
import { getNodeLib } from 'selectors/config'; import { getNodeLib, getOffline } from 'selectors/config';
import { getWalletInst, getWalletConfigTokens } from 'selectors/wallet'; import { getWalletInst, getWalletConfigTokens } from 'selectors/wallet';
import { import {
updateAccountBalance, updateAccountBalance,
@ -39,7 +39,7 @@ import { IFullWallet, fromV3 } from 'ethereumjs-wallet';
// init module // init module
configuredStore.getState(); configuredStore.getState();
const offline = false;
const pkey = '31e97f395cabc6faa37d8a9d6bb185187c35704e7b976c7a110e2f0eab37c344'; const pkey = '31e97f395cabc6faa37d8a9d6bb185187c35704e7b976c7a110e2f0eab37c344';
const wallet = PrivKeyWallet(Buffer.from(pkey, 'hex')); const wallet = PrivKeyWallet(Buffer.from(pkey, 'hex'));
const address = '0xe2EdC95134bbD88443bc6D55b809F7d0C2f0C854'; const address = '0xe2EdC95134bbD88443bc6D55b809F7d0C2f0C854';
@ -83,90 +83,108 @@ const utcKeystore = {
// necessary so we can later inject a mocked web3 to the window // necessary so we can later inject a mocked web3 to the window
describe('updateAccountBalance*', () => { describe('updateAccountBalance*', () => {
const gen1 = updateAccountBalance(); const gen = updateAccountBalance();
const gen2 = updateAccountBalance();
it('should select offline', () => {
expect(gen.next().value).toEqual(select(getOffline));
});
it('should put setBalancePending', () => { it('should put setBalancePending', () => {
expect(gen1.next().value).toEqual(put(setBalancePending())); expect(gen.next(false).value).toEqual(put(setBalancePending()));
}); });
it('should select getWalletInst', () => { it('should select getWalletInst', () => {
expect(gen1.next().value).toEqual(select(getWalletInst)); expect(gen.next(false).value).toEqual(select(getWalletInst));
});
it('should return if wallet is falsey', () => {
gen2.next();
gen2.next();
gen2.next(null);
expect(gen2.next().done).toBe(true);
}); });
it('should select getNodeLib', () => { it('should select getNodeLib', () => {
expect(gen1.next(wallet).value).toEqual(select(getNodeLib)); expect(gen.next(wallet).value).toEqual(select(getNodeLib));
}); });
it('should apply wallet.getAddressString', () => { it('should apply wallet.getAddressString', () => {
expect(gen1.next(node).value).toEqual(apply(wallet, wallet.getAddressString)); expect(gen.next(node).value).toEqual(apply(wallet, wallet.getAddressString));
}); });
it('should apply node.getBalance', () => { it('should apply node.getBalance', () => {
expect(gen1.next(address).value).toEqual(apply(node, node.getBalance, [address])); expect(gen.next(address).value).toEqual(apply(node, node.getBalance, [address]));
}); });
it('should put setBalanceFulfilled', () => { it('should put setBalanceFulfilled', () => {
expect(gen1.next(balance).value).toEqual(put(setBalanceFullfilled(balance))); expect(gen.next(balance).value).toEqual(put(setBalanceFullfilled(balance)));
}); });
it('should be done', () => { it('should be done', () => {
expect(gen1.next().done).toEqual(true); expect(gen.next().done).toEqual(true);
});
it('should bail out if offline', () => {
const offlineGen = updateAccountBalance();
offlineGen.next();
expect(offlineGen.next(true).done).toBe(true);
});
it('should bail out if wallet inst is missing', () => {
const noWalletGen = updateAccountBalance();
noWalletGen.next();
noWalletGen.next(false);
noWalletGen.next(false);
expect(noWalletGen.next(null).done).toBe(true);
}); });
}); });
describe('updateTokenBalances*', () => { describe('updateTokenBalances*', () => {
const gen1 = cloneableGenerator(updateTokenBalances)(); const gen = cloneableGenerator(updateTokenBalances)();
const gen2 = updateTokenBalances();
const gen3 = updateTokenBalances();
it('should select getWalletInst', () => { it('should bail out if offline', () => {
expect(gen1.next().value).toEqual(select(getWalletInst)); const offlineGen = gen.clone();
expect(offlineGen.next());
expect(offlineGen.next(true).done).toBe(true);
}); });
it('should select getWalletConfigTokens', () => { it('should select getOffline', () => {
expect(gen1.next(wallet).value).toEqual(select(getWalletConfigTokens)); expect(gen.next().value).toEqual(select(getOffline));
});
it('should select getWalletInst', () => {
expect(gen.next(offline).value).toEqual(select(getWalletInst));
}); });
it('should return if wallet is falsey', () => { it('should return if wallet is falsey', () => {
gen2.next(); const noWalletGen = gen.clone();
gen2.next(null); noWalletGen.next(null);
expect(gen2.next().done).toEqual(true); expect(noWalletGen.next().done).toEqual(true);
}); });
it('should return if tokens are falsey', () => { it('should select getWalletConfigTokens', () => {
gen3.next(); expect(gen.next(wallet).value).toEqual(select(getWalletConfigTokens));
gen3.next(wallet); });
expect(gen3.next({}).done).toEqual(true);
it('should return if no tokens are requested', () => {
const noTokensGen = gen.clone();
noTokensGen.next({});
expect(noTokensGen.next().done).toEqual(true);
}); });
it('should put setTokenBalancesPending', () => { it('should put setTokenBalancesPending', () => {
expect(gen1.next(tokens).value).toEqual(put(setTokenBalancesPending())); expect(gen.next(tokens).value).toEqual(put(setTokenBalancesPending()));
}); });
it('should throw and put setTokenBalancesRejected', () => { it('should put setTokenBalancesRejected on throw', () => {
const gen4 = gen1.clone(); const throwGen = gen.clone();
if (gen4.throw) { if (throwGen.throw) {
expect(gen4.throw().value).toEqual(put(setTokenBalancesRejected())); expect(throwGen.throw().value).toEqual(put(setTokenBalancesRejected()));
} }
}); });
it('should call getTokenBalances', () => { it('should call getTokenBalances', () => {
expect(gen1.next().value).toEqual(call(getTokenBalances, wallet, tokens)); expect(gen.next().value).toEqual(call(getTokenBalances, wallet, tokens));
}); });
it('should put setTokenBalancesFufilled', () => { it('should put setTokenBalancesFufilled', () => {
expect(gen1.next({}).value).toEqual(put(setTokenBalancesFulfilled({}))); expect(gen.next({}).value).toEqual(put(setTokenBalancesFulfilled({})));
}); });
it('should be done', () => { it('should be done', () => {
expect(gen1.next().done).toEqual(true); expect(gen.next().done).toEqual(true);
}); });
}); });