diff options
author | Kadinsky <kandinsky454@protonmail.ch> | 2018-10-18 18:12:24 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-10-18 18:12:24 +0800 |
commit | d0df0747217dca230aadbee72337d7111a1383e7 (patch) | |
tree | 4df7d1a0fd40f7e953af595ddac9f9b7c4aec0d3 /packages | |
parent | 376034ac7e2053ea81aa637983d1f6a7e61588d9 (diff) | |
parent | 6ea386a7af89c1b8a4df94b656ae1772c29c1401 (diff) | |
download | dexon-sol-tools-d0df0747217dca230aadbee72337d7111a1383e7.tar.gz dexon-sol-tools-d0df0747217dca230aadbee72337d7111a1383e7.tar.zst dexon-sol-tools-d0df0747217dca230aadbee72337d7111a1383e7.zip |
Merge pull request #1142 from 0xProject/feature/instant/asset-buyer-errors
[instant] Asset buyer errors
Diffstat (limited to 'packages')
20 files changed, 400 insertions, 155 deletions
diff --git a/packages/instant/package.json b/packages/instant/package.json index 0c4b470fa..5c4436c52 100644 --- a/packages/instant/package.json +++ b/packages/instant/package.json @@ -55,6 +55,7 @@ "react-dom": "^16.5.2", "react-redux": "^5.0.7", "redux": "^4.0.0", + "redux-devtools-extension": "^2.13.5", "styled-components": "^3.4.9", "ts-optchain": "^0.1.1" }, diff --git a/packages/instant/src/components/animations/slide_animations.tsx b/packages/instant/src/components/animations/slide_animations.tsx new file mode 100644 index 000000000..1f10a2ed6 --- /dev/null +++ b/packages/instant/src/components/animations/slide_animations.tsx @@ -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> +); diff --git a/packages/instant/src/components/animations/slide_up_and_down_animation.tsx b/packages/instant/src/components/animations/slide_up_and_down_animation.tsx deleted file mode 100644 index 9c18e0933..000000000 --- a/packages/instant/src/components/animations/slide_up_and_down_animation.tsx +++ /dev/null @@ -1,96 +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>; - } -} diff --git a/packages/instant/src/components/asset_amount_input.tsx b/packages/instant/src/components/asset_amount_input.tsx index 7c6b03ee9..db3dbe7f3 100644 --- a/packages/instant/src/components/asset_amount_input.tsx +++ b/packages/instant/src/components/asset_amount_input.tsx @@ -1,9 +1,9 @@ -import { AssetProxyId } from '@0xproject/types'; import { BigNumber } from '@0xproject/utils'; import * as _ from 'lodash'; import * as React from 'react'; -import { assetMetaData } from '../data/asset_meta_data'; +import { assetDataUtil } from '../util/asset_data'; + import { ColorOption } from '../style/theme'; import { util } from '../util/util'; @@ -26,26 +26,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()} + {assetDataUtil.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); }; diff --git a/packages/instant/src/components/instant_heading.tsx b/packages/instant/src/components/instant_heading.tsx index 492c1b2c0..f57b59b73 100644 --- a/packages/instant/src/components/instant_heading.tsx +++ b/packages/instant/src/components/instant_heading.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import { SelectedAssetAmountInput } from '../containers/selected_asset_amount_input'; import { ColorOption } from '../style/theme'; +import { AsyncProcessState } from '../types'; import { format } from '../util/format'; import { Container, Flex, Text } from './ui'; @@ -12,20 +13,45 @@ export interface InstantHeadingProps { selectedAssetAmount?: BigNumber; totalEthBaseAmount?: BigNumber; ethUsdPrice?: BigNumber; + quoteState: AsyncProcessState; } -const displaytotalEthBaseAmount = ({ selectedAssetAmount, totalEthBaseAmount }: InstantHeadingProps): string => { +const Placeholder = () => ( + <Text fontWeight="bold" fontColor={ColorOption.white}> + — + </Text> +); +const displaytotalEthBaseAmount = ({ + selectedAssetAmount, + totalEthBaseAmount, +}: InstantHeadingProps): React.ReactNode => { if (_.isUndefined(selectedAssetAmount)) { return '0 ETH'; } - return format.ethBaseAmount(totalEthBaseAmount, 4, '...loading'); + return format.ethBaseAmount(totalEthBaseAmount, 4, <Placeholder />); }; -const displayUsdAmount = ({ totalEthBaseAmount, selectedAssetAmount, ethUsdPrice }: InstantHeadingProps): string => { +const displayUsdAmount = ({ + totalEthBaseAmount, + selectedAssetAmount, + ethUsdPrice, +}: InstantHeadingProps): React.ReactNode => { if (_.isUndefined(selectedAssetAmount)) { return '$0.00'; } - return format.ethBaseAmountInUsd(totalEthBaseAmount, ethUsdPrice, 2, '...loading'); + return format.ethBaseAmountInUsd(totalEthBaseAmount, ethUsdPrice, 2, <Placeholder />); +}; + +const loadingOrAmount = (quoteState: AsyncProcessState, amount: React.ReactNode): React.ReactNode => { + if (quoteState === AsyncProcessState.PENDING) { + return ( + <Text fontWeight="bold" fontColor={ColorOption.white}> + …loading + </Text> + ); + } else { + return amount; + } }; export const InstantHeading: React.StatelessComponent<InstantHeadingProps> = props => ( @@ -47,11 +73,11 @@ export const InstantHeading: React.StatelessComponent<InstantHeadingProps> = pro <Flex direction="column" justify="space-between"> <Container marginBottom="5px"> <Text fontSize="16px" fontColor={ColorOption.white} fontWeight={500}> - {displaytotalEthBaseAmount(props)} + {loadingOrAmount(props.quoteState, displaytotalEthBaseAmount(props))} </Text> </Container> <Text fontSize="16px" fontColor={ColorOption.white} opacity={0.7}> - {displayUsdAmount(props)} + {loadingOrAmount(props.quoteState, displayUsdAmount(props))} </Text> </Flex> </Flex> diff --git a/packages/instant/src/components/sliding_error.tsx b/packages/instant/src/components/sliding_error.tsx index 0237fb7e9..3865a8797 100644 --- a/packages/instant/src/components/sliding_error.tsx +++ b/packages/instant/src/components/sliding_error.tsx @@ -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'; @@ -20,8 +20,8 @@ export const Error: React.StatelessComponent<ErrorProps> = props => ( borderRadius="6px" marginBottom="10px" > - <Container marginRight="5px" display="inline"> - {props.icon} + <Container marginRight="5px" display="inline" top="3px" position="relative"> + <Text fontSize="20px">{props.icon}</Text> </Container> <Text fontWeight="500" fontColor={ColorOption.darkOrange}> {props.message} @@ -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> + ); +}; diff --git a/packages/instant/src/components/zero_ex_instant_container.tsx b/packages/instant/src/components/zero_ex_instant_container.tsx index 51f9dc63e..cf918d890 100644 --- a/packages/instant/src/components/zero_ex_instant_container.tsx +++ b/packages/instant/src/components/zero_ex_instant_container.tsx @@ -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'; @@ -12,6 +13,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" diff --git a/packages/instant/src/containers/latest_error.tsx b/packages/instant/src/containers/latest_error.tsx new file mode 100644 index 000000000..08ea418e7 --- /dev/null +++ b/packages/instant/src/containers/latest_error.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; + +import { connect } from 'react-redux'; + +import { SlidingError } from '../components/sliding_error'; +import { LatestErrorDisplay, State } from '../redux/reducer'; +import { errorUtil } from '../util/error'; + +export interface LatestErrorComponentProps { + assetData?: string; + latestError?: any; + slidingDirection: 'down' | 'up'; +} + +export const LatestErrorComponent: React.StatelessComponent<LatestErrorComponentProps> = props => { + if (!props.latestError) { + return <div />; + } + const { icon, message } = errorUtil.errorDescription(props.latestError, props.assetData); + return <SlidingError direction={props.slidingDirection} icon={icon} message={message} />; +}; + +interface ConnectedState { + assetData?: string; + latestError?: any; + slidingDirection: 'down' | 'up'; +} +export interface LatestErrorProps {} +const mapStateToProps = (state: State, _ownProps: LatestErrorProps): ConnectedState => ({ + assetData: state.selectedAssetData, + latestError: state.latestError, + slidingDirection: state.latestErrorDisplay === LatestErrorDisplay.Present ? 'up' : 'down', +}); + +export const LatestError = connect(mapStateToProps)(LatestErrorComponent); diff --git a/packages/instant/src/containers/selected_asset_amount_input.ts b/packages/instant/src/containers/selected_asset_amount_input.ts index f2ca96ae4..0873f1ab6 100644 --- a/packages/instant/src/containers/selected_asset_amount_input.ts +++ b/packages/instant/src/containers/selected_asset_amount_input.ts @@ -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 { errorUtil } from '../util/error'; import { AssetAmountInput } from '../components/asset_amount_input'; @@ -35,15 +37,25 @@ const mapStateToProps = (state: State, _ownProps: SelectedAssetAmountInputProps) const updateBuyQuoteAsync = async ( dispatch: Dispatch<Action>, - assetData?: string, - assetAmount?: BigNumber, + assetData: string, + assetAmount: BigNumber, ): Promise<void> => { - if (_.isUndefined(assetAmount) || _.isUndefined(assetData)) { - return; - } // get a new buy quote. const baseUnitValue = Web3Wrapper.toBaseUnitAmount(assetAmount, zrxDecimals); - const newBuyQuote = await assetBuyer.getBuyQuoteAsync(assetData, baseUnitValue); + + // mark quote as pending + dispatch(actions.updateBuyQuoteStatePending()); + + let newBuyQuote: BuyQuote | undefined; + try { + newBuyQuote = await assetBuyer.getBuyQuoteAsync(assetData, baseUnitValue); + } catch (error) { + dispatch(actions.updateBuyQuoteStateFailure()); + errorUtil.errorFlasher.flashNewError(dispatch, error); + return; + } + // We have a successful new buy quote + errorUtil.errorFlasher.clearError(dispatch); // invalidate the last buy quote. dispatch(actions.updateLatestBuyQuote(newBuyQuote)); }; @@ -60,9 +72,14 @@ const mapDispatchToProps = ( // invalidate the last buy quote. dispatch(actions.updateLatestBuyQuote(undefined)); // reset our buy state - dispatch(actions.updateSelectedAssetBuyState(AsyncProcessState.NONE)); - // tslint:disable-next-line:no-floating-promises - debouncedUpdateBuyQuoteAsync(dispatch, assetData, value); + dispatch(actions.updatebuyOrderState(AsyncProcessState.NONE)); + + if (!_.isUndefined(value) && !_.isUndefined(assetData)) { + // even if it's debounced, give them the illusion it's loading + dispatch(actions.updateBuyQuoteStatePending()); + // tslint:disable-next-line:no-floating-promises + debouncedUpdateBuyQuoteAsync(dispatch, assetData, value); + } }, }); diff --git a/packages/instant/src/containers/selected_asset_buy_button.ts b/packages/instant/src/containers/selected_asset_buy_button.ts index 4cbaf5537..7d3919468 100644 --- a/packages/instant/src/containers/selected_asset_buy_button.ts +++ b/packages/instant/src/containers/selected_asset_buy_button.ts @@ -39,14 +39,14 @@ const textForState = (state: AsyncProcessState): string => { }; const mapStateToProps = (state: State, _ownProps: SelectedAssetBuyButtonProps): ConnectedState => ({ - text: textForState(state.selectedAssetBuyState), + text: textForState(state.buyOrderState), buyQuote: state.latestBuyQuote, }); const mapDispatchToProps = (dispatch: Dispatch<Action>, ownProps: SelectedAssetBuyButtonProps): ConnectedDispatch => ({ - onClick: buyQuote => dispatch(actions.updateSelectedAssetBuyState(AsyncProcessState.PENDING)), - onBuySuccess: buyQuote => dispatch(actions.updateSelectedAssetBuyState(AsyncProcessState.SUCCESS)), - onBuyFailure: buyQuote => dispatch(actions.updateSelectedAssetBuyState(AsyncProcessState.FAILURE)), + onClick: buyQuote => dispatch(actions.updatebuyOrderState(AsyncProcessState.PENDING)), + onBuySuccess: buyQuote => dispatch(actions.updatebuyOrderState(AsyncProcessState.SUCCESS)), + onBuyFailure: buyQuote => dispatch(actions.updatebuyOrderState(AsyncProcessState.FAILURE)), }); export const SelectedAssetBuyButton: React.ComponentClass<SelectedAssetBuyButtonProps> = connect( diff --git a/packages/instant/src/containers/selected_asset_instant_heading.ts b/packages/instant/src/containers/selected_asset_instant_heading.ts index c97cfe11a..be31527f1 100644 --- a/packages/instant/src/containers/selected_asset_instant_heading.ts +++ b/packages/instant/src/containers/selected_asset_instant_heading.ts @@ -5,6 +5,7 @@ import { connect } from 'react-redux'; import { oc } from 'ts-optchain'; import { State } from '../redux/reducer'; +import { AsyncProcessState } from '../types'; import { InstantHeading } from '../components/instant_heading'; @@ -14,12 +15,14 @@ interface ConnectedState { selectedAssetAmount?: BigNumber; totalEthBaseAmount?: BigNumber; ethUsdPrice?: BigNumber; + quoteState: AsyncProcessState; } const mapStateToProps = (state: State, _ownProps: InstantHeadingProps): ConnectedState => ({ selectedAssetAmount: state.selectedAssetAmount, totalEthBaseAmount: oc(state).latestBuyQuote.worstCaseQuoteInfo.totalEthAmount(), ethUsdPrice: state.ethUsdPrice, + quoteState: state.quoteState, }); export const SelectedAssetInstantHeading: React.ComponentClass<InstantHeadingProps> = connect(mapStateToProps)( diff --git a/packages/instant/src/redux/actions.ts b/packages/instant/src/redux/actions.ts index 7d07b4950..bec847742 100644 --- a/packages/instant/src/redux/actions.ts +++ b/packages/instant/src/redux/actions.ts @@ -25,12 +25,22 @@ 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', + UPDATE_BUY_QUOTE_STATE_PENDING = 'UPDATE_BUY_QUOTE_STATE_PENDING', + UPDATE_BUY_QUOTE_STATE_FAILURE = 'UPDATE_BUY_QUOTE_STATE_FAILURE', + SET_ERROR = 'SET_ERROR', + HIDE_ERROR = 'HIDE_ERROR', + CLEAR_ERROR = 'CLEAR_ERROR', } export const actions = { updateEthUsdPrice: (price?: BigNumber) => createAction(ActionTypes.UPDATE_ETH_USD_PRICE, price), updateSelectedAssetAmount: (amount?: BigNumber) => createAction(ActionTypes.UPDATE_SELECTED_ASSET_AMOUNT, amount), - updateSelectedAssetBuyState: (buyState: AsyncProcessState) => + updatebuyOrderState: (buyState: AsyncProcessState) => createAction(ActionTypes.UPDATE_SELECTED_ASSET_BUY_STATE, buyState), updateLatestBuyQuote: (buyQuote?: BuyQuote) => createAction(ActionTypes.UPDATE_LATEST_BUY_QUOTE, buyQuote), + updateBuyQuoteStatePending: () => createAction(ActionTypes.UPDATE_BUY_QUOTE_STATE_PENDING), + updateBuyQuoteStateFailure: () => createAction(ActionTypes.UPDATE_BUY_QUOTE_STATE_FAILURE), + setError: (error?: any) => createAction(ActionTypes.SET_ERROR, error), + hideError: () => createAction(ActionTypes.HIDE_ERROR), + clearError: () => createAction(ActionTypes.CLEAR_ERROR), }; diff --git a/packages/instant/src/redux/reducer.ts b/packages/instant/src/redux/reducer.ts index adecf2ab7..54290483b 100644 --- a/packages/instant/src/redux/reducer.ts +++ b/packages/instant/src/redux/reducer.ts @@ -7,21 +7,31 @@ import { AsyncProcessState } from '../types'; import { Action, ActionTypes } from './actions'; +export enum LatestErrorDisplay { + Present, + Hidden, +} export interface State { selectedAssetData?: string; selectedAssetAmount?: BigNumber; - selectedAssetBuyState: AsyncProcessState; + buyOrderState: AsyncProcessState; ethUsdPrice?: BigNumber; latestBuyQuote?: BuyQuote; + quoteState: AsyncProcessState; + latestError?: any; + latestErrorDisplay: LatestErrorDisplay; } export const INITIAL_STATE: State = { // TODO: Remove hardcoded zrxAssetData selectedAssetData: zrxAssetData, selectedAssetAmount: undefined, - selectedAssetBuyState: AsyncProcessState.NONE, + buyOrderState: AsyncProcessState.NONE, ethUsdPrice: undefined, latestBuyQuote: undefined, + latestError: undefined, + latestErrorDisplay: LatestErrorDisplay.Hidden, + quoteState: AsyncProcessState.NONE, }; export const reducer = (state: State = INITIAL_STATE, action: Action): State => { @@ -40,11 +50,41 @@ export const reducer = (state: State = INITIAL_STATE, action: Action): State => return { ...state, latestBuyQuote: action.data, + quoteState: AsyncProcessState.SUCCESS, + }; + case ActionTypes.UPDATE_BUY_QUOTE_STATE_PENDING: + return { + ...state, + latestBuyQuote: undefined, + quoteState: AsyncProcessState.PENDING, + }; + case ActionTypes.UPDATE_BUY_QUOTE_STATE_FAILURE: + return { + ...state, + latestBuyQuote: undefined, + quoteState: AsyncProcessState.FAILURE, }; case ActionTypes.UPDATE_SELECTED_ASSET_BUY_STATE: return { ...state, - selectedAssetBuyState: action.data, + buyOrderState: action.data, + }; + case ActionTypes.SET_ERROR: + return { + ...state, + latestError: action.data, + latestErrorDisplay: LatestErrorDisplay.Present, + }; + case ActionTypes.HIDE_ERROR: + return { + ...state, + latestErrorDisplay: LatestErrorDisplay.Hidden, + }; + case ActionTypes.CLEAR_ERROR: + return { + ...state, + latestError: undefined, + latestErrorDisplay: LatestErrorDisplay.Hidden, }; default: return state; diff --git a/packages/instant/src/redux/store.ts b/packages/instant/src/redux/store.ts index fcd19f9a8..b9ce9c0c1 100644 --- a/packages/instant/src/redux/store.ts +++ b/packages/instant/src/redux/store.ts @@ -1,6 +1,7 @@ import * as _ from 'lodash'; import { createStore, Store as ReduxStore } from 'redux'; +import { devToolsEnhancer } from 'redux-devtools-extension/developmentOnly'; import { reducer, State } from './reducer'; -export const store: ReduxStore<State> = createStore(reducer); +export const store: ReduxStore<State> = createStore(reducer, devToolsEnhancer({})); diff --git a/packages/instant/src/types.ts b/packages/instant/src/types.ts index bf3ee392f..f0ffb893b 100644 --- a/packages/instant/src/types.ts +++ b/packages/instant/src/types.ts @@ -2,10 +2,10 @@ import { AssetProxyId, ObjectMap } from '@0xproject/types'; // Reusable export enum AsyncProcessState { - NONE, - PENDING, - SUCCESS, - FAILURE, + NONE = 'None', + PENDING = 'Pending', + SUCCESS = 'Success', + FAILURE = 'Failure', } export type FunctionType = (...args: any[]) => any; diff --git a/packages/instant/src/util/asset_data.ts b/packages/instant/src/util/asset_data.ts new file mode 100644 index 000000000..f7c5b78cd --- /dev/null +++ b/packages/instant/src/util/asset_data.ts @@ -0,0 +1,21 @@ +import * as _ from 'lodash'; + +import { AssetProxyId } from '@0xproject/types'; + +import { assetMetaData } from '../data/asset_meta_data'; + +export const assetDataUtil = { + bestNameForAsset: (assetData: string | undefined, defaultString: string) => { + if (_.isUndefined(assetData)) { + return defaultString; + } + const metaData = assetMetaData[assetData]; + if (_.isUndefined(metaData)) { + return defaultString; + } + if (metaData.assetProxyId === AssetProxyId.ERC20) { + return metaData.symbol.toUpperCase(); + } + return defaultString; + }, +}; diff --git a/packages/instant/src/util/error.ts b/packages/instant/src/util/error.ts new file mode 100644 index 000000000..48cb131a9 --- /dev/null +++ b/packages/instant/src/util/error.ts @@ -0,0 +1,62 @@ +import { AssetBuyerError } from '@0xproject/asset-buyer'; +import { Dispatch } from 'redux'; + +import { Action, actions } from '../redux/actions'; +import { assetDataUtil } from '../util/asset_data'; + +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); + } + } +} + +const humanReadableMessageForError = (error: Error, assetData?: string): string | undefined => { + const hasInsufficientLiquidity = + error.message === AssetBuyerError.InsufficientAssetLiquidity || + error.message === AssetBuyerError.InsufficientZrxLiquidity; + if (hasInsufficientLiquidity) { + const assetName = assetDataUtil.bestNameForAsset(assetData, 'of this asset'); + return `Not enough ${assetName} available`; + } + + if ( + error.message === AssetBuyerError.StandardRelayerApiError || + error.message.startsWith(AssetBuyerError.AssetUnavailable) + ) { + const assetName = assetDataUtil.bestNameForAsset(assetData, 'This asset'); + return `${assetName} is currently unavailable`; + } + + return undefined; +}; + +export const errorUtil = { + errorFlasher: new ErrorFlasher(), + 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...', + }; + }, +}; diff --git a/packages/instant/src/util/format.ts b/packages/instant/src/util/format.ts index b62c968fb..09eb880b2 100644 --- a/packages/instant/src/util/format.ts +++ b/packages/instant/src/util/format.ts @@ -5,14 +5,22 @@ import * as _ from 'lodash'; import { ethDecimals } from '../constants'; export const format = { - ethBaseAmount: (ethBaseAmount?: BigNumber, decimalPlaces: number = 4, defaultText: string = '0 ETH'): string => { + ethBaseAmount: ( + ethBaseAmount?: BigNumber, + decimalPlaces: number = 4, + defaultText: React.ReactNode = '0 ETH', + ): React.ReactNode => { if (_.isUndefined(ethBaseAmount)) { return defaultText; } const ethUnitAmount = Web3Wrapper.toUnitAmount(ethBaseAmount, ethDecimals); return format.ethUnitAmount(ethUnitAmount, decimalPlaces); }, - ethUnitAmount: (ethUnitAmount?: BigNumber, decimalPlaces: number = 4, defaultText: string = '0 ETH'): string => { + ethUnitAmount: ( + ethUnitAmount?: BigNumber, + decimalPlaces: number = 4, + defaultText: React.ReactNode = '0 ETH', + ): React.ReactNode => { if (_.isUndefined(ethUnitAmount)) { return defaultText; } @@ -23,8 +31,8 @@ export const format = { ethBaseAmount?: BigNumber, ethUsdPrice?: BigNumber, decimalPlaces: number = 2, - defaultText: string = '$0.00', - ): string => { + defaultText: React.ReactNode = '$0.00', + ): React.ReactNode => { if (_.isUndefined(ethBaseAmount) || _.isUndefined(ethUsdPrice)) { return defaultText; } @@ -35,8 +43,8 @@ export const format = { ethUnitAmount?: BigNumber, ethUsdPrice?: BigNumber, decimalPlaces: number = 2, - defaultText: string = '$0.00', - ): string => { + defaultText: React.ReactNode = '$0.00', + ): React.ReactNode => { if (_.isUndefined(ethUnitAmount) || _.isUndefined(ethUsdPrice)) { return defaultText; } diff --git a/packages/instant/test/util/asset_data.test.ts b/packages/instant/test/util/asset_data.test.ts new file mode 100644 index 000000000..cf247142a --- /dev/null +++ b/packages/instant/test/util/asset_data.test.ts @@ -0,0 +1,17 @@ +import { assetDataUtil } from '../../src/util/asset_data'; + +const ZRX_ASSET_DATA = '0xf47261b0000000000000000000000000e41d2489571d322189246dafa5ebde1f4699f498'; + +describe('assetDataUtil', () => { + describe('bestNameForAsset', () => { + it('should return default string if assetData is undefined', () => { + expect(assetDataUtil.bestNameForAsset(undefined, 'xyz')).toEqual('xyz'); + }); + it('should return default string if assetData isnt found', () => { + expect(assetDataUtil.bestNameForAsset('fake', 'mah default')).toEqual('mah default'); + }); + it('should return ZRX for ZRX assetData', () => { + expect(assetDataUtil.bestNameForAsset(ZRX_ASSET_DATA, 'mah default')).toEqual('ZRX'); + }); + }); +}); diff --git a/packages/instant/test/util/error.test.ts b/packages/instant/test/util/error.test.ts new file mode 100644 index 000000000..56f3a1e86 --- /dev/null +++ b/packages/instant/test/util/error.test.ts @@ -0,0 +1,48 @@ +import { AssetBuyerError } from '@0xproject/asset-buyer'; + +import { errorUtil } from '../../src/util/error'; + +const ZRX_ASSET_DATA = '0xf47261b0000000000000000000000000e41d2489571d322189246dafa5ebde1f4699f498'; + +describe('errorUtil', () => { + describe('errorFlasher', () => { + it('should return error and asset name for InsufficientAssetLiquidity', () => { + const insufficientAssetError = new Error(AssetBuyerError.InsufficientAssetLiquidity); + expect(errorUtil.errorDescription(insufficientAssetError, ZRX_ASSET_DATA).message).toEqual( + 'Not enough ZRX available', + ); + }); + it('should return error default name for InsufficientAssetLiquidity', () => { + const insufficientZrxError = new Error(AssetBuyerError.InsufficientZrxLiquidity); + expect(errorUtil.errorDescription(insufficientZrxError).message).toEqual( + 'Not enough of this asset available', + ); + }); + it('should return asset name for InsufficientAssetLiquidity', () => { + const insufficientZrxError = new Error(AssetBuyerError.InsufficientZrxLiquidity); + expect(errorUtil.errorDescription(insufficientZrxError, ZRX_ASSET_DATA).message).toEqual( + 'Not enough ZRX available', + ); + }); + it('should return unavailable error and asset name for StandardRelayerApiError', () => { + const standardRelayerError = new Error(AssetBuyerError.StandardRelayerApiError); + expect(errorUtil.errorDescription(standardRelayerError, ZRX_ASSET_DATA).message).toEqual( + 'ZRX is currently unavailable', + ); + }); + it('should return error for AssetUnavailable error', () => { + const assetUnavailableError = new Error( + `${AssetBuyerError.AssetUnavailable}: For assetData ${ZRX_ASSET_DATA}`, + ); + expect(errorUtil.errorDescription(assetUnavailableError, ZRX_ASSET_DATA).message).toEqual( + 'ZRX is currently unavailable', + ); + }); + it('should return default for AssetUnavailable error', () => { + const assetUnavailableError = new Error(`${AssetBuyerError.AssetUnavailable}: For assetData xyz`); + expect(errorUtil.errorDescription(assetUnavailableError, 'xyz').message).toEqual( + 'This asset is currently unavailable', + ); + }); + }); +}); |