feat(instant): Handle AssetBuyer errors

This commit is contained in:
Steve Klebanoff
2018-10-16 11:25:52 -07:00
parent f36352be47
commit db77cd10c5
13 changed files with 228 additions and 120 deletions

View File

@@ -0,0 +1,54 @@
import * as React from 'react';
import { keyframes, styled } from '../../style/theme';
const slideKeyframeGenerator = (fromY: string, toY: string) => keyframes`
from {
position: relative;
top: ${fromY};
}
to {
position: relative;
top: ${toY};
}
`;
export interface SlideAnimationProps {
keyframes: string;
animationType: string;
animationDirection?: string;
}
export const SlideAnimation =
styled.div <
SlideAnimationProps >
`
animation-name: ${props => props.keyframes};
animation-duration: 0.3s;
animation-timing-function: ${props => props.animationType};
animation-delay: 0s;
animation-iteration-count: 1;
animation-fill-mode: ${props => props.animationDirection || 'none'};
position: relative;
`;
export interface SlideAnimationComponentProps {
downY: string;
}
export const SlideUpAnimation: React.StatelessComponent<SlideAnimationComponentProps> = props => (
<SlideAnimation animationType="ease-in" keyframes={slideKeyframeGenerator(props.downY, '0px')}>
{props.children}
</SlideAnimation>
);
export const SlideDownAnimation: React.StatelessComponent<SlideAnimationComponentProps> = props => (
<SlideAnimation
animationDirection="forwards"
animationType="cubic-bezier(0.25, 0.1, 0.25, 1)"
keyframes={slideKeyframeGenerator('0px', props.downY)}
>
{props.children}
</SlideAnimation>
);

View File

@@ -1,95 +0,0 @@
import * as React from 'react';
import { keyframes, styled } from '../../style/theme';
const slideKeyframeGenerator = (fromY: string, toY: string) => keyframes`
from {
position: relative;
top: ${fromY};
}
to {
position: relative;
top: ${toY};
}
`;
export interface SlideAnimationProps {
keyframes: string;
animationType: string;
animationDirection?: string;
}
export const SlideAnimation =
styled.div <
SlideAnimationProps >
`
animation-name: ${props => props.keyframes};
animation-duration: 0.3s;
animation-timing-function: ${props => props.animationType};
animation-delay: 0s;
animation-iteration-count: 1;
animation-fill-mode: ${props => props.animationDirection || 'none'};
position: relative;
`;
export interface SlideAnimationComponentProps {
downY: string;
}
export const SlideUpAnimationComponent: React.StatelessComponent<SlideAnimationComponentProps> = props => (
<SlideAnimation animationType="ease-in" keyframes={slideKeyframeGenerator(props.downY, '0px')}>
{props.children}
</SlideAnimation>
);
export const SlideDownAnimationComponent: React.StatelessComponent<SlideAnimationComponentProps> = props => (
<SlideAnimation
animationDirection="forwards"
animationType="cubic-bezier(0.25, 0.1, 0.25, 1)"
keyframes={slideKeyframeGenerator('0px', props.downY)}
>
{props.children}
</SlideAnimation>
);
export interface SlideUpAndDownAnimationProps extends SlideAnimationComponentProps {
delayMs: number;
}
enum SlideState {
Up = 'up',
Down = 'down',
}
interface SlideUpAndDownState {
slideState: SlideState;
}
export class SlideUpAndDownAnimation extends React.Component<SlideUpAndDownAnimationProps, SlideUpAndDownState> {
public state = {
slideState: SlideState.Up,
};
private _timeoutId?: number;
public render(): React.ReactNode {
return this._renderSlide();
}
public componentDidMount(): void {
this._timeoutId = window.setTimeout(() => {
this.setState({
slideState: SlideState.Down,
});
}, this.props.delayMs);
return;
}
public componentWillUnmount(): void {
if (this._timeoutId) {
window.clearTimeout(this._timeoutId);
}
}
private _renderSlide(): React.ReactNode {
const SlideComponent = this.state.slideState === 'up' ? SlideUpAnimationComponent : SlideDownAnimationComponent;
return <SlideComponent downY={this.props.downY}>{this.props.children}</SlideComponent>;
}
}

View File

@@ -3,7 +3,8 @@ import { BigNumber } from '@0xproject/utils';
import * as _ from 'lodash';
import * as React from 'react';
import { assetMetaData } from '../data/asset_meta_data';
import { bestNameForAsset } from '../util/asset_data';
import { ColorOption } from '../style/theme';
import { util } from '../util/util';
@@ -26,26 +27,12 @@ export class AssetAmountInput extends React.Component<AssetAmountInputProps> {
<AmountInput {...rest} onChange={this._handleChange} />
<Container display="inline-block" marginLeft="10px">
<Text fontSize={rest.fontSize} fontColor={ColorOption.white} textTransform="uppercase">
{this._getAssetSymbolLabel()}
{bestNameForAsset(this.props.assetData, '???')}
</Text>
</Container>
</Container>
);
}
private readonly _getAssetSymbolLabel = (): string => {
const unknownLabel = '???';
if (_.isUndefined(this.props.assetData)) {
return unknownLabel;
}
const metaData = assetMetaData[this.props.assetData];
if (_.isUndefined(metaData)) {
return unknownLabel;
}
if (metaData.assetProxyId === AssetProxyId.ERC20) {
return metaData.symbol;
}
return unknownLabel;
};
private readonly _handleChange = (value?: BigNumber): void => {
this.props.onChange(value, this.props.assetData);
};

View File

@@ -2,7 +2,7 @@ import * as React from 'react';
import { ColorOption } from '../style/theme';
import { SlideUpAndDownAnimation } from './animations/slide_up_and_down_animation';
import { SlideDownAnimation, SlideUpAnimation } from './animations/slide_animations';
import { Container, Text } from './ui';
@@ -29,8 +29,16 @@ export const Error: React.StatelessComponent<ErrorProps> = props => (
</Container>
);
export const SlidingError: React.StatelessComponent<ErrorProps> = props => (
<SlideUpAndDownAnimation downY="120px" delayMs={5000}>
<Error icon={props.icon} message={props.message} />
</SlideUpAndDownAnimation>
);
export type SlidingDirection = 'up' | 'down';
export interface SlidingErrorProps extends ErrorProps {
direction: SlidingDirection;
}
export const SlidingError: React.StatelessComponent<SlidingErrorProps> = props => {
const AnimationComponent = props.direction === 'up' ? SlideUpAnimation : SlideDownAnimation;
return (
<AnimationComponent downY="120px">
<Error icon={props.icon} message={props.message} />
</AnimationComponent>
);
};

View File

@@ -1,6 +1,7 @@
import * as React from 'react';
import { LatestBuyQuoteOrderDetails } from '../containers/latest_buy_quote_order_details';
import { LatestError } from '../containers/latest_error';
import { SelectedAssetBuyButton } from '../containers/selected_asset_buy_button';
import { SelectedAssetInstantHeading } from '../containers/selected_asset_instant_heading';
@@ -16,6 +17,9 @@ export interface ZeroExInstantContainerProps {}
export const ZeroExInstantContainer: React.StatelessComponent<ZeroExInstantContainerProps> = props => (
<Container width="350px">
<Container zIndex={1} position="relative">
<LatestError />
</Container>
<Container
zIndex={2}
position="relative"

View File

@@ -0,0 +1,36 @@
import * as React from 'react';
import { connect } from 'react-redux';
import { SlidingError } from '../components/sliding_error';
import { State } from '../redux/reducer';
import { errorDescription } from '../util/error_description';
export interface LatestErrorComponentProps {
assetData?: string;
latestError?: any;
latestErrorDismissed?: boolean;
}
export const LatestErrorComponent: React.StatelessComponent<LatestErrorComponentProps> = props => {
if (!props.latestError) {
return <div />;
}
const slidingDirection = props.latestErrorDismissed ? 'down' : 'up';
const { icon, message } = errorDescription(props.latestError, props.assetData);
return <SlidingError direction={slidingDirection} icon={icon} message={message} />;
};
interface ConnectedState {
assetData?: string;
latestError?: any;
latestErrorDismissed?: boolean;
}
export interface LatestErrorProps {}
const mapStateToProps = (state: State, _ownProps: LatestErrorProps): ConnectedState => ({
assetData: state.selectedAssetData,
latestError: state.latestError,
latestErrorDismissed: state.latestErrorDismissed,
});
export const LatestError = connect(mapStateToProps)(LatestErrorComponent);

View File

@@ -1,3 +1,4 @@
import { BuyQuote } from '@0xproject/asset-buyer';
import { BigNumber } from '@0xproject/utils';
import { Web3Wrapper } from '@0xproject/web3-wrapper';
import * as _ from 'lodash';
@@ -11,6 +12,7 @@ import { State } from '../redux/reducer';
import { ColorOption } from '../style/theme';
import { AsyncProcessState } from '../types';
import { assetBuyer } from '../util/asset_buyer';
import { errorFlasher } from '../util/error_flasher';
import { AssetAmountInput } from '../components/asset_amount_input';
@@ -43,7 +45,16 @@ const updateBuyQuoteAsync = async (
}
// get a new buy quote.
const baseUnitValue = Web3Wrapper.toBaseUnitAmount(assetAmount, zrxDecimals);
const newBuyQuote = await assetBuyer.getBuyQuoteAsync(assetData, baseUnitValue);
let newBuyQuote: BuyQuote | undefined;
try {
newBuyQuote = await assetBuyer.getBuyQuoteAsync(assetData, baseUnitValue);
errorFlasher.clearError(dispatch);
} catch (error) {
errorFlasher.flashNewError(dispatch, error);
return;
}
// invalidate the last buy quote.
dispatch(actions.updateLatestBuyQuote(newBuyQuote));
};

View File

@@ -25,6 +25,9 @@ export enum ActionTypes {
UPDATE_SELECTED_ASSET_AMOUNT = 'UPDATE_SELECTED_ASSET_AMOUNT',
UPDATE_SELECTED_ASSET_BUY_STATE = 'UPDATE_SELECTED_ASSET_BUY_STATE',
UPDATE_LATEST_BUY_QUOTE = 'UPDATE_LATEST_BUY_QUOTE',
SET_ERROR = 'SET_ERROR',
HIDE_ERROR = 'HIDE_ERROR',
CLEAR_ERROR = 'CLEAR_ERROR',
}
export const actions = {
@@ -33,4 +36,7 @@ export const actions = {
updateSelectedAssetBuyState: (buyState: AsyncProcessState) =>
createAction(ActionTypes.UPDATE_SELECTED_ASSET_BUY_STATE, buyState),
updateLatestBuyQuote: (buyQuote?: BuyQuote) => createAction(ActionTypes.UPDATE_LATEST_BUY_QUOTE, buyQuote),
setError: (error?: any) => createAction(ActionTypes.SET_ERROR, error),
hideError: () => createAction(ActionTypes.HIDE_ERROR),
clearError: () => createAction(ActionTypes.CLEAR_ERROR),
};

View File

@@ -7,13 +7,22 @@ import { AsyncProcessState } from '../types';
import { Action, ActionTypes } from './actions';
export interface State {
interface BaseState {
selectedAssetData?: string;
selectedAssetAmount?: BigNumber;
selectedAssetBuyState: AsyncProcessState;
ethUsdPrice?: BigNumber;
latestBuyQuote?: BuyQuote;
}
interface StateWithError extends BaseState {
latestError: any;
latestErrorDismissed: boolean;
}
interface StateWithoutError extends BaseState {
latestError: undefined;
latestErrorDismissed: undefined;
}
export type State = StateWithError | StateWithoutError;
export const INITIAL_STATE: State = {
// TODO: Remove hardcoded zrxAssetData
@@ -22,6 +31,8 @@ export const INITIAL_STATE: State = {
selectedAssetBuyState: AsyncProcessState.NONE,
ethUsdPrice: undefined,
latestBuyQuote: undefined,
latestError: undefined,
latestErrorDismissed: undefined,
};
export const reducer = (state: State = INITIAL_STATE, action: Action): State => {
@@ -46,6 +57,23 @@ export const reducer = (state: State = INITIAL_STATE, action: Action): State =>
...state,
selectedAssetBuyState: action.data,
};
case ActionTypes.SET_ERROR:
return {
...state,
latestError: action.data,
latestErrorDismissed: false,
};
case ActionTypes.HIDE_ERROR:
return {
...state,
latestErrorDismissed: true,
};
case ActionTypes.CLEAR_ERROR:
return {
...state,
latestError: undefined,
latestErrorDismissed: undefined,
};
default:
return state;
}

View File

@@ -3,4 +3,5 @@ import { createStore, Store as ReduxStore } from 'redux';
import { reducer, State } from './reducer';
export const store: ReduxStore<State> = createStore(reducer);
const reduxDevTools = (window as any).__REDUX_DEVTOOLS_EXTENSION__;
export const store: ReduxStore<State> = createStore(reducer, reduxDevTools && reduxDevTools());

View File

@@ -0,0 +1,18 @@
import { AssetProxyId } from '@0xproject/types';
import { assetMetaData } from '../data/asset_meta_data';
// TODO: tests for this
export const bestNameForAsset = (assetData: string | undefined, defaultString: string) => {
if (assetData === undefined) {
return defaultString;
}
const metaData = assetMetaData[assetData];
if (metaData === undefined) {
return defaultString;
}
if (metaData.assetProxyId === AssetProxyId.ERC20) {
return metaData.symbol.toUpperCase();
}
return defaultString;
};

View File

@@ -0,0 +1,23 @@
import { AssetBuyerError } from '@0xproject/asset-buyer';
import { bestNameForAsset } from '../util/asset_data';
const humanReadableMessageForError = (error: Error, assetData?: string): string | undefined => {
if (error.message === AssetBuyerError.InsufficientAssetLiquidity) {
const assetName = bestNameForAsset(assetData, 'of this asset');
return `Not enough ${assetName} available`;
}
return undefined;
};
export const errorDescription = (error?: any, assetData?: string): { icon: string; message: string } => {
let bestMessage: string | undefined;
if (error instanceof Error) {
bestMessage = humanReadableMessageForError(error, assetData);
}
return {
icon: '😢',
message: bestMessage || 'Something went wrong...',
};
};

View File

@@ -0,0 +1,27 @@
import { Dispatch } from 'redux';
import { Action, actions } from '../redux/actions';
class ErrorFlasher {
private _timeoutId?: number;
public flashNewError(dispatch: Dispatch<Action>, error: any, delayMs: number = 7000): void {
this._clearTimeout();
// dispatch new message
dispatch(actions.setError(error));
this._timeoutId = window.setTimeout(() => {
dispatch(actions.hideError());
}, delayMs);
}
public clearError(dispatch: Dispatch<Action>): void {
this._clearTimeout();
dispatch(actions.hideError());
}
private _clearTimeout(): void {
if (this._timeoutId) {
window.clearTimeout(this._timeoutId);
}
}
}
export const errorFlasher = new ErrorFlasher();