Merge pull request #53 from gnosis/feature/WA-232-remove-custom-token

WA-232 - Feature:  remove custom ERC20 tokens
This commit is contained in:
Adolfo Panizo 2018-07-27 13:14:00 +02:00 committed by GitHub
commit 614fa659de
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 376 additions and 23 deletions

View File

@ -16,8 +16,8 @@ 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 RemoveToken from '~/routes/tokens/component/RemoveToken'
import TokenComponent from './Token'
// import RemoveToken from '~/routes/tokens/component/RemoveToken'
const safeIcon = require('~/routes/safe/component/Safe/assets/gnosis_safe.svg')
@ -49,11 +49,23 @@ class TokenLayout extends React.PureComponent<TokenProps, State> {
})
}
/*
onRemoveToken = () => {
this.setState({ component: <RemoveToken /> })
}
*/
onReset = () => {
this.setState({ component: undefined })
}
onRemoveToken = (token: Token) => {
const { safeAddress, removeToken } = this.props
this.setState({
component: <RemoveToken
token={token}
safeAddress={safeAddress}
removeTokenAction={removeToken}
onReset={this.onReset}
/>,
})
}
onEnableToken = (token: Token) => {
const { enableToken, safe } = this.props
const safeAddress = safe.get('address')
@ -83,6 +95,7 @@ class TokenLayout extends React.PureComponent<TokenProps, State> {
token={token}
onDisableToken={this.onDisableToken}
onEnableToken={this.onEnableToken}
onRemove={this.onRemoveToken}
/>
))}
</MuiList>

View File

@ -0,0 +1,38 @@
// @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'
type Props = {
name: string,
funds: string,
symbol: string,
}
type FormProps = {
submitting: boolean,
}
const spinnerStyle = {
minHeight: '50px',
}
const Review = ({ name, funds, symbol }: Props) => ({ submitting }: FormProps) => (
<Block>
<Heading tag="h2">Remove CUSTOM ERC 20 Token</Heading>
<Paragraph align="left">
<Bold>You are about to remove the custom token: </Bold> {name}
</Paragraph>
<Paragraph align="left">
<Bold>{`You have ${funds} ${symbol} in your wallet`}</Bold>
</Paragraph>
<Block style={spinnerStyle}>
{ submitting && <CircularProgress size={50} /> }
</Block>
</Block>
)
export default Review

View File

@ -0,0 +1,73 @@
// @flow
import * as React from 'react'
import { type Token } from '~/routes/tokens/store/model/token'
import Stepper from '~/components/Stepper'
import RemoveTokenAction from '~/routes/tokens/store/actions/removeToken'
import Review from '~/routes/tokens/component/RemoveToken/Review'
const getSteps = () => [
'Review remove token operation',
]
type Props = {
token: Token,
safeAddress: string,
removeTokenAction: typeof RemoveTokenAction,
onReset: () => void,
}
type State = {
done: boolean,
}
export const REMOVE_TOKEN_RESET_BUTTON_TEXT = 'RESET'
export const removeToken = async (safeAddress: string, token: Token, removeTokenAction: typeof RemoveTokenAction) =>
removeTokenAction(safeAddress, token)
class RemoveToken extends React.PureComponent<Props, State> {
state = {
done: false,
}
onRemoveReset = () => {
this.setState({ done: false }, this.props.onReset())
}
executeRemoveOperation = async () => {
try {
const { token, safeAddress, removeTokenAction } = this.props
await removeToken(safeAddress, token, removeTokenAction)
this.setState({ done: true })
} catch (error) {
this.setState({ done: false })
// eslint-disable-next-line
console.log('Error while removing owner ' + error)
}
}
render() {
const { done } = this.state
const { token } = this.props
const finishedButton = <Stepper.FinishButton title={REMOVE_TOKEN_RESET_BUTTON_TEXT} />
const steps = getSteps()
return (
<React.Fragment>
<Stepper
finishedTransaction={done}
finishedButton={finishedButton}
onSubmit={this.executeRemoveOperation}
steps={steps}
onReset={this.onRemoveReset}
>
<Stepper.Page name={token.get('name')} symbol={token.get('symbol')} funds={token.get('funds')}>
{ Review }
</Stepper.Page>
</Stepper>
</React.Fragment>
)
}
}
export default RemoveToken

View File

@ -9,13 +9,13 @@ import CardContent from '@material-ui/core/CardContent'
import CardMedia from '@material-ui/core/CardMedia'
import Typography from '@material-ui/core/Typography'
import { isEther } from '~/utils/tokens'
// import Delete from '@material-ui/icons/Delete'
// import IconButton from '@material-ui/core/IconButton'
import Delete from '@material-ui/icons/Delete'
import IconButton from '@material-ui/core/IconButton'
import { type WithStyles } from '~/theme/mui'
type Props = WithStyles & {
token: Token,
onRemoveToken: (balance: Token)=> void,
onRemove: (token: Token)=> void,
onEnableToken: (token: Token) => void,
onDisableToken: (token: Token) => void,
}
@ -42,12 +42,12 @@ const styles = () => ({
},
})
class TokenComponent extends React.Component<Props, State> {
class TokenComponent extends React.PureComponent<Props, State> {
state = {
checked: this.props.token.get('status'),
}
// onRemoveClick = () => this.props.onRemoveToken(this.props.token)
onRemoveClick = () => this.props.onRemove(this.props.token)
handleChange = (e: SyntheticInputEvent<HTMLInputElement>) => {
const { checked } = e.target
@ -75,16 +75,14 @@ class TokenComponent extends React.Component<Props, State> {
color="primary"
/>
{symbol}
{ token.get('removable') &&
<IconButton aria-label="Delete" onClick={this.onRemoveClick}>
<Delete />
</IconButton>
}
</Typography>
</CardContent>
</Block>
{/*
<Block className={classes.controls}>
<IconButton aria-label="Delete" onClick={this.onRemoveClick}>
<Delete />
</IconButton>
</Block>
*/}
<CardMedia
className={classes.cover}
image={token.get('logoUrl')}

View File

@ -1,5 +1,6 @@
// @flow
import addToken from '~/routes/tokens/store/actions/addToken'
import removeToken from '~/routes/tokens/store/actions/removeToken'
import enableToken from '~/routes/tokens/store/actions/enableToken'
import disableToken from '~/routes/tokens/store/actions/disableToken'
import { fetchTokens } from '~/routes/tokens/store/actions/fetchTokens'
@ -8,10 +9,12 @@ export type Actions = {
enableToken: typeof enableToken,
disableToken: typeof disableToken,
addToken: typeof addToken,
removeToken: typeof removeToken,
}
export default {
addToken,
removeToken,
enableToken,
disableToken,
fetchTokens,

View File

@ -22,7 +22,7 @@ class TokensView extends React.PureComponent<Props> {
render() {
const {
tokens, addresses, safe, safeAddress, disableToken, enableToken, addToken,
tokens, addresses, safe, safeAddress, disableToken, enableToken, addToken, removeToken,
} = this.props
return (
@ -35,6 +35,7 @@ class TokensView extends React.PureComponent<Props> {
disableToken={disableToken}
enableToken={enableToken}
addToken={addToken}
removeToken={removeToken}
/>
</Page>
)

View File

@ -0,0 +1,20 @@
// @flow
import { createAction } from 'redux-actions'
import { type Token } from '~/routes/tokens/store/model/token'
export const REMOVE_TOKEN = 'REMOVE_TOKEN'
type RemoveTokenProps = {
safeAddress: string,
token: Token,
}
const removeToken = createAction(
REMOVE_TOKEN,
(safeAddress: string, token: Token): RemoveTokenProps => ({
safeAddress,
token,
}),
)
export default removeToken

View File

@ -2,11 +2,12 @@
import { List, Map } from 'immutable'
import { handleActions, type ActionType } from 'redux-actions'
import addToken, { ADD_TOKEN } from '~/routes/tokens/store/actions/addToken'
import removeToken, { REMOVE_TOKEN } from '~/routes/tokens/store/actions/removeToken'
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 { setActiveTokenAddresses, getActiveTokenAddresses, setToken } from '~/utils/localStorage/tokens'
import { setActiveTokenAddresses, getActiveTokenAddresses, setToken, removeTokenFromStorage } from '~/utils/localStorage/tokens'
import { ensureOnce } from '~/utils/singleton'
import { calculateActiveErc20TokensFrom } from '~/utils/tokens'
@ -16,6 +17,12 @@ export type State = Map<string, Map<string, Token>>
const setTokensOnce = ensureOnce(setActiveTokenAddresses)
const removeFromActiveTokens = (safeAddress: string, tokenAddress: string) => {
const activeTokens = getActiveTokenAddresses(safeAddress)
const index = activeTokens.indexOf(tokenAddress)
setActiveTokenAddresses(safeAddress, activeTokens.delete(index))
}
export default handleActions({
[ADD_TOKENS]: (state: State, action: ActionType<typeof addTokens>): State => {
const { safeAddress, tokens } = action.payload
@ -40,12 +47,18 @@ export default handleActions({
setToken(safeAddress, token)
return state.setIn([safeAddress, tokenAddress], token)
},
[REMOVE_TOKEN]: (state: State, action: ActionType<typeof removeToken>): State => {
const { safeAddress, token } = action.payload
const tokenAddress = token.get('address')
removeFromActiveTokens(safeAddress, tokenAddress)
removeTokenFromStorage(safeAddress, token)
return state.removeIn([safeAddress, tokenAddress])
},
[DISABLE_TOKEN]: (state: State, action: ActionType<typeof disableToken>): State => {
const { address, safeAddress } = action.payload
const activeTokens = getActiveTokenAddresses(safeAddress)
const index = activeTokens.indexOf(address)
setActiveTokenAddresses(safeAddress, activeTokens.delete(index))
removeFromActiveTokens(safeAddress, address)
return state.setIn([safeAddress, address, 'status'], false)
},

View File

@ -0,0 +1,83 @@
// @flow
import * as TestUtils from 'react-dom/test-utils'
import { getWeb3 } from '~/wallets/getWeb3'
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 { travelToTokens } from '~/test/builder/safe.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_SYMBOL_PARAM, TOKEN_DECIMALS_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 { sleep } from '~/utils/timer'
import TokenComponent from '~/routes/tokens/component/Token'
import { testToken } from '~/test/builder/tokens.dom.utils'
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('remove custom ERC 20 tokens', 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)
const TokensDom = travelToTokens(store, safeAddress)
await sleep(400)
// WHEN
const buttons = TestUtils.scryRenderedDOMComponentsWithTag(TokensDom, 'button')
expect(buttons.length).toBe(2)
const removeUserButton = buttons[0]
expect(removeUserButton.getAttribute('aria-label')).toBe('Delete')
TestUtils.Simulate.click(removeUserButton)
await sleep(400)
const form = TestUtils.findRenderedDOMComponentWithTag(TokensDom, 'form')
// submit it
TestUtils.Simulate.submit(form)
TestUtils.Simulate.submit(form)
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)
})
})

View File

@ -0,0 +1,97 @@
// @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, tokenListSelector } from '~/routes/tokens/store/selectors'
import removeTokenAction from '~/routes/tokens/store/actions/removeToken'
import { makeToken } from '~/routes/tokens/store/model/token'
import { removeToken } from '~/routes/tokens/component/RemoveToken'
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',
},
]))
})
const checkTokensOf = (store: Store, safeAddress: string) => {
const match: Match = buildMathPropsFrom(safeAddress)
const activeTokenList = activeTokensSelector(store.getState(), { match })
expect(activeTokenList.count()).toBe(1)
testToken(activeTokenList.get(0), 'ETH', true)
const tokenList = tokenListSelector(store.getState(), { match })
expect(tokenList.count()).toBe(2)
testToken(tokenList.get(0), 'FTE', false)
testToken(tokenList.get(1), 'ETH', true)
}
it('removes custom ERC 20 including page reload', 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)
const token = makeToken({
address: secondErc20Token.address,
name: 'Custom ERC20 Token',
symbol: 'CTS',
decimals: 10,
logoUrl: 'https://example.com',
status: true,
removable: true,
})
const customRemoveTokensFnc: any = (...args) => store.dispatch(removeTokenAction(...args))
await removeToken(safeAddress, token, customRemoveTokensFnc)
checkTokensOf(store, safeAddress)
// WHEN
const reloadedStore = aNewStore()
await reloadedStore.dispatch(fetchTokensModule.fetchTokens(safeAddress))
travelToSafe(reloadedStore, safeAddress) // reload
// THEN
checkTokensOf(reloadedStore, safeAddress)
})
})

View File

@ -51,3 +51,17 @@ export const setToken = (safeAddress: string, token: Token) => {
console.log('Error adding token in localstorage')
}
}
export const removeTokenFromStorage = (safeAddress: string, token: Token) => {
const data: List<TokenProps> = getTokens(safeAddress)
try {
const index = data.indexOf(token)
const serializedState = JSON.stringify(data.remove(index))
const key = getTokensKey(safeAddress)
localStorage.setItem(key, serializedState)
} catch (err) {
// eslint-disable-next-line
console.log('Error removing token in localstorage')
}
}