(Feature) Erc721 modal lists (#661)

* Add Assets sections

* (add) collectibles tab

* (add) criptokitty items

* (add) collectible items, definitive edition

* (fix) collectibles were overlapping with bottom banner

* (fix) wording

* (fix) responsive issues

* Install `async-sema` dependency

* Create collectible source classes

- source from mocked data and opensea, it's extendable to import information from other sources

* Update `Collectible` implementation to use new data source

* Create constants file to better handle env variables and default values

* Add description to item's cards

- also added a mocked class with real data

* Fix `saveTxToHistory`, remove hardcoded `CALL`

* Fix after merge development

* Set background color for collectible based on data info

- Changed `withStyles` in favor of a hook-like approach with `makeStyles`

* Enhance collectible card info and group title

* Use current safeAddress to query for collectibles information

- also migrated from `withStyles` to `makeStyles`

* Use proper key values for lists and set more significant names

* update yarn.lock after merge

* Fix linting error

* Move ethAsToken verification outside loop

* Use absolute route for `SendModal` import

* Move Collectibles into redux store

* Update yarn.lock file

* Selectable NFTs

* Divide the `collectible` store into `nftAssets` and `nftTokens`

- Also updated components to retain functionality
- Created a `textShortener` function for better presentation

* Update `yarn.lock`

* Update `yarn.lock`

* Fix item background color

* Clears the tokenID select field when the collectible selected changes

* Open Send modal from the assets section

* Use token name for the token selection dropdown

* Refactor Balances tabs: reduces the amount of props received, exported tokens lists to a component

* Refactor Balances tabs: reduces the amount of props received, exported tokens lists to a component

* Add openZeppelin contracts dependency

* Create ERC721 getter

* Fix types, default values and clean code

* Fix: properly refresh list of collectibles when switching safes

* Add ReviewCollectible step in send NFT

* Displays the assets in the manage list

* Fixs add custom token/asset modal cancel button

* Change items shadow

* Give option to choose what to send by clicking 'Send' button in AddressBook

* Disable [Send] button for Collectibles if not owner

* Set Coins as default option in assets tab

- also fixed styles for `Coins` option

* Use collectible icon in send modal

* Set default message when no assets available

- removed pagination feature

* Create SafeVersionProvider to better handle version-related tasks

Provides:
- current and latest versions,
- a boolean indicating a need for update,
- an upgradeSafe callback to trigger upgrade from any place,
- a list of enabled features, depending on the current version
  - the latter needs a refactor like extract features outside the provider
   and define constants for the features.

* Force build

* Update `yarn.lock`

* Disable Manage list for NFTs

* Implements manage list to add/remove assets

* Implements manage list to add/remove assets

* Merge branch 'feature/#469-ERC721-feature-implementation' of https://github.com/gnosis/safe-react into feature/#469-ERC721-feature-implementation

# Conflicts:
#	src/routes/safe/components/Balances/Collectibles/index.jsx
#	src/routes/safe/components/Balances/index.jsx
#	src/utils/constants.js

* Implements blacklisted assets

* Fix container shadow

- Also fixes tables shadow, thanks to @gabitoesmiapodo

* Enable nested routes for balances (assets) tab

* Default to `/balance` if invalid nested path

* Disable [Send Collectible] button, if not supported by safe

* Change sub-menu buttons to clickable text

* Replace Paragraph with Link

* Fix invalid props errors for Link component

* Fallback to `transferFrom` if `safeTransferFrom` is not implemented

* Use `transfer` as fallback to ERC-721's `safeTransferFrom`

- need to identify ERC721 token using `transfer` and `name` methods

* Display failed transactions

* Use react.lazy for collectibles' modals

* Identify ERC-721 token transaction

* Adds initial components for AddCustomAsset support

* Fix Send Collectibles modal layout/behavior

- disable dropdown list if there's no item to pick
- fix placeholder for tokens list
- fix dropdown list styles

* Set default `isSuccessful` flag to `true`

* Fixs erc721Enabled check

* Adds margin to modal icon
Fix search bar

* Fix hidding buttons in coins table

* Fixs display all available assets by default

* Fixs modal assets

* Fixs blacklistedAssetsAddresses save to storage
Fixs show token button

Co-authored-by: fernandomg <fernando.greco@gmail.com>
Co-authored-by: Gabriel Rodriguez Alsina <gabriel.rodriguez@altoros.com>
This commit is contained in:
Agustin Pane 2020-03-25 17:40:31 -03:00 committed by GitHub
parent 5d7fa6428f
commit b5bc0304f3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1158 additions and 219 deletions

View File

@ -0,0 +1,133 @@
// @flow
import * as React from 'react'
import { useSelector } from 'react-redux'
import semverLessThan from 'semver/functions/lt'
import satisfies from 'semver/functions/satisfies'
import semverValid from 'semver/functions/valid'
import Modal from '~/components/Modal'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
import { getCurrentMasterContractLastVersion } from '~/logic/safe/utils/safeVersion'
import UpdateSafeModal from '~/routes/safe/components/Settings/UpdateSafeModal'
import { safeParamAddressFromStateSelector } from '~/routes/safe/store/selectors'
type TSafeVersion = {
currentVersion: string,
featuresEnabled: string[],
lastVersion: string,
needsUpdate: boolean,
upgradeSafe: () => void,
}
export const SafeVersionContext = React.createContext<TSafeVersion>({
currentVersion: '',
featuresEnabled: [],
lastVersion: '',
needsUpdate: false,
upgradeSafe: () => {},
})
const SafeVersionProvider = ({ children }: { children: React.Node }) => {
const safeAddress = useSelector(safeParamAddressFromStateSelector)
const { currentVersion, lastVersion } = useSafeVersions(safeAddress)
const needsUpdate = useUpdateChecker(currentVersion, lastVersion)
const featuresEnabled = useFeaturesEnabled(currentVersion)
const [willUpgrade, setWillUpgrade] = React.useState(false)
const upgradeSafe = () => {
setWillUpgrade(true)
}
const closeUpgrade = () => {
setWillUpgrade(false)
}
return (
<SafeVersionContext.Provider value={{ currentVersion, featuresEnabled, lastVersion, needsUpdate, upgradeSafe }}>
<>
{children}
<Modal description="Update Safe" handleClose={closeUpgrade} open={willUpgrade} title="Update Safe">
<UpdateSafeModal onClose={closeUpgrade} safeAddress={safeAddress} />
</Modal>
</>
</SafeVersionContext.Provider>
)
}
function useSafeVersions(safeAddress: string) {
const [versions, setVersions] = React.useState({ currentVersion: '', lastVersion: '' })
React.useEffect(() => {
let isCurrent = true
async function getVersions() {
const [currentSafeInstance, lastSafeVersion] = await Promise.all([
getGnosisSafeInstanceAt(safeAddress),
getCurrentMasterContractLastVersion(),
])
const version = await currentSafeInstance.VERSION()
setVersions({ currentVersion: version, lastVersion: lastSafeVersion })
}
if (isCurrent) {
getVersions().catch(console.error)
}
return () => {
isCurrent = false
}
}, [safeAddress])
return versions
}
function useUpdateChecker(currentVersion, lastSafeVersion) {
const [needsUpdate, setNeedsUpdate] = React.useState(false)
React.useEffect(() => {
let isCurrent = true
async function checkIfNeedsUpdate() {
const current = semverValid(currentVersion)
const latest = semverValid(lastSafeVersion)
const needsUpdate = latest ? semverLessThan(current, latest) : false
setNeedsUpdate(needsUpdate)
}
if (isCurrent) {
checkIfNeedsUpdate()
}
return () => {
isCurrent = false
}
}, [currentVersion])
return needsUpdate
}
function useFeaturesEnabled(currentVersion) {
const [featuresEnabled, setFeaturesEnabled] = React.useState([])
React.useEffect(() => {
const features = [
{ name: 'ERC721', validVersion: '>=1.1.1' },
{ name: 'ERC1155', validVersion: '>=1.1.1' },
]
const enabledFeatures = features.reduce((acc, feature) => {
if (satisfies(currentVersion, feature.validVersion)) {
acc.push(feature.name)
}
return acc
}, [])
setFeaturesEnabled(enabledFeatures)
}, [currentVersion])
return featuresEnabled
}
export default SafeVersionProvider

View File

@ -14,6 +14,7 @@ import CookiesBanner from '~/components/CookiesBanner'
import Footer from '~/components/Footer'
import Header from '~/components/Header'
import Notifier from '~/components/Notifier'
import SafeVersionProvider from '~/components/SafeVersionProvider'
import SidebarProvider from '~/components/Sidebar'
import Backdrop from '~/components/layout/Backdrop'
import Img from '~/components/layout/Img'
@ -67,11 +68,13 @@ const PageFrame = ({ children, classes, currentNetwork }: Props) => {
maxSnack={5}
>
<Notifier />
<SidebarProvider>
<Header />
{children}
<Footer />
</SidebarProvider>
<SafeVersionProvider>
<SidebarProvider>
<Header />
{children}
<Footer />
</SidebarProvider>
</SafeVersionProvider>
</SnackbarProvider>
<CookiesBanner />
</div>

View File

@ -1,6 +1,38 @@
// @flow
import { List, Set } from 'immutable'
import type { Selector } from 'reselect'
import { createSelector } from 'reselect'
import { NFT_ASSETS_REDUCER_ID, NFT_TOKENS_REDUCER_ID } from '~/logic/collectibles/store/reducer/collectibles'
import type { NFTAssets } from '~/routes/safe/components/Balances/Collectibles/types'
import { safeActiveAssetsSelector } from '~/routes/safe/store/selectors'
import type { GlobalState } from '~/store'
export const nftAssetsSelector = (state: GlobalState) => state[NFT_ASSETS_REDUCER_ID]
export const nftTokensSelector = (state: GlobalState) => state[NFT_TOKENS_REDUCER_ID]
export const nftAssetsListSelector: Selector<GlobalState, NFTAssets, List<NFTAssets>> = createSelector(
nftAssetsSelector,
(assets: NFTAssets) => {
return assets ? List(Object.entries(assets).map(item => item[1])) : List([])
},
)
export const activeNftAssetsListSelector: Selector<GlobalState, NFTAssets, List<NFTAssets>> = createSelector(
nftAssetsListSelector,
safeActiveAssetsSelector,
(assets: List<NFTAssets>, activeAssetsList: Set<string>) => {
return assets.filter(asset => activeAssetsList.has(asset.address))
},
)
export const safeActiveSelectorMap: Selector<GlobalState, NFTAssets, List<NFTAssets>> = createSelector(
activeNftAssetsListSelector,
(activeAssets: List<NFTAssets>) => {
let assetsMap = {}
activeAssets.forEach(asset => {
assetsMap[asset.address] = asset
})
return assetsMap
},
)

View File

@ -0,0 +1,49 @@
// @flow
import type { Dispatch as ReduxDispatch } from 'redux'
import fetchCollectibles from '~/logic/collectibles/store/actions/fetchCollectibles'
import { nftAssetsSelector } from '~/logic/collectibles/store/selectors'
import updateActiveAssets from '~/routes/safe/store/actions/updateActiveAssets'
import {
safeActiveAssetsSelectorBySafe,
safeBlacklistedAssetsSelectorBySafe,
safesMapSelector,
} from '~/routes/safe/store/selectors'
import { type GetState, type GlobalState } from '~/store'
const activateAssetsByBalance = (safeAddress: string) => async (
dispatch: ReduxDispatch<GlobalState>,
getState: GetState,
) => {
try {
await dispatch(fetchCollectibles())
const state = getState()
const safes = safesMapSelector(state)
const availableAssets = nftAssetsSelector(state)
const alreadyActiveAssets = safeActiveAssetsSelectorBySafe(safeAddress, safes)
const blacklistedAssets = safeBlacklistedAssetsSelectorBySafe(safeAddress, safes)
// active tokens by balance, excluding those already blacklisted and the `null` address
const activeByBalance = Object.entries(availableAssets)
.filter(asset => {
const { address, numberOfTokens } = asset[1]
return address !== null && !blacklistedAssets.has(address) && numberOfTokens > 0
})
.map(asset => {
return asset[0]
})
// need to persist those already active assets, despite its balances
const activeAssets = alreadyActiveAssets.union(activeByBalance)
// update list of active tokens
dispatch(updateActiveAssets(safeAddress, activeAssets))
} catch (err) {
console.error('Error fetching active assets list', err)
}
return null
}
export default activateAssetsByBalance

View File

@ -2,6 +2,7 @@
import { List } from 'immutable'
import logo from '~/assets/icons/icon_etherTokens.svg'
import { getStandardTokenContract } from '~/logic/tokens/store/actions/fetchTokens'
import { type Token, makeToken } from '~/logic/tokens/store/model/token'
import { getWeb3 } from '~/logic/wallets/getWeb3'
@ -65,3 +66,15 @@ export const isMultisendTransaction = (data: string, value: number): boolean =>
// 7de7edef - changeMasterCopy (550, 8)
export const isUpgradeTransaction = (data: string) =>
!!data && data.substr(308, 8) === 'f08a0323' && data.substr(550, 8) === '7de7edef'
export const isERC721Contract = async (contractAddress: string): boolean => {
const ERC721Token = await getStandardTokenContract()
let isERC721 = false
try {
isERC721 = true
await ERC721Token.at(contractAddress)
} catch (error) {
console.warn('Asset not found')
}
return isERC721
}

View File

@ -89,14 +89,12 @@ export const getAccountFrom: Function = async (web3Provider): Promise<string | n
}
const getNetworkIdFrom = async web3Provider => {
const networkId = await web3Provider.eth.net.getId()
return networkId
return await web3Provider.eth.net.getId()
}
export const getProviderInfo: Function = async (
web3Provider,
providerName?: string = 'Wallet',
providerName: string = 'Wallet',
): Promise<ProviderProps> => {
web3 = new Web3(web3Provider)
@ -117,9 +115,7 @@ export const getProviderInfo: Function = async (
export const getAddressFromENS = async (name: string) => {
const ens = new ENS(web3)
const address = await ens.resolver(name).addr()
return address
return await ens.resolver(name).addr()
}
export const setWeb3 = (provider: Object) => {

View File

@ -0,0 +1,129 @@
// @flow
import TableCell from '@material-ui/core/TableCell'
import TableContainer from '@material-ui/core/TableContainer'
import TableRow from '@material-ui/core/TableRow'
import { makeStyles } from '@material-ui/core/styles'
import CallMade from '@material-ui/icons/CallMade'
import CallReceived from '@material-ui/icons/CallReceived'
import classNames from 'classnames/bind'
import React from 'react'
import { useSelector } from 'react-redux'
import { styles } from './styles'
import Table from '~/components/Table'
import type { Column } from '~/components/Table/TableHead'
import { cellWidth } from '~/components/Table/TableHead'
import Button from '~/components/layout/Button'
import Row from '~/components/layout/Row'
import { currencyValuesListSelector, currentCurrencySelector } from '~/logic/currencyValues/store/selectors'
import { BALANCE_ROW_TEST_ID } from '~/routes/safe/components/Balances'
import AssetTableCell from '~/routes/safe/components/Balances/AssetTableCell'
import type { BalanceRow } from '~/routes/safe/components/Balances/dataFetcher'
import {
BALANCE_TABLE_ASSET_ID,
BALANCE_TABLE_BALANCE_ID,
BALANCE_TABLE_VALUE_ID,
generateColumns,
getBalanceData,
} from '~/routes/safe/components/Balances/dataFetcher'
import { extendedSafeTokensSelector, grantedSelector } from '~/routes/safe/container/selector'
const useStyles = makeStyles(styles)
type Props = {
showSendFunds: Function,
showReceiveFunds: Function,
}
const Coins = (props: Props) => {
const { showReceiveFunds, showSendFunds } = props
const classes = useStyles()
const columns = generateColumns()
const autoColumns = columns.filter(c => !c.custom)
const currencySelected = useSelector(currentCurrencySelector)
const activeTokens = useSelector(extendedSafeTokensSelector)
const currencyValues = useSelector(currencyValuesListSelector)
const granted = useSelector(grantedSelector)
const filteredData = getBalanceData(activeTokens, currencySelected, currencyValues)
return (
<TableContainer>
<Table
columns={columns}
data={filteredData}
defaultFixed
defaultOrderBy={BALANCE_TABLE_ASSET_ID}
defaultRowsPerPage={10}
label="Balances"
size={filteredData.size}
>
{(sortedData: Array<BalanceRow>) =>
sortedData.map((row: any, index: number) => (
<TableRow className={classes.hide} data-testid={BALANCE_ROW_TEST_ID} key={index} tabIndex={-1}>
{autoColumns.map((column: Column) => {
const { align, id, width } = column
let cellItem
switch (id) {
case BALANCE_TABLE_ASSET_ID: {
cellItem = <AssetTableCell asset={row[id]} />
break
}
case BALANCE_TABLE_BALANCE_ID: {
cellItem = <div>{row[id]}</div>
break
}
case BALANCE_TABLE_VALUE_ID: {
cellItem = <div className={classes.currencyValueRow}>{row[id]}</div>
break
}
default: {
cellItem = null
break
}
}
return (
<TableCell align={align} component="td" key={id} style={cellWidth(width)}>
{cellItem}
</TableCell>
)
})}
<TableCell component="td">
<Row align="end" className={classes.actions}>
{granted && (
<Button
className={classes.send}
color="primary"
onClick={() => showSendFunds(row.asset.address)}
size="small"
testId="balance-send-btn"
variant="contained"
>
<CallMade alt="Send Transaction" className={classNames(classes.leftIcon, classes.iconSmall)} />
Send
</Button>
)}
<Button
className={classes.receive}
color="primary"
onClick={showReceiveFunds}
size="small"
variant="contained"
>
<CallReceived
alt="Receive Transaction"
className={classNames(classes.leftIcon, classes.iconSmall)}
/>
Receive
</Button>
</Row>
</TableCell>
</TableRow>
))
}
</Table>
</TableContainer>
)
}
export default Coins

View File

@ -0,0 +1,47 @@
// @flow
import { sm, xs } from '~/theme/variables'
export const styles = () => ({
iconSmall: {
fontSize: 16,
},
hide: {
'&:hover': {
backgroundColor: '#fff3e2',
},
'&:hover $actions': {
visibility: 'initial',
},
'&:focus $actions': {
visibility: 'initial',
},
},
actions: {
justifyContent: 'flex-end',
visibility: 'hidden',
},
receive: {
width: '95px',
minWidth: '95px',
marginLeft: sm,
borderRadius: xs,
'& > span': {
fontSize: '14px',
},
},
send: {
width: '75px',
minWidth: '75px',
borderRadius: xs,
'& > span': {
fontSize: '14px',
},
},
leftIcon: {
marginRight: sm,
},
currencyValueRow: {
maxWidth: '125px',
textAlign: 'right',
},
})

View File

@ -7,9 +7,10 @@ import { useSelector } from 'react-redux'
import Item from './components/Item'
import Paragraph from '~/components/layout/Paragraph'
import type { NFTAssetsState, NFTTokensState } from '~/logic/collectibles/store/reducer/collectibles'
import { nftAssetsSelector, nftTokensSelector } from '~/logic/collectibles/store/selectors'
import type { NFTTokensState } from '~/logic/collectibles/store/reducer/collectibles'
import { activeNftAssetsListSelector, nftTokensSelector } from '~/logic/collectibles/store/selectors'
import SendModal from '~/routes/safe/components/Balances/SendModal'
import { safeSelector } from '~/routes/safe/store/selectors'
import { fontColor, lg, screenSm, screenXs } from '~/theme/variables'
const useStyles = makeStyles({
@ -79,9 +80,9 @@ const Collectibles = () => {
const classes = useStyles()
const [selectedToken, setSelectedToken] = React.useState({})
const [sendNFTsModalOpen, setSendNFTsModalOpen] = React.useState(false)
const nftAssets: NFTAssetsState = useSelector(nftAssetsSelector)
const { address, ethBalance, name } = useSelector(safeSelector)
const nftTokens: NFTTokensState = useSelector(nftTokensSelector)
const nftAssetsKeys = Object.keys(nftAssets)
const activeAssetsList = useSelector(activeNftAssetsListSelector)
const handleItemSend = nftToken => {
setSelectedToken(nftToken)
@ -91,10 +92,8 @@ const Collectibles = () => {
return (
<Card className={classes.cardOuter}>
<div className={classes.cardInner}>
{nftAssetsKeys.length ? (
nftAssetsKeys.map(assetAddress => {
const nftAsset = nftAssets[assetAddress]
{activeAssetsList.size ? (
activeAssetsList.map(nftAsset => {
return (
<React.Fragment key={nftAsset.slug}>
<div className={classes.title}>
@ -122,8 +121,11 @@ const Collectibles = () => {
</div>
<SendModal
activeScreenType="sendCollectible"
ethBalance={ethBalance}
isOpen={sendNFTsModalOpen}
onClose={() => setSendNFTsModalOpen(false)}
safeAddress={address}
safeName={name}
selectedToken={selectedToken}
/>
</Card>

View File

@ -24,7 +24,7 @@ import Img from '~/components/layout/Img'
import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row'
import type { NFTAssetsState, NFTTokensState } from '~/logic/collectibles/store/reducer/collectibles'
import { nftAssetsSelector, nftTokensSelector } from '~/logic/collectibles/store/selectors'
import { nftTokensSelector, safeActiveSelectorMap } from '~/logic/collectibles/store/selectors'
import type { NFTToken } from '~/routes/safe/components/Balances/Collectibles/types'
import SafeInfo from '~/routes/safe/components/Balances/SendModal/SafeInfo'
import AddressBookInput from '~/routes/safe/components/Balances/SendModal/screens/AddressBookInput'
@ -58,7 +58,7 @@ const useStyles = makeStyles(styles)
const SendCollectible = ({ initialValues, onClose, onNext, recipientAddress, selectedToken = {} }: Props) => {
const classes = useStyles()
const { address: safeAddress, ethBalance, name: safeName } = useSelector(safeSelector)
const nftAssets: NFTAssetsState = useSelector(nftAssetsSelector)
const nftAssets: NFTAssetsState = useSelector(safeActiveSelectorMap)
const nftTokens: NFTTokensState = useSelector(nftTokensSelector)
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false)
const [selectedEntry, setSelectedEntry] = useState<Object | null>({

View File

@ -4,7 +4,7 @@ import { withStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close'
import { List } from 'immutable'
import React, { useState } from 'react'
import { connect } from 'react-redux'
import { connect, useSelector } from 'react-redux'
import actions, { type Actions } from './actions'
import { styles } from './style'
@ -13,11 +13,18 @@ import Hairline from '~/components/layout/Hairline'
import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row'
import { type Token } from '~/logic/tokens/store/model/token'
import { orderedTokenListSelector } from '~/logic/tokens/store/selectors'
import AddCustomAssetComponent from '~/routes/safe/components/Balances/Tokens/screens/AddCustomAsset'
import AddCustomToken from '~/routes/safe/components/Balances/Tokens/screens/AddCustomToken'
import AssetsList from '~/routes/safe/components/Balances/Tokens/screens/AssetsList'
import TokenList from '~/routes/safe/components/Balances/Tokens/screens/TokenList'
import { extendedSafeTokensSelector } from '~/routes/safe/container/selector'
import { safeBlacklistedTokensSelector } from '~/routes/safe/store/selectors'
export const MANAGE_TOKENS_MODAL_CLOSE_BUTTON_TEST_ID = 'manage-tokens-close-modal-btn'
type ActiveScreen = 'tokenList' | 'addCustomToken' | 'assetsList' | 'addCustomAsset'
type Props = Actions & {
onClose: () => void,
classes: Object,
@ -25,24 +32,25 @@ type Props = Actions & {
safeAddress: string,
activeTokens: List<Token>,
blacklistedTokens: List<Token>,
modalScreen: ActiveScreen,
}
type ActiveScreen = 'tokenList' | 'addCustomToken'
const Tokens = (props: Props) => {
const [activeScreen, setActiveScreen] = useState<ActiveScreen>('tokenList')
const {
activateTokenForAllSafes,
activeTokens,
addToken,
blacklistedTokens,
classes,
fetchTokens,
modalScreen,
onClose,
safeAddress,
tokens,
updateActiveTokens,
updateBlacklistedTokens,
} = props
const tokens = useSelector(orderedTokenListSelector)
const activeTokens = useSelector(extendedSafeTokensSelector)
const blacklistedTokens = useSelector(safeBlacklistedTokensSelector)
const [activeScreen, setActiveScreen] = useState<ActiveScreen>(modalScreen)
return (
<>
@ -67,18 +75,23 @@ const Tokens = (props: Props) => {
updateBlacklistedTokens={updateBlacklistedTokens}
/>
)}
{activeScreen === 'assetsList' && <AssetsList setActiveScreen={setActiveScreen} />}
{activeScreen === 'addCustomToken' && (
<AddCustomToken
activateTokenForAllSafes={activateTokenForAllSafes}
activeTokens={activeTokens}
addToken={addToken}
onClose={onClose}
parentList={'tokenList'}
safeAddress={safeAddress}
setActiveScreen={setActiveScreen}
tokens={tokens}
updateActiveTokens={updateActiveTokens}
/>
)}
{activeScreen === 'addCustomAsset' && (
<AddCustomAssetComponent onClose={onClose} parentList={'assetsList'} setActiveScreen={setActiveScreen} />
)}
</>
)
}

View File

@ -0,0 +1,190 @@
// @flow
import { withStyles } from '@material-ui/core/styles'
import { List } from 'immutable'
import React, { useState } from 'react'
import { FormSpy } from 'react-final-form'
import { useSelector } from 'react-redux'
import { styles } from './style'
import { getSymbolAndDecimalsFromContract } from './utils'
import Checkbox from '~/components/forms/Checkbox'
import Field from '~/components/forms/Field'
import GnoForm from '~/components/forms/GnoForm'
import TextField from '~/components/forms/TextField'
import { composeValidators, minMaxLength, mustBeEthereumAddress, required } from '~/components/forms/validator'
import Block from '~/components/layout/Block'
import Button from '~/components/layout/Button'
import Col from '~/components/layout/Col'
import Hairline from '~/components/layout/Hairline'
import Img from '~/components/layout/Img'
import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row'
import type { NFTAssetsState } from '~/logic/collectibles/store/reducer/collectibles'
import { nftAssetsListSelector } from '~/logic/collectibles/store/selectors'
import { type Token, type TokenProps } from '~/logic/tokens/store/model/token'
import {
addressIsAssetContract,
doesntExistInAssetsList,
} from '~/routes/safe/components/Balances/Tokens/screens/AddCustomAsset/validators'
import TokenPlaceholder from '~/routes/safe/components/Balances/assets/token_placeholder.svg'
export const ADD_CUSTOM_ASSET_ADDRESS_INPUT_TEST_ID = 'add-custom-asset-address-input'
export const ADD_CUSTOM_ASSET_SYMBOLS_INPUT_TEST_ID = 'add-custom-asset-symbols-input'
export const ADD_CUSTOM_ASSET_DECIMALS_INPUT_TEST_ID = 'add-custom-asset-decimals-input'
export const ADD_CUSTOM_ASSET_FORM = 'add-custom-asset-form'
type Props = {
classes: Object,
addToken: Function,
updateActiveTokens: Function,
safeAddress: string,
activeTokens: List<TokenProps>,
tokens: List<Token>,
setActiveScreen: Function,
onClose: Function,
activateTokenForAllSafes: Function,
parentList: 'assetsList' | 'tokenList',
}
const INITIAL_FORM_STATE = {
address: '',
decimals: '',
symbol: '',
logoUri: '',
}
const AddCustomAsset = (props: Props) => {
const { classes, onClose, parentList, setActiveScreen } = props
const nftAssetsList: NFTAssetsState = useSelector(nftAssetsListSelector)
const [formValues, setFormValues] = useState(INITIAL_FORM_STATE)
const handleSubmit = () => {
onClose()
}
const populateFormValuesFromAddress = async (tokenAddress: string) => {
const tokenData = await getSymbolAndDecimalsFromContract(tokenAddress)
if (tokenData.length) {
const [symbol, decimals] = tokenData
setFormValues({
address: tokenAddress,
symbol,
decimals,
name: symbol,
})
}
}
const formSpyOnChangeHandler = async state => {
const { dirty, errors, submitSucceeded, validating, values } = state
// for some reason this is called after submitting, we don't need to update the values
// after submit
if (submitSucceeded) {
return
}
if (dirty && !validating && errors.address) {
setFormValues(INITIAL_FORM_STATE)
}
if (!errors.address && !validating && dirty) {
await populateFormValuesFromAddress(values.address)
}
}
const goBack = () => {
setActiveScreen(parentList)
}
return (
<>
<GnoForm initialValues={formValues} onSubmit={handleSubmit} testId={ADD_CUSTOM_ASSET_FORM}>
{() => (
<>
<Block className={classes.formContainer}>
<Paragraph className={classes.title} noMargin size="lg" weight="bolder">
Add custom asset
</Paragraph>
<Field
className={classes.addressInput}
component={TextField}
name="address"
placeholder="Asset contract address*"
testId={ADD_CUSTOM_ASSET_ADDRESS_INPUT_TEST_ID}
text="Token contract address*"
type="text"
validate={composeValidators(
required,
mustBeEthereumAddress,
doesntExistInAssetsList(nftAssetsList),
addressIsAssetContract,
)}
/>
<FormSpy
onChange={formSpyOnChangeHandler}
subscription={{
values: true,
errors: true,
validating: true,
dirty: true,
submitSucceeded: true,
}}
/>
<Row>
<Col layout="column" xs={6}>
<Field
className={classes.addressInput}
component={TextField}
name="symbol"
placeholder="Token symbol*"
testId={ADD_CUSTOM_ASSET_SYMBOLS_INPUT_TEST_ID}
text="Token symbol"
type="text"
validate={composeValidators(required, minMaxLength(2, 12))}
/>
<Field
className={classes.addressInput}
component={TextField}
disabled
name="decimals"
placeholder="Token decimals*"
testId={ADD_CUSTOM_ASSET_DECIMALS_INPUT_TEST_ID}
text="Token decimals*"
type="text"
/>
<Block justify="left">
<Field className={classes.checkbox} component={Checkbox} name="showForAllSafes" type="checkbox" />
<Paragraph className={classes.checkboxLabel} size="md" weight="bolder">
Activate assets for all Safes
</Paragraph>
</Block>
</Col>
<Col align="center" layout="column" xs={6}>
<Paragraph className={classes.tokenImageHeading}>Token Image</Paragraph>
<Img alt="Token image" height={100} src={TokenPlaceholder} />
</Col>
</Row>
</Block>
<Hairline />
<Row align="center" className={classes.buttonRow}>
<Button minHeight={42} minWidth={140} onClick={goBack}>
Cancel
</Button>
<Button color="primary" minHeight={42} minWidth={140} type="submit" variant="contained">
Save
</Button>
</Row>
</>
)}
</GnoForm>
</>
)
}
const AddCustomAssetComponent = withStyles(styles)(AddCustomAsset)
export default AddCustomAssetComponent

View File

@ -0,0 +1,34 @@
// @flow
import { lg, md } from '~/theme/variables'
export const styles = () => ({
title: {
padding: `${lg} 0 20px`,
fontSize: md,
},
formContainer: {
padding: '0 20px',
minHeight: '369px',
},
addressInput: {
marginBottom: '15px',
display: 'flex',
flexGrow: 1,
backgroundColor: 'red',
},
tokenImageHeading: {
margin: '0 0 15px',
},
checkbox: {
padding: '0 7px 0 0',
width: '18px',
height: '18px',
},
checkboxLabel: {
letterSpacing: '-0.5px',
},
buttonRow: {
height: '84px',
justifyContent: 'center',
},
})

View File

@ -0,0 +1,17 @@
// @flow
import { getHumanFriendlyToken } from '~/logic/tokens/store/actions/fetchTokens'
export const getSymbolAndDecimalsFromContract = async (tokenAddress: string) => {
const tokenContract = await getHumanFriendlyToken()
const token = await tokenContract.at(tokenAddress)
let values
try {
const [symbol, decimals] = await Promise.all([token.symbol(), token.decimals()])
values = [symbol, decimals.toString()]
} catch {
values = []
}
return values
}

View File

@ -0,0 +1,25 @@
// @flow
import { List } from 'immutable'
import { simpleMemoize } from '~/components/forms/validator'
import { isERC721Contract } from '~/logic/tokens/utils/tokenHelpers'
import { sameAddress } from '~/logic/wallets/ethAddresses'
import type { NFTAsset } from '~/routes/safe/components/Balances/Collectibles/types'
// eslint-disable-next-line
export const addressIsAssetContract = simpleMemoize(async (tokenAddress: string) => {
const isAsset = await isERC721Contract(tokenAddress)
if (!isAsset) {
return 'Not a asset address'
}
})
// eslint-disable-next-line
export const doesntExistInAssetsList = (assetsList: List<NFTAsset>) =>
simpleMemoize((tokenAddress: string) => {
const tokenIndex = assetsList.findIndex(({ address }) => sameAddress(address, tokenAddress))
if (tokenIndex !== -1) {
return 'Token already exists in your token list'
}
})

View File

@ -38,6 +38,7 @@ type Props = {
setActiveScreen: Function,
onClose: Function,
activateTokenForAllSafes: Function,
parentList: 'assetsList' | 'tokenList',
}
const INITIAL_FORM_STATE = {
@ -54,6 +55,7 @@ const AddCustomToken = (props: Props) => {
addToken,
classes,
onClose,
parentList,
safeAddress,
setActiveScreen,
tokens,
@ -112,8 +114,8 @@ const AddCustomToken = (props: Props) => {
}
}
const goBackToTokenList = () => {
setActiveScreen('tokenList')
const goBack = () => {
setActiveScreen(parentList)
}
return (
@ -187,7 +189,7 @@ const AddCustomToken = (props: Props) => {
</Block>
<Hairline />
<Row align="center" className={classes.buttonRow}>
<Button minHeight={42} minWidth={140} onClick={goBackToTokenList}>
<Button minHeight={42} minWidth={140} onClick={goBack}>
Cancel
</Button>
<Button color="primary" minHeight={42} minWidth={140} type="submit" variant="contained">

View File

@ -0,0 +1,60 @@
// @flow
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 Switch from '@material-ui/core/Switch'
import { withStyles } from '@material-ui/core/styles'
import { List, Set } from 'immutable'
import React, { memo } from 'react'
import { styles } from './style'
import Img from '~/components/layout/Img'
import { ETH_ADDRESS } from '~/logic/tokens/utils/tokenHelpers'
import type { NFTAsset } from '~/routes/safe/components/Balances/Collectibles/types'
import { setImageToPlaceholder } from '~/routes/safe/components/Balances/utils'
export const TOGGLE_ASSET_TEST_ID = 'toggle-asset-btn'
type Props = {
data: {
activeAssetsAddresses: Set<string>,
assets: List<NFTAsset>,
onSwitch: Function,
},
style: Object,
index: number,
classes: Object,
}
// eslint-disable-next-line react/display-name
const AssetRow = memo(({ classes, data, index, style }: Props) => {
const { activeAssetsAddresses, assets, onSwitch } = data
const asset: NFTAsset = assets.get(index)
const { address, image, name, symbol } = asset
const isActive = activeAssetsAddresses.has(asset.address)
return (
<div style={style}>
<ListItem classes={{ root: classes.tokenRoot }} className={classes.token}>
<ListItemIcon className={classes.tokenIcon}>
<Img alt={name} height={28} onError={setImageToPlaceholder} src={image} />
</ListItemIcon>
<ListItemText primary={symbol} secondary={name} />
{address !== ETH_ADDRESS && (
<ListItemSecondaryAction>
<Switch
checked={isActive}
inputProps={{ 'data-testid': `${symbol}_${TOGGLE_ASSET_TEST_ID}` }}
onChange={onSwitch(asset)}
/>
</ListItemSecondaryAction>
)}
</ListItem>
</div>
)
})
export default withStyles(styles)(AssetRow)

View File

@ -0,0 +1,164 @@
// @flow
import CircularProgress from '@material-ui/core/CircularProgress'
import MuiList from '@material-ui/core/List'
import { makeStyles } from '@material-ui/core/styles'
import Search from '@material-ui/icons/Search'
import cn from 'classnames'
import { List } from 'immutable'
import SearchBar from 'material-ui-search-bar'
import React, { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { FixedSizeList } from 'react-window'
import { styles } from './style'
import Spacer from '~/components/Spacer'
import Block from '~/components/layout/Block'
import Button from '~/components/layout/Button'
import Divider from '~/components/layout/Divider'
import Hairline from '~/components/layout/Hairline'
import Row from '~/components/layout/Row'
import type { NFTAssetsState } from '~/logic/collectibles/store/reducer/collectibles'
import { nftAssetsListSelector } from '~/logic/collectibles/store/selectors'
import type { NFTAsset } from '~/routes/safe/components/Balances/Collectibles/types'
import AssetRow from '~/routes/safe/components/Balances/Tokens/screens/AssetsList/AssetRow'
import updateActiveAssets from '~/routes/safe/store/actions/updateActiveAssets'
import updateBlacklistedAssets from '~/routes/safe/store/actions/updateBlacklistedAssets'
import {
safeActiveAssetsListSelector,
safeBlacklistedAssetsSelector,
safeParamAddressFromStateSelector,
} from '~/routes/safe/store/selectors'
const useStyles = makeStyles(styles)
export const ADD_CUSTOM_ASSET_BUTTON_TEST_ID = 'add-custom-asset-btn'
type Props = {
setActiveScreen: Function,
}
const filterBy = (filter: string, nfts: List<NFTAsset>): List<NFTAsset> =>
nfts.filter(
(asset: NFTAsset) =>
!filter ||
asset.description.toLowerCase().includes(filter.toLowerCase()) ||
asset.name.toLowerCase().includes(filter.toLowerCase()) ||
asset.symbol.toLowerCase().includes(filter.toLowerCase()),
)
const AssetsList = (props: Props) => {
const classes = useStyles()
const searchClasses = {
input: classes.searchInput,
root: classes.searchRoot,
iconButton: classes.searchIcon,
searchContainer: classes.searchContainer,
}
const dispatch = useDispatch()
const activeAssetsList = useSelector(safeActiveAssetsListSelector)
const blacklistedAssets = useSelector(safeBlacklistedAssetsSelector)
const safeAddress = useSelector(safeParamAddressFromStateSelector)
const [filterValue, setFilterValue] = useState('')
const [activeAssetsAddresses, setActiveAssetsAddresses] = useState(activeAssetsList)
const [blacklistedAssetsAddresses, setBlacklistedAssetsAddresses] = useState(blacklistedAssets)
const nftAssetsList: NFTAssetsState = useSelector(nftAssetsListSelector)
useEffect(() => {
dispatch(updateActiveAssets(safeAddress, activeAssetsAddresses))
dispatch(updateBlacklistedAssets(safeAddress, blacklistedAssetsAddresses))
}, [activeAssetsAddresses, blacklistedAssetsAddresses])
const onCancelSearch = () => {
setFilterValue('')
}
const onChangeSearchBar = (value: string) => {
setFilterValue(value)
}
const getItemKey = index => {
return index
}
const onSwitch = (asset: NFTAsset) => () => {
const { address } = asset
const activeAssetsAddressesResult = activeAssetsAddresses.contains(address)
? activeAssetsAddresses.remove(address)
: activeAssetsAddresses.add(address)
const blacklistedAssetsAddressesResult = activeAssetsAddresses.has(address)
? blacklistedAssetsAddresses.add(address)
: blacklistedAssetsAddresses.remove(address)
setActiveAssetsAddresses(activeAssetsAddressesResult)
setBlacklistedAssetsAddresses(blacklistedAssetsAddressesResult)
return {
activeAssetsAddresses: activeAssetsAddressesResult,
blacklistedAssetsAddresses: blacklistedAssetsAddressesResult,
}
}
const createItemData = assetsList => {
return {
assets: assetsList,
activeAssetsAddresses,
onSwitch,
}
}
const nftAssetsFilteredList = filterBy(filterValue, nftAssetsList)
const itemData = createItemData(nftAssetsFilteredList)
const switchToAddCustomAssetScreen = () => props.setActiveScreen('addCustomAsset')
return (
<>
<Block className={classes.root}>
<Row align="center" className={cn(classes.padding, classes.actions)}>
<Search className={classes.search} />
<SearchBar
classes={searchClasses}
onCancelSearch={onCancelSearch}
onChange={onChangeSearchBar}
placeholder="Search by name or symbol"
searchIcon={<div />}
value={filterValue}
/>
<Spacer />
<Divider />
<Spacer />
<Button
classes={{ label: classes.addBtnLabel }}
className={classes.add}
color="primary"
disabled
onClick={switchToAddCustomAssetScreen}
size="small"
testId={ADD_CUSTOM_ASSET_BUTTON_TEST_ID}
variant="contained"
>
+ Add custom asset
</Button>
</Row>
<Hairline />
</Block>
{!nftAssetsList.size && (
<Block className={classes.progressContainer} justify="center">
<CircularProgress />
</Block>
)}
{nftAssetsList.size > 0 && (
<MuiList className={classes.list}>
<FixedSizeList
height={413}
itemCount={nftAssetsFilteredList.size}
itemData={itemData}
itemKey={getItemKey}
itemSize={51}
overscanCount={process.env.NODE_ENV === 'test' ? 100 : 10}
width={500}
>
{AssetRow}
</FixedSizeList>
</MuiList>
)}
</>
)
}
export default AssetsList

View File

@ -0,0 +1,76 @@
// @flow
import { md, mediumFontSize, secondaryText, sm, xs } from '~/theme/variables'
export const styles = () => ({
root: {
minHeight: '52px',
},
search: {
color: secondaryText,
paddingLeft: sm,
},
padding: {
padding: `0 ${md}`,
},
add: {
fontSize: '11px',
fontWeight: 'normal',
paddingRight: md,
paddingLeft: md,
},
addBtnLabel: {
fontSize: mediumFontSize,
},
actions: {
height: '50px',
},
list: {
overflow: 'hidden',
overflowY: 'scroll',
padding: 0,
height: '100%',
},
tokenIcon: {
marginRight: sm,
height: '28px',
width: '28px',
},
searchInput: {
backgroundColor: 'transparent',
lineHeight: 'initial',
fontSize: '13px',
padding: 0,
'& > input::placeholder': {
letterSpacing: '-0.5px',
fontSize: mediumFontSize,
color: 'black',
},
'& > input': {
letterSpacing: '-0.5px',
},
},
progressContainer: {
width: '100%',
height: '100%',
alignItems: 'center',
},
searchContainer: {
width: '180px',
marginLeft: xs,
marginRight: xs,
},
searchRoot: {
letterSpacing: '-0.5px',
fontSize: '13px',
border: 'none',
boxShadow: 'none',
'& > button': {
display: 'none',
},
},
searchIcon: {
'&:hover': {
backgroundColor: 'transparent !important',
},
},
})

View File

@ -1,24 +1,13 @@
// @flow
import TableCell from '@material-ui/core/TableCell'
import TableContainer from '@material-ui/core/TableContainer'
import TableRow from '@material-ui/core/TableRow'
import { withStyles } from '@material-ui/core/styles'
import CallMade from '@material-ui/icons/CallMade'
import CallReceived from '@material-ui/icons/CallReceived'
import classNames from 'classnames/bind'
import { List } from 'immutable'
import * as React from 'react'
import AssetTableCell from './AssetTableCell'
import Receive from './Receive'
import Tokens from './Tokens'
import { BALANCE_TABLE_ASSET_ID, type BalanceRow, generateColumns, getBalanceData } from './dataFetcher'
import { styles } from './style'
import Modal from '~/components/Modal'
import Table from '~/components/Table'
import { type Column, cellWidth } from '~/components/Table/TableHead'
import Button from '~/components/layout/Button'
import ButtonLink from '~/components/layout/ButtonLink'
import Col from '~/components/layout/Col'
import Divider from '~/components/layout/Divider'
@ -27,9 +16,9 @@ import Row from '~/components/layout/Row'
import type { BalanceCurrencyType } from '~/logic/currencyValues/store/model/currencyValues'
import { type Token } from '~/logic/tokens/store/model/token'
import { SAFELIST_ADDRESS } from '~/routes/routes'
import Coins from '~/routes/safe/components/Balances/Coins'
import Collectibles from '~/routes/safe/components/Balances/Collectibles'
import SendModal from '~/routes/safe/components/Balances/SendModal'
import { BALANCE_TABLE_BALANCE_ID, BALANCE_TABLE_VALUE_ID } from '~/routes/safe/components/Balances/dataFetcher'
import DropdownCurrency from '~/routes/safe/components/DropdownCurrency'
import { history } from '~/store'
@ -44,17 +33,19 @@ type State = {
showCollectibles: boolean,
showReceive: boolean,
showToken: boolean,
showManageCollectibleModal: boolean,
}
type Props = {
activateTokensByBalance: Function,
activateAssetsByBalance: Function,
activeTokens: List<Token>,
blacklistedTokens: List<Token>,
classes: Object,
createTransaction?: Function,
createTransaction: Function,
currencySelected: string,
currencyValues: BalanceCurrencyType[],
ethBalance?: string,
ethBalance: string,
featuresEnabled: string[],
fetchCurrencyValues: Function,
fetchTokens: Function,
@ -64,7 +55,7 @@ type Props = {
tokens: List<Token>,
}
type Action = 'Token' | 'Send' | 'Receive'
type Action = 'Token' | 'Send' | 'Receive' | 'ManageCollectibleModal'
class Balances extends React.Component<Props, State> {
constructor(props) {
@ -73,6 +64,7 @@ class Balances extends React.Component<Props, State> {
erc721Enabled: false,
subMenuOptions: [],
showToken: false,
showManageCollectibleModal: false,
sendFunds: {
isOpen: false,
selectedToken: undefined,
@ -88,9 +80,10 @@ class Balances extends React.Component<Props, State> {
static isCollectiblesLocation = /\/balances\/collectibles$/
componentDidMount(): void {
const { activateTokensByBalance, fetchCurrencyValues, safeAddress } = this.props
const { activateAssetsByBalance, activateTokensByBalance, fetchCurrencyValues, safeAddress } = this.props
fetchCurrencyValues(safeAddress)
activateTokensByBalance(safeAddress)
activateAssetsByBalance(safeAddress)
const showCollectibles = Balances.isCollectiblesLocation.test(history.location.pathname)
const showCoins = Balances.isCoinsLocation.test(history.location.pathname)
@ -151,23 +144,17 @@ class Balances extends React.Component<Props, State> {
}
render() {
const { erc721Enabled, sendFunds, showCoins, showCollectibles, showReceive, showToken, subMenuOptions } = this.state
const {
activeTokens,
blacklistedTokens,
classes,
currencySelected,
currencyValues,
granted,
safeAddress,
safeName,
tokens,
} = this.props
const columns = generateColumns()
const autoColumns = columns.filter(c => !c.custom)
const filteredData = getBalanceData(activeTokens, currencySelected, currencyValues)
erc721Enabled,
sendFunds,
showCoins,
showCollectibles,
showManageCollectibleModal,
showReceive,
showToken,
subMenuOptions,
} = this.state
const { activeTokens, classes, createTransaction, ethBalance, safeAddress, safeName } = this.props
return (
<>
@ -190,121 +177,43 @@ class Balances extends React.Component<Props, State> {
))}
</Col>
<Col className={classes.tokenControls} end="sm" sm={6} xs={12}>
{showCoins && (
<>
<DropdownCurrency />
<ButtonLink
className={classes.manageTokensButton}
onClick={this.onShow('Token')}
size="lg"
testId="manage-tokens-btn"
>
Manage List
</ButtonLink>
<Modal
description="Enable and disable tokens to be listed"
handleClose={this.onHide('Token')}
open={showToken}
title="Manage List"
>
<Tokens
activeTokens={activeTokens}
blacklistedTokens={blacklistedTokens}
onClose={this.onHide('Token')}
safeAddress={safeAddress}
tokens={tokens}
/>
</Modal>
</>
)}
{showCoins && <DropdownCurrency />}
<ButtonLink
className={classes.manageTokensButton}
onClick={erc721Enabled && showCollectibles ? this.onShow('ManageCollectibleModal') : this.onShow('Token')}
size="lg"
testId="manage-tokens-btn"
>
Manage List
</ButtonLink>
<Modal
description={
erc721Enabled ? 'Enable and disables assets to be listed' : 'Enable and disable tokens to be listed'
}
handleClose={showManageCollectibleModal ? this.onHide('ManageCollectibleModal') : this.onHide('Token')}
open={showToken || showManageCollectibleModal}
title="Manage List"
>
<Tokens
modalScreen={showManageCollectibleModal ? 'assetsList' : 'tokenList'}
onClose={showManageCollectibleModal ? this.onHide('ManageCollectibleModal') : this.onHide('Token')}
safeAddress={safeAddress}
/>
</Modal>
</Col>
</Row>
{showCoins && (
<TableContainer>
<Table
columns={columns}
data={filteredData}
defaultFixed
defaultOrderBy={BALANCE_TABLE_ASSET_ID}
defaultRowsPerPage={10}
label="Balances"
size={filteredData.size}
>
{(sortedData: Array<BalanceRow>) =>
sortedData.map((row: any, index: number) => (
<TableRow className={classes.hide} data-testid={BALANCE_ROW_TEST_ID} key={index} tabIndex={-1}>
{autoColumns.map((column: Column) => {
const { align, id, width } = column
let cellItem
switch (id) {
case BALANCE_TABLE_ASSET_ID: {
cellItem = <AssetTableCell asset={row[id]} />
break
}
case BALANCE_TABLE_BALANCE_ID: {
cellItem = <div>{row[id]}</div>
break
}
case BALANCE_TABLE_VALUE_ID: {
cellItem = <div className={classes.currencyValueRow}>{row[id]}</div>
break
}
default: {
cellItem = null
break
}
}
return (
<TableCell align={align} component="td" key={id} style={cellWidth(width)}>
{cellItem}
</TableCell>
)
})}
<TableCell component="td">
<Row align="end" className={classes.actions}>
{granted && (
<Button
className={classes.send}
color="primary"
onClick={() => this.showSendFunds(row.asset.address)}
size="small"
testId="balance-send-btn"
variant="contained"
>
<CallMade
alt="Send Transaction"
className={classNames(classes.leftIcon, classes.iconSmall)}
/>
Send
</Button>
)}
<Button
className={classes.receive}
color="primary"
onClick={this.onShow('Receive')}
size="small"
variant="contained"
>
<CallReceived
alt="Receive Transaction"
className={classNames(classes.leftIcon, classes.iconSmall)}
/>
Receive
</Button>
</Row>
</TableCell>
</TableRow>
))
}
</Table>
</TableContainer>
)}
{showCoins && <Coins showReceiveFunds={this.onShow('Receive')} showSendFunds={this.showSendFunds} />}
{erc721Enabled && showCollectibles && <Collectibles />}
<SendModal
activeScreenType="sendFunds"
createTransaction={createTransaction}
ethBalance={ethBalance}
isOpen={sendFunds.isOpen}
onClose={this.hideSendFunds}
safeAddress={safeAddress}
safeName={safeName}
selectedToken={sendFunds.selectedToken}
tokens={activeTokens}
/>
<Modal
description="Receive Tokens Form"

View File

@ -1,11 +1,7 @@
// @flow
import { md, screenSm, secondary, sm, xs } from '~/theme/variables'
import { md, screenSm, secondary, xs } from '~/theme/variables'
export const styles = (theme: Object) => ({
root: {
marginRight: sm,
width: '20px',
},
export const styles = () => ({
controls: {
alignItems: 'center',
boxSizing: 'border-box',
@ -62,42 +58,12 @@ export const styles = (theme: Object) => ({
marginLeft: '0',
},
},
actionIcon: {
marginRight: theme.spacing(1),
},
iconSmall: {
fontSize: 16,
},
receiveModal: {
height: 'auto',
maxWidth: 'calc(100% - 30px)',
minHeight: '544px',
overflow: 'hidden',
},
hide: {
'&:hover': {
backgroundColor: '#fff3e2',
},
'&:hover $actions': {
visibility: 'initial',
},
'&:focus $actions': {
visibility: 'initial',
},
},
actions: {
justifyContent: 'flex-end',
visibility: 'hidden',
},
receive: {
width: '95px',
minWidth: '95px',
marginLeft: sm,
borderRadius: xs,
'& > span': {
fontSize: '14px',
},
},
send: {
width: '75px',
minWidth: '75px',
@ -106,17 +72,4 @@ export const styles = (theme: Object) => ({
fontSize: '14px',
},
},
leftIcon: {
marginRight: sm,
},
links: {
textDecoration: 'underline',
'&:hover': {
cursor: 'pointer',
},
},
currencyValueRow: {
maxWidth: '125px',
textAlign: 'right',
},
})

View File

@ -67,6 +67,7 @@ type Props = SelectorProps &
const Layout = (props: Props) => {
const {
activateAssetsByBalance,
activateTokensByBalance,
activeTokens,
addressBook,
@ -313,6 +314,7 @@ const Layout = (props: Props) => {
path={`${match.path}/balances/:assetType?`}
render={() => (
<Balances
activateAssetsByBalance={activateAssetsByBalance}
activateTokensByBalance={activateTokensByBalance}
activeTokens={activeTokens}
blacklistedTokens={blacklistedTokens}

View File

@ -4,6 +4,7 @@ import { updateAddressBookEntry } from '~/logic/addressBook/store/actions/update
import fetchCollectibles from '~/logic/collectibles/store/actions/fetchCollectibles'
import fetchCurrencyValues from '~/logic/currencyValues/store/actions/fetchCurrencyValues'
import addViewedSafe from '~/logic/currentSession/store/actions/addViewedSafe'
import activateAssetsByBalance from '~/logic/tokens/store/actions/activateAssetsByBalance'
import activateTokensByBalance from '~/logic/tokens/store/actions/activateTokensByBalance'
import fetchTokens from '~/logic/tokens/store/actions/fetchTokens'
import createTransaction from '~/routes/safe/store/actions/createTransaction'
@ -27,6 +28,7 @@ export type Actions = {
fetchEtherBalance: typeof fetchEtherBalance,
fetchLatestMasterContractVersion: typeof fetchLatestMasterContractVersion,
activateTokensByBalance: typeof activateTokensByBalance,
activateAssetsByBalance: typeof activateAssetsByBalance,
checkAndUpdateSafeOwners: typeof checkAndUpdateSafe,
fetchCurrencyValues: typeof fetchCurrencyValues,
loadAddressBook: typeof loadAddressBookFromStorage,
@ -43,6 +45,7 @@ export default {
fetchTokens,
fetchTransactions,
activateTokensByBalance,
activateAssetsByBalance,
updateSafe,
fetchEtherBalance,
fetchLatestMasterContractVersion,

View File

@ -136,6 +136,7 @@ class SafeView extends React.Component<Props, State> {
render() {
const { sendFunds, showReceive } = this.state
const {
activateAssetsByBalance,
activateTokensByBalance,
activeTokens,
addressBook,
@ -161,6 +162,7 @@ class SafeView extends React.Component<Props, State> {
return (
<Page>
<Layout
activateAssetsByBalance={activateAssetsByBalance}
activateTokensByBalance={activateTokensByBalance}
activeTokens={activeTokens}
addressBook={addressBook}

View File

@ -0,0 +1,25 @@
// @flow
import { Set } from 'immutable'
import type { Dispatch as ReduxDispatch } from 'redux'
import updateSafe from './updateSafe'
import { type GlobalState } from '~/store'
// the selector uses ownProps argument/router props to get the address of the safe
// so in order to use it I had to recreate the same structure
// const generateMatchProps = (safeAddress: string) => ({
// match: {
// params: {
// [SAFE_PARAM_ADDRESS]: safeAddress,
// },
// },
// })
const updateActiveAssets = (safeAddress: string, activeAssets: Set<string>) => async (
dispatch: ReduxDispatch<GlobalState>,
) => {
dispatch(updateSafe({ address: safeAddress, activeAssets }))
}
export default updateActiveAssets

View File

@ -0,0 +1,15 @@
// @flow
import { Set } from 'immutable'
import type { Dispatch as ReduxDispatch } from 'redux'
import updateSafe from './updateSafe'
import { type GlobalState } from '~/store'
const updateBlacklistedAssets = (safeAddress: string, blacklistedAssets: Set<string>) => async (
dispatch: ReduxDispatch<GlobalState>,
) => {
dispatch(updateSafe({ address: safeAddress, blacklistedAssets }))
}
export default updateBlacklistedAssets

View File

@ -11,7 +11,9 @@ export type SafeProps = {
owners: List<Owner>,
balances?: Map<string, string>,
activeTokens: Set<string>,
activeAssets: Set<string>,
blacklistedTokens: Set<string>,
blacklistedAssets: Set<string>,
ethBalance?: string,
nonce: number,
latestIncomingTxBlock?: number,
@ -28,7 +30,9 @@ const SafeRecord: RecordFactory<SafeProps> = Record({
ethBalance: 0,
owners: List([]),
activeTokens: new Set(),
activeAssets: new Set(),
blacklistedTokens: new Set(),
blacklistedAssets: new Set(),
balances: Map({}),
nonce: 0,
latestIncomingTxBlock: 0,

View File

@ -26,7 +26,9 @@ export const buildSafe = (storedSafe: SafeProps) => {
const addresses = storedSafe.owners.map(owner => getWeb3().utils.toChecksumAddress(owner.address))
const owners = buildOwnersFrom(Array.from(names), Array.from(addresses))
const activeTokens = Set(storedSafe.activeTokens)
const activeAssets = Set(storedSafe.activeAssets)
const blacklistedTokens = Set(storedSafe.blacklistedTokens)
const blacklistedAssets = Set(storedSafe.blacklistedAssets)
const balances = Map(storedSafe.balances)
const safe: SafeProps = {
@ -35,6 +37,8 @@ export const buildSafe = (storedSafe: SafeProps) => {
balances,
activeTokens,
blacklistedTokens,
activeAssets,
blacklistedAssets,
}
return safe

View File

@ -191,6 +191,26 @@ export const safeActiveTokensSelector: OutputSelector<GlobalState, RouterProps,
},
)
export const safeActiveAssetsSelector: OutputSelector<GlobalState, RouterProps, List<string>> = createSelector(
safeSelector,
(safe: Safe) => {
if (!safe) {
return List()
}
return safe.activeAssets
},
)
export const safeActiveAssetsListSelector: OutputSelector<GlobalState, RouterProps, List<string>> = createSelector(
safeActiveAssetsSelector,
(safeList: []) => {
if (!safeList) {
return Set([])
}
return Set(safeList)
},
)
export const safeBlacklistedTokensSelector: OutputSelector<GlobalState, RouterProps, List<string>> = createSelector(
safeSelector,
(safe: Safe) => {
@ -202,12 +222,29 @@ export const safeBlacklistedTokensSelector: OutputSelector<GlobalState, RouterPr
},
)
export const safeBlacklistedAssetsSelector: OutputSelector<GlobalState, RouterProps, List<string>> = createSelector(
safeSelector,
(safe: Safe) => {
if (!safe) {
return List()
}
return safe.blacklistedAssets
},
)
export const safeActiveTokensSelectorBySafe = (safeAddress: string, safes: Map<string, Safe>): List<string> =>
safes.get(safeAddress).get('activeTokens')
export const safeBlacklistedTokensSelectorBySafe = (safeAddress: string, safes: Map<string, Safe>): List<string> =>
safes.get(safeAddress).get('blacklistedTokens')
export const safeActiveAssetsSelectorBySafe = (safeAddress: string, safes: Map<string, Safe>): List<string> =>
safes.get(safeAddress).get('activeAssets')
export const safeBlacklistedAssetsSelectorBySafe = (safeAddress: string, safes: Map<string, Safe>): List<string> =>
safes.get(safeAddress).get('blacklistedAssets')
export const safeBalancesSelector: OutputSelector<GlobalState, RouterProps, Map<string, string>> = createSelector(
safeSelector,
(safe: Safe) => {