make token list a separate screen inside tokens list modal, so adding custom token may be implemented

This commit is contained in:
mmv 2019-04-19 15:12:07 +04:00
parent e4453c7d0c
commit 399aa9f84b
10 changed files with 270 additions and 225 deletions

View File

@ -3,7 +3,7 @@ import * as React from 'react'
import Block from '~/components/layout/Block' import Block from '~/components/layout/Block'
import Img from '~/components/layout/Img' import Img from '~/components/layout/Img'
import Paragraph from '~/components/layout/Paragraph' import Paragraph from '~/components/layout/Paragraph'
import TokenPlaceholder from '../Tokens/assets/token_placeholder.svg' import TokenPlaceholder from '../assets/token_placeholder.svg'
const setImageToPlaceholder = (e) => { const setImageToPlaceholder = (e) => {
e.target.onerror = null e.target.onerror = null

View File

@ -1,31 +1,16 @@
// @flow // @flow
import * as React from 'react' import * as React from 'react'
import { connect } from 'react-redux' import { connect } from 'react-redux'
import { List, Set } from 'immutable' import { List } from 'immutable'
import classNames from 'classnames/bind'
import SearchBar from 'material-ui-search-bar'
import { withStyles } from '@material-ui/core/styles' import { withStyles } from '@material-ui/core/styles'
import MuiList from '@material-ui/core/List'
import Img from '~/components/layout/Img'
import Block from '~/components/layout/Block'
import ListItem from '@material-ui/core/ListItem'
import ListItemIcon from '@material-ui/core/ListItemIcon'
import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'
import ListItemText from '@material-ui/core/ListItemText'
import Close from '@material-ui/icons/Close' import Close from '@material-ui/icons/Close'
import Search from '@material-ui/icons/Search'
import IconButton from '@material-ui/core/IconButton' import IconButton from '@material-ui/core/IconButton'
import Paragraph from '~/components/layout/Paragraph' import Paragraph from '~/components/layout/Paragraph'
import Button from '~/components/layout/Button'
import Switch from '@material-ui/core/Switch'
import Divider from '~/components/layout/Divider'
import Hairline from '~/components/layout/Hairline' import Hairline from '~/components/layout/Hairline'
import Spacer from '~/components/Spacer'
import Row from '~/components/layout/Row' import Row from '~/components/layout/Row'
import { ETH_ADDRESS } from '~/logic/tokens/utils/tokenHelpers' import TokenList from '~/routes/safe/component/Balances/Tokens/screens/tokenList'
import { type Token } from '~/logic/tokens/store/model/token' import { type Token } from '~/logic/tokens/store/model/token'
import actions, { type Actions } from './actions' import actions, { type Actions } from './actions'
import TokenPlaceholder from './assets/token_placeholder.svg'
import { styles } from './style' import { styles } from './style'
type Props = Actions & { type Props = Actions & {
@ -37,145 +22,40 @@ type Props = Actions & {
} }
type State = { type State = {
filter: string, activeScreen: string,
activeTokensAddresses: Set<string>,
} }
const filterBy = (filter: string, tokens: List<Token>): List<Token> => tokens.filter(
(token: Token) => !filter
|| token.symbol.toLowerCase().includes(filter.toLowerCase())
|| token.name.toLowerCase().includes(filter.toLowerCase()),
)
// OPTIMIZATION IDEA (Thanks Andre)
// Calculate active tokens on component mount, store it in component state
// After user closes modal, dispatch an action so we dont have 100500 actions
// And selectors dont recalculate
class Tokens extends React.Component<Props, State> { class Tokens extends React.Component<Props, State> {
state = { state = {
activeScreen: '', activeScreen: 'tokenList',
filter: '',
activeTokensAddresses: Set([]),
activeTokensCalculated: false,
}
componentDidMount() {
const { fetchTokens, safeAddress } = this.props
fetchTokens(safeAddress)
}
static getDerivedStateFromProps(nextProps, prevState) {
// I moved this logic here because if placed in ComponentDidMount
// the user would see Switches switch and this method fires before the component mounts
if (!prevState.activeTokensCalculated) {
const { activeTokens } = nextProps
return {
activeTokensAddresses: Set(activeTokens.map(({ address }) => address)),
activeTokensCalculated: true,
}
}
return null
}
componentWillUnmount() {
const { activeTokensAddresses } = this.state
const { updateActiveTokens, safeAddress } = this.props
updateActiveTokens(safeAddress, activeTokensAddresses.toList())
}
onCancelSearch = () => {
this.setState(() => ({ filter: '' }))
}
onChangeSearchBar = (value) => {
this.setState(() => ({ filter: value }))
}
onSwitch = (token: Token) => () => {
const { activeTokensAddresses } = this.state
if (activeTokensAddresses.has(token.address)) {
this.setState({
activeTokensAddresses: activeTokensAddresses.remove(token.address),
})
} else {
this.setState({
activeTokensAddresses: activeTokensAddresses.add(token.address),
})
}
}
setImageToPlaceholder = (e) => {
e.target.onerror = null
e.target.src = TokenPlaceholder
} }
render() { render() {
const { onClose, classes, tokens } = this.props const {
const { filter, activeTokensAddresses } = this.state onClose, classes, tokens, activeTokens, fetchTokens, updateActiveTokens, safeAddress,
const searchClasses = { } = this.props
input: classes.searchInput, const { activeScreen } = this.state
root: classes.searchRoot,
iconButton: classes.searchIcon,
searchContainer: classes.searchContainer,
}
const filteredTokens = filterBy(filter, tokens)
return ( return (
<React.Fragment> <React.Fragment>
<Block className={classes.root}> <Row align="center" grow className={classes.heading}>
<Row align="center" grow className={classes.heading}> <Paragraph className={classes.manage} noMargin>
<Paragraph className={classes.manage} noMargin> Manage Tokens
Manage Tokens </Paragraph>
</Paragraph> <IconButton onClick={onClose} disableRipple>
<IconButton onClick={onClose} disableRipple> <Close className={classes.close} />
<Close className={classes.close} /> </IconButton>
</IconButton> </Row>
</Row> <Hairline />
<Hairline /> {activeScreen === 'tokenList' && (
<Row align="center" className={classNames(classes.padding, classes.actions)}> <TokenList
<Search className={classes.search} /> tokens={tokens}
<SearchBar activeTokens={activeTokens}
placeholder="Search by name or symbol" fetchTokens={fetchTokens}
classes={searchClasses} updateActiveTokens={updateActiveTokens}
searchIcon={<div />} safeAddress={safeAddress}
onChange={this.onChangeSearchBar} />
onCancelSearch={this.onCancelSearch} )}
/>
<Spacer />
<Divider />
<Spacer />
<Button variant="contained" size="small" color="secondary" className={classes.add}>
+ ADD CUSTOM TOKEN
</Button>
</Row>
<Hairline />
</Block>
<MuiList className={classes.list}>
{filteredTokens.map((token: Token) => {
const isActive = activeTokensAddresses.has(token.address)
return (
<ListItem key={token.address} className={classes.token}>
<ListItemIcon>
<Img src={token.logoUri} height={28} alt={token.name} onError={this.setImageToPlaceholder} />
</ListItemIcon>
<ListItemText primary={token.symbol} secondary={token.name} />
{token.address !== ETH_ADDRESS && (
<ListItemSecondaryAction>
<Switch onChange={this.onSwitch(token)} checked={isActive} />
</ListItemSecondaryAction>
)}
</ListItem>
)
})}
</MuiList>
</React.Fragment> </React.Fragment>
) )
} }

View File

@ -1,13 +0,0 @@
// @flow
import React, { Component } from 'react'
export default class TokenLIst extends Component {
render() {
return (
<div>
token list
</div>
)
}
}

View File

@ -1,4 +0,0 @@
// @flow
import TokenList from './TokenList'
export default TokenList

View File

@ -0,0 +1,167 @@
// @flow
import * as React from 'react'
import { List, Set } from 'immutable'
import cn from 'classnames'
import SearchBar from 'material-ui-search-bar'
import { withStyles } from '@material-ui/core/styles'
import MuiList from '@material-ui/core/List'
import Img from '~/components/layout/Img'
import Block from '~/components/layout/Block'
import ListItem from '@material-ui/core/ListItem'
import ListItemIcon from '@material-ui/core/ListItemIcon'
import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'
import ListItemText from '@material-ui/core/ListItemText'
import Search from '@material-ui/icons/Search'
import Button from '~/components/layout/Button'
import Switch from '@material-ui/core/Switch'
import Divider from '~/components/layout/Divider'
import Hairline from '~/components/layout/Hairline'
import Spacer from '~/components/Spacer'
import Row from '~/components/layout/Row'
import { ETH_ADDRESS } from '~/logic/tokens/utils/tokenHelpers'
import { type Token } from '~/logic/tokens/store/model/token'
import { setImageToPlaceholder } from '~/routes/safe/component/Balances/utils'
import { styles } from './style'
type Props = {
classes: Object,
tokens: List<Token>,
safeAddress: string,
activeTokens: List<Token>,
fetchTokens: Function,
updateActiveTokens: Function,
}
type State = {
filter: string,
activeTokensAddresses: Set<string>,
}
const filterBy = (filter: string, tokens: List<Token>): List<Token> => tokens.filter(
(token: Token) => !filter
|| token.symbol.toLowerCase().includes(filter.toLowerCase())
|| token.name.toLowerCase().includes(filter.toLowerCase()),
)
// OPTIMIZATION IDEA (Thanks Andre)
// Calculate active tokens on component mount, store it in component state
// After user closes modal, dispatch an action so we dont have 100500 actions
// And selectors dont recalculate
class Tokens extends React.Component<Props, State> {
state = {
filter: '',
activeTokensAddresses: Set([]),
activeTokensCalculated: false,
}
componentDidMount() {
const { fetchTokens } = this.props
fetchTokens()
}
static getDerivedStateFromProps(nextProps, prevState) {
// I moved this logic here because if placed in ComponentDidMount
// the user would see Switches switch and this method fires before the component mounts
if (!prevState.activeTokensCalculated) {
const { activeTokens } = nextProps
return {
activeTokensAddresses: Set(activeTokens.map(({ address }) => address)),
activeTokensCalculated: true,
}
}
return null
}
componentWillUnmount() {
const { activeTokensAddresses } = this.state
const { updateActiveTokens, safeAddress } = this.props
updateActiveTokens(safeAddress, activeTokensAddresses.toList())
}
onCancelSearch = () => {
this.setState(() => ({ filter: '' }))
}
onChangeSearchBar = (value) => {
this.setState(() => ({ filter: value }))
}
onSwitch = (token: Token) => () => {
const { activeTokensAddresses } = this.state
if (activeTokensAddresses.has(token.address)) {
this.setState({
activeTokensAddresses: activeTokensAddresses.remove(token.address),
})
} else {
this.setState({
activeTokensAddresses: activeTokensAddresses.add(token.address),
})
}
}
render() {
const { classes, tokens } = this.props
const { filter, activeTokensAddresses } = this.state
const searchClasses = {
input: classes.searchInput,
root: classes.searchRoot,
iconButton: classes.searchIcon,
searchContainer: classes.searchContainer,
}
const filteredTokens = filterBy(filter, tokens)
return (
<React.Fragment>
<Block className={classes.root}>
<Row align="center" className={cn(classes.padding, classes.actions)}>
<Search className={classes.search} />
<SearchBar
placeholder="Search by name or symbol"
classes={searchClasses}
searchIcon={<div />}
onChange={this.onChangeSearchBar}
onCancelSearch={this.onCancelSearch}
/>
<Spacer />
<Divider />
<Spacer />
<Button variant="contained" size="small" color="secondary" className={classes.add}>
+ ADD CUSTOM TOKEN
</Button>
</Row>
<Hairline />
</Block>
<MuiList className={classes.list}>
{filteredTokens.map((token: Token) => {
const isActive = activeTokensAddresses.has(token.address)
return (
<ListItem key={token.address} className={classes.token}>
<ListItemIcon>
<Img src={token.logoUri} height={28} alt={token.name} onError={setImageToPlaceholder} />
</ListItemIcon>
<ListItemText primary={token.symbol} secondary={token.name} />
{token.address !== ETH_ADDRESS && (
<ListItemSecondaryAction>
<Switch onChange={this.onSwitch(token)} checked={isActive} />
</ListItemSecondaryAction>
)}
</ListItem>
)
})}
</MuiList>
</React.Fragment>
)
}
}
const TokenComponent = withStyles(styles)(Tokens)
export default TokenComponent

View File

@ -0,0 +1,63 @@
// @flow
import {
md, sm, xs, mediumFontSize, border,
} from '~/theme/variables'
export const styles = () => ({
root: {
minHeight: '48px',
},
search: {
color: '#a2a8ba',
paddingLeft: sm,
},
padding: {
padding: `0 ${md}`,
},
add: {
fontWeight: 'normal',
paddingRight: md,
paddingLeft: md,
},
list: {
overflow: 'hidden',
overflowY: 'scroll',
padding: 0,
height: '100%',
},
token: {
minHeight: '50px',
borderBottom: `1px solid ${border}`,
},
searchInput: {
backgroundColor: 'transparent',
lineHeight: 'initial',
fontSize: mediumFontSize,
padding: 0,
'& > input::placeholder': {
letterSpacing: '-0.5px',
fontSize: mediumFontSize,
color: 'black',
},
'& > input': {
letterSpacing: '-0.5px',
},
},
searchContainer: {
width: '180px',
marginLeft: xs,
marginRight: xs,
},
searchRoot: {
letterSpacing: '-0.5px',
fontFamily: 'Roboto Mono, monospace',
fontSize: mediumFontSize,
border: 'none',
boxShadow: 'none',
},
searchIcon: {
'&:hover': {
backgroundColor: 'transparent !important',
},
},
})

View File

@ -1,76 +1,18 @@
// @flow // @flow
import { import { lg, sm } from '~/theme/variables'
lg, md, sm, xs, mediumFontSize, border,
} from '~/theme/variables'
export const styles = () => ({ export const styles = () => ({
root: {
minHeight: '127px',
},
heading: { heading: {
padding: `${sm} ${lg}`, padding: `${sm} ${lg}`,
justifyContent: 'space-between', justifyContent: 'space-between',
maxHeight: '75px',
boxSizing: 'border-box',
}, },
manage: { manage: {
fontSize: '24px', fontSize: '24px',
}, },
actions: {
height: '50px',
},
close: { close: {
height: '35px', height: '35px',
width: '35px', width: '35px',
}, },
search: {
color: '#a2a8ba',
paddingLeft: sm,
},
padding: {
padding: `0 ${md}`,
},
add: {
fontWeight: 'normal',
paddingRight: md,
paddingLeft: md,
},
list: {
overflow: 'hidden',
overflowY: 'scroll',
padding: 0,
},
token: {
minHeight: '50px',
borderBottom: `1px solid ${border}`,
},
searchInput: {
backgroundColor: 'transparent',
lineHeight: 'initial',
fontSize: mediumFontSize,
padding: 0,
'& > input::placeholder': {
letterSpacing: '-0.5px',
fontSize: mediumFontSize,
color: 'black',
},
'& > input': {
letterSpacing: '-0.5px',
},
},
searchContainer: {
width: '180px',
marginLeft: xs,
marginRight: xs,
},
searchRoot: {
letterSpacing: '-0.5px',
fontFamily: 'Roboto Mono, monospace',
fontSize: mediumFontSize,
border: 'none',
boxShadow: 'none',
},
searchIcon: {
'&:hover': {
backgroundColor: 'transparent !important',
},
},
}) })

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,3 @@
// @flow
export * from './setTokenImgToPlaceholder'

View File

@ -0,0 +1,7 @@
// @flow
import TokenPlaceholder from '~/routes/safe/component/Balances/assets/token_placeholder.svg'
export const setImageToPlaceholder = (e) => {
e.target.onerror = null
e.target.src = TokenPlaceholder
}