diff options
author | Fabio Berger <me@fabioberger.com> | 2017-11-22 04:03:08 +0800 |
---|---|---|
committer | Fabio Berger <me@fabioberger.com> | 2017-11-22 04:03:08 +0800 |
commit | 3660ba28d73d70d08bf14c33ef680e5ef3ec7f3b (patch) | |
tree | f101656799da807489253e17bea7abfaea90b62d /packages/website/ts/components/generate_order | |
parent | 037f466e1f80f635b48f3235258402e2ce75fb7b (diff) | |
download | dexon-sol-tools-3660ba28d73d70d08bf14c33ef680e5ef3ec7f3b.tar.gz dexon-sol-tools-3660ba28d73d70d08bf14c33ef680e5ef3ec7f3b.tar.zst dexon-sol-tools-3660ba28d73d70d08bf14c33ef680e5ef3ec7f3b.zip |
Add website to mono repo, update packages to align with existing sub-packages, use new subscribeAsync 0x.js method
Diffstat (limited to 'packages/website/ts/components/generate_order')
3 files changed, 876 insertions, 0 deletions
diff --git a/packages/website/ts/components/generate_order/asset_picker.tsx b/packages/website/ts/components/generate_order/asset_picker.tsx new file mode 100644 index 000000000..59826d06e --- /dev/null +++ b/packages/website/ts/components/generate_order/asset_picker.tsx @@ -0,0 +1,291 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {colors} from 'material-ui/styles'; +import Dialog from 'material-ui/Dialog'; +import GridList from 'material-ui/GridList/GridList'; +import GridTile from 'material-ui/GridList/GridTile'; +import FlatButton from 'material-ui/FlatButton'; +import {utils} from 'ts/utils/utils'; +import {Blockchain} from 'ts/blockchain'; +import {Dispatcher} from 'ts/redux/dispatcher'; +import { + Token, + AssetToken, + TokenByAddress, + Styles, + TokenState, + DialogConfigs, + TokenVisibility, +} from 'ts/types'; +import {NewTokenForm} from 'ts/components/generate_order/new_token_form'; +import {trackedTokenStorage} from 'ts/local_storage/tracked_token_storage'; +import {TrackTokenConfirmation} from 'ts/components/track_token_confirmation'; +import {TokenIcon} from 'ts/components/ui/token_icon'; + +const TOKEN_ICON_DIMENSION = 100; +const TILE_DIMENSION = 146; +enum AssetViews { + ASSET_PICKER = 'ASSET_PICKER', + NEW_TOKEN_FORM = 'NEW_TOKEN_FORM', + CONFIRM_TRACK_TOKEN = 'CONFIRM_TRACK_TOKEN', +} + +interface AssetPickerProps { + userAddress: string; + blockchain: Blockchain; + dispatcher: Dispatcher; + networkId: number; + isOpen: boolean; + currentTokenAddress: string; + onTokenChosen: (tokenAddress: string) => void; + tokenByAddress: TokenByAddress; + tokenVisibility?: TokenVisibility; +} + +interface AssetPickerState { + assetView: AssetViews; + hoveredAddress: string | undefined; + chosenTrackTokenAddress: string; + isAddingTokenToTracked: boolean; +} + +export class AssetPicker extends React.Component<AssetPickerProps, AssetPickerState> { + public static defaultProps: Partial<AssetPickerProps> = { + tokenVisibility: TokenVisibility.ALL, + }; + private dialogConfigsByAssetView: {[assetView: string]: DialogConfigs}; + constructor(props: AssetPickerProps) { + super(props); + this.state = { + assetView: AssetViews.ASSET_PICKER, + hoveredAddress: undefined, + chosenTrackTokenAddress: undefined, + isAddingTokenToTracked: false, + }; + this.dialogConfigsByAssetView = { + [AssetViews.ASSET_PICKER]: { + title: 'Select token', + isModal: false, + actions: [], + }, + [AssetViews.NEW_TOKEN_FORM]: { + title: 'Add an ERC20 token', + isModal: false, + actions: [], + }, + [AssetViews.CONFIRM_TRACK_TOKEN]: { + title: 'Tracking confirmation', + isModal: true, + actions: [ + <FlatButton + key="noTracking" + label="No" + onTouchTap={this.onTrackConfirmationRespondedAsync.bind(this, false)} + />, + <FlatButton + key="yesTrack" + label="Yes" + onTouchTap={this.onTrackConfirmationRespondedAsync.bind(this, true)} + />, + ], + }, + }; + } + public render() { + const dialogConfigs: DialogConfigs = this.dialogConfigsByAssetView[this.state.assetView]; + return ( + <Dialog + title={dialogConfigs.title} + titleStyle={{fontWeight: 100}} + modal={dialogConfigs.isModal} + open={this.props.isOpen} + actions={dialogConfigs.actions} + onRequestClose={this.onCloseDialog.bind(this)} + > + {this.state.assetView === AssetViews.ASSET_PICKER && + this.renderAssetPicker() + } + {this.state.assetView === AssetViews.NEW_TOKEN_FORM && + <NewTokenForm + blockchain={this.props.blockchain} + onNewTokenSubmitted={this.onNewTokenSubmitted.bind(this)} + tokenByAddress={this.props.tokenByAddress} + /> + } + {this.state.assetView === AssetViews.CONFIRM_TRACK_TOKEN && + this.renderConfirmTrackToken() + } + </Dialog> + ); + } + private renderConfirmTrackToken() { + const token = this.props.tokenByAddress[this.state.chosenTrackTokenAddress]; + return ( + <TrackTokenConfirmation + tokens={[token]} + tokenByAddress={this.props.tokenByAddress} + networkId={this.props.networkId} + isAddingTokenToTracked={this.state.isAddingTokenToTracked} + /> + ); + } + private renderAssetPicker() { + return ( + <div + className="clearfix flex flex-wrap" + style={{overflowY: 'auto', maxWidth: 720, maxHeight: 356, marginBottom: 10}} + > + {this.renderGridTiles()} + </div> + ); + } + private renderGridTiles() { + let isHovered; + let tileStyles; + const gridTiles = _.map(this.props.tokenByAddress, (token: Token, address: string) => { + if ((this.props.tokenVisibility === TokenVisibility.TRACKED && !token.isTracked) || + (this.props.tokenVisibility === TokenVisibility.UNTRACKED && token.isTracked)) { + return null; // Skip + } + isHovered = this.state.hoveredAddress === address; + tileStyles = { + cursor: 'pointer', + opacity: isHovered ? 0.6 : 1, + }; + return ( + <div + key={address} + style={{width: TILE_DIMENSION, height: TILE_DIMENSION, ...tileStyles}} + className="p2 mx-auto" + onClick={this.onChooseToken.bind(this, address)} + onMouseEnter={this.onToggleHover.bind(this, address, true)} + onMouseLeave={this.onToggleHover.bind(this, address, false)} + > + <div className="p1 center"> + <TokenIcon token={token} diameter={TOKEN_ICON_DIMENSION} /> + </div> + <div className="center">{token.name}</div> + </div> + ); + }); + const otherTokenKey = 'otherToken'; + isHovered = this.state.hoveredAddress === otherTokenKey; + tileStyles = { + cursor: 'pointer', + opacity: isHovered ? 0.6 : 1, + }; + if (this.props.tokenVisibility !== TokenVisibility.TRACKED) { + gridTiles.push(( + <div + key={otherTokenKey} + style={{width: TILE_DIMENSION, height: TILE_DIMENSION, ...tileStyles}} + className="p2 mx-auto" + onClick={this.onCustomAssetChosen.bind(this)} + onMouseEnter={this.onToggleHover.bind(this, otherTokenKey, true)} + onMouseLeave={this.onToggleHover.bind(this, otherTokenKey, false)} + > + <div className="p1 center"> + <i + style={{fontSize: 105, paddingLeft: 1, paddingRight: 1}} + className="zmdi zmdi-plus-circle" + /> + </div> + <div className="center">Other ERC20 Token</div> + </div> + )); + } + return gridTiles; + } + private onToggleHover(address: string, isHovered: boolean) { + const hoveredAddress = isHovered ? address : undefined; + this.setState({ + hoveredAddress, + }); + } + private onCloseDialog() { + this.setState({ + assetView: AssetViews.ASSET_PICKER, + }); + this.props.onTokenChosen(this.props.currentTokenAddress); + } + private onChooseToken(tokenAddress: string) { + const token = this.props.tokenByAddress[tokenAddress]; + if (token.isTracked) { + this.props.onTokenChosen(tokenAddress); + } else { + this.setState({ + assetView: AssetViews.CONFIRM_TRACK_TOKEN, + chosenTrackTokenAddress: tokenAddress, + }); + } + } + private getTitle() { + switch (this.state.assetView) { + case AssetViews.ASSET_PICKER: + return 'Select token'; + + case AssetViews.NEW_TOKEN_FORM: + return 'Add an ERC20 token'; + + case AssetViews.CONFIRM_TRACK_TOKEN: + return 'Tracking confirmation'; + + default: + throw utils.spawnSwitchErr('assetView', this.state.assetView); + } + } + private onCustomAssetChosen() { + this.setState({ + assetView: AssetViews.NEW_TOKEN_FORM, + }); + } + private onNewTokenSubmitted(newToken: Token, newTokenState: TokenState) { + this.props.dispatcher.updateTokenStateByAddress({ + [newToken.address]: newTokenState, + }); + trackedTokenStorage.addTrackedTokenToUser(this.props.userAddress, this.props.networkId, newToken); + this.props.dispatcher.addTokenToTokenByAddress(newToken); + this.setState({ + assetView: AssetViews.ASSET_PICKER, + }); + this.props.onTokenChosen(newToken.address); + } + private async onTrackConfirmationRespondedAsync(didUserAcceptTracking: boolean) { + if (!didUserAcceptTracking) { + this.setState({ + isAddingTokenToTracked: false, + assetView: AssetViews.ASSET_PICKER, + chosenTrackTokenAddress: undefined, + }); + this.onCloseDialog(); + return; + } + this.setState({ + isAddingTokenToTracked: true, + }); + const tokenAddress = this.state.chosenTrackTokenAddress; + const token = this.props.tokenByAddress[tokenAddress]; + const newTokenEntry = _.assign({}, token); + + newTokenEntry.isTracked = true; + trackedTokenStorage.addTrackedTokenToUser(this.props.userAddress, this.props.networkId, newTokenEntry); + this.props.dispatcher.updateTokenByAddress([newTokenEntry]); + + const [ + balance, + allowance, + ] = await this.props.blockchain.getCurrentUserTokenBalanceAndAllowanceAsync(token.address); + this.props.dispatcher.updateTokenStateByAddress({ + [token.address]: { + balance, + allowance, + }, + }); + this.setState({ + isAddingTokenToTracked: false, + assetView: AssetViews.ASSET_PICKER, + chosenTrackTokenAddress: undefined, + }); + this.props.onTokenChosen(tokenAddress); + } +} diff --git a/packages/website/ts/components/generate_order/generate_order_form.tsx b/packages/website/ts/components/generate_order/generate_order_form.tsx new file mode 100644 index 000000000..e9026d9bc --- /dev/null +++ b/packages/website/ts/components/generate_order/generate_order_form.tsx @@ -0,0 +1,348 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {ZeroEx, Order} from '0x.js'; +import BigNumber from 'bignumber.js'; +import {Blockchain} from 'ts/blockchain'; +import Divider from 'material-ui/Divider'; +import Dialog from 'material-ui/Dialog'; +import {colors} from 'material-ui/styles'; +import {Dispatcher} from 'ts/redux/dispatcher'; +import {utils} from 'ts/utils/utils'; +import {SchemaValidator} from 'ts/schemas/validator'; +import {orderSchema} from 'ts/schemas/order_schema'; +import {Alert} from 'ts/components/ui/alert'; +import {OrderJSON} from 'ts/components/order_json'; +import {IdenticonAddressInput} from 'ts/components/inputs/identicon_address_input'; +import {TokenInput} from 'ts/components/inputs/token_input'; +import {TokenAmountInput} from 'ts/components/inputs/token_amount_input'; +import {HashInput} from 'ts/components/inputs/hash_input'; +import {ExpirationInput} from 'ts/components/inputs/expiration_input'; +import {LifeCycleRaisedButton} from 'ts/components/ui/lifecycle_raised_button'; +import {errorReporter} from 'ts/utils/error_reporter'; +import {HelpTooltip} from 'ts/components/ui/help_tooltip'; +import {SwapIcon} from 'ts/components/ui/swap_icon'; +import { + Side, + SideToAssetToken, + SignatureData, + HashData, + TokenByAddress, + TokenStateByAddress, + BlockchainErrs, + Token, + AlertTypes, +} from 'ts/types'; + +enum SigningState { + UNSIGNED, + SIGNING, + SIGNED, +} + +interface GenerateOrderFormProps { + blockchain: Blockchain; + blockchainErr: BlockchainErrs; + blockchainIsLoaded: boolean; + dispatcher: Dispatcher; + hashData: HashData; + orderExpiryTimestamp: BigNumber; + networkId: number; + userAddress: string; + orderSignatureData: SignatureData; + orderTakerAddress: string; + orderSalt: BigNumber; + sideToAssetToken: SideToAssetToken; + tokenByAddress: TokenByAddress; + tokenStateByAddress: TokenStateByAddress; +} + +interface GenerateOrderFormState { + globalErrMsg: string; + shouldShowIncompleteErrs: boolean; + signingState: SigningState; +} + +const style = { + paper: { + display: 'inline-block', + position: 'relative', + textAlign: 'center', + width: '100%', + }, +}; + +export class GenerateOrderForm extends React.Component<GenerateOrderFormProps, any> { + private validator: SchemaValidator; + constructor(props: GenerateOrderFormProps) { + super(props); + this.state = { + globalErrMsg: '', + shouldShowIncompleteErrs: false, + signingState: SigningState.UNSIGNED, + }; + this.validator = new SchemaValidator(); + } + public componentDidMount() { + window.scrollTo(0, 0); + } + public render() { + const dispatcher = this.props.dispatcher; + const depositTokenAddress = this.props.sideToAssetToken[Side.deposit].address; + const depositToken = this.props.tokenByAddress[depositTokenAddress]; + const depositTokenState = this.props.tokenStateByAddress[depositTokenAddress]; + const receiveTokenAddress = this.props.sideToAssetToken[Side.receive].address; + const receiveToken = this.props.tokenByAddress[receiveTokenAddress]; + const receiveTokenState = this.props.tokenStateByAddress[receiveTokenAddress]; + const takerExplanation = 'If a taker is specified, only they are<br> \ + allowed to fill this order. If no taker is<br> \ + specified, anyone is able to fill it.'; + const exchangeContractIfExists = this.props.blockchain.getExchangeContractAddressIfExists(); + return ( + <div className="clearfix mb2 lg-px4 md-px4 sm-px2"> + <h3>Generate an order</h3> + <Divider /> + <div className="mx-auto" style={{maxWidth: 580}}> + <div className="pt3"> + <div className="mx-auto clearfix"> + <div className="lg-col md-col lg-col-5 md-col-5 sm-col sm-col-5 sm-pb2"> + <TokenInput + userAddress={this.props.userAddress} + blockchain={this.props.blockchain} + blockchainErr={this.props.blockchainErr} + dispatcher={this.props.dispatcher} + label="Selling" + side={Side.deposit} + networkId={this.props.networkId} + assetToken={this.props.sideToAssetToken[Side.deposit]} + updateChosenAssetToken={dispatcher.updateChosenAssetToken.bind(dispatcher)} + tokenByAddress={this.props.tokenByAddress} + /> + <TokenAmountInput + label="Sell amount" + token={depositToken} + tokenState={depositTokenState} + amount={this.props.sideToAssetToken[Side.deposit].amount} + onChange={this.onTokenAmountChange.bind(this, depositToken, Side.deposit)} + shouldShowIncompleteErrs={this.state.shouldShowIncompleteErrs} + shouldCheckBalance={true} + shouldCheckAllowance={true} + /> + </div> + <div className="lg-col md-col lg-col-2 md-col-2 sm-col sm-col-2 xs-hide"> + <div className="p1"> + <SwapIcon + swapTokensFn={dispatcher.swapAssetTokenSymbols.bind(dispatcher)} + /> + </div> + </div> + <div className="lg-col md-col lg-col-5 md-col-5 sm-col sm-col-5 sm-pb2"> + <TokenInput + userAddress={this.props.userAddress} + blockchain={this.props.blockchain} + blockchainErr={this.props.blockchainErr} + dispatcher={this.props.dispatcher} + label="Buying" + side={Side.receive} + networkId={this.props.networkId} + assetToken={this.props.sideToAssetToken[Side.receive]} + updateChosenAssetToken={dispatcher.updateChosenAssetToken.bind(dispatcher)} + tokenByAddress={this.props.tokenByAddress} + /> + <TokenAmountInput + label="Receive amount" + token={receiveToken} + tokenState={receiveTokenState} + amount={this.props.sideToAssetToken[Side.receive].amount} + onChange={this.onTokenAmountChange.bind(this, receiveToken, Side.receive)} + shouldShowIncompleteErrs={this.state.shouldShowIncompleteErrs} + shouldCheckBalance={false} + shouldCheckAllowance={false} + /> + </div> + </div> + </div> + <div className="pt1 sm-pb2 lg-px4 md-px4"> + <div className="lg-px3 md-px3"> + <div style={{fontSize: 12, color: colors.grey500}}>Expiration</div> + <ExpirationInput + orderExpiryTimestamp={this.props.orderExpiryTimestamp} + updateOrderExpiry={dispatcher.updateOrderExpiry.bind(dispatcher)} + /> + </div> + </div> + <div className="pt1 flex mx-auto"> + <IdenticonAddressInput + label="Taker" + initialAddress={this.props.orderTakerAddress} + updateOrderAddress={this.updateOrderAddress.bind(this)} + /> + <div className="pt3"> + <div className="pl1"> + <HelpTooltip + explanation={takerExplanation} + /> + </div> + </div> + </div> + <div> + <HashInput + blockchain={this.props.blockchain} + blockchainIsLoaded={this.props.blockchainIsLoaded} + hashData={this.props.hashData} + label="Order Hash" + /> + </div> + <div className="pt2"> + <div className="center"> + <LifeCycleRaisedButton + labelReady="Sign hash" + labelLoading="Signing..." + labelComplete="Hash signed!" + onClickAsyncFn={this.onSignClickedAsync.bind(this)} + /> + </div> + {this.state.globalErrMsg !== '' && + <Alert type={AlertTypes.ERROR} message={this.state.globalErrMsg} /> + } + </div> + </div> + <Dialog + title="Order JSON" + titleStyle={{fontWeight: 100}} + modal={false} + open={this.state.signingState === SigningState.SIGNED} + onRequestClose={this.onCloseOrderJSONDialog.bind(this)} + > + <OrderJSON + exchangeContractIfExists={exchangeContractIfExists} + orderExpiryTimestamp={this.props.orderExpiryTimestamp} + orderSignatureData={this.props.orderSignatureData} + orderTakerAddress={this.props.orderTakerAddress} + orderMakerAddress={this.props.userAddress} + orderSalt={this.props.orderSalt} + orderMakerFee={this.props.hashData.makerFee} + orderTakerFee={this.props.hashData.takerFee} + orderFeeRecipient={this.props.hashData.feeRecipientAddress} + networkId={this.props.networkId} + sideToAssetToken={this.props.sideToAssetToken} + tokenByAddress={this.props.tokenByAddress} + /> + </Dialog> + </div> + ); + } + private onTokenAmountChange(token: Token, side: Side, isValid: boolean, amount?: BigNumber) { + this.props.dispatcher.updateChosenAssetToken(side, {address: token.address, amount}); + } + private onCloseOrderJSONDialog() { + // Upon closing the order JSON dialog, we update the orderSalt stored in the Redux store + // with a new value so that if a user signs the identical order again, the newly signed + // orderHash will not collide with the previously generated orderHash. + this.props.dispatcher.updateOrderSalt(ZeroEx.generatePseudoRandomSalt()); + this.setState({ + signingState: SigningState.UNSIGNED, + }); + } + private async onSignClickedAsync(): Promise<boolean> { + if (this.props.blockchainErr !== '') { + this.props.dispatcher.updateShouldBlockchainErrDialogBeOpen(true); + return false; + } + + // Check if all required inputs were supplied + const debitToken = this.props.sideToAssetToken[Side.deposit]; + const debitBalance = this.props.tokenStateByAddress[debitToken.address].balance; + const debitAllowance = this.props.tokenStateByAddress[debitToken.address].allowance; + const receiveAmount = this.props.sideToAssetToken[Side.receive].amount; + if (!_.isUndefined(debitToken.amount) && !_.isUndefined(receiveAmount) && + debitToken.amount.gt(0) && receiveAmount.gt(0) && + this.props.userAddress !== '' && + debitBalance.gte(debitToken.amount) && debitAllowance.gte(debitToken.amount)) { + const didSignSuccessfully = await this.signTransactionAsync(); + if (didSignSuccessfully) { + this.setState({ + globalErrMsg: '', + shouldShowIncompleteErrs: false, + }); + } + return didSignSuccessfully; + } else { + let globalErrMsg = 'You must fix the above errors in order to generate a valid order'; + if (this.props.userAddress === '') { + globalErrMsg = 'You must enable wallet communication'; + this.props.dispatcher.updateShouldBlockchainErrDialogBeOpen(true); + } + this.setState({ + globalErrMsg, + shouldShowIncompleteErrs: true, + }); + return false; + } + } + private async signTransactionAsync(): Promise<boolean> { + this.setState({ + signingState: SigningState.SIGNING, + }); + const exchangeContractAddr = this.props.blockchain.getExchangeContractAddressIfExists(); + if (_.isUndefined(exchangeContractAddr)) { + this.props.dispatcher.updateShouldBlockchainErrDialogBeOpen(true); + this.setState({ + isSigning: false, + }); + return false; + } + const hashData = this.props.hashData; + + const zeroExOrder: Order = { + exchangeContractAddress: exchangeContractAddr, + expirationUnixTimestampSec: hashData.orderExpiryTimestamp, + feeRecipient: hashData.feeRecipientAddress, + maker: hashData.orderMakerAddress, + makerFee: hashData.makerFee, + makerTokenAddress: hashData.depositTokenContractAddr, + makerTokenAmount: hashData.depositAmount, + salt: hashData.orderSalt, + taker: hashData.orderTakerAddress, + takerFee: hashData.takerFee, + takerTokenAddress: hashData.receiveTokenContractAddr, + takerTokenAmount: hashData.receiveAmount, + }; + const orderHash = ZeroEx.getOrderHashHex(zeroExOrder); + + let globalErrMsg = ''; + try { + const signatureData = await this.props.blockchain.signOrderHashAsync(orderHash); + const order = utils.generateOrder(this.props.networkId, exchangeContractAddr, this.props.sideToAssetToken, + hashData.orderExpiryTimestamp, this.props.orderTakerAddress, + this.props.userAddress, hashData.makerFee, hashData.takerFee, + hashData.feeRecipientAddress, signatureData, this.props.tokenByAddress, + hashData.orderSalt); + const validationResult = this.validator.validate(order, orderSchema); + if (validationResult.errors.length > 0) { + globalErrMsg = 'Order signing failed. Please refresh and try again'; + utils.consoleLog(`Unexpected error occured: Order validation failed: + ${validationResult.errors}`); + } + } catch (err) { + const errMsg = '' + err; + if (utils.didUserDenyWeb3Request(errMsg)) { + globalErrMsg = 'User denied sign request'; + } else { + globalErrMsg = 'An unexpected error occured. Please try refreshing the page'; + utils.consoleLog(`Unexpected error occured: ${err}`); + utils.consoleLog(err.stack); + await errorReporter.reportAsync(err); + } + } + this.setState({ + signingState: globalErrMsg === '' ? SigningState.SIGNED : SigningState.UNSIGNED, + globalErrMsg, + }); + return globalErrMsg === ''; + } + private updateOrderAddress(address?: string): void { + if (!_.isUndefined(address)) { + this.props.dispatcher.updateOrderTakerAddress(address); + } + } +} diff --git a/packages/website/ts/components/generate_order/new_token_form.tsx b/packages/website/ts/components/generate_order/new_token_form.tsx new file mode 100644 index 000000000..95c05f5bb --- /dev/null +++ b/packages/website/ts/components/generate_order/new_token_form.tsx @@ -0,0 +1,237 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import {colors} from 'material-ui/styles'; +import TextField from 'material-ui/TextField'; +import {constants} from 'ts/utils/constants'; +import {Blockchain} from 'ts/blockchain'; +import {Token, TokenState, TokenByAddress, AlertTypes} from 'ts/types'; +import {AddressInput} from 'ts/components/inputs/address_input'; +import {Alert} from 'ts/components/ui/alert'; +import {LifeCycleRaisedButton} from 'ts/components/ui/lifecycle_raised_button'; +import {RequiredLabel} from 'ts/components/ui/required_label'; +import BigNumber from 'bignumber.js'; + +interface NewTokenFormProps { + blockchain: Blockchain; + tokenByAddress: TokenByAddress; + onNewTokenSubmitted: (token: Token, tokenState: TokenState) => void; +} + +interface NewTokenFormState { + globalErrMsg: string; + name: string; + nameErrText: string; + symbol: string; + symbolErrText: string; + address: string; + shouldShowAddressIncompleteErr: boolean; + decimals: string; + decimalsErrText: string; +} + +export class NewTokenForm extends React.Component<NewTokenFormProps, NewTokenFormState> { + constructor(props: NewTokenFormProps) { + super(props); + this.state = { + address: '', + globalErrMsg: '', + name: '', + nameErrText: '', + shouldShowAddressIncompleteErr: false, + symbol: '', + symbolErrText: '', + decimals: '18', + decimalsErrText: '', + }; + } + public render() { + return ( + <div className="mx-auto pb2" style={{width: 256}}> + <div> + <TextField + floatingLabelFixed={true} + floatingLabelStyle={{color: colors.grey500}} + floatingLabelText={<RequiredLabel label="Name" />} + value={this.state.name} + errorText={this.state.nameErrText} + onChange={this.onTokenNameChanged.bind(this)} + /> + </div> + <div> + <TextField + floatingLabelFixed={true} + floatingLabelStyle={{color: colors.grey500}} + floatingLabelText={<RequiredLabel label="Symbol" />} + value={this.state.symbol} + errorText={this.state.symbolErrText} + onChange={this.onTokenSymbolChanged.bind(this)} + /> + </div> + <div> + <AddressInput + isRequired={true} + label="Contract address" + initialAddress="" + shouldShowIncompleteErrs={this.state.shouldShowAddressIncompleteErr} + updateAddress={this.onTokenAddressChanged.bind(this)} + /> + </div> + <div> + <TextField + floatingLabelFixed={true} + floatingLabelStyle={{color: colors.grey500}} + floatingLabelText={<RequiredLabel label="Decimals" />} + value={this.state.decimals} + errorText={this.state.decimalsErrText} + onChange={this.onTokenDecimalsChanged.bind(this)} + /> + </div> + <div className="pt2 mx-auto" style={{width: 120}}> + <LifeCycleRaisedButton + labelReady="Add" + labelLoading="Adding..." + labelComplete="Added!" + onClickAsyncFn={this.onAddNewTokenClickAsync.bind(this)} + /> + </div> + {this.state.globalErrMsg !== '' && + <Alert type={AlertTypes.ERROR} message={this.state.globalErrMsg} /> + } + </div> + ); + } + private async onAddNewTokenClickAsync() { + // Trigger validation of name and symbol + this.onTokenNameChanged(undefined, this.state.name); + this.onTokenSymbolChanged(undefined, this.state.symbol); + this.onTokenDecimalsChanged(undefined, this.state.decimals); + + const isAddressIncomplete = this.state.address === ''; + let doesContractExist = false; + if (!isAddressIncomplete) { + doesContractExist = await this.props.blockchain.doesContractExistAtAddressAsync(this.state.address); + } + + let hasBalanceAllowanceErr = false; + let balance = new BigNumber(0); + let allowance = new BigNumber(0); + if (doesContractExist) { + try { + [ + balance, + allowance, + ] = await this.props.blockchain.getCurrentUserTokenBalanceAndAllowanceAsync(this.state.address); + } catch (err) { + hasBalanceAllowanceErr = true; + } + } + + let globalErrMsg = ''; + if (this.state.nameErrText !== '' || this.state.symbolErrText !== '' || + this.state.decimalsErrText !== '' || isAddressIncomplete) { + globalErrMsg = 'Please fix the above issues'; + } else if (!doesContractExist) { + globalErrMsg = 'No contract found at supplied address'; + } else if (hasBalanceAllowanceErr) { + globalErrMsg = 'Unsuccessful call to `balanceOf` and/or `allowance` on supplied contract address'; + } else if (!isAddressIncomplete && !_.isUndefined(this.props.tokenByAddress[this.state.address])) { + globalErrMsg = 'A token already exists with this address'; + } + + if (globalErrMsg !== '') { + this.setState({ + globalErrMsg, + shouldShowAddressIncompleteErr: isAddressIncomplete, + }); + return; + } + + const newToken: Token = { + address: this.state.address, + decimals: _.parseInt(this.state.decimals), + iconUrl: undefined, + name: this.state.name, + symbol: this.state.symbol.toUpperCase(), + isTracked: true, + isRegistered: false, + }; + const newTokenState: TokenState = { + balance, + allowance, + }; + this.props.onNewTokenSubmitted(newToken, newTokenState); + } + private onTokenNameChanged(e: any, name: string) { + let nameErrText = ''; + const maxLength = 30; + const tokens = _.values(this.props.tokenByAddress); + const tokenWithNameIfExists = _.find(tokens, {name}); + const tokenWithNameExists = !_.isUndefined(tokenWithNameIfExists); + if (name === '') { + nameErrText = 'Name is required'; + } else if (!this.isValidName(name)) { + nameErrText = 'Name should only contain letters, digits and spaces'; + } else if (name.length > maxLength) { + nameErrText = `Max length is ${maxLength}`; + } else if (tokenWithNameExists) { + nameErrText = 'Token with this name already exists'; + } + + this.setState({ + name, + nameErrText, + }); + } + private onTokenSymbolChanged(e: any, symbol: string) { + let symbolErrText = ''; + const maxLength = 5; + const tokens = _.values(this.props.tokenByAddress); + const tokenWithSymbolExists = !_.isUndefined(_.find(tokens, {symbol})); + if (symbol === '') { + symbolErrText = 'Symbol is required'; + } else if (!this.isLetters(symbol)) { + symbolErrText = 'Can only include letters'; + } else if (symbol.length > maxLength) { + symbolErrText = `Max length is ${maxLength}`; + } else if (tokenWithSymbolExists) { + symbolErrText = 'Token with symbol already exists'; + } + + this.setState({ + symbol, + symbolErrText, + }); + } + private onTokenDecimalsChanged(e: any, decimals: string) { + let decimalsErrText = ''; + const maxLength = 2; + if (decimals === '') { + decimalsErrText = 'Decimals is required'; + } else if (!this.isInteger(decimals)) { + decimalsErrText = 'Must be an integer'; + } else if (decimals.length > maxLength) { + decimalsErrText = `Max length is ${maxLength}`; + } + + this.setState({ + decimals, + decimalsErrText, + }); + } + private onTokenAddressChanged(address?: string) { + if (!_.isUndefined(address)) { + this.setState({ + address, + }); + } + } + private isValidName(input: string) { + return /^[a-z0-9 ]+$/i.test(input); + } + private isInteger(input: string) { + return /^[0-9]+$/i.test(input); + } + private isLetters(input: string) { + return /^[a-zA-Z]+$/i.test(input); + } +} |