Redux promise middleware (v2) (#233)

* add redux-promise-middleware to package.json and update package-lock.json

* intergrate redux-promise-middleware and simplify rates by replacing saga with promise

* fix unrelated breaking test

* -improve user messaging when network request fails. \n Clean up rates actions and reducers

* Address tslint errors
This commit is contained in:
Daniel Ternyak 2017-09-25 20:41:11 -07:00 committed by GitHub
parent 3660260efb
commit af84a589c5
19 changed files with 175 additions and 134 deletions

View File

@ -1,19 +1,11 @@
import * as interfaces from './actionTypes';
import { TypeKeys } from './constants';
import { fetchRates, CCResponse } from './actionPayloads';
export type TFiatRequestedRates = typeof fiatRequestedRates;
export function fiatRequestedRates(): interfaces.FiatRequestedRatesAction {
export type TFetchCCRates = typeof fetchCCRates;
export function fetchCCRates(): interfaces.FetchCCRates {
return {
type: TypeKeys.RATES_FIAT_REQUESTED
};
}
export type TFiatSucceededRates = typeof fiatSucceededRates;
export function fiatSucceededRates(payload: {
[key: string]: number;
}): interfaces.FiatSucceededRatesAction {
return {
type: TypeKeys.RATES_FIAT_SUCCEEDED,
payload
type: TypeKeys.RATES_FETCH_CC,
payload: fetchRates()
};
}

View File

@ -0,0 +1,22 @@
import { handleJSONResponse } from 'api/utils';
export const symbols = ['USD', 'EUR', 'GBP', 'BTC', 'CHF', 'REP'];
const symbolsURL = symbols.join(',');
// TODO - internationalize
const ERROR_MESSAGE = 'Could not fetch rate data.';
const CCApi = 'https://min-api.cryptocompare.com';
const CCRates = CCSymbols => `${CCApi}/data/price?fsym=ETH&tsyms=${CCSymbols}`;
export interface CCResponse {
BTC: number;
EUR: number;
GBP: number;
CHF: number;
REP: number;
}
export const fetchRates = (): Promise<CCResponse> =>
fetch(CCRates(symbolsURL)).then(response =>
handleJSONResponse(response, ERROR_MESSAGE)
);

View File

@ -1,13 +1,23 @@
import { TypeKeys } from './constants';
export interface FiatRequestedRatesAction {
type: TypeKeys.RATES_FIAT_REQUESTED;
import { CCResponse } from './actionPayloads';
export interface FetchCCRates {
type: TypeKeys.RATES_FETCH_CC;
payload: Promise<CCResponse>;
}
/*** Set rates ***/
export interface FiatSucceededRatesAction {
type: TypeKeys.RATES_FIAT_SUCCEEDED;
payload: { [key: string]: number };
export interface FetchCCRatesSucceeded {
type: TypeKeys.RATES_FETCH_CC_SUCCEEDED;
payload: CCResponse;
}
export interface FetchCCRatesFailed {
type: TypeKeys.RATES_FETCH_CC_FAILED;
}
/*** Union Type ***/
export type RatesAction = FiatSucceededRatesAction | FiatRequestedRatesAction;
export type RatesAction =
| FetchCCRatesSucceeded
| FetchCCRates
| FetchCCRatesFailed;

View File

@ -1,4 +1,5 @@
export enum TypeKeys {
RATES_FIAT_REQUESTED = 'RATES_FIAT_REQUESTED',
RATES_FIAT_SUCCEEDED = 'RATES_FIAT_SUCCEEDED'
RATES_FETCH_CC = 'RATES_FETCH_CC',
RATES_FETCH_CC_FAILED = 'RATES_FETCH_CC_FAILED',
RATES_FETCH_CC_SUCCEEDED = 'RATES_FETCH_CC_SUCCEEDED'
}

View File

@ -1,2 +1,3 @@
export * from './actionCreators';
export * from './actionTypes';
export * from './actionPayloads';

View File

@ -2,10 +2,7 @@ export function checkHttpStatus(response) {
if (response.status >= 200 && response.status < 300) {
return response;
} else {
const error = new Error(response.statusText);
// TODO: why assign response?
// error.response = response;
throw error;
return new Error(response.statusText);
}
}
@ -15,8 +12,7 @@ export function parseJSON(response) {
export async function handleJSONResponse(response, errorMessage) {
if (response.ok) {
const json = await response.json();
return json;
return await response.json();
}
if (errorMessage) {
throw new Error(errorMessage);

View File

@ -1,4 +1,4 @@
import { FiatRequestedRatesAction } from 'actions/rates';
import { TFetchCCRates } from 'actions/rates';
import { Identicon } from 'components/ui';
import { NetworkConfig } from 'config/data';
import { Ether } from 'libs/units';
@ -12,7 +12,7 @@ interface Props {
balance: Ether;
wallet: IWallet;
network: NetworkConfig;
fiatRequestedRates(): FiatRequestedRatesAction;
fetchCCRates: TFetchCCRates;
}
interface State {
@ -26,9 +26,9 @@ export default class AccountInfo extends React.Component<Props, State> {
};
public componentDidMount() {
this.props.fiatRequestedRates();
this.props.wallet.getAddress().then(addr => {
this.setState({ address: addr });
this.props.fetchCCRates();
this.props.wallet.getAddress().then(address => {
this.setState({ address });
});
}
@ -57,9 +57,7 @@ export default class AccountInfo extends React.Component<Props, State> {
<div className="AccountInfo-address-icon">
<Identicon address={address} size="100%" />
</div>
<div className="AccountInfo-address-addr">
{address}
</div>
<div className="AccountInfo-address-addr">{address}</div>
</div>
</div>
@ -82,26 +80,29 @@ export default class AccountInfo extends React.Component<Props, State> {
</ul>
</div>
{(!!blockExplorer || !!tokenExplorer) &&
<div className="AccountInfo-section">
<h5 className="AccountInfo-section-header">
{translate('sidebar_TransHistory')}
</h5>
<ul className="AccountInfo-list">
{!!blockExplorer &&
<li className="AccountInfo-list-item">
<a href={blockExplorer.address(address)} target="_blank">
{`${network.name} (${blockExplorer.name})`}
</a>
</li>}
{!!tokenExplorer &&
<li className="AccountInfo-list-item">
<a href={tokenExplorer.address(address)} target="_blank">
{`Tokens (${tokenExplorer.name})`}
</a>
</li>}
</ul>
</div>}
{(!!blockExplorer || !!tokenExplorer) && (
<div className="AccountInfo-section">
<h5 className="AccountInfo-section-header">
{translate('sidebar_TransHistory')}
</h5>
<ul className="AccountInfo-list">
{!!blockExplorer && (
<li className="AccountInfo-list-item">
<a href={blockExplorer.address(address)} target="_blank">
{`${network.name} (${blockExplorer.name})`}
</a>
</li>
)}
{!!tokenExplorer && (
<li className="AccountInfo-list-item">
<a href={tokenExplorer.address(address)} target="_blank">
{`Tokens (${tokenExplorer.name})`}
</a>
</li>
)}
</ul>
</div>
)}
</div>
);
}

View File

@ -3,27 +3,26 @@ import React from 'react';
import translate from 'translations';
import { formatNumber } from 'utils/formatters';
import './EquivalentValues.scss';
const ratesKeys = ['BTC', 'REP', 'EUR', 'USD', 'GBP', 'CHF'];
import { State } from 'reducers/rates';
import { symbols } from 'actions/rates';
interface Props {
balance?: Ether;
rates?: { [key: string]: number };
rates?: State['rates'];
ratesError?: State['ratesError'];
}
export default class EquivalentValues extends React.Component<Props, {}> {
public render() {
const { balance, rates } = this.props;
const { balance, rates, ratesError } = this.props;
return (
<div className="EquivalentValues">
<h5 className="EquivalentValues-title">
{translate('sidebar_Equiv')}
</h5>
<h5 className="EquivalentValues-title">{translate('sidebar_Equiv')}</h5>
<ul className="EquivalentValues-values">
{rates
? ratesKeys.map(key => {
? symbols.map(key => {
if (!rates[key]) {
return null;
}
@ -33,14 +32,15 @@ export default class EquivalentValues extends React.Component<Props, {}> {
{key}:
</span>
<span className="EquivalentValues-values-currency-value">
{' '}{balance
{' '}
{balance
? formatNumber(balance.amount.times(rates[key]))
: '???'}
</span>
</li>
);
})
: <h5>No rates were loaded.</h5>}
: ratesError && <h5>{ratesError}</h5>}
</ul>
</div>
);

View File

@ -5,10 +5,7 @@ import {
TRemoveCustomToken
} from 'actions/customTokens';
import { showNotification, TShowNotification } from 'actions/notifications';
import {
fiatRequestedRates as dFiatRequestedRates,
TFiatRequestedRates
} from 'actions/rates';
import { fetchCCRates as dFetchCCRates, TFetchCCRates } from 'actions/rates';
import { NetworkConfig } from 'config/data';
import { Ether } from 'libs/units';
import { IWallet } from 'libs/wallet/IWallet';
@ -25,17 +22,19 @@ import AccountInfo from './AccountInfo';
import EquivalentValues from './EquivalentValues';
import Promos from './Promos';
import TokenBalances from './TokenBalances';
import { State } from 'reducers/rates';
interface Props {
wallet: IWallet;
balance: Ether;
network: NetworkConfig;
tokenBalances: TokenBalance[];
rates: { [key: string]: number };
rates: State['rates'];
ratesError: State['ratesError'];
showNotification: TShowNotification;
addCustomToken: TAddCustomToken;
removeCustomToken: TRemoveCustomToken;
fiatRequestedRates: TFiatRequestedRates;
fetchCCRates: TFetchCCRates;
}
interface Block {
@ -52,7 +51,8 @@ export class BalanceSidebar extends React.Component<Props, {}> {
network,
tokenBalances,
rates,
fiatRequestedRates
ratesError,
fetchCCRates
} = this.props;
if (!wallet) {
return null;
@ -66,7 +66,7 @@ export class BalanceSidebar extends React.Component<Props, {}> {
wallet={wallet}
balance={balance}
network={network}
fiatRequestedRates={fiatRequestedRates}
fetchCCRates={fetchCCRates}
/>
)
},
@ -87,20 +87,26 @@ export class BalanceSidebar extends React.Component<Props, {}> {
},
{
name: 'Equivalent Values',
content: <EquivalentValues balance={balance} rates={rates} />
content: (
<EquivalentValues
balance={balance}
rates={rates}
ratesError={ratesError}
/>
)
}
];
return (
<aside>
{blocks.map(block =>
{blocks.map(block => (
<section
className={`Block ${block.isFullWidth ? 'is-full-width' : ''}`}
key={block.name}
>
{block.content}
</section>
)}
))}
</aside>
);
}
@ -112,7 +118,8 @@ function mapStateToProps(state: AppState) {
balance: state.wallet.balance,
tokenBalances: getTokenBalances(state),
network: getNetworkConfig(state),
rates: state.rates
rates: state.rates.rates,
ratesError: state.rates.ratesError
};
}
@ -120,5 +127,5 @@ export default connect(mapStateToProps, {
addCustomToken,
removeCustomToken,
showNotification,
fiatRequestedRates: dFiatRequestedRates
fetchCCRates: dFetchCCRates
})(BalanceSidebar);

View File

@ -104,8 +104,8 @@ export default class PaperWallet extends React.Component<Props, State> {
if (!this.props.wallet) {
return;
}
this.props.wallet.getAddress().then(addr => {
this.setState({ address: addr });
this.props.wallet.getAddress().then(address => {
this.setState({ address });
});
}

View File

@ -27,8 +27,8 @@ export default class DownloadWallet extends Component<Props, State> {
};
public componentDidMount() {
this.props.wallet.getAddress().then(addr => {
this.setState({ address: addr });
this.props.wallet.getAddress().then(address => {
this.setState({ address });
});
}

View File

@ -1,17 +1,33 @@
import { FiatSucceededRatesAction, RatesAction } from 'actions/rates';
import {
FetchCCRatesSucceeded,
FetchCCRatesFailed,
RatesAction,
CCResponse
} from 'actions/rates';
import { TypeKeys } from 'actions/rates/constants';
import { Optional } from 'utils/types';
// SYMBOL -> PRICE TO BUY 1 ETH
export interface State {
[key: string]: number;
rates?: Optional<CCResponse>;
ratesError?: string | null;
}
export const INITIAL_STATE: State = {};
function fiatSucceededRates(
function fetchCCRatesSucceeded(
state: State,
action: FiatSucceededRatesAction
action: FetchCCRatesSucceeded
): State {
return action.payload;
return { ...state, rates: action.payload };
}
function fetchCCRatesFailed(state: State, action: FetchCCRatesFailed): State {
// TODO: Make library for error messages
return {
...state,
ratesError: 'Sorry. We were unable to fetch equivalent rates.'
};
}
export function rates(
@ -19,8 +35,10 @@ export function rates(
action: RatesAction
): State {
switch (action.type) {
case TypeKeys.RATES_FIAT_SUCCEEDED:
return fiatSucceededRates(state, action);
case TypeKeys.RATES_FETCH_CC_SUCCEEDED:
return fetchCCRatesSucceeded(state, action);
case TypeKeys.RATES_FETCH_CC_FAILED:
return fetchCCRatesFailed(state, action);
default:
return state;
}

View File

@ -2,7 +2,6 @@ import handleConfigChanges from './config';
import contracts from './contracts';
import deterministicWallets from './deterministicWallets';
import notifications from './notifications';
import rates from './rates';
import {
bityTimeRemaining,
pollBityOrderStatusSaga,
@ -19,7 +18,6 @@ export default {
getBityRatesSaga,
contracts,
notifications,
rates,
wallet,
deterministicWallets
};

View File

@ -1,27 +0,0 @@
import { fiatSucceededRates } from 'actions/rates';
import { handleJSONResponse } from 'api/utils';
import { SagaIterator } from 'redux-saga';
import { call, put, takeLatest } from 'redux-saga/effects';
const symbols = ['USD', 'EUR', 'GBP', 'BTC', 'CHF', 'REP'];
const symbolsURL = symbols.join(',');
// TODO - internationalize
const ERROR_MESSAGE = 'Could not fetch rate data.';
const fetchRates = () =>
fetch(
`https://min-api.cryptocompare.com/data/price?fsym=ETH&tsyms=${symbolsURL}`
).then(response => handleJSONResponse(response, ERROR_MESSAGE));
export function* handleRatesRequest(): SagaIterator {
try {
const rates = yield call(fetchRates);
yield put(fiatSucceededRates(rates));
} catch (error) {
yield put({ type: 'RATES_FIAT_FAILED', payload: error });
}
}
export default function* ratesSaga(): SagaIterator {
yield takeLatest('RATES_FIAT_REQUESTED', handleRatesRequest);
}

View File

@ -1,5 +0,0 @@
import { Effect } from 'redux-saga/effects';
export type Yield = Effect | {};
export type Return = void;
export type Next = any;

View File

@ -8,14 +8,11 @@ import { applyMiddleware, createStore } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import { createLogger } from 'redux-logger';
import createSagaMiddleware from 'redux-saga';
import {
loadState,
loadStatePropertyOrEmptyObject,
saveState
} from 'utils/localStorage';
import { loadStatePropertyOrEmptyObject, saveState } from 'utils/localStorage';
import RootReducer from './reducers';
import { State as CustomTokenState } from './reducers/customTokens';
import { State as SwapState } from './reducers/swap';
import promiseMiddleware from 'redux-promise-middleware';
import sagas from './sagas';
@ -27,17 +24,26 @@ const configureStore = () => {
collapsed: true
});
const sagaMiddleware = createSagaMiddleware();
const reduxPromiseMiddleWare = promiseMiddleware({
promiseTypeSuffixes: ['REQUESTED', 'SUCCEEDED', 'FAILED']
});
let middleware;
let store;
if (process.env.NODE_ENV !== 'production') {
(window as MyWindow).Perf = Perf;
middleware = composeWithDevTools(
applyMiddleware(sagaMiddleware, logger, routerMiddleware(history as any))
applyMiddleware(
sagaMiddleware,
logger,
reduxPromiseMiddleWare,
routerMiddleware(history as any)
)
);
} else {
middleware = applyMiddleware(
sagaMiddleware,
reduxPromiseMiddleWare,
routerMiddleware(history as any)
);
}

4
common/utils/types.ts Normal file
View File

@ -0,0 +1,4 @@
// Maps interface keys to optional
export type Optional<T> = { [P in keyof T]?: T[P] };
// Maps interface keys to nullable
export type Nullable<T> = { [P in keyof T]: T[P] | null };

14
package-lock.json generated
View File

@ -145,6 +145,15 @@
"redux": "3.7.2"
}
},
"@types/redux-promise-middleware": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/@types/redux-promise-middleware/-/redux-promise-middleware-0.0.8.tgz",
"integrity": "sha512-5GFSEoerhY5EAXgtc276k3TOq8HSY4vmqI0AinzpejJlgRHW5Aw6gl2wmy0cqxoPqkhrd8yWbs2uxRa5FOMdvQ==",
"dev": true,
"requires": {
"redux": "3.7.2"
}
},
"@types/redux-saga": {
"version": "0.10.5",
"resolved": "https://registry.npmjs.org/@types/redux-saga/-/redux-saga-0.10.5.tgz",
@ -10878,6 +10887,11 @@
"deep-diff": "0.3.8"
}
},
"redux-promise-middleware": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/redux-promise-middleware/-/redux-promise-middleware-4.4.1.tgz",
"integrity": "sha512-1B5eiSGbZIbTKutIwYBwvz8visA5Bdnqtlxm2UnVsl7pgyU7hc7wigBwj17cLOnvG6deO4yft9NXFjWpg7k7PQ=="
},
"redux-saga": {
"version": "0.15.4",
"resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-0.15.4.tgz",

View File

@ -33,6 +33,7 @@
"redux": "^3.6.0",
"redux-form": "^6.6.3",
"redux-logger": "^3.0.1",
"redux-promise-middleware": "^4.4.1",
"redux-saga": "^0.15.3",
"scryptsy": "^2.0.0",
"store2": "^2.5.5",
@ -56,6 +57,7 @@
"@types/react-router-redux": "^4.0.50",
"@types/redux-form": "^7.0.5",
"@types/redux-logger": "^3.0.3",
"@types/redux-promise-middleware": "0.0.8",
"@types/redux-saga": "^0.10.5",
"@types/uuid": "^3.4.2",
"@types/webpack-env": "^1.13.1",
@ -107,8 +109,7 @@
"db": "nodemon ./db",
"build": "webpack --config webpack_config/webpack.prod.js",
"prebuild": "check-node-version --package",
"build:demo":
"BUILD_GH_PAGES=true webpack --config webpack_config/webpack.prod.js",
"build:demo": "BUILD_GH_PAGES=true webpack --config webpack_config/webpack.prod.js",
"prebuild:demo": "check-node-version --package",
"test": "jest --config=jest_config/jest.config.json --coverage",
"pretest": "check-node-version --package",
@ -116,14 +117,16 @@
"predev": "check-node-version --package",
"dev:https": "HTTPS=true node webpack_config/server.js",
"predev:https": "check-node-version --package",
"derivation-checker":
"webpack --config=./webpack_config/webpack.derivation-checker.js && node ./dist/derivation-checker.js",
"derivation-checker": "webpack --config=./webpack_config/webpack.derivation-checker.js && node ./dist/derivation-checker.js",
"tslint": "tslint --project . --exclude common/vendor/*",
"postinstall": "webpack --config=./webpack_config/webpack.dll.js",
"start": "npm run dev",
"precommit": "lint-staged"
},
"lint-staged": {
"*.{ts,tsx}": ["prettier --write --single-quote", "git add"]
"*.{ts,tsx}": [
"prettier --write --single-quote",
"git add"
]
}
}