Merge pull request #52 from gnosis/feature/WA-232-add-custom-tokens
WA-232 - Feature: Add custom ERC 20 tokens
This commit is contained in:
commit
cb3e5f5445
|
@ -1,7 +1,7 @@
|
|||
import 'babel-polyfill'
|
||||
import { addDecorator, configure } from '@storybook/react'
|
||||
import { withKnobs } from '@storybook/addon-knobs'
|
||||
import { MuiThemeProvider } from 'material-ui/styles'
|
||||
import { MuiThemeProvider } from '@material-ui/core/styles'
|
||||
import * as React from 'react'
|
||||
import { Provider } from 'react-redux'
|
||||
import StoryRouter from 'storybook-router'
|
||||
|
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -6,16 +6,16 @@ type ControlProps = {
|
|||
next: string,
|
||||
onPrevious: () => void,
|
||||
firstPage: boolean,
|
||||
submitting: boolean,
|
||||
disabled: boolean,
|
||||
}
|
||||
|
||||
const ControlButtons = ({
|
||||
next, firstPage, onPrevious, submitting,
|
||||
next, firstPage, onPrevious, disabled,
|
||||
}: ControlProps) => (
|
||||
<React.Fragment>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={firstPage || submitting}
|
||||
disabled={firstPage || disabled}
|
||||
onClick={onPrevious}
|
||||
>
|
||||
Back
|
||||
|
@ -24,7 +24,7 @@ const ControlButtons = ({
|
|||
variant="raised"
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
disabled={disabled}
|
||||
>
|
||||
{next}
|
||||
</Button>
|
||||
|
@ -37,16 +37,16 @@ type Props = {
|
|||
onPrevious: () => void,
|
||||
firstPage: boolean,
|
||||
lastPage: boolean,
|
||||
submitting: boolean,
|
||||
disabled: boolean,
|
||||
}
|
||||
|
||||
const Controls = ({
|
||||
finishedTx, finishedButton, onPrevious, firstPage, lastPage, submitting,
|
||||
finishedTx, finishedButton, onPrevious, firstPage, lastPage, disabled,
|
||||
}: Props) => (
|
||||
finishedTx
|
||||
? <React.Fragment>{finishedButton}</React.Fragment>
|
||||
: <ControlButtons
|
||||
submitting={submitting}
|
||||
disabled={disabled}
|
||||
next={lastPage ? 'Finish' : 'Next'}
|
||||
firstPage={firstPage}
|
||||
onPrevious={onPrevious}
|
||||
|
|
|
@ -13,6 +13,7 @@ import Controls from './Controls'
|
|||
export { default as Step } from './Step'
|
||||
|
||||
type Props = {
|
||||
disabledWhenValidating?: boolean,
|
||||
classes: Object,
|
||||
steps: string[],
|
||||
finishedTransaction: boolean,
|
||||
|
@ -30,6 +31,7 @@ type State = {
|
|||
|
||||
type PageProps = {
|
||||
children: Function,
|
||||
prepareNextInitialProps: (values: Object) => {},
|
||||
}
|
||||
|
||||
class GnoStepper extends React.PureComponent<Props, State> {
|
||||
|
@ -62,8 +64,10 @@ class GnoStepper extends React.PureComponent<Props, State> {
|
|||
}))
|
||||
}
|
||||
|
||||
getPageProps = (pages: React$Node): PageProps => React.Children.toArray(pages)[this.state.page].props
|
||||
|
||||
getActivePageFrom = (pages: React$Node) => {
|
||||
const activePageProps = React.Children.toArray(pages)[this.state.page].props
|
||||
const activePageProps = this.getPageProps(pages)
|
||||
const { children, ...props } = activePageProps
|
||||
|
||||
return children(props)
|
||||
|
@ -76,18 +80,28 @@ class GnoStepper extends React.PureComponent<Props, State> {
|
|||
return activePage.props.validate ? activePage.props.validate(values) : {}
|
||||
}
|
||||
|
||||
next = (values: Object) =>
|
||||
next = async (values: Object) => {
|
||||
const activePageProps = this.getPageProps(this.props.children)
|
||||
const { prepareNextInitialProps } = activePageProps
|
||||
|
||||
let pageInitialProps
|
||||
if (prepareNextInitialProps) {
|
||||
pageInitialProps = await prepareNextInitialProps(values)
|
||||
}
|
||||
|
||||
const finalValues = { ...values, ...pageInitialProps }
|
||||
this.setState(state => ({
|
||||
page: Math.min(state.page + 1, React.Children.count(this.props.children) - 1),
|
||||
values,
|
||||
values: finalValues,
|
||||
}))
|
||||
}
|
||||
|
||||
previous = () =>
|
||||
this.setState(state => ({
|
||||
page: Math.max(state.page - 1, 0),
|
||||
}))
|
||||
|
||||
handleSubmit = (values: Object) => {
|
||||
handleSubmit = async (values: Object) => {
|
||||
const { children, onSubmit } = this.props
|
||||
const { page } = this.state
|
||||
const isLastPage = page === React.Children.count(children) - 1
|
||||
|
@ -100,7 +114,7 @@ class GnoStepper extends React.PureComponent<Props, State> {
|
|||
|
||||
render() {
|
||||
const {
|
||||
steps, children, finishedTransaction, finishedButton, classes,
|
||||
steps, children, finishedTransaction, finishedButton, classes, disabledWhenValidating = false,
|
||||
} = this.props
|
||||
const { page, values } = this.state
|
||||
const activePage = this.getActivePageFrom(children)
|
||||
|
@ -123,11 +137,14 @@ class GnoStepper extends React.PureComponent<Props, State> {
|
|||
validation={this.validate}
|
||||
render={activePage}
|
||||
>
|
||||
{(submitting: boolean) => (
|
||||
{(submitting: boolean, validating: boolean) => {
|
||||
const disabled = disabledWhenValidating ? submitting || validating : submitting
|
||||
|
||||
return (
|
||||
<Row align="end" margin="lg" grow>
|
||||
<Col xs={12} center="xs">
|
||||
<Controls
|
||||
submitting={submitting}
|
||||
disabled={disabled}
|
||||
finishedTx={finishedTransaction}
|
||||
finishedButton={finished}
|
||||
onPrevious={this.previous}
|
||||
|
@ -136,7 +153,8 @@ class GnoStepper extends React.PureComponent<Props, State> {
|
|||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
)
|
||||
}}
|
||||
</GnoForm>
|
||||
</React.Fragment>
|
||||
)
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
// @flow
|
||||
import * as React from 'react'
|
||||
import { Field } from 'react-final-form'
|
||||
|
||||
export default Field
|
||||
// $FlowFixMe
|
||||
const GnoField = ({ ...props }): React.Element<*> => <Field {...props} />
|
||||
|
||||
export default GnoField
|
||||
|
|
|
@ -35,7 +35,7 @@ const GnoForm = ({
|
|||
render={({ handleSubmit, ...rest }) => (
|
||||
<form onSubmit={handleSubmit} style={stylesBasedOn(padding)}>
|
||||
{render(rest)}
|
||||
{children(rest.submitting, rest.submitSucceeded)}
|
||||
{children(rest.submitting, rest.validating)}
|
||||
</form>
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -20,6 +20,16 @@ export const greaterThan = (min: number) => (value: string) => {
|
|||
return `Should be greater than ${min}`
|
||||
}
|
||||
|
||||
const regexQuery = /^(?:(?:https?|ftp):\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,}))\.?)(?::\d{2,5})?(?:[/?#]\S*)?$/i
|
||||
const url = new RegExp(regexQuery)
|
||||
export const mustBeUrl = (value: string) => {
|
||||
if (url.test(value)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return 'Please, provide a valid url'
|
||||
}
|
||||
|
||||
export const minValue = (min: number) => (value: string) => {
|
||||
if (Number.isNaN(Number(value)) || Number.parseFloat(value) >= Number(min)) {
|
||||
return undefined
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
// @flow
|
||||
import * as React from 'react'
|
||||
import { Field } from 'react-final-form'
|
||||
import Field from '~/components/forms/Field'
|
||||
import TextField from '~/components/forms/TextField'
|
||||
import { composeValidators, minValue, mustBeInteger, required } from '~/components/forms/validator'
|
||||
import Block from '~/components/layout/Block'
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// @flow
|
||||
import { storiesOf } from '@storybook/react'
|
||||
import * as React from 'react'
|
||||
import { Map } from 'immutable'
|
||||
import { List } from 'immutable'
|
||||
import styles from '~/components/layout/PageFrame/index.scss'
|
||||
import { SafeFactory } from '~/routes/safe/store/test/builder/safe.builder'
|
||||
import { makeToken } from '~/routes/tokens/store/model/token'
|
||||
|
@ -30,7 +30,7 @@ storiesOf('Routes /safe:address', module)
|
|||
userAddress="foo"
|
||||
safe={undefined}
|
||||
provider="METAMASK"
|
||||
activeTokens={Map()}
|
||||
activeTokens={List([])}
|
||||
fetchBalance={() => {}}
|
||||
/>
|
||||
))
|
||||
|
@ -39,7 +39,7 @@ storiesOf('Routes /safe:address', module)
|
|||
userAddress="foo"
|
||||
safe={undefined}
|
||||
provider=""
|
||||
activeTokens={Map()}
|
||||
activeTokens={List([])}
|
||||
fetchBalance={() => {}}
|
||||
/>
|
||||
))
|
||||
|
@ -51,7 +51,7 @@ storiesOf('Routes /safe:address', module)
|
|||
userAddress="foo"
|
||||
safe={safe}
|
||||
provider="METAMASK"
|
||||
activeTokens={Map().set('ETH', ethBalance)}
|
||||
activeTokens={List([]).push(ethBalance)}
|
||||
fetchBalance={() => {}}
|
||||
/>
|
||||
)
|
||||
|
@ -64,7 +64,7 @@ storiesOf('Routes /safe:address', module)
|
|||
userAddress="foo"
|
||||
safe={safe}
|
||||
provider="METAMASK"
|
||||
activeTokens={Map().set('ETH', ethBalance)}
|
||||
activeTokens={List([]).push(ethBalance)}
|
||||
fetchBalance={() => {}}
|
||||
/>
|
||||
)
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// @flow
|
||||
import List from '@material-ui/core/List'
|
||||
import ListComponent from '@material-ui/core/List'
|
||||
import * as React from 'react'
|
||||
import { Map } from 'immutable'
|
||||
import { List } from 'immutable'
|
||||
import Block from '~/components/layout/Block'
|
||||
import Col from '~/components/layout/Col'
|
||||
import Bold from '~/components/layout/Bold'
|
||||
|
@ -30,7 +30,7 @@ const safeIcon = require('./assets/gnosis_safe.svg')
|
|||
|
||||
type SafeProps = {
|
||||
safe: Safe,
|
||||
tokens: Map<string, Token>,
|
||||
tokens: List<Token>,
|
||||
userAddress: string,
|
||||
}
|
||||
|
||||
|
@ -43,12 +43,17 @@ const listStyle = {
|
|||
}
|
||||
|
||||
const getEthBalanceFrom = (tokens: List<Token>) => {
|
||||
const ethToken = tokens.filter(token => token.get('symbol') === 'ETH')
|
||||
if (ethToken.count() === 0) {
|
||||
const filteredTokens = tokens.filter(token => token.get('symbol') === 'ETH')
|
||||
if (filteredTokens.count() === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Number(ethToken.get(0).get('funds'))
|
||||
const ethToken = filteredTokens.get(0)
|
||||
if (!ethToken) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Number(ethToken.get('funds'))
|
||||
}
|
||||
|
||||
class GnoSafe extends React.PureComponent<SafeProps, State> {
|
||||
|
@ -100,7 +105,7 @@ class GnoSafe extends React.PureComponent<SafeProps, State> {
|
|||
component: <SendToken
|
||||
safe={safe}
|
||||
token={ercToken}
|
||||
key={ercToken.get('symbol')}
|
||||
key={ercToken.get('address')}
|
||||
onReset={this.onListTransactions}
|
||||
/>,
|
||||
})
|
||||
|
@ -115,7 +120,7 @@ class GnoSafe extends React.PureComponent<SafeProps, State> {
|
|||
return (
|
||||
<Row grow>
|
||||
<Col sm={12} top="xs" md={5} margin="xl" overflow>
|
||||
<List style={listStyle}>
|
||||
<ListComponent style={listStyle}>
|
||||
<BalanceInfo tokens={tokens} onMoveFunds={this.onMoveTokens} safeAddress={address} />
|
||||
<Owners
|
||||
owners={safe.owners}
|
||||
|
@ -127,7 +132,7 @@ class GnoSafe extends React.PureComponent<SafeProps, State> {
|
|||
<Address address={address} />
|
||||
<DailyLimit balance={ethBalance} dailyLimit={safe.get('dailyLimit')} onWithdraw={this.onWithdraw} onEditDailyLimit={this.onEditDailyLimit} />
|
||||
<MultisigTx onSeeTxs={this.onListTransactions} />
|
||||
</List>
|
||||
</ListComponent>
|
||||
</Col>
|
||||
<Col sm={12} center="xs" md={7} margin="xl" layout="column">
|
||||
<Block margin="xl">
|
||||
|
|
|
@ -42,7 +42,7 @@ const getTransferData = async (tokenAddress: string, to: string, amount: BigNumb
|
|||
const processTokenTransfer = async (safe: Safe, token: Token, to: string, amount: number, userAddress: string) => {
|
||||
const symbol = token.get('symbol')
|
||||
const nonce = Date.now()
|
||||
const name = `Send ${amount} ${token.get('symbol')} to ${to}`
|
||||
const name = `Send ${amount} ${symbol} to ${to}`
|
||||
const value = isEther(symbol) ? amount : 0
|
||||
const tokenAddress = token.get('address')
|
||||
const destination = isEther(symbol) ? to : tokenAddress
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
// @flow
|
||||
import { List, Map } from 'immutable'
|
||||
import { List } from 'immutable'
|
||||
import { createSelector, createStructuredSelector, type Selector } from 'reselect'
|
||||
import { safeSelector, type RouterProps, type SafeSelectorProps } from '~/routes/safe/store/selectors'
|
||||
import { providerNameSelector, userAccountSelector } from '~/wallets/store/selectors/index'
|
||||
|
@ -13,7 +13,7 @@ import { type Token } from '~/routes/tokens/store/model/token'
|
|||
export type SelectorProps = {
|
||||
safe: SafeSelectorProps,
|
||||
provider: string,
|
||||
activeTokens: Map<string, Token>,
|
||||
activeTokens: List<Token>,
|
||||
userAddress: string,
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
// @flow
|
||||
import { storiesOf } from '@storybook/react'
|
||||
import * as React from 'react'
|
||||
import { List } from 'immutable'
|
||||
import styles from '~/components/layout/PageFrame/index.scss'
|
||||
import AddTokenForm from './index'
|
||||
|
||||
const FrameDecorator = story => (
|
||||
<div className={styles.frame} style={{ textAlign: 'center' }}>
|
||||
{ story() }
|
||||
</div>
|
||||
)
|
||||
storiesOf('Components', module)
|
||||
.addDecorator(FrameDecorator)
|
||||
.add('AddTokenForm', () => (
|
||||
// $FlowFixMe
|
||||
<AddTokenForm tokens={List([]).toArray()} safeAddress="" />
|
||||
))
|
|
@ -0,0 +1,56 @@
|
|||
// @flow
|
||||
import * as React from 'react'
|
||||
import Field from '~/components/forms/Field'
|
||||
import TextField from '~/components/forms/TextField'
|
||||
import { composeValidators, required, mustBeEthereumAddress, uniqueAddress } from '~/components/forms/validator'
|
||||
import Block from '~/components/layout/Block'
|
||||
import Heading from '~/components/layout/Heading'
|
||||
import { promisify } from '~/utils/promisify'
|
||||
import { getWeb3 } from '~/wallets/getWeb3'
|
||||
import { EMPTY_DATA } from '~/wallets/ethTransactions'
|
||||
import { getStandardTokenContract } from '~/routes/tokens/store/actions/fetchTokens'
|
||||
|
||||
type Props = {
|
||||
addresses: string[],
|
||||
}
|
||||
|
||||
export const TOKEN_ADRESS_PARAM = 'tokenAddress'
|
||||
|
||||
export const token = async (tokenAddress: string) => {
|
||||
const code = await promisify(cb => getWeb3().eth.getCode(tokenAddress, cb))
|
||||
const isDeployed = code !== EMPTY_DATA
|
||||
|
||||
if (!isDeployed) {
|
||||
return 'Specified address is not deployed on the current network'
|
||||
}
|
||||
|
||||
const erc20Token = await getStandardTokenContract()
|
||||
const instance = await erc20Token.at(tokenAddress)
|
||||
const supply = await instance.totalSupply()
|
||||
|
||||
if (Number(supply) === 0) {
|
||||
return 'Specified address is not a valid standard token'
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const FirstPage = ({ addresses }: Props) => () => (
|
||||
<Block margin="md">
|
||||
<Heading tag="h2" margin="lg">
|
||||
Add Custom ERC20 Token
|
||||
</Heading>
|
||||
<Block margin="md">
|
||||
<Field
|
||||
name={TOKEN_ADRESS_PARAM}
|
||||
component={TextField}
|
||||
type="text"
|
||||
validate={composeValidators(required, mustBeEthereumAddress, uniqueAddress(addresses), token)}
|
||||
placeholder="ERC20 Token Address*"
|
||||
text="ERC20 Token Address"
|
||||
/>
|
||||
</Block>
|
||||
</Block>
|
||||
)
|
||||
|
||||
export default FirstPage
|
|
@ -0,0 +1,45 @@
|
|||
// @flow
|
||||
import * as React from 'react'
|
||||
import CircularProgress from '@material-ui/core/CircularProgress'
|
||||
import Block from '~/components/layout/Block'
|
||||
import Bold from '~/components/layout/Bold'
|
||||
import Heading from '~/components/layout/Heading'
|
||||
import Paragraph from '~/components/layout/Paragraph'
|
||||
import { TOKEN_ADRESS_PARAM } from '~/routes/tokens/component/AddToken/FirstPage'
|
||||
import { TOKEN_LOGO_URL_PARAM, TOKEN_NAME_PARAM, TOKEN_SYMBOL_PARAM, TOKEN_DECIMALS_PARAM } from '~/routes/tokens/component/AddToken/SecondPage'
|
||||
|
||||
type FormProps = {
|
||||
values: Object,
|
||||
submitting: boolean,
|
||||
}
|
||||
|
||||
const spinnerStyle = {
|
||||
minHeight: '50px',
|
||||
}
|
||||
|
||||
const Review = () => ({ values, submitting }: FormProps) => (
|
||||
<Block>
|
||||
<Heading tag="h2">Review ERC20 Token operation</Heading>
|
||||
<Paragraph align="left">
|
||||
<Bold>Token address: </Bold> {values[TOKEN_ADRESS_PARAM]}
|
||||
</Paragraph>
|
||||
<Paragraph align="left">
|
||||
<Bold>Token name: </Bold> {values[TOKEN_NAME_PARAM]}
|
||||
</Paragraph>
|
||||
<Paragraph align="left">
|
||||
<Bold>Token symbol: </Bold> {values[TOKEN_SYMBOL_PARAM]}
|
||||
</Paragraph>
|
||||
<Paragraph align="left">
|
||||
<Bold>Token decimals: </Bold> {values[TOKEN_DECIMALS_PARAM]}
|
||||
</Paragraph>
|
||||
<Paragraph align="left">
|
||||
<Bold>Token logo: </Bold> {values[TOKEN_LOGO_URL_PARAM]}
|
||||
</Paragraph>
|
||||
<Block style={spinnerStyle}>
|
||||
{ submitting && <CircularProgress size={50} /> }
|
||||
</Block>
|
||||
</Block>
|
||||
)
|
||||
|
||||
|
||||
export default Review
|
|
@ -0,0 +1,62 @@
|
|||
// @flow
|
||||
import * as React from 'react'
|
||||
import Field from '~/components/forms/Field'
|
||||
import TextField from '~/components/forms/TextField'
|
||||
import { composeValidators, required, mustBeInteger, mustBeUrl } from '~/components/forms/validator'
|
||||
import Block from '~/components/layout/Block'
|
||||
import Heading from '~/components/layout/Heading'
|
||||
|
||||
export const TOKEN_NAME_PARAM = 'tokenName'
|
||||
export const TOKEN_SYMBOL_PARAM = 'tokenSymbol'
|
||||
export const TOKEN_DECIMALS_PARAM = 'tokenDecimals'
|
||||
export const TOKEN_LOGO_URL_PARAM = 'tokenLogo'
|
||||
|
||||
const SecondPage = () => () => (
|
||||
<Block margin="md">
|
||||
<Heading tag="h2" margin="lg">
|
||||
Complete Custom Token information
|
||||
</Heading>
|
||||
<Block margin="md">
|
||||
<Field
|
||||
name={TOKEN_NAME_PARAM}
|
||||
component={TextField}
|
||||
type="text"
|
||||
validate={required}
|
||||
placeholder="ERC20 Token Name*"
|
||||
text="ERC20 Token Name"
|
||||
/>
|
||||
</Block>
|
||||
<Block margin="md">
|
||||
<Field
|
||||
name={TOKEN_SYMBOL_PARAM}
|
||||
component={TextField}
|
||||
type="text"
|
||||
validate={required}
|
||||
placeholder="ERC20 Token Symbol*"
|
||||
text="ERC20 Token Symbol"
|
||||
/>
|
||||
</Block>
|
||||
<Block margin="md">
|
||||
<Field
|
||||
name={TOKEN_DECIMALS_PARAM}
|
||||
component={TextField}
|
||||
type="text"
|
||||
validate={composeValidators(required, mustBeInteger)}
|
||||
placeholder="ERC20 Token Decimals*"
|
||||
text="ERC20 Token Decimals"
|
||||
/>
|
||||
</Block>
|
||||
<Block margin="md">
|
||||
<Field
|
||||
name={TOKEN_LOGO_URL_PARAM}
|
||||
component={TextField}
|
||||
type="text"
|
||||
validate={composeValidators(required, mustBeUrl)}
|
||||
placeholder="ERC20 Token Logo url*"
|
||||
text="ERC20 Token Logo"
|
||||
/>
|
||||
</Block>
|
||||
</Block>
|
||||
)
|
||||
|
||||
export default SecondPage
|
|
@ -0,0 +1,128 @@
|
|||
// @flow
|
||||
import * as React from 'react'
|
||||
import Stepper from '~/components/Stepper'
|
||||
import { getHumanFriendlyToken } from '~/routes/tokens/store/actions/fetchTokens'
|
||||
import FirstPage, { TOKEN_ADRESS_PARAM } from '~/routes/tokens/component/AddToken/FirstPage'
|
||||
import SecondPage, { TOKEN_SYMBOL_PARAM, TOKEN_DECIMALS_PARAM, TOKEN_LOGO_URL_PARAM, TOKEN_NAME_PARAM } from '~/routes/tokens/component/AddToken/SecondPage'
|
||||
import { makeToken, type Token } from '~/routes/tokens/store/model/token'
|
||||
import addTokenAction from '~/routes/tokens/store/actions/addToken'
|
||||
import { getWeb3 } from '~/wallets/getWeb3'
|
||||
import { promisify } from '~/utils/promisify'
|
||||
import { EMPTY_DATA } from '~/wallets/ethTransactions'
|
||||
import Review from './Review'
|
||||
|
||||
export const getSteps = () => [
|
||||
'Fill Add Token Form', 'Check optional attributes', 'Review Information',
|
||||
]
|
||||
|
||||
type Props = {
|
||||
tokens: string[],
|
||||
safeAddress: string,
|
||||
addToken: typeof addTokenAction,
|
||||
}
|
||||
|
||||
type State = {
|
||||
done: boolean,
|
||||
}
|
||||
|
||||
export const ADD_TOKEN_RESET_BUTTON_TEXT = 'RESET'
|
||||
|
||||
export const addTokenFnc = async (values: Object, addToken: typeof addTokenAction, safeAddress: string) => {
|
||||
const address = values[TOKEN_ADRESS_PARAM]
|
||||
const name = values[TOKEN_NAME_PARAM]
|
||||
const symbol = values[TOKEN_SYMBOL_PARAM]
|
||||
const decimals = values[TOKEN_DECIMALS_PARAM]
|
||||
const logo = values[TOKEN_LOGO_URL_PARAM]
|
||||
|
||||
const token: Token = makeToken({
|
||||
address,
|
||||
name,
|
||||
symbol,
|
||||
decimals: Number(decimals),
|
||||
logoUrl: logo,
|
||||
status: true,
|
||||
removable: true,
|
||||
})
|
||||
|
||||
return addToken(safeAddress, token)
|
||||
}
|
||||
|
||||
class AddToken extends React.Component<Props, State> {
|
||||
state = {
|
||||
done: false,
|
||||
}
|
||||
|
||||
onAddToken = async (values: Object) => {
|
||||
const { addToken, safeAddress } = this.props
|
||||
|
||||
const result = addTokenFnc(values, addToken, safeAddress)
|
||||
this.setState({ done: true })
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
onReset = () => {
|
||||
this.setState({ done: false })
|
||||
}
|
||||
|
||||
fetchInitialPropsSecondPage = async (values: Object) => {
|
||||
const tokenAddress = values[TOKEN_ADRESS_PARAM]
|
||||
const erc20Token = await getHumanFriendlyToken()
|
||||
const instance = await erc20Token.at(tokenAddress)
|
||||
|
||||
const dataName = await instance.contract.name.getData()
|
||||
const nameResult = await promisify(cb => getWeb3().eth.call({ to: tokenAddress, data: dataName }, cb))
|
||||
const hasName = nameResult !== EMPTY_DATA
|
||||
|
||||
const dataSymbol = await instance.contract.symbol.getData()
|
||||
const symbolResult = await promisify(cb => getWeb3().eth.call({ to: tokenAddress, data: dataSymbol }, cb))
|
||||
const hasSymbol = symbolResult !== EMPTY_DATA
|
||||
|
||||
const dataDecimals = await instance.contract.decimals.getData()
|
||||
const decimalsResult = await promisify(cb => getWeb3().eth.call({ to: tokenAddress, data: dataDecimals }, cb))
|
||||
const hasDecimals = decimalsResult !== EMPTY_DATA
|
||||
|
||||
|
||||
const name = hasName ? await instance.name() : undefined
|
||||
const symbol = hasSymbol ? await instance.symbol() : undefined
|
||||
const decimals = hasDecimals ? `${await instance.decimals()}` : undefined
|
||||
|
||||
return ({
|
||||
[TOKEN_SYMBOL_PARAM]: symbol,
|
||||
[TOKEN_DECIMALS_PARAM]: decimals,
|
||||
[TOKEN_NAME_PARAM]: name,
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
const { tokens, safeAddress } = this.props
|
||||
const { done } = this.state
|
||||
const steps = getSteps()
|
||||
const finishedButton = <Stepper.FinishButton title={ADD_TOKEN_RESET_BUTTON_TEXT} />
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Stepper
|
||||
finishedTransaction={done}
|
||||
finishedButton={finishedButton}
|
||||
onSubmit={this.onAddToken}
|
||||
steps={steps}
|
||||
onReset={this.onReset}
|
||||
disabledWhenValidating
|
||||
>
|
||||
<Stepper.Page addresses={tokens} prepareNextInitialProps={this.fetchInitialPropsSecondPage}>
|
||||
{ FirstPage }
|
||||
</Stepper.Page>
|
||||
<Stepper.Page safeAddress={safeAddress}>
|
||||
{ SecondPage }
|
||||
</Stepper.Page>
|
||||
<Stepper.Page>
|
||||
{ Review }
|
||||
</Stepper.Page>
|
||||
</Stepper>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default AddToken
|
|
@ -4,6 +4,7 @@ import * as React from 'react'
|
|||
import Block from '~/components/layout/Block'
|
||||
import Col from '~/components/layout/Col'
|
||||
import AccountBalanceWallet from '@material-ui/icons/AccountBalanceWallet'
|
||||
import AddCircle from '@material-ui/icons/AddCircle'
|
||||
import Link from '~/components/layout/Link'
|
||||
import Bold from '~/components/layout/Bold'
|
||||
import Img from '~/components/layout/Img'
|
||||
|
@ -14,8 +15,8 @@ import { type Token } from '~/routes/tokens/store/model/token'
|
|||
import { type SelectorProps } from '~/routes/tokens/container/selector'
|
||||
import { type Actions } from '~/routes/tokens/container/actions'
|
||||
import { SAFELIST_ADDRESS } from '~/routes/routes'
|
||||
import AddToken from '~/routes/tokens/component/AddToken'
|
||||
import TokenComponent from './Token'
|
||||
// import AddToken from '~/routes/tokens/component/AddToken'
|
||||
// import RemoveToken from '~/routes/tokens/component/RemoveToken'
|
||||
|
||||
const safeIcon = require('~/routes/safe/component/Safe/assets/gnosis_safe.svg')
|
||||
|
@ -35,12 +36,20 @@ class TokenLayout extends React.PureComponent<TokenProps, State> {
|
|||
state = {
|
||||
component: undefined,
|
||||
}
|
||||
/*
|
||||
|
||||
onAddToken = () => {
|
||||
const { addresses } = this.props
|
||||
this.setState({ component: <AddToken/> })
|
||||
const { addresses, safeAddress, addToken } = this.props
|
||||
|
||||
this.setState({
|
||||
component: <AddToken
|
||||
addToken={addToken}
|
||||
tokens={addresses.toArray()}
|
||||
safeAddress={safeAddress}
|
||||
/>,
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
onRemoveToken = () => {
|
||||
this.setState({ component: <RemoveToken /> })
|
||||
}
|
||||
|
@ -68,12 +77,14 @@ class TokenLayout extends React.PureComponent<TokenProps, State> {
|
|||
<Row grow>
|
||||
<Col sm={12} top="xs" md={5} margin="xl" overflow>
|
||||
<MuiList style={listStyle}>
|
||||
{tokens.map((token: Token) => (<TokenComponent
|
||||
key={token.get('symbol')}
|
||||
{tokens.map((token: Token) => (
|
||||
<TokenComponent
|
||||
key={token.get('address')}
|
||||
token={token}
|
||||
onDisableToken={this.onDisableToken}
|
||||
onEnableToken={this.onEnableToken}
|
||||
/>))}
|
||||
/>
|
||||
))}
|
||||
</MuiList>
|
||||
</Col>
|
||||
<Col sm={12} center="xs" md={7} margin="xl" layout="column">
|
||||
|
@ -82,6 +93,9 @@ class TokenLayout extends React.PureComponent<TokenProps, State> {
|
|||
<IconButton to={`${SAFELIST_ADDRESS}/${safeAddress}`} component={Link}>
|
||||
<AccountBalanceWallet />
|
||||
</IconButton>
|
||||
<IconButton onClick={this.onAddToken}>
|
||||
<AddCircle />
|
||||
</IconButton>
|
||||
<Bold>{name}</Bold>
|
||||
</Paragraph>
|
||||
</Block>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
// @flow
|
||||
import addToken from '~/routes/tokens/store/actions/addToken'
|
||||
import enableToken from '~/routes/tokens/store/actions/enableToken'
|
||||
import disableToken from '~/routes/tokens/store/actions/disableToken'
|
||||
import { fetchTokens } from '~/routes/tokens/store/actions/fetchTokens'
|
||||
|
@ -6,9 +7,11 @@ import { fetchTokens } from '~/routes/tokens/store/actions/fetchTokens'
|
|||
export type Actions = {
|
||||
enableToken: typeof enableToken,
|
||||
disableToken: typeof disableToken,
|
||||
addToken: typeof addToken,
|
||||
}
|
||||
|
||||
export default {
|
||||
addToken,
|
||||
enableToken,
|
||||
disableToken,
|
||||
fetchTokens,
|
||||
|
|
|
@ -22,7 +22,7 @@ class TokensView extends React.PureComponent<Props> {
|
|||
|
||||
render() {
|
||||
const {
|
||||
tokens, addresses, safe, safeAddress, disableToken, enableToken,
|
||||
tokens, addresses, safe, safeAddress, disableToken, enableToken, addToken,
|
||||
} = this.props
|
||||
|
||||
return (
|
||||
|
@ -34,6 +34,7 @@ class TokensView extends React.PureComponent<Props> {
|
|||
safeAddress={safeAddress}
|
||||
disableToken={disableToken}
|
||||
enableToken={enableToken}
|
||||
addToken={addToken}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
|
|
|
@ -8,7 +8,7 @@ import { type Token } from '~/routes/tokens/store/model/token'
|
|||
|
||||
export type SelectorProps = {
|
||||
tokens: List<Token>,
|
||||
addresses: List<String>,
|
||||
addresses: List<string>,
|
||||
safe: Safe,
|
||||
safeAddress: string,
|
||||
}
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
// @flow
|
||||
import { createAction } from 'redux-actions'
|
||||
import { type Token } from '~/routes/tokens/store/model/token'
|
||||
|
||||
export const ADD_TOKEN = 'ADD_TOKEN'
|
||||
|
||||
type AddTokenProps = {
|
||||
safeAddress: string,
|
||||
token: Token,
|
||||
}
|
||||
|
||||
const addToken = createAction(
|
||||
ADD_TOKEN,
|
||||
(safeAddress: string, token: Token): AddTokenProps => ({
|
||||
safeAddress,
|
||||
token,
|
||||
}),
|
||||
)
|
||||
|
||||
export default addToken
|
|
@ -8,7 +8,6 @@ const disableToken = createAction(
|
|||
DISABLE_TOKEN,
|
||||
(safeAddress: string, token: Token) => ({
|
||||
safeAddress,
|
||||
symbol: token.get('symbol'),
|
||||
address: token.get('address'),
|
||||
}),
|
||||
)
|
||||
|
|
|
@ -8,7 +8,6 @@ const enableToken = createAction(
|
|||
ENABLE_TOKEN,
|
||||
(safeAddress: string, token: Token) => ({
|
||||
safeAddress,
|
||||
symbol: token.get('symbol'),
|
||||
address: token.get('address'),
|
||||
}),
|
||||
)
|
||||
|
|
|
@ -3,11 +3,12 @@ import { List, Map } from 'immutable'
|
|||
import contract from 'truffle-contract'
|
||||
import type { Dispatch as ReduxDispatch } from 'redux'
|
||||
import StandardToken from '@gnosis.pm/util-contracts/build/contracts/StandardToken.json'
|
||||
import HumanFriendlyToken from '@gnosis.pm/util-contracts/build/contracts/HumanFriendlyToken.json'
|
||||
import { getWeb3 } from '~/wallets/getWeb3'
|
||||
import { type GlobalState } from '~/store/index'
|
||||
import { makeToken, type Token, type TokenProps } from '~/routes/tokens/store/model/token'
|
||||
import { ensureOnce } from '~/utils/singleton'
|
||||
import { getTokens } from '~/utils/localStorage/tokens'
|
||||
import { getActiveTokenAddresses, getTokens } from '~/utils/localStorage/tokens'
|
||||
import { getSafeEthToken } from '~/utils/tokens'
|
||||
import { enhancedFetch } from '~/utils/fetch'
|
||||
import addTokens from './addTokens'
|
||||
|
@ -19,6 +20,15 @@ const createStandardTokenContract = async () => {
|
|||
|
||||
return erc20Token
|
||||
}
|
||||
const createHumanFriendlyTokenContract = async () => {
|
||||
const web3 = getWeb3()
|
||||
const humanErc20Token = await contract(HumanFriendlyToken)
|
||||
humanErc20Token.setProvider(web3.currentProvider)
|
||||
|
||||
return humanErc20Token
|
||||
}
|
||||
|
||||
export const getHumanFriendlyToken = ensureOnce(createHumanFriendlyTokenContract)
|
||||
|
||||
export const getStandardTokenContract = ensureOnce(createStandardTokenContract)
|
||||
|
||||
|
@ -38,9 +48,9 @@ export const fetchTokensData = async () => {
|
|||
|
||||
export const fetchTokens = (safeAddress: string) =>
|
||||
async (dispatch: ReduxDispatch<GlobalState>) => {
|
||||
const tokens: List<string> = getTokens(safeAddress)
|
||||
const tokens: List<string> = getActiveTokenAddresses(safeAddress)
|
||||
const ethBalance = await getSafeEthToken(safeAddress)
|
||||
|
||||
const customTokens = getTokens(safeAddress)
|
||||
const json = await exports.fetchTokensData()
|
||||
|
||||
try {
|
||||
|
@ -51,10 +61,18 @@ export const fetchTokens = (safeAddress: string) =>
|
|||
return makeToken({ ...item, status, funds })
|
||||
}))
|
||||
|
||||
const balances: Map<string, Token> = Map().withMutations((map) => {
|
||||
balancesRecords.forEach(record => map.set(record.get('symbol'), record))
|
||||
const customTokenRecords = await Promise.all(customTokens.map(async (item: TokenProps) => {
|
||||
const status = tokens.includes(item.address)
|
||||
const funds = status ? await calculateBalanceOf(item.address, safeAddress, item.decimals) : '0'
|
||||
|
||||
map.set('ETH', ethBalance)
|
||||
return makeToken({ ...item, status, funds })
|
||||
}))
|
||||
|
||||
const balances: Map<string, Token> = Map().withMutations((map) => {
|
||||
balancesRecords.forEach(record => map.set(record.get('address'), record))
|
||||
customTokenRecords.forEach(record => map.set(record.get('address'), record))
|
||||
|
||||
map.set(ethBalance.get('address'), ethBalance)
|
||||
})
|
||||
|
||||
return dispatch(addTokens(safeAddress, balances))
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
// @flow
|
||||
import { List, Map } from 'immutable'
|
||||
import { handleActions, type ActionType } from 'redux-actions'
|
||||
import addToken, { ADD_TOKEN } from '~/routes/tokens/store/actions/addToken'
|
||||
import addTokens, { ADD_TOKENS } from '~/routes/tokens/store/actions/addTokens'
|
||||
import { type Token } from '~/routes/tokens/store/model/token'
|
||||
import disableToken, { DISABLE_TOKEN } from '~/routes/tokens/store/actions/disableToken'
|
||||
import enableToken, { ENABLE_TOKEN } from '~/routes/tokens/store/actions/enableToken'
|
||||
import { setTokens, getTokens } from '~/utils/localStorage/tokens'
|
||||
import { setActiveTokenAddresses, getActiveTokenAddresses, setToken } from '~/utils/localStorage/tokens'
|
||||
import { ensureOnce } from '~/utils/singleton'
|
||||
import { calculateActiveErc20TokensFrom } from '~/utils/tokens'
|
||||
|
||||
|
@ -13,7 +14,7 @@ export const TOKEN_REDUCER_ID = 'tokens'
|
|||
|
||||
export type State = Map<string, Map<string, Token>>
|
||||
|
||||
const setTokensOnce = ensureOnce(setTokens)
|
||||
const setTokensOnce = ensureOnce(setActiveTokenAddresses)
|
||||
|
||||
export default handleActions({
|
||||
[ADD_TOKENS]: (state: State, action: ActionType<typeof addTokens>): State => {
|
||||
|
@ -30,21 +31,30 @@ export default handleActions({
|
|||
return prevSafe.equals(tokens) ? prevSafe : tokens
|
||||
})
|
||||
},
|
||||
[ADD_TOKEN]: (state: State, action: ActionType<typeof addToken>): State => {
|
||||
const { safeAddress, token } = action.payload
|
||||
|
||||
const tokenAddress = token.get('address')
|
||||
const activeTokens = getActiveTokenAddresses(safeAddress)
|
||||
setActiveTokenAddresses(safeAddress, activeTokens.push(tokenAddress))
|
||||
setToken(safeAddress, token)
|
||||
return state.setIn([safeAddress, tokenAddress], token)
|
||||
},
|
||||
[DISABLE_TOKEN]: (state: State, action: ActionType<typeof disableToken>): State => {
|
||||
const { address, safeAddress, symbol } = action.payload
|
||||
const { address, safeAddress } = action.payload
|
||||
|
||||
const activeTokens = getTokens(safeAddress)
|
||||
const activeTokens = getActiveTokenAddresses(safeAddress)
|
||||
const index = activeTokens.indexOf(address)
|
||||
setTokens(safeAddress, activeTokens.delete(index))
|
||||
setActiveTokenAddresses(safeAddress, activeTokens.delete(index))
|
||||
|
||||
return state.setIn([safeAddress, symbol, 'status'], false)
|
||||
return state.setIn([safeAddress, address, 'status'], false)
|
||||
},
|
||||
[ENABLE_TOKEN]: (state: State, action: ActionType<typeof enableToken>): State => {
|
||||
const { address, safeAddress, symbol } = action.payload
|
||||
const { address, safeAddress } = action.payload
|
||||
|
||||
const activeTokens = getTokens(safeAddress)
|
||||
setTokens(safeAddress, activeTokens.push(address))
|
||||
const activeTokens = getActiveTokenAddresses(safeAddress)
|
||||
setActiveTokenAddresses(safeAddress, activeTokens.push(address))
|
||||
|
||||
return state.setIn([safeAddress, symbol, 'status'], true)
|
||||
return state.setIn([safeAddress, address, 'status'], true)
|
||||
},
|
||||
}, Map())
|
||||
|
|
|
@ -7,6 +7,7 @@ import { type Token } from '~/routes/tokens/store/model/token'
|
|||
import { TOKEN_REDUCER_ID } from '~/routes/tokens/store/reducer/tokens'
|
||||
import { addEtherTo, addTknTo } from '~/test/utils/tokenMovements'
|
||||
import { dispatchTknBalance } from '~/test/utils/transactions/moveTokens.helper'
|
||||
import { ETH_ADDRESS } from '~/utils/tokens'
|
||||
|
||||
describe('Safe - redux balance property', () => {
|
||||
let store
|
||||
|
@ -18,7 +19,16 @@ describe('Safe - redux balance property', () => {
|
|||
|
||||
it('reducer should return 0 to just deployed safe', async () => {
|
||||
// GIVEN
|
||||
const tokenList = ['WE', '<3', 'GNO', 'OMG', 'RDN']
|
||||
const tokenList = [
|
||||
'0x975be7f72cea31fd83d0cb2a197f9136f38696b7', // WE
|
||||
'0xb3a4bc89d8517e0e2c9b66703d09d3029ffa1e6d', // <3
|
||||
'0x5f92161588c6178130ede8cbdc181acec66a9731', // GNO
|
||||
'0xb63d06025d580a94d59801f2513f5d309c079559', // OMG
|
||||
'0x3615757011112560521536258c1E7325Ae3b48AE', // RDN
|
||||
'0xc778417E063141139Fce010982780140Aa0cD5Ab', // Wrapped Ether
|
||||
'0x979861dF79C7408553aAF20c01Cfb3f81CCf9341', // OLY
|
||||
'0', // ETH
|
||||
]
|
||||
|
||||
// WHEN
|
||||
await store.dispatch(fetchTokensAction.fetchTokens(address))
|
||||
|
@ -29,7 +39,7 @@ describe('Safe - redux balance property', () => {
|
|||
|
||||
const safeBalances: Map<string, Token> | typeof undefined = tokens.get(address)
|
||||
if (!safeBalances) throw new Error()
|
||||
expect(safeBalances.size).toBe(6)
|
||||
expect(safeBalances.size).toBe(8)
|
||||
|
||||
tokenList.forEach((token: string) => {
|
||||
const record = safeBalances.get(token)
|
||||
|
@ -49,9 +59,9 @@ describe('Safe - redux balance property', () => {
|
|||
|
||||
const safeBalances: Map<string, Token> | typeof undefined = tokens.get(address)
|
||||
if (!safeBalances) throw new Error()
|
||||
expect(safeBalances.size).toBe(6)
|
||||
expect(safeBalances.size).toBe(8)
|
||||
|
||||
const ethBalance = safeBalances.get('ETH')
|
||||
const ethBalance = safeBalances.get(ETH_ADDRESS)
|
||||
if (!ethBalance) throw new Error()
|
||||
expect(ethBalance.get('funds')).toBe('0.03456')
|
||||
})
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
// @flow
|
||||
import * as TestUtils from 'react-dom/test-utils'
|
||||
import { getWeb3 } from '~/wallets/getWeb3'
|
||||
import { type Match } from 'react-router-dom'
|
||||
import { promisify } from '~/utils/promisify'
|
||||
import TokenComponent from '~/routes/tokens/component/Token'
|
||||
import { getFirstTokenContract, getSecondTokenContract } from '~/test/utils/tokenMovements'
|
||||
import { aNewStore } from '~/store'
|
||||
import { aMinedSafe } from '~/test/builder/safe.redux.builder'
|
||||
import { travelToTokens } from '~/test/builder/safe.dom.utils'
|
||||
import { sleep } from '~/utils/timer'
|
||||
import { buildMathPropsFrom } from '~/test/utils/buildReactRouterProps'
|
||||
import { tokenListSelector } from '~/routes/tokens/store/selectors'
|
||||
import { testToken } from '~/test/builder/tokens.dom.utils'
|
||||
import * as fetchTokensModule from '~/routes/tokens/store/actions/fetchTokens'
|
||||
import * as enhancedFetchModule from '~/utils/fetch'
|
||||
import { clickOnAddToken, fillAddress, fillHumanReadableInfo } from '~/test/utils/tokens/addToken.helper'
|
||||
|
||||
describe('DOM > Feature > Add new ERC 20 Tokens', () => {
|
||||
let web3
|
||||
let accounts
|
||||
let firstErc20Token
|
||||
let secondErc20Token
|
||||
|
||||
beforeAll(async () => {
|
||||
web3 = getWeb3()
|
||||
accounts = await promisify(cb => web3.eth.getAccounts(cb))
|
||||
firstErc20Token = await getFirstTokenContract(web3, accounts[0])
|
||||
secondErc20Token = await getSecondTokenContract(web3, accounts[0])
|
||||
|
||||
// $FlowFixMe
|
||||
enhancedFetchModule.enhancedFetch = jest.fn()
|
||||
enhancedFetchModule.enhancedFetch.mockImplementation(() => Promise.resolve([
|
||||
{
|
||||
address: firstErc20Token.address,
|
||||
name: 'First Token Example',
|
||||
symbol: 'FTE',
|
||||
decimals: 18,
|
||||
logoUrl: 'https://upload.wikimedia.org/wikipedia/commons/c/c0/Earth_simple_icon.png',
|
||||
},
|
||||
]))
|
||||
})
|
||||
|
||||
it('adds a second erc 20 token filling the form', async () => {
|
||||
// GIVEN
|
||||
const store = aNewStore()
|
||||
const safeAddress = await aMinedSafe(store)
|
||||
await store.dispatch(fetchTokensModule.fetchTokens(safeAddress))
|
||||
const TokensDom = await travelToTokens(store, safeAddress)
|
||||
await sleep(400)
|
||||
const tokens = TestUtils.scryRenderedComponentsWithType(TokensDom, TokenComponent)
|
||||
expect(tokens.length).toBe(2)
|
||||
testToken(tokens[0].props.token, 'FTE', false)
|
||||
testToken(tokens[1].props.token, 'ETH', true)
|
||||
|
||||
// WHEN
|
||||
await clickOnAddToken(TokensDom)
|
||||
await fillAddress(TokensDom, secondErc20Token)
|
||||
await fillHumanReadableInfo(TokensDom)
|
||||
|
||||
// THEN
|
||||
const match: Match = buildMathPropsFrom(safeAddress)
|
||||
const tokenList = tokenListSelector(store.getState(), { match })
|
||||
expect(tokenList.count()).toBe(3)
|
||||
|
||||
testToken(tokenList.get(0), 'FTE', false)
|
||||
testToken(tokenList.get(1), 'ETH', true)
|
||||
testToken(tokenList.get(2), 'TKN', true)
|
||||
})
|
||||
})
|
|
@ -13,7 +13,7 @@ import { travelToTokens } from '~/test/builder/safe.dom.utils'
|
|||
import { sleep } from '~/utils/timer'
|
||||
import { buildMathPropsFrom } from '~/test/utils/buildReactRouterProps'
|
||||
import { tokenListSelector, activeTokensSelector } from '~/routes/tokens/store/selectors'
|
||||
import { getTokens } from '~/utils/localStorage/tokens'
|
||||
import { getActiveTokenAddresses } from '~/utils/localStorage/tokens'
|
||||
import { enableFirstToken, testToken } from '~/test/builder/tokens.dom.utils'
|
||||
import * as fetchTokensModule from '~/routes/tokens/store/actions/fetchTokens'
|
||||
import * as enhancedFetchModule from '~/utils/fetch'
|
||||
|
@ -79,12 +79,16 @@ describe('DOM > Feature > Enable and disable default tokens', () => {
|
|||
await addTknTo(safeAddress, 50, firstErc20Token)
|
||||
await addTknTo(safeAddress, 50, secondErc20Token)
|
||||
await store.dispatch(fetchTokensModule.fetchTokens(safeAddress))
|
||||
|
||||
const match: Match = buildMathPropsFrom(safeAddress)
|
||||
let tokenList = tokenListSelector(store.getState(), { match })
|
||||
expect(tokenList.count()).toBe(3)
|
||||
|
||||
await enableFirstToken(store, safeAddress)
|
||||
tokenList = tokenListSelector(store.getState(), { match })
|
||||
expect(tokenList.count()).toBe(3) // assuring the enableToken do not add extra info
|
||||
|
||||
// THEN
|
||||
const match: Match = buildMathPropsFrom(safeAddress)
|
||||
const tokenList = tokenListSelector(store.getState(), { match })
|
||||
|
||||
testToken(tokenList.get(0), 'FTE', true)
|
||||
testToken(tokenList.get(1), 'STE', false)
|
||||
testToken(tokenList.get(2), 'ETH', true)
|
||||
|
@ -108,19 +112,19 @@ describe('DOM > Feature > Enable and disable default tokens', () => {
|
|||
it('localStorage always returns a list', async () => {
|
||||
const store = aNewStore()
|
||||
const safeAddress = await aMinedSafe(store)
|
||||
let tokens: List<string> = getTokens(safeAddress)
|
||||
let tokens: List<string> = getActiveTokenAddresses(safeAddress)
|
||||
expect(tokens).toEqual(List([]))
|
||||
|
||||
await store.dispatch(fetchTokensModule.fetchTokens(safeAddress))
|
||||
tokens = getTokens(safeAddress)
|
||||
tokens = getActiveTokenAddresses(safeAddress)
|
||||
expect(tokens.count()).toBe(0)
|
||||
|
||||
await enableFirstToken(store, safeAddress)
|
||||
tokens = getTokens(safeAddress)
|
||||
tokens = getActiveTokenAddresses(safeAddress)
|
||||
expect(tokens.count()).toBe(1)
|
||||
|
||||
await store.dispatch(fetchTokensModule.fetchTokens(safeAddress))
|
||||
tokens = getTokens(safeAddress)
|
||||
tokens = getActiveTokenAddresses(safeAddress)
|
||||
expect(tokens.count()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
// @flow
|
||||
import { getWeb3 } from '~/wallets/getWeb3'
|
||||
import { type Match } from 'react-router-dom'
|
||||
import { promisify } from '~/utils/promisify'
|
||||
import { getFirstTokenContract, getSecondTokenContract } from '~/test/utils/tokenMovements'
|
||||
import { aNewStore } from '~/store'
|
||||
import { aMinedSafe } from '~/test/builder/safe.redux.builder'
|
||||
import { travelToSafe } from '~/test/builder/safe.dom.utils'
|
||||
import { buildMathPropsFrom } from '~/test/utils/buildReactRouterProps'
|
||||
import { testToken } from '~/test/builder/tokens.dom.utils'
|
||||
import * as fetchTokensModule from '~/routes/tokens/store/actions/fetchTokens'
|
||||
import * as enhancedFetchModule from '~/utils/fetch'
|
||||
import { TOKEN_ADRESS_PARAM } from '~/routes/tokens/component/AddToken/FirstPage'
|
||||
import { TOKEN_NAME_PARAM, TOKEN_DECIMALS_PARAM, TOKEN_SYMBOL_PARAM, TOKEN_LOGO_URL_PARAM } from '~/routes/tokens/component/AddToken/SecondPage'
|
||||
import addToken from '~/routes/tokens/store/actions/addToken'
|
||||
import { addTokenFnc } from '~/routes/tokens/component/AddToken'
|
||||
import { activeTokensSelector } from '~/routes/tokens/store/selectors'
|
||||
|
||||
describe('DOM > Feature > Add new ERC 20 Tokens', () => {
|
||||
let web3
|
||||
let accounts
|
||||
let firstErc20Token
|
||||
let secondErc20Token
|
||||
|
||||
beforeAll(async () => {
|
||||
web3 = getWeb3()
|
||||
accounts = await promisify(cb => web3.eth.getAccounts(cb))
|
||||
firstErc20Token = await getFirstTokenContract(web3, accounts[0])
|
||||
secondErc20Token = await getSecondTokenContract(web3, accounts[0])
|
||||
|
||||
// $FlowFixMe
|
||||
enhancedFetchModule.enhancedFetch = jest.fn()
|
||||
enhancedFetchModule.enhancedFetch.mockImplementation(() => Promise.resolve([
|
||||
{
|
||||
address: firstErc20Token.address,
|
||||
name: 'First Token Example',
|
||||
symbol: 'FTE',
|
||||
decimals: 18,
|
||||
logoUrl: 'https://upload.wikimedia.org/wikipedia/commons/c/c0/Earth_simple_icon.png',
|
||||
},
|
||||
]))
|
||||
})
|
||||
|
||||
it('persist added custom ERC 20 tokens as active when reloading the page', async () => {
|
||||
// GIVEN
|
||||
const store = aNewStore()
|
||||
const safeAddress = await aMinedSafe(store)
|
||||
await store.dispatch(fetchTokensModule.fetchTokens(safeAddress))
|
||||
|
||||
const values = {
|
||||
[TOKEN_ADRESS_PARAM]: secondErc20Token.address,
|
||||
[TOKEN_NAME_PARAM]: 'Custom ERC20 Token',
|
||||
[TOKEN_SYMBOL_PARAM]: 'CTS',
|
||||
[TOKEN_DECIMALS_PARAM]: '10',
|
||||
[TOKEN_LOGO_URL_PARAM]: 'https://example.com',
|
||||
}
|
||||
|
||||
const customAddTokensFn: any = (...args) => store.dispatch(addToken(...args))
|
||||
await addTokenFnc(values, customAddTokensFn, safeAddress)
|
||||
travelToSafe(store, safeAddress)
|
||||
|
||||
// WHEN
|
||||
const reloadedStore = aNewStore()
|
||||
await reloadedStore.dispatch(fetchTokensModule.fetchTokens(safeAddress))
|
||||
travelToSafe(reloadedStore, safeAddress) // reload
|
||||
|
||||
// THEN
|
||||
const match: Match = buildMathPropsFrom(safeAddress)
|
||||
const activeTokenList = activeTokensSelector(reloadedStore.getState(), { match })
|
||||
expect(activeTokenList.count()).toBe(2)
|
||||
|
||||
testToken(activeTokenList.get(0), 'CTS', true)
|
||||
testToken(activeTokenList.get(1), 'ETH', true)
|
||||
})
|
||||
})
|
|
@ -1,6 +1,7 @@
|
|||
// @flow
|
||||
import { getGnosisSafeInstanceAt } from '~/wallets/safeContracts'
|
||||
import Stepper from '~/components/Stepper'
|
||||
import GnoStepper from '~/components/Stepper'
|
||||
import Stepper from '@material-ui/core/Stepper'
|
||||
import TestUtils from 'react-dom/test-utils'
|
||||
|
||||
export const printOutApprove = async (
|
||||
|
@ -29,9 +30,11 @@ export const printOutApprove = async (
|
|||
|
||||
const MAX_TIMES_EXECUTED = 35
|
||||
const INTERVAL = 500
|
||||
|
||||
type FinsihedTx = {
|
||||
finishedTransaction: boolean,
|
||||
}
|
||||
|
||||
export const whenExecuted = (
|
||||
SafeDom: React$Component<any, any>,
|
||||
ParentComponent: React$ElementType,
|
||||
|
@ -45,9 +48,9 @@ export const whenExecuted = (
|
|||
|
||||
// $FlowFixMe
|
||||
const SafeComponent = TestUtils.findRenderedComponentWithType(SafeDom, ParentComponent)
|
||||
type StepperType = React$Component<FinsihedTx, any>
|
||||
type GnoStepperType = React$Component<FinsihedTx, any>
|
||||
// $FlowFixMe
|
||||
const StepperComponent: StepperType = TestUtils.findRenderedComponentWithType(SafeComponent, Stepper)
|
||||
const StepperComponent: GnoStepperType = TestUtils.findRenderedComponentWithType(SafeComponent, GnoStepper)
|
||||
|
||||
if (StepperComponent.props.finishedTransaction === true) {
|
||||
clearInterval(interval)
|
||||
|
@ -56,3 +59,32 @@ export const whenExecuted = (
|
|||
times += 1
|
||||
}, INTERVAL)
|
||||
})
|
||||
|
||||
type MiddleStep = {
|
||||
activeStep: number
|
||||
}
|
||||
|
||||
export const whenOnNext = (
|
||||
SafeDom: React$Component<any, any>,
|
||||
ParentComponent: React$ElementType,
|
||||
position: number,
|
||||
): Promise<void> => new Promise((resolve, reject) => {
|
||||
let times = 0
|
||||
const interval = setInterval(() => {
|
||||
if (times >= MAX_TIMES_EXECUTED) {
|
||||
clearInterval(interval)
|
||||
reject()
|
||||
}
|
||||
|
||||
// $FlowFixMe
|
||||
const SafeComponent = TestUtils.findRenderedComponentWithType(SafeDom, ParentComponent)
|
||||
type StepperType = React$Component<MiddleStep, any>
|
||||
// $FlowFixMe
|
||||
const StepperComponent: StepperType = TestUtils.findRenderedComponentWithType(SafeComponent, Stepper)
|
||||
if (StepperComponent.props.activeStep === position) {
|
||||
clearInterval(interval)
|
||||
resolve()
|
||||
}
|
||||
times += 1
|
||||
}, INTERVAL)
|
||||
})
|
||||
|
|
|
@ -4,7 +4,7 @@ import { getProviderInfo, getBalanceInEtherOf, getWeb3 } from '~/wallets/getWeb3
|
|||
import { promisify } from '~/utils/promisify'
|
||||
import withdraw, { DESTINATION_PARAM, VALUE_PARAM } from '~/routes/safe/component/Withdraw/withdraw'
|
||||
import { type Safe } from '~/routes/safe/store/model/safe'
|
||||
import Token from '#/test/FixedSupplyToken.json'
|
||||
import Token from '#/test/TestToken.json'
|
||||
import { ensureOnce } from '~/utils/singleton'
|
||||
import { toNative } from '~/wallets/tokens'
|
||||
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
// @flow
|
||||
import { sleep } from '~/utils/timer'
|
||||
import * as TestUtils from 'react-dom/test-utils'
|
||||
import AddToken from '~/routes/tokens/component/AddToken'
|
||||
import { whenOnNext, whenExecuted } from '~/test/utils/logTransactions'
|
||||
|
||||
export const clickOnAddToken = async (TokensDom: React$Component<any, any>) => {
|
||||
const buttons = TestUtils.scryRenderedDOMComponentsWithTag(TokensDom, 'button')
|
||||
expect(buttons.length).toBe(1)
|
||||
TestUtils.Simulate.click(buttons[0])
|
||||
await sleep(400)
|
||||
}
|
||||
|
||||
export const fillAddress = async (TokensDom: React$Component<any, any>, secondErc20Token: any) => {
|
||||
// fill the form
|
||||
const AddTokenComponent = TestUtils.findRenderedComponentWithType(TokensDom, AddToken)
|
||||
if (!AddTokenComponent) throw new Error()
|
||||
|
||||
const inputs = TestUtils.scryRenderedDOMComponentsWithTag(AddTokenComponent, 'input')
|
||||
expect(inputs.length).toBe(1)
|
||||
const tokenAddressInput = inputs[0]
|
||||
TestUtils.Simulate.change(tokenAddressInput, { target: { value: `${secondErc20Token.address}` } })
|
||||
// $FlowFixMe
|
||||
const form = TestUtils.findRenderedDOMComponentWithTag(AddTokenComponent, 'form')
|
||||
// submit it
|
||||
TestUtils.Simulate.submit(form)
|
||||
|
||||
return whenOnNext(TokensDom, AddToken, 1)
|
||||
}
|
||||
|
||||
export const fillHumanReadableInfo = async (TokensDom: React$Component<any, any>) => {
|
||||
// fill the form
|
||||
const AddTokenComponent = TestUtils.findRenderedComponentWithType(TokensDom, AddToken)
|
||||
if (!AddTokenComponent) throw new Error()
|
||||
|
||||
const inputs = TestUtils.scryRenderedDOMComponentsWithTag(AddTokenComponent, 'input')
|
||||
expect(inputs.length).toBe(4)
|
||||
TestUtils.Simulate.change(inputs[3], { target: { value: 'https://my.token.image/foo' } })
|
||||
const form = TestUtils.findRenderedDOMComponentWithTag(AddTokenComponent, 'form')
|
||||
|
||||
// submit it
|
||||
TestUtils.Simulate.submit(form)
|
||||
TestUtils.Simulate.submit(form)
|
||||
|
||||
return whenExecuted(TokensDom, AddToken)
|
||||
}
|
|
@ -5,7 +5,6 @@ import { type Owner } from '~/routes/safe/store/model/owner'
|
|||
export const SAFES_KEY = 'SAFES'
|
||||
export const TX_KEY = 'TX'
|
||||
export const OWNERS_KEY = 'OWNERS'
|
||||
export const TOKENS_KEY = 'TOKENS'
|
||||
|
||||
export const load = (key: string) => {
|
||||
try {
|
||||
|
|
|
@ -1,13 +1,18 @@
|
|||
// @flow
|
||||
import { List } from 'immutable'
|
||||
import { load, TOKENS_KEY } from '~/utils/localStorage'
|
||||
import { load } from '~/utils/localStorage'
|
||||
import { type Token, type TokenProps } from '~/routes/tokens/store/model/token'
|
||||
|
||||
export const ACTIVE_TOKENS_KEY = 'ACTIVE_TOKENS'
|
||||
export const TOKENS_KEY = 'TOKENS'
|
||||
|
||||
const getActiveTokensKey = (safeAddress: string) => `${ACTIVE_TOKENS_KEY}-${safeAddress}`
|
||||
const getTokensKey = (safeAddress: string) => `${TOKENS_KEY}-${safeAddress}`
|
||||
|
||||
export const setTokens = (safeAddress: string, tokens: List<string>) => {
|
||||
export const setActiveTokenAddresses = (safeAddress: string, tokens: List<string>) => {
|
||||
try {
|
||||
const serializedState = JSON.stringify(tokens)
|
||||
const key = getTokensKey(safeAddress)
|
||||
const key = getActiveTokensKey(safeAddress)
|
||||
localStorage.setItem(key, serializedState)
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line
|
||||
|
@ -15,14 +20,34 @@ export const setTokens = (safeAddress: string, tokens: List<string>) => {
|
|||
}
|
||||
}
|
||||
|
||||
export const getTokens = (safeAddress: string): List<string> => {
|
||||
const key = getTokensKey(safeAddress)
|
||||
export const getActiveTokenAddresses = (safeAddress: string): List<string> => {
|
||||
const key = getActiveTokensKey(safeAddress)
|
||||
const data = load(key)
|
||||
|
||||
return data ? List(data) : List()
|
||||
}
|
||||
|
||||
export const storedTokensBefore = (safeAddress: string) => {
|
||||
const key = getTokensKey(safeAddress)
|
||||
const key = getActiveTokensKey(safeAddress)
|
||||
return localStorage.getItem(key) === null
|
||||
}
|
||||
|
||||
export const getTokens = (safeAddress: string): List<TokenProps> => {
|
||||
const key = getTokensKey(safeAddress)
|
||||
const data = load(key)
|
||||
|
||||
return data ? List(data) : List()
|
||||
}
|
||||
|
||||
export const setToken = (safeAddress: string, token: Token) => {
|
||||
const data: List<TokenProps> = getTokens(safeAddress)
|
||||
|
||||
try {
|
||||
const serializedState = JSON.stringify(data.push(token))
|
||||
const key = getTokensKey(safeAddress)
|
||||
localStorage.setItem(key, serializedState)
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line
|
||||
console.log('Error adding token in localstorage')
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import logo from '~/assets/icons/icon_etherTokens.svg'
|
|||
import { getBalanceInEtherOf } from '~/wallets/getWeb3'
|
||||
import { makeToken, type Token } from '~/routes/tokens/store/model/token'
|
||||
|
||||
export const ETH_ADDRESS = '0'
|
||||
export const isEther = (symbol: string) => symbol === 'ETH'
|
||||
|
||||
export const getSafeEthToken = async (safeAddress: string) => {
|
||||
|
|
|
@ -5448,8 +5448,8 @@ fill-range@^4.0.0:
|
|||
to-regex-range "^2.1.0"
|
||||
|
||||
final-form@^4.2.1:
|
||||
version "4.8.2"
|
||||
resolved "https://registry.yarnpkg.com/final-form/-/final-form-4.8.2.tgz#905de0a5107f889b1eb86dbbdea96f2d40fcb20d"
|
||||
version "4.8.3"
|
||||
resolved "https://registry.yarnpkg.com/final-form/-/final-form-4.8.3.tgz#86a03da6cd6459ed8fe3737dbd9dc87ed40c11d7"
|
||||
|
||||
finalhandler@1.1.1:
|
||||
version "1.1.1"
|
||||
|
@ -9799,8 +9799,8 @@ react-event-listener@^0.6.0:
|
|||
warning "^4.0.1"
|
||||
|
||||
react-final-form@^3.1.2:
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/react-final-form/-/react-final-form-3.6.2.tgz#fc61b5abfc8e4afb0fd4dea1a4b70cca07aa3e05"
|
||||
version "3.6.4"
|
||||
resolved "https://registry.yarnpkg.com/react-final-form/-/react-final-form-3.6.4.tgz#1ca37935c2af0bc659a53b293dd84a75d2381548"
|
||||
|
||||
react-fuzzy@^0.5.2:
|
||||
version "0.5.2"
|
||||
|
|
Loading…
Reference in New Issue