Merge pull request #179 from gnosis/74-safe-list-sidebar

Feature #74: Safe list sidebar
This commit is contained in:
Mikhail Mikheev 2019-09-24 13:43:32 +04:00 committed by GitHub
commit baae35061b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
56 changed files with 1364 additions and 1009 deletions

View File

@ -45,7 +45,8 @@
],
"react/require-default-props": 0,
"react/no-array-index-key": 0,
"react/jsx-props-no-spreading": 0
"react/jsx-props-no-spreading": 0,
"react/state-in-constructor": 0
},
"env": {
"jest/globals": true,

View File

@ -30,18 +30,18 @@
],
"dependencies": {
"@gnosis.pm/safe-contracts": "^1.0.0",
"@gnosis.pm/util-contracts": "2.0.1",
"@material-ui/core": "4.4.2",
"@material-ui/icons": "4.4.1",
"@gnosis.pm/util-contracts": "2.0.4",
"@material-ui/core": "4.4.3",
"@material-ui/icons": "4.4.3",
"@testing-library/jest-dom": "4.1.0",
"@welldone-software/why-did-you-render": "3.3.3",
"@welldone-software/why-did-you-render": "3.3.5",
"axios": "0.19.0",
"bignumber.js": "9.0.0",
"connected-react-router": "6.5.2",
"date-fns": "2.1.0",
"date-fns": "2.2.1",
"ethereum-ens": "0.7.8",
"final-form": "4.18.5",
"history": "4.10.0",
"history": "4.10.1",
"immortal-db": "^1.0.2",
"immutable": "^4.0.0-rc.9",
"material-ui-search-bar": "^1.0.0-beta.13",
@ -51,7 +51,7 @@
"react-dom": "16.9.0",
"react-final-form": "6.3.0",
"react-final-form-listeners": "^1.0.2",
"react-hot-loader": "4.12.13",
"react-hot-loader": "4.12.14",
"react-infinite-scroll-component": "4.5.3",
"react-qr-reader": "^2.2.1",
"react-redux": "7.1.1",
@ -89,10 +89,10 @@
"@babel/preset-flow": "^7.0.0-beta.40",
"@babel/preset-react": "^7.0.0-beta.40",
"@sambego/storybook-state": "^1.0.7",
"@storybook/addon-actions": "5.1.11",
"@storybook/addon-knobs": "5.1.11",
"@storybook/addon-links": "5.1.11",
"@storybook/react": "5.1.11",
"@storybook/addon-actions": "5.2.1",
"@storybook/addon-knobs": "5.2.1",
"@storybook/addon-links": "5.2.1",
"@storybook/react": "5.2.1",
"@testing-library/react": "9.1.4",
"autoprefixer": "9.6.1",
"babel-core": "^7.0.0-bridge.0",
@ -115,7 +115,7 @@
"ethereumjs-abi": "0.6.8",
"extract-text-webpack-plugin": "^4.0.0-beta.0",
"file-loader": "4.2.0",
"flow-bin": "0.107.0",
"flow-bin": "0.108.0",
"fs-extra": "8.1.0",
"html-loader": "^0.5.5",
"html-webpack-plugin": "^3.0.4",
@ -132,15 +132,15 @@
"storybook-host": "5.1.0",
"storybook-router": "^0.3.3",
"style-loader": "1.0.0",
"truffle": "5.0.35",
"truffle": "5.0.37",
"truffle-contract": "4.0.31",
"truffle-solidity-loader": "0.1.32",
"uglifyjs-webpack-plugin": "2.2.0",
"url-loader": "^2.1.0",
"webpack": "4.39.3",
"webpack-bundle-analyzer": "3.4.1",
"webpack-cli": "3.3.8",
"webpack-dev-server": "3.8.0",
"webpack": "4.40.2",
"webpack-bundle-analyzer": "3.5.1",
"webpack-cli": "3.3.9",
"webpack-dev-server": "3.8.1",
"webpack-manifest-plugin": "^2.0.0-rc.2"
}
}

View File

@ -11,6 +11,7 @@ require('dotenv').config({ silent: true })
const jest = require('jest')
const argv = process.argv.slice(2)
argv.push('--runInBand')
// Watch unless on CI or in coverage mode
if (!process.env.CI && argv.indexOf('--coverage') < 0) {

View File

@ -1,20 +0,0 @@
// @flow
import React from 'react'
import Block from '~/components/layout/Block'
import Link from '~/components/layout/Link'
import Paragraph from '~/components/layout/Paragraph'
import { WELCOME_ADDRESS, SAFELIST_ADDRESS } from '~/routes/routes'
import styles from './index.scss'
const Footer = () => (
<Block className={styles.footer}>
<Link to={WELCOME_ADDRESS}>
<Paragraph size="sm" color="primary" noMargin>Add Safe</Paragraph>
</Link>
<Link to={SAFELIST_ADDRESS}>
<Paragraph size="sm" color="primary" noMargin>Safe List</Paragraph>
</Link>
</Block>
)
export default Footer

View File

@ -1,24 +0,0 @@
.footer {
font-size: $smallFontSize;
display: grid;
grid-template-columns: 100px 100px 1fr;
grid-template-rows: 36px;
justify-items: center;
align-items: center;
border: solid 0.5px $border;
background-color: white;
margin-top: 50px;
}
@media only screen and (max-width: $(screenXs)px) {
.footer {
grid-template-columns: none;
grid-template-rows: auto auto;
grid-row-gap: $sm;
justify-items: center;
}
.footer > a {
padding: 0;
}
}

View File

@ -1,16 +0,0 @@
// @flow
import { storiesOf } from '@storybook/react'
import * as React from 'react'
import styles from '~/components/layout/PageFrame/index.scss'
import Component from './index'
const FrameDecorator = (story) => (
<div className={styles.frame}>
<div style={{ flex: '1' }} />
{story()}
</div>
)
storiesOf('Components /Footer', module)
.addDecorator(FrameDecorator)
.add('Loaded', () => <Component />)

View File

@ -12,8 +12,11 @@ import Col from '~/components/layout/Col'
import Img from '~/components/layout/Img'
import Row from '~/components/layout/Row'
import Spacer from '~/components/Spacer'
import { border, sm, md } from '~/theme/variables'
import {
border, sm, md, headerHeight,
} from '~/theme/variables'
import Provider from './Provider'
import SafeListHeader from './SafeListHeader'
const logo = require('../assets/gnosis-safe-logo.svg')
@ -32,10 +35,12 @@ const styles = () => ({
left: '4px',
},
summary: {
borderBottom: `solid 1px ${border}`,
borderBottom: `solid 2px ${border}`,
alignItems: 'center',
height: '53px',
height: headerHeight,
boxShadow: '0 2px 4px 0 rgba(212, 212, 211, 0.59)',
backgroundColor: 'white',
zIndex: 1301,
},
logo: {
padding: `${sm} ${md}`,
@ -55,6 +60,8 @@ const Layout = openHoc(({
</Link>
</Col>
<Divider />
<SafeListHeader />
<Divider />
<Spacer />
<Divider />
<Provider open={open} toggle={toggle} info={providerInfo}>

View File

@ -0,0 +1,68 @@
// @flow
import * as React from 'react'
import { connect } from 'react-redux'
import { makeStyles } from '@material-ui/core/styles'
import IconButton from '@material-ui/core/IconButton'
import ExpandMoreIcon from '@material-ui/icons/ExpandMore'
import ExpandLessIcon from '@material-ui/icons/ExpandLess'
import Paragraph from '~/components/layout/Paragraph'
import Col from '~/components/layout/Col'
import {
xs, sm, md, border,
} from '~/theme/variables'
import { safesCountSelector } from '~/routes/safe/store/selectors'
import { SidebarContext } from '~/components/Sidebar'
export const TOGGLE_SIDEBAR_BTN_TESTID = 'TOGGLE_SIDEBAR_BTN'
const useStyles = makeStyles({
container: {
flexGrow: 0,
padding: `0 ${md}`,
},
counter: {
background: border,
padding: xs,
borderRadius: '3px',
marginLeft: sm,
lineHeight: 'normal',
},
icon: {
marginLeft: sm,
},
})
type Props = {
safesCount: number,
}
const { useContext } = React
const SafeListHeader = ({ safesCount }: Props) => {
const classes = useStyles()
const { toggleSidebar, isOpen } = useContext(SidebarContext)
return (
<Col start="xs" middle="xs" className={classes.container}>
Safes
{' '}
<Paragraph size="xs" className={classes.counter}>
{safesCount}
</Paragraph>
<IconButton
onClick={toggleSidebar}
className={classes.icon}
aria-label="Expand Safe List"
data-testid={TOGGLE_SIDEBAR_BTN_TESTID}
>
{isOpen ? <ExpandLessIcon /> : <ExpandMoreIcon color="secondary" />}
</IconButton>
</Col>
)
}
export default connect<Object, Object, ?Function, ?Object>(
// $FlowFixMe
(state) => ({ safesCount: safesCountSelector(state) }),
null,
)(SafeListHeader)

View File

@ -0,0 +1,36 @@
// @flow
import * as React from 'react'
import { makeStyles } from '@material-ui/core/styles'
import Img from '~/components/layout/Img'
import Block from '~/components/layout/Block'
import Paragraph from '~/components/layout/Paragraph'
import { primary, sm } from '~/theme/variables'
import StarIcon from './assets/star.svg'
const useStyles = makeStyles({
container: {
background: primary,
padding: '5px',
boxSizing: 'border-box',
width: '73px',
justifyContent: 'space-around',
marginLeft: sm,
color: '#fff',
borderRadius: '3px',
},
})
const DefaultBadge = () => {
const classes = useStyles()
return (
<Block align="left" className={classes.container}>
<Img src={StarIcon} alt="Star Icon" />
<Paragraph noMargin size="xs">
default
</Paragraph>
</Block>
)
}
export default DefaultBadge

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12">
<path fill="#FFF" fill-rule="nonzero" d="M6 0L3.852 3.336 0 4.344l2.52 3.084-.228 3.972L6 9.954 9.708 11.4 9.48 7.428 12 4.344 8.148 3.336 6 0zM4.428 4.8c.372 0 .672.3.672.678 0 .371-.3.672-.672.672a.672.672 0 0 1-.678-.672c0-.378.3-.678.678-.678zm3.15 0c.372 0 .672.3.672.678 0 .371-.3.672-.672.672a.672.672 0 0 1-.678-.672c0-.378.3-.678.678-.678zM4.2 7.5h3.6a1.95 1.95 0 0 1-3.6 0z"/>
</svg>

After

Width:  |  Height:  |  Size: 482 B

View File

@ -0,0 +1,114 @@
// @flow
import * as React from 'react'
import { makeStyles } from '@material-ui/core/styles'
import { List } from 'immutable'
import MuiList from '@material-ui/core/List'
import ListItem from '@material-ui/core/ListItem'
import ListItemIcon from '@material-ui/core/ListItemIcon'
import ListItemText from '@material-ui/core/ListItemText'
import Link from '~/components/layout/Link'
import Hairline from '~/components/layout/Hairline'
import Paragraph from '~/components/layout/Paragraph'
import ButtonLink from '~/components/layout/ButtonLink'
import Identicon from '~/components/Identicon'
import {
mediumFontSize, sm, secondary, primary,
} from '~/theme/variables'
import { shortVersionOf, sameAddress } from '~/logic/wallets/ethAddresses'
import { type Safe } from '~/routes/safe/store/models/safe'
import { SAFELIST_ADDRESS } from '~/routes/routes'
import DefaultBadge from './DefaultBadge'
export const SIDEBAR_SAFELIST_ROW_TESTID = 'SIDEBAR_SAFELIST_ROW_TESTID'
type SafeListProps = {
safes: List<Safe>,
onSafeClick: Function,
setDefaultSafe: Function,
defaultSafe: string,
}
const useStyles = makeStyles({
icon: {
marginRight: sm,
},
list: {
overflow: 'hidden',
overflowY: 'scroll',
padding: 0,
height: '100%',
},
listItemRoot: {
paddingTop: 0,
paddingBottom: 0,
'&:hover $makeDefaultBtn': {
visibility: 'initial',
},
'&:focus $makeDefaultBtn': {
visibility: 'initial',
},
},
safeName: {
color: secondary,
},
safeAddress: {
color: primary,
fontSize: mediumFontSize,
},
makeDefaultBtn: {
padding: 0,
marginLeft: sm,
visibility: 'hidden',
},
})
const SafeList = ({
safes, onSafeClick, setDefaultSafe, defaultSafe,
}: SafeListProps) => {
const classes = useStyles()
return (
<MuiList className={classes.list}>
{safes.map((safe) => (
<React.Fragment key={safe.address}>
<Link to={`${SAFELIST_ADDRESS}/${safe.address}`} onClick={onSafeClick} data-testid={SIDEBAR_SAFELIST_ROW_TESTID}>
<ListItem classes={{ root: classes.listItemRoot }}>
<ListItemIcon>
<Identicon address={safe.address} diameter={32} className={classes.icon} />
</ListItemIcon>
<ListItemText
primary={safe.name}
secondary={shortVersionOf(safe.address, 4)}
classes={{ primary: classes.safeName, secondary: classes.safeAddress }}
/>
<Paragraph size="lg" color="primary">
{safe.ethBalance}
{' '}
ETH
</Paragraph>
{sameAddress(defaultSafe, safe.address) ? (
<DefaultBadge />
) : (
<ButtonLink
className={classes.makeDefaultBtn}
size="sm"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setDefaultSafe(safe.address)
}}
>
Make default
</ButtonLink>
)}
</ListItem>
</Link>
<Hairline />
</React.Fragment>
))}
</MuiList>
)
}
export default SafeList

View File

@ -0,0 +1,142 @@
// @flow
import * as React from 'react'
import { List } from 'immutable'
import SearchBar from 'material-ui-search-bar'
import { connect } from 'react-redux'
import ClickAwayListener from '@material-ui/core/ClickAwayListener'
import Drawer from '@material-ui/core/Drawer'
import SearchIcon from '@material-ui/icons/Search'
import Divider from '~/components/layout/Divider'
import Button from '~/components/layout/Button'
import Link from '~/components/layout/Link'
import Spacer from '~/components/Spacer'
import Hairline from '~/components/layout/Hairline'
import Row from '~/components/layout/Row'
import { type Safe } from '~/routes/safe/store/models/safe'
import { defaultSafeSelector } from '~/routes/safe/store/selectors'
import setDefaultSafe from '~/routes/safe/store/actions/setDefaultSafe'
import { sortedSafeListSelector } from './selectors'
import useSidebarStyles from './style'
import SafeList from './SafeList'
import { WELCOME_ADDRESS } from '~/routes/routes'
const { useState, useEffect } = React
type TSidebarContext = {
isOpen: boolean,
toggleSidebar: Function,
}
export const SidebarContext = React.createContext<TSidebarContext>({
isOpen: false,
toggleSidebar: () => {},
})
type SidebarProps = {
children: React.Node,
safes: List<Safe>,
setDefaultSafeAction: Function,
defaultSafe: string,
}
const filterBy = (filter: string, safes: List<Safe>): List<Safe> => safes.filter(
(safe: Safe) => !filter
|| safe.address.toLowerCase().includes(filter.toLowerCase())
|| safe.name.toLowerCase().includes(filter.toLowerCase()),
)
const Sidebar = ({
children, safes, setDefaultSafeAction, defaultSafe,
}: SidebarProps) => {
const [isOpen, setIsOpen] = useState<boolean>(false)
const [filter, setFilter] = useState<string>('')
const classes = useSidebarStyles()
useEffect(() => {
setTimeout(() => {
setFilter('')
}, 300)
}, [isOpen])
const searchClasses = {
input: classes.searchInput,
root: classes.searchRoot,
iconButton: classes.searchIconInput,
searchContainer: classes.searchContainer,
}
const toggleSidebar = () => {
setIsOpen((prevIsOpen) => !prevIsOpen)
}
const handleFilterChange = (value: string) => {
setFilter(value)
}
const handleFilterCancel = () => {
setFilter('')
}
const handleEsc = (e: SyntheticKeyboardEvent<*>) => {
if (e.keyCode === 27) {
toggleSidebar()
}
}
const filteredSafes = filterBy(filter, safes)
return (
<SidebarContext.Provider value={{ isOpen, toggleSidebar }}>
<ClickAwayListener onClickAway={toggleSidebar}>
<Drawer
className={classes.sidebar}
open={isOpen}
onKeyDown={handleEsc}
classes={{ paper: classes.sidebarPaper }}
ModalProps={{ onBackdropClick: toggleSidebar }}
>
<div className={classes.headerPlaceholder} />
<Row align="center">
<SearchIcon className={classes.searchIcon} />
<SearchBar
classes={searchClasses}
placeholder="Search by name or address"
searchIcon={<div />}
onChange={handleFilterChange}
onCancelSearch={handleFilterCancel}
/>
<Spacer />
<Divider />
<Spacer />
<Button
component={Link}
to={WELCOME_ADDRESS}
className={classes.addSafeBtn}
variant="contained"
size="small"
color="primary"
onClick={toggleSidebar}
>
+ Add Safe
</Button>
<Spacer />
</Row>
<Hairline />
<SafeList
safes={filteredSafes}
onSafeClick={toggleSidebar}
setDefaultSafe={setDefaultSafeAction}
defaultSafe={defaultSafe}
/>
</Drawer>
</ClickAwayListener>
{children}
</SidebarContext.Provider>
)
}
export default connect<Object, Object, ?Function, ?Object>(
// $FlowFixMe
(state) => ({ safes: sortedSafeListSelector(state), defaultSafe: defaultSafeSelector(state) }),
{ setDefaultSafeAction: setDefaultSafe },
)(Sidebar)

View File

@ -0,0 +1,11 @@
// @flow
import { List } from 'immutable'
import { createSelector, type Selector } from 'reselect'
import { type Safe } from '~/routes/safe/store/models/safe'
import { safesListSelector } from '~/routes/safe/store/selectors'
import { type GlobalState } from '~/store/index'
export const sortedSafeListSelector: Selector<GlobalState, {}, List<Safe>> = createSelector(
safesListSelector,
(safes: List<Safe>): List<Safe> => safes.sort((a: Safe, b: Safe) => (a.name > b.name ? 1 : -1)),
)

View File

@ -0,0 +1,59 @@
// @flow
import { makeStyles } from '@material-ui/core/styles'
import {
xs, mediumFontSize, secondaryText, md, headerHeight,
} from '~/theme/variables'
const sidebarWidth = '400px'
const useSidebarStyles = makeStyles({
sidebar: {
width: sidebarWidth,
},
sidebarPaper: {
width: sidebarWidth,
},
headerPlaceholder: {
minHeight: headerHeight,
},
addSafeBtn: {
fontSize: mediumFontSize,
},
searchIcon: {
color: secondaryText,
paddingLeft: md,
},
searchInput: {
backgroundColor: 'transparent',
lineHeight: 'initial',
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',
border: 'none',
boxShadow: 'none',
'& > button': {
display: 'none',
},
},
searchIconInput: {
'&:hover': {
backgroundColor: 'transparent !important',
},
},
})
export default useSidebarStyles

View File

@ -4,7 +4,7 @@ import { border } from '~/theme/variables'
const style = {
height: '100%',
borderRight: `solid 1px ${border}`,
borderRight: `solid 2px ${border}`,
}
const Divider = () => <div style={style} />

View File

@ -1,7 +1,7 @@
// @flow
import React from 'react'
import Footer from '~/components/Footer'
import * as React from 'react'
import Header from '~/components/Header'
import SidebarProvider from '~/components/Sidebar'
import { SharedSnackbarProvider } from '~/components/SharedSnackBar'
import styles from './index.scss'
@ -12,9 +12,10 @@ type Props = {
const PageFrame = ({ children }: Props) => (
<SharedSnackbarProvider>
<div className={styles.frame}>
<Header />
{children}
<Footer />
<SidebarProvider>
<Header />
{children}
</SidebarProvider>
</div>
</SharedSnackbarProvider>
)

View File

@ -7,6 +7,7 @@ import Root from '~/components/Root'
import { store } from '~/store'
import loadSafesFromStorage from '~/routes/safe/store/actions/loadSafesFromStorage'
import loadActiveTokens from '~/logic/tokens/store/actions/loadActiveTokens'
import loadDefaultSafe from '~/routes/safe/store/actions/loadDefaultSafe'
if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line
@ -16,5 +17,6 @@ if (process.env.NODE_ENV !== 'production') {
store.dispatch(loadActiveTokens())
store.dispatch(loadSafesFromStorage())
store.dispatch(loadDefaultSafe())
ReactDOM.render(<Root />, document.getElementById('root'))

View File

@ -6,6 +6,7 @@ import { loadFromStorage, saveToStorage, removeFromStorage } from '~/utils/stora
export const SAFES_KEY = 'SAFES'
export const TX_KEY = 'TX'
export const OWNERS_KEY = 'OWNERS'
export const DEFAULT_SAFE_KEY = 'DEFAULT_SAFE'
export const getSafeName = async (safeAddress: string) => {
const safes = await loadFromStorage(SAFES_KEY)
@ -42,11 +43,26 @@ export const getOwners = async (safeAddress: string): Promise<Map<string, string
return data ? Map(data) : Map()
}
export const getDefaultSafe = async (): Promise<string> => {
const defaultSafe = await loadFromStorage(DEFAULT_SAFE_KEY)
return defaultSafe || ''
}
export const saveDefaultSafe = async (safeAddress: string): Promise<void> => {
try {
await saveToStorage(DEFAULT_SAFE_KEY, safeAddress)
} catch (err) {
// eslint-disable-next-line
console.error('Error saving default safe to storage: ', err)
}
}
export const removeOwners = async (safeAddress: string): Promise<void> => {
try {
await removeFromStorage(`${OWNERS_KEY}-${safeAddress}`)
} catch (err) {
// eslint-disable-next-line
console.log('Error removing owners from localstorage')
console.error('Error removing owners from localstorage: ', err)
}
}

View File

@ -49,4 +49,4 @@ export const isAddressAToken = async (tokenAddress: string) => {
return call !== '0x'
}
export const isTokenTransfer = async (data: string, value: number) => data.substring(0, 10) === '0xa9059cbb' && value === 0
export const isTokenTransfer = async (data: string, value: number) => data && data.substring(0, 10) === '0xa9059cbb' && value === 0

View File

@ -7,7 +7,7 @@ export const PROVIDER_REDUCER_ID = 'providers'
export type State = Provider
export default handleActions(
export default handleActions<State, Function>(
{
[ADD_PROVIDER]: (state: State, { payload }: ActionType<typeof addProvider>) => makeProvider(payload),
},

View File

@ -1,6 +1,11 @@
// @flow
import React from 'react'
import { Switch, Redirect, Route } from 'react-router-dom'
import React, { useState, useEffect } from 'react'
import { connect } from 'react-redux'
import {
Switch, Redirect, Route, withRouter,
} from 'react-router-dom'
import { defaultSafeSelector } from '~/routes/safe/store/selectors'
import Loader from '~/components/Loader'
import Welcome from './welcome/container'
import {
SAFELIST_ADDRESS,
@ -13,8 +18,6 @@ import {
const Safe = React.lazy(() => import('./safe/container'))
const SafeList = React.lazy(() => import('./safeList/container'))
const Open = React.lazy(() => import('./open/container/Open'))
const Opening = React.lazy(() => import('./opening/container'))
@ -23,16 +26,54 @@ const Load = React.lazy(() => import('./load/container/Load'))
const SAFE_ADDRESS = `${SAFELIST_ADDRESS}/:${SAFE_PARAM_ADDRESS}`
const Routes = () => (
<Switch>
<Redirect exact from="/" to={WELCOME_ADDRESS} />
<Route exact path={WELCOME_ADDRESS} component={Welcome} />
<Route exact path={OPEN_ADDRESS} component={Open} />
<Route exact path={SAFELIST_ADDRESS} component={SafeList} />
<Route exact path={SAFE_ADDRESS} component={Safe} />
<Route exact path={OPENING_ADDRESS} component={Opening} />
<Route exact path={LOAD_ADDRESS} component={Load} />
</Switch>
)
type RoutesProps = {
defaultSafe?: string,
location: Object,
}
export default Routes
const Routes = ({ defaultSafe, location }: RoutesProps) => {
const [isInitialLoad, setInitialLoad] = useState<boolean>(true)
useEffect(() => {
if (location.pathname !== '/') {
setInitialLoad(false)
}
}, [])
return (
<Switch>
<Route
exact
path="/"
render={() => {
if (!isInitialLoad) {
return <Redirect to={WELCOME_ADDRESS} />
}
if (typeof defaultSafe === 'undefined') {
return <Loader />
}
setInitialLoad(false)
if (defaultSafe) {
return <Redirect to={`${SAFELIST_ADDRESS}/${defaultSafe}`} />
}
return <Redirect to={WELCOME_ADDRESS} />
}}
/>
<Route exact path={WELCOME_ADDRESS} component={Welcome} />
<Route exact path={OPEN_ADDRESS} component={Open} />
<Route exact path={SAFE_ADDRESS} component={Safe} />
<Route exact path={OPENING_ADDRESS} component={Opening} />
<Route exact path={LOAD_ADDRESS} component={Load} />
<Redirect to="/" />
</Switch>
)
}
export default connect<Object, Object, ?Function, ?Object>(
// $FlowFixMe
(state) => ({ defaultSafe: defaultSafeSelector(state) }),
null,
)(withRouter(Routes))

View File

@ -50,6 +50,7 @@ export const SAFE_MASTERCOPY_ERROR = 'Mastercopy used by this safe is not the sa
export const safeFieldsValidation = async (values: Object) => {
const errors = {}
const web3 = getWeb3()
const safeAddress = values[FIELD_LOAD_ADDRESS]
if (!safeAddress || mustBeEthereumAddress(safeAddress) !== undefined) {
return errors

View File

@ -24,6 +24,7 @@ export const loadSafe = async (
) => {
const safeProps = await buildSafe(safeAddress, safeName)
safeProps.owners = owners
await addSafe(safeProps)
const storedSafes = (await loadFromStorage(SAFES_KEY)) || {}

View File

@ -1,5 +1,5 @@
// @flow
import { addSafe } from '~/routes/safe/store/actions/addSafe'
import addSafe from '~/routes/safe/store/actions/addSafe'
export type Actions = {
addSafe: Function,

View File

@ -7,7 +7,7 @@ import Block from '~/components/layout/Block'
import Heading from '~/components/layout/Heading'
import Row from '~/components/layout/Row'
import Review from '~/routes/open/components/ReviewInformation'
import SafeNameField, { safeNameValidation } from '~/routes/open/components/SafeNameForm'
import SafeNameField from '~/routes/open/components/SafeNameForm'
import SafeOwnersFields from '~/routes/open/components/SafeOwnersConfirmationsForm'
import { getOwnerNameBy, getOwnerAddressBy, FIELD_CONFIRMATIONS } from '~/routes/open/components/fields'
import { history } from '~/store'

View File

@ -3,8 +3,9 @@ import * as React from 'react'
import { connect } from 'react-redux'
import Page from '~/components/layout/Page'
import {
getAccountsFrom, getThresholdFrom, getNamesFrom, getSafeNameFrom,
getAccountsFrom, getThresholdFrom, getNamesFrom, getSafeNameFrom, getOwnersFrom,
} from '~/routes/open/utils/safeDataExtractor'
import { buildSafe } from '~/routes/safe/store/actions/fetchSafe'
import { getGnosisSafeInstanceAt, deploySafeContract, initContracts } from '~/logic/contracts/safeContracts'
import { checkReceiptStatus } from '~/logic/wallets/ethTransactions'
import { history } from '~/store'
@ -24,18 +25,23 @@ export type OpenState = {
}
export const createSafe = async (values: Object, userAccount: string, addSafe: AddSafe): Promise<OpenState> => {
const accounts = getAccountsFrom(values)
const ownerAddresses = getAccountsFrom(values)
const numConfirmations = getThresholdFrom(values)
const name = getSafeNameFrom(values)
const owners = getNamesFrom(values)
const ownersNames = getNamesFrom(values)
const safe = await deploySafeContract(accounts, numConfirmations, userAccount)
await initContracts()
const safe = await deploySafeContract(ownerAddresses, numConfirmations, userAccount)
await checkReceiptStatus(safe.tx)
const safeAddress = safe.logs[0].args.proxy
const safeContract = await getGnosisSafeInstanceAt(safeAddress)
const safeProps = await buildSafe(safeAddress, name)
const owners = getOwnersFrom(ownersNames, ownerAddresses.sort())
safeProps.owners = owners
addSafe(name, safeContract.address, numConfirmations, owners, accounts)
addSafe(safeProps)
if (stillInOpeningView()) {
const url = {

View File

@ -1,5 +1,6 @@
// @flow
import { makeOwner } from '~/routes/safe/store/models/owner'
import { List } from 'immutable'
import { makeOwner, type Owner } from '~/routes/safe/store/models/owner'
export const getAccountsFrom = (values: Object): string[] => {
const accounts = Object.keys(values)
@ -17,10 +18,10 @@ export const getNamesFrom = (values: Object): string[] => {
return accounts.map((account) => values[account]).slice(0, values.owners)
}
export const getOwnersFrom = (names: string[], addresses: string[]): Array<string, string> => {
export const getOwnersFrom = (names: string[], addresses: string[]): List<Owner> => {
const owners = names.map((name: string, index: number) => makeOwner({ name, address: addresses[index] }))
return owners
return List(owners)
}
export const getThresholdFrom = (values: Object): number => Number(values.confirmations)

View File

@ -137,7 +137,6 @@ class Layout extends React.Component<Props, State> {
className={classes.send}
onClick={() => showSendFunds('Ether')}
disabled={!granted}
testId="balance-send-btn"
>
<CallMade alt="Send Transaction" className={classNames(classes.leftIcon, classes.iconSmall)} />
Send

View File

@ -60,7 +60,7 @@ const ApproveTxModal = ({
const { title, description } = getModalTitleAndDescription(thresholdReached)
const oneConfirmationLeft = tx.confirmations.size + 1 === threshold
const handleExecuteCheckbox = () => setApproveAndExecute(prevApproveAndExecute => !prevApproveAndExecute)
const handleExecuteCheckbox = () => setApproveAndExecute((prevApproveAndExecute) => !prevApproveAndExecute)
return (
<SharedSnackbarConsumer>

View File

@ -3,6 +3,7 @@ import * as React from 'react'
import { connect } from 'react-redux'
import Page from '~/components/layout/Page'
import Layout from '~/routes/safe/components/Layout'
import { type Token } from '~/logic/tokens/store/model/token'
import selector, { type SelectorProps } from './selector'
import actions, { type Actions } from './actions'

View File

@ -59,7 +59,7 @@ export const grantedSelector: Selector<GlobalState, RouterProps, boolean> = crea
return false
}
const owners: List<Owner> = safe.get('owners')
const { owners }: List<Owner> = safe
if (!owners) {
return false
}

View File

@ -1,10 +1,12 @@
// @flow
import { List } from 'immutable'
import { createAction } from 'redux-actions'
import type { Dispatch as ReduxDispatch } from 'redux'
import type { Dispatch as ReduxDispatch, GetState } from 'redux'
import { type GlobalState } from '~/store'
import SafeRecord, { type Safe } from '~/routes/safe/store/models/safe'
import { makeOwner, type Owner } from '~/routes/safe/store/models/owner'
import { safesListSelector } from '~/routes/safe/store/selectors'
import { type Safe } from '~/routes/safe/store/models/safe'
import { makeOwner } from '~/routes/safe/store/models/owner'
import setDefaultSafe from '~/routes/safe/store/actions/setDefaultSafe'
export const ADD_SAFE = 'ADD_SAFE'
@ -22,19 +24,18 @@ export const addSafe = createAction<string, Function, ActionReturn>(ADD_SAFE, (s
safe,
}))
const saveSafe = (name: string, address: string, threshold: number, ownersName: string[], ownersAddress: string[]) => (
const saveSafe = (safe: Safe) => (
dispatch: ReduxDispatch<GlobalState>,
getState: GetState<GlobalState>,
) => {
const owners: List<Owner> = buildOwnersFrom(ownersName, ownersAddress)
const safe: Safe = SafeRecord({
name,
address,
threshold,
owners,
})
const state = getState()
const safeList = safesListSelector(state)
dispatch(addSafe(safe))
if (safeList.size === 0) {
dispatch(setDefaultSafe(safe.address))
}
}
export default saveSafe

View File

@ -4,11 +4,10 @@ import { List, Map } from 'immutable'
import { type GlobalState } from '~/store/index'
import { makeOwner } from '~/routes/safe/store/models/owner'
import type { SafeProps } from '~/routes/safe/store/models/safe'
import { addSafe } from '~/routes/safe/store/actions/addSafe'
import addSafe from '~/routes/safe/store/actions/addSafe'
import { getOwners, getSafeName } from '~/logic/safe/utils'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
import { getBalanceInEtherOf } from '~/logic/wallets/getWeb3'
import updateSafe from '~/routes/safe/store/actions/updateSafe'
const buildOwnersFrom = (
safeOwners: string[],
@ -36,16 +35,12 @@ export const buildSafe = async (safeAddress: string, safeName: string) => {
return safe
}
export default (safeAddress: string, update: boolean = false) => async (dispatch: ReduxDispatch<GlobalState>) => {
export default (safeAddress: string) => async (dispatch: ReduxDispatch<GlobalState>) => {
try {
const safeName = (await getSafeName(safeAddress)) || 'LOADED SAFE'
const safeProps: SafeProps = await buildSafe(safeAddress, safeName)
if (update) {
dispatch(updateSafe(safeProps))
} else {
dispatch(addSafe(safeProps))
}
dispatch(addSafe(safeProps))
} catch (err) {
// eslint-disable-next-line
console.error('Error while updating safe information: ', err)

View File

@ -0,0 +1,18 @@
// @flow
import type { Dispatch as ReduxDispatch } from 'redux'
import { type GlobalState } from '~/store/index'
import { getDefaultSafe } from '~/logic/safe/utils'
import setDefaultSafe from './setDefaultSafe'
const loadDefaultSafe = () => async (dispatch: ReduxDispatch<GlobalState>) => {
try {
const defaultSafe: string = await getDefaultSafe()
dispatch(setDefaultSafe(defaultSafe))
} catch (err) {
// eslint-disable-next-line
console.error('Error while getting defautl safe from storage:', err)
}
}
export default loadDefaultSafe

View File

@ -0,0 +1,8 @@
// @flow
import { createAction } from 'redux-actions'
export const SET_DEFAULT_SAFE = 'SET_DEFAULT_SAFE'
const setDefaultSafe = createAction<string, *>(SET_DEFAULT_SAFE)
export default setDefaultSafe

View File

@ -9,14 +9,17 @@ import { REMOVE_SAFE_OWNER } from '~/routes/safe/store/actions/removeSafeOwner'
import { REPLACE_SAFE_OWNER } from '~/routes/safe/store/actions/replaceSafeOwner'
import { EDIT_SAFE_OWNER } from '~/routes/safe/store/actions/editSafeOwner'
import { type GlobalState } from '~/store/'
import { saveSafes, setOwners, removeOwners } from '~/logic/safe/utils'
import { safesMapSelector } from '~/routes/safeList/store/selectors'
import { getActiveTokensAddressesForAllSafes } from '~/routes/safe/store/selectors'
import {
saveSafes, setOwners, removeOwners, saveDefaultSafe,
} from '~/logic/safe/utils'
import { safesMapSelector, getActiveTokensAddressesForAllSafes } from '~/routes/safe/store/selectors'
import { tokensSelector } from '~/logic/tokens/store/selectors'
import type { Token } from '~/logic/tokens/store/model/token'
import { makeOwner } from '~/routes/safe/store/models/owner'
import { saveActiveTokens } from '~/logic/tokens/utils/tokensStorage'
import { ACTIVATE_TOKEN_FOR_ALL_SAFES } from '~/routes/safe/store/actions/activateTokenForAllSafes'
import { SET_DEFAULT_SAFE } from '~/routes/safe/store/actions/setDefaultSafe'
const watchedActions = [
ADD_SAFE,
@ -27,6 +30,7 @@ const watchedActions = [
REPLACE_SAFE_OWNER,
EDIT_SAFE_OWNER,
ACTIVATE_TOKEN_FOR_ALL_SAFES,
SET_DEFAULT_SAFE,
]
const recalculateActiveTokens = (state: GlobalState): void => {
@ -109,6 +113,12 @@ const safeStorageMware = (store: Store<GlobalState>) => (next: Function) => asyn
setOwners(safeAddress, owners.update(ownerToUpdateIndex, (owner) => owner.set('name', ownerName)))
break
}
case SET_DEFAULT_SAFE: {
if (action.payload) {
saveDefaultSafe(action.payload)
break
}
}
default:
break
}

View File

@ -2,11 +2,9 @@
import { Map, List } from 'immutable'
import { handleActions, type ActionType } from 'redux-actions'
import { ADD_SAFE, buildOwnersFrom } from '~/routes/safe/store/actions/addSafe'
import SafeRecord, { type Safe, type SafeProps } from '~/routes/safe/store/models/safe'
import SafeRecord, { type SafeProps } from '~/routes/safe/store/models/safe'
import TokenBalance from '~/routes/safe/store/models/tokenBalance'
import { makeOwner, type OwnerProps } from '~/routes/safe/store/models/owner'
import { loadFromStorage } from '~/utils/storage'
import { SAFES_KEY } from '~/logic/safe/utils'
import { UPDATE_SAFE } from '~/routes/safe/store/actions/updateSafe'
import { ACTIVATE_TOKEN_FOR_ALL_SAFES } from '~/routes/safe/store/actions/activateTokenForAllSafes'
import { REMOVE_SAFE } from '~/routes/safe/store/actions/removeSafe'
@ -14,10 +12,11 @@ import { ADD_SAFE_OWNER } from '~/routes/safe/store/actions/addSafeOwner'
import { REMOVE_SAFE_OWNER } from '~/routes/safe/store/actions/removeSafeOwner'
import { REPLACE_SAFE_OWNER } from '~/routes/safe/store/actions/replaceSafeOwner'
import { EDIT_SAFE_OWNER } from '~/routes/safe/store/actions/editSafeOwner'
import { SET_DEFAULT_SAFE } from '~/routes/safe/store/actions/setDefaultSafe'
export const SAFE_REDUCER_ID = 'safes'
export type State = Map<string, Safe>
export type SafeReducerState = Map<string, *>
export const buildSafe = (storedSafe: SafeProps) => {
const names = storedSafe.owners.map((owner: OwnerProps) => owner.name)
@ -36,40 +35,15 @@ export const buildSafe = (storedSafe: SafeProps) => {
return safe
}
const buildSafesFrom = (loadedSafes: Object): Map<string, Safe> => {
const safes: Map<string, Safe> = Map()
const keys = Object.keys(loadedSafes)
try {
const safeRecords = keys.map((address: string) => buildSafe(loadedSafes[address]))
return safes.withMutations(async (map) => {
safeRecords.forEach((safe: SafeProps) => map.set(safe.address, safe))
})
} catch (err) {
// eslint-disable-next-line
console.log('Error while fetching safes information')
return Map()
}
}
export const safesInitialState = async (): Promise<State> => {
const storedSafes = await loadFromStorage(SAFES_KEY)
const safes = storedSafes ? buildSafesFrom(storedSafes) : Map()
return safes
}
export default handleActions<State, *>(
export default handleActions<SafeReducerState, *>(
{
[UPDATE_SAFE]: (state: State, action: ActionType<Function>): State => {
[UPDATE_SAFE]: (state: SafeReducerState, action: ActionType<Function>): SafeReducerState => {
const safe = action.payload
const safeAddress = safe.address
return state.update(safeAddress, (prevSafe) => prevSafe.merge(safe))
return state.updateIn(['safes', safeAddress], (prevSafe) => prevSafe.merge(safe))
},
[ACTIVATE_TOKEN_FOR_ALL_SAFES]: (state: State, action: ActionType<Function>): State => {
[ACTIVATE_TOKEN_FOR_ALL_SAFES]: (state: SafeReducerState, action: ActionType<Function>): SafeReducerState => {
const tokenAddress = action.payload
const newState = state.withMutations((map) => {
@ -77,59 +51,59 @@ export default handleActions<State, *>(
const safeActiveTokens = map.getIn([safeAddress, 'activeTokens'])
const activeTokens = safeActiveTokens.push(tokenAddress)
map.update(safeAddress, (prevSafe) => prevSafe.merge({ activeTokens }))
map.updateIn(['safes', safeAddress], (prevSafe) => prevSafe.merge({ activeTokens }))
})
})
return newState
},
[ADD_SAFE]: (state: State, action: ActionType<Function>): State => {
[ADD_SAFE]: (state: SafeReducerState, action: ActionType<Function>): SafeReducerState => {
const { safe }: { safe: SafeProps } = action.payload
// if you add a new safe it needs to be set as a record
// in case of update it shouldn't, because a record would be initialized
// with initial props and it would overwrite existing ones
if (state.has(safe.address)) {
return state.update(safe.address, (prevSafe) => prevSafe.merge(safe))
if (state.hasIn(['safes', safe.address])) {
return state.updateIn(['safes', safe.address], (prevSafe) => prevSafe.merge(safe))
}
return state.set(safe.address, SafeRecord(safe))
return state.setIn(['safes', safe.address], SafeRecord(safe))
},
[REMOVE_SAFE]: (state: State, action: ActionType<Function>): State => {
[REMOVE_SAFE]: (state: SafeReducerState, action: ActionType<Function>): SafeReducerState => {
const safeAddress = action.payload
return state.delete(safeAddress)
return state.deleteIn(['safes', safeAddress])
},
[ADD_SAFE_OWNER]: (state: State, action: ActionType<Function>): State => {
[ADD_SAFE_OWNER]: (state: SafeReducerState, action: ActionType<Function>): SafeReducerState => {
const { safeAddress, ownerName, ownerAddress } = action.payload
return state.update(safeAddress, (prevSafe) => prevSafe.merge({
return state.updateIn(['safes', safeAddress], (prevSafe) => prevSafe.merge({
owners: prevSafe.owners.push(makeOwner({ address: ownerAddress, name: ownerName })),
}))
},
[REMOVE_SAFE_OWNER]: (state: State, action: ActionType<Function>): State => {
[REMOVE_SAFE_OWNER]: (state: SafeReducerState, action: ActionType<Function>): SafeReducerState => {
const { safeAddress, ownerAddress } = action.payload
return state.update(safeAddress, (prevSafe) => prevSafe.merge({
return state.updateIn(['safes', safeAddress], (prevSafe) => prevSafe.merge({
owners: prevSafe.owners.filter((o) => o.address.toLowerCase() !== ownerAddress.toLowerCase()),
}))
},
[REPLACE_SAFE_OWNER]: (state: State, action: ActionType<Function>): State => {
[REPLACE_SAFE_OWNER]: (state: SafeReducerState, action: ActionType<Function>): SafeReducerState => {
const {
safeAddress, oldOwnerAddress, ownerName, ownerAddress,
} = action.payload
return state.update(safeAddress, (prevSafe) => prevSafe.merge({
return state.updateIn(['safes', safeAddress], (prevSafe) => prevSafe.merge({
owners: prevSafe.owners
.filter((o) => o.address.toLowerCase() !== oldOwnerAddress.toLowerCase())
.push(makeOwner({ address: ownerAddress, name: ownerName })),
}))
},
[EDIT_SAFE_OWNER]: (state: State, action: ActionType<Function>): State => {
[EDIT_SAFE_OWNER]: (state: SafeReducerState, action: ActionType<Function>): SafeReducerState => {
const { safeAddress, ownerAddress, ownerName } = action.payload
return state.update(safeAddress, (prevSafe) => {
return state.updateIn(['safes', safeAddress], (prevSafe) => {
const ownerToUpdateIndex = prevSafe.owners.findIndex(
(o) => o.address.toLowerCase() === ownerAddress.toLowerCase(),
)
@ -137,6 +111,11 @@ export default handleActions<State, *>(
return prevSafe.merge({ owners: updatedOwners })
})
},
[SET_DEFAULT_SAFE]: (state: SafeReducerState, action: ActionType<Function>): SafeReducerState => state.set('defaultSafe', action.payload),
},
Map(),
Map({
// $FlowFixMe
defaultSafe: undefined,
safes: Map(),
}),
)

View File

@ -5,11 +5,10 @@ import { createSelector, createStructuredSelector, type Selector } from 'reselec
import { type GlobalState } from '~/store/index'
import { SAFE_PARAM_ADDRESS } from '~/routes/routes'
import { type Safe } from '~/routes/safe/store/models/safe'
import { safesMapSelector } from '~/routes/safeList/store/selectors'
import { type State as TransactionsState, TRANSACTIONS_REDUCER_ID } from '~/routes/safe/store/reducer/transactions'
import { type Transaction } from '~/routes/safe/store/models/transaction'
import { type Confirmation } from '~/routes/safe/store/models/confirmation'
import { safesListSelector } from '~/routes/safeList/store/selectors/'
import { SAFE_REDUCER_ID } from '~/routes/safe/store/reducer/safe'
export type RouterProps = {
match: Match,
@ -23,6 +22,25 @@ type TransactionProps = {
transaction: Transaction,
}
const safesStateSelector = (state: GlobalState): Map<string, *> => state[SAFE_REDUCER_ID]
export const safesMapSelector = (state: GlobalState): Map<string, Safe> => state[SAFE_REDUCER_ID].get('safes')
export const safesListSelector: Selector<GlobalState, {}, List<Safe>> = createSelector(
safesMapSelector,
(safes: Map<string, Safe>): List<Safe> => safes.toList(),
)
export const safesCountSelector: Selector<GlobalState, {}, number> = createSelector(
safesMapSelector,
(safes: Map<string, Safe>): number => safes.size,
)
export const defaultSafeSelector: Selector<GlobalState, {}, string> = createSelector(
safesStateSelector,
(safeState: Map<string, *>): string => safeState.get('defaultSafe'),
)
const transactionsSelector = (state: GlobalState): TransactionsState => state[TRANSACTIONS_REDUCER_ID]
const oneTransactionSelector = (state: GlobalState, props: TransactionProps) => props.transaction

View File

@ -1,130 +0,0 @@
// @flow
/*
import { aNewStore } from '~/store'
import { aDeployedSafe } from '~/routes/safe/store/test/builder/deployedSafe.builder'
import { getWeb3 } from '~/logic/wallets/getWeb3'
import { sleep } from '~/utils/timer'
import { type Match } from 'react-router-dom'
import { promisify } from '~/utils/promisify'
import { processTransaction } from '~/routes/safe/component/Transactions/processTransactions'
import {
confirmationsTransactionSelector,
safeSelector,
safeTransactionsSelector
} from '~/routes/safe/store/selectors'
import { getTransactionFromReduxStore } from '~/routes/safe/test/testMultisig'
import { buildMathPropsFrom } from '~/test/utils/buildReactRouterProps'
import { createTransaction } from '~/wallets/createTransactions'
import { getGnosisSafeInstanceAt } from '~/wallets/safeContracts'
import fetchTransactions from '~/routes/safe/store/actions/fetchTransactions'
*/
describe('React DOM TESTS > Change threshold', () => {
it('should update the threshold directly if safe has 1 threshold', async () => {})
})
/*
// GIVEN
const numOwners = 2
const threshold = 1
const store = aNewStore()
const address = await aDeployedSafe(store, 10, threshold, numOwners)
const accounts = await promisify(cb => getWeb3().eth.getAccounts(cb))
const match: Match = buildMathPropsFrom(address)
const safe = safeSelector(store.getState(), { match })
if (!safe) throw new Error()
const gnosisSafe = await getGnosisSafeInstanceAt(safeAddress)
// WHEN
const nonce = Date.now()
const data = gnosisSafe.contract.changeThreshold.getData(2)
await createTransaction(safe, "Change Safe's threshold", address, 0, nonce, accounts[0], data)
await sleep(1500)
await store.dispatch(fetchTransactions())
// THEN
const transactions = safeTransactionsSelector(store.getState(), { safeAddress: address })
expect(transactions.count()).toBe(1)
const thresholdTx = transactions.get(0)
if (!thresholdTx) throw new Error()
expect(thresholdTx.get('tx')).not.toBe(null)
expect(thresholdTx.get('tx')).not.toBe(undefined)
expect(thresholdTx.get('tx')).not.toBe('')
const safeThreshold = await gnosisSafe.getThreshold()
expect(Number(safeThreshold)).toEqual(2)
})
const changeThreshold = async (store, safeAddress, executor) => {
const tx = getTransactionFromReduxStore(store, safeAddress)
if (!tx) throw new Error()
const confirmed = confirmationsTransactionSelector(store.getState(), { transaction: tx })
const data = tx.get('data')
expect(data).not.toBe(null)
expect(data).not.toBe(undefined)
expect(data).not.toBe('')
await processTransaction(safeAddress, tx, confirmed, executor)
await sleep(1800)
}
it('should wait for confirmation to update threshold when safe has 1+ threshold', async () => {
// GIVEN
const numOwners = 3
const threshold = 2
const store = aNewStore()
const address = await aDeployedSafe(store, 10, threshold, numOwners)
const accounts = await promisify(cb => getWeb3().eth.getAccounts(cb))
const match: Match = buildMathPropsFrom(address)
const safe = safeSelector(store.getState(), { match })
if (!safe) throw new Error()
const gnosisSafe = await getGnosisSafeInstanceAt(safeAddress)
// WHEN
const nonce = Date.now()
const data = gnosisSafe.contract.changeThreshold.getData(3)
await createTransaction(safe, "Change Safe's threshold", address, 0, nonce, accounts[0], data)
await sleep(1500)
await store.dispatch(fetchTransactions())
let transactions = safeTransactionsSelector(store.getState(), { safeAddress: address })
if (!transactions) throw new Error()
expect(transactions.count()).toBe(1)
let thresholdTx = transactions.get(0)
if (!thresholdTx) throw new Error()
expect(thresholdTx.get('tx')).toBe('')
let firstOwnerConfirmation = thresholdTx.get('confirmations').get(0)
if (!firstOwnerConfirmation) throw new Error()
expect(firstOwnerConfirmation.get('status')).toBe(true)
let secondOwnerConfirmation = thresholdTx.get('confirmations').get(1)
if (!secondOwnerConfirmation) throw new Error()
expect(secondOwnerConfirmation.get('status')).toBe(false)
let safeThreshold = await gnosisSafe.getThreshold()
expect(Number(safeThreshold)).toEqual(2)
// THEN
await changeThreshold(store, address, accounts[1])
safeThreshold = await gnosisSafe.getThreshold()
expect(Number(safeThreshold)).toEqual(3)
await store.dispatch(fetchTransactions())
sleep(1200)
transactions = safeTransactionsSelector(store.getState(), { safeAddress: address })
expect(transactions.count()).toBe(1)
thresholdTx = transactions.get(0)
if (!thresholdTx) throw new Error()
expect(thresholdTx.get('tx')).not.toBe(undefined)
expect(thresholdTx.get('tx')).not.toBe(null)
expect(thresholdTx.get('tx')).not.toBe('')
firstOwnerConfirmation = thresholdTx.get('confirmations').get(0)
if (!firstOwnerConfirmation) throw new Error()
expect(firstOwnerConfirmation.get('status')).toBe(true)
secondOwnerConfirmation = thresholdTx.get('confirmations').get(1)
if (!secondOwnerConfirmation) throw new Error()
expect(secondOwnerConfirmation.get('status')).toBe(true)
})
})
*/

View File

@ -1,9 +0,0 @@
// @flow
import { safeTransactionsSelector } from '~/routes/safe/store/selectors/index'
import { type GlobalState } from '~/store/index'
export const getTransactionFromReduxStore = (store: Store<GlobalState>, address: string, index: number = 0) => {
const transactions = safeTransactionsSelector(store.getState(), { safeAddress: address })
return transactions.get(index)
}

View File

@ -1,25 +0,0 @@
// @flow
import { List } from 'immutable'
import * as React from 'react'
import NoSafe from '~/components/NoSafe'
import { type Safe } from '~/routes/safe/store/models/safe'
import SafeTable from '~/routes/safeList/components/SafeTable'
type Props = {
safes: List<Safe>,
provider: string,
}
const SafeList = ({ safes, provider }: Props) => {
const safesAvailable = safes && safes.count() > 0
return (
<>
{ safesAvailable
? <SafeTable safes={safes} />
: <NoSafe provider={provider} text="No safes created, please create a new one" />}
</>
)
}
export default SafeList

View File

@ -1,13 +0,0 @@
// @flow
import { storiesOf } from '@storybook/react'
import { List } from 'immutable'
import * as React from 'react'
import styles from '~/components/layout/PageFrame/index.scss'
import Component from './Layout'
const FrameDecorator = (story) => <div className={styles.frame}>{story()}</div>
storiesOf('Routes /safes', module)
.addDecorator(FrameDecorator)
.add('Safe List whithout safes and connected', () => <Component provider="METAMASK" safes={List([])} />)
.add('Safe List whithout safes and NOT connected', () => <Component provider="" safes={List([])} />)

View File

@ -1,44 +0,0 @@
// @flow
import { List } from 'immutable'
import * as React from 'react'
import Button from '~/components/layout/Button'
import Link from '~/components/layout/Link'
import Table, {
TableBody, TableCell, TableHead, TableRow,
} from '~/components/layout/Table'
import { type Safe } from '~/routes/safe/store/models/safe'
import { SAFELIST_ADDRESS } from '~/routes/routes'
type Props = {
safes: List<Safe>
}
const SafeTable = ({ safes }: Props) => (
<Table size={900}>
<TableHead>
<TableRow>
<TableCell>Open</TableCell>
<TableCell>Name</TableCell>
<TableCell>Deployed Address</TableCell>
<TableCell align="right">Confirmations</TableCell>
<TableCell align="right">Number of owners</TableCell>
</TableRow>
</TableHead>
<TableBody>
{safes.map(safe => (
<TableRow key={safe.address}>
<TableCell>
<Link to={`${SAFELIST_ADDRESS}/${safe.address}`}>
<Button variant="contained" size="small" color="primary">Open</Button>
</Link>
</TableCell>
<TableCell padding="none">{safe.get('name')}</TableCell>
<TableCell padding="none">{safe.get('address')}</TableCell>
<TableCell padding="none" align="right">{safe.get('threshold')}</TableCell>
<TableCell padding="none" align="right">{safe.get('owners').count()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
export default SafeTable

View File

@ -1,21 +0,0 @@
// @flow
import { List } from 'immutable'
import * as React from 'react'
import { connect } from 'react-redux'
import Page from '~/components/layout/Page'
import { type Safe } from '~/routes/safe/store/models/safe'
import Layout from '../components/Layout'
import selector from './selector'
type Props = {
safes: List<Safe>,
provider: string,
}
const SafeList = ({ safes, provider }: Props) => (
<Page>
<Layout safes={safes} provider={provider} />
</Page>
)
export default connect<*, *, *, *>(selector)(SafeList)

View File

@ -1,9 +0,0 @@
// @flow
import { createStructuredSelector } from 'reselect'
import { safesListSelector } from '~/routes/safeList/store/selectors'
import { providerNameSelector } from '~/logic/wallets/store/selectors'
export default createStructuredSelector<Object, *>({
safes: safesListSelector,
provider: providerNameSelector,
})

View File

@ -1,13 +0,0 @@
// @flow
import { List, Map } from 'immutable'
import { createSelector, type Selector } from 'reselect'
import { type GlobalState } from '~/store/index'
import { type Safe } from '~/routes/safe/store/models/safe'
import { SAFE_REDUCER_ID } from '~/routes/safe/store/reducer/safe'
export const safesMapSelector = (state: GlobalState): Map<string, Safe> => state[SAFE_REDUCER_ID]
export const safesListSelector: Selector<GlobalState, {}, List<Safe>> = createSelector(
safesMapSelector,
(safes: Map<string, Safe>): List<Safe> => safes.toList(),
)

View File

@ -6,7 +6,7 @@ import {
} from 'redux'
import thunk from 'redux-thunk'
import provider, { PROVIDER_REDUCER_ID, type State as ProviderState } from '~/logic/wallets/store/reducer/provider'
import safe, { SAFE_REDUCER_ID, type State as SafeState } from '~/routes/safe/store/reducer/safe'
import safe, { SAFE_REDUCER_ID, type SafeReducerState as SafeState } from '~/routes/safe/store/reducer/safe'
import safeStorage from '~/routes/safe/store/middleware/safeStorage'
import tokens, { TOKEN_REDUCER_ID, type State as TokensState } from '~/logic/tokens/store/reducer/tokens'
import transactions, {
@ -37,6 +37,9 @@ const reducers: Reducer<GlobalState> = combineReducers({
[TRANSACTIONS_REDUCER_ID]: transactions,
})
export const store: Store<GlobalState> = createStore(reducers, finalCreateStore)
export const store: Store<GlobalState> = createStore(
reducers,
finalCreateStore,
)
export const aNewStore = (localState?: Object): Store<GlobalState> => createStore(reducers, localState, finalCreateStore)

View File

@ -72,6 +72,7 @@ export const aMinedSafe = async (
store: Store<GlobalState>,
owners: number = 1,
threshold: number = 1,
name: string = 'Safe Name',
): Promise<string> => {
const provider = await getProviderInfo()
const walletRecord = makeProvider(provider)
@ -79,7 +80,7 @@ export const aMinedSafe = async (
const accounts = await getWeb3().eth.getAccounts()
const form = {
[FIELD_NAME]: 'Safe Name',
[FIELD_NAME]: name,
[FIELD_CONFIRMATIONS]: `${threshold}`,
[FIELD_OWNERS]: `${owners}`,
}

View File

@ -1,20 +1,21 @@
// @flow
import * as React from 'react'
import { type Store } from 'redux'
import { render, fireEvent } from '@testing-library/react'
import { render, fireEvent, act } from '@testing-library/react'
import { Provider } from 'react-redux'
import { ConnectedRouter } from 'connected-react-router'
import { sleep } from '~/utils/timer'
import { ADD_OWNER_BUTTON } from '~/routes/open/components/SafeOwnersConfirmationsForm'
import Open from '~/routes/open/container/Open'
import { aNewStore, history, type GlobalState } from '~/store'
import { sleep } from '~/utils/timer'
import { getProviderInfo, getWeb3 } from '~/logic/wallets/getWeb3'
import addProvider from '~/logic/wallets/store/actions/addProvider'
import { makeProvider } from '~/logic/wallets/store/model/provider'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
import { whenSafeDeployed } from './builder/safe.dom.utils'
// https://github.com/testing-library/@testing-library/react/issues/281
// For some reason it warns about events not wrapped in act
// But they're wrapped :(
const originalError = console.error
beforeAll(() => {
console.error = (...args) => {
@ -53,9 +54,10 @@ const deploySafe = async (createSafeForm: any, threshold: number, numOwners: num
// Fill Safe's name
const nameInput: HTMLInputElement = createSafeForm.getByPlaceholderText('Name of the new Safe')
fireEvent.change(nameInput, { target: { value: 'Adolfo Safe' } })
fireEvent.submit(form)
await sleep(400)
await act(async () => {
fireEvent.change(nameInput, { target: { value: 'Adolfo Safe' } })
fireEvent.submit(form)
})
// Fill owners
const addedUpfront = 1
@ -63,7 +65,11 @@ const deploySafe = async (createSafeForm: any, threshold: number, numOwners: num
expect(addOwnerButton.getElementsByTagName('span')[0].textContent).toEqual(ADD_OWNER_BUTTON)
for (let i = addedUpfront; i < numOwners; i += 1) {
fireEvent.click(addOwnerButton)
/* eslint-disable */
await act(async () => {
fireEvent.click(addOwnerButton)
})
/* eslint-enable */
}
const ownerNameInputs = createSafeForm.getAllByPlaceholderText('Owner Name*')
@ -75,23 +81,31 @@ const deploySafe = async (createSafeForm: any, threshold: number, numOwners: num
const ownerNameInput = ownerNameInputs[i]
const ownerAddressInput = ownerAddressInputs[i]
fireEvent.change(ownerNameInput, { target: { value: `Owner ${i + 1}` } })
fireEvent.change(ownerAddressInput, { target: { value: accounts[i] } })
/* eslint-disable */
await act(async () => {
fireEvent.change(ownerNameInput, { target: { value: `Owner ${i + 1}` } })
fireEvent.change(ownerAddressInput, { target: { value: accounts[i] } })
})
/* eslint-enable */
}
// Fill Threshold
// The test is fragile here, MUI select btn is hard to find
const thresholdSelect = createSafeForm.getAllByRole('button')[2]
fireEvent.click(thresholdSelect)
await act(async () => {
fireEvent.click(thresholdSelect)
})
const thresholdOptions = createSafeForm.getAllByRole('option')
fireEvent.click(thresholdOptions[numOwners - 1])
fireEvent.submit(form)
await sleep(400)
await act(async () => {
fireEvent.click(thresholdOptions[numOwners - 1])
fireEvent.submit(form)
})
// Submit
fireEvent.submit(form)
await sleep(400)
await act(async () => {
fireEvent.submit(form)
})
// giving some time to the component for updating its state with safe
// before destroying its context
@ -100,6 +114,7 @@ const deploySafe = async (createSafeForm: any, threshold: number, numOwners: num
const aDeployedSafe = async (specificStore: Store<GlobalState>, threshold?: number = 1, numOwners?: number = 1) => {
const safe: React.Component<{}> = await renderOpenSafeForm(specificStore)
await sleep(1500)
const safeAddress = await deploySafe(safe, threshold, numOwners)
return safeAddress

View File

@ -1,5 +1,5 @@
// @flow
import { fireEvent } from '@testing-library/react'
import { fireEvent, waitForElement } from '@testing-library/react'
import { aNewStore } from '~/store'
import { aMinedSafe } from '~/test/builder/safe.redux.builder'
import { sendEtherTo } from '~/test/utils/tokenMovements'
@ -59,18 +59,20 @@ describe('DOM > Feature > Sending Funds', () => {
expect(txRows.length).toBe(1)
fireEvent.click(txRows[0])
await sleep(100)
fireEvent.click(SafeDom.getByTestId(CONFIRM_TX_BTN_TEST_ID))
await sleep(100)
const confirmBtn = await waitForElement(() => SafeDom.getByTestId(CONFIRM_TX_BTN_TEST_ID))
fireEvent.click(confirmBtn)
// Travel confirm modal
fireEvent.click(SafeDom.getByTestId(APPROVE_TX_MODAL_SUBMIT_BTN_TEST_ID))
await sleep(2000)
const approveTxBtn = await waitForElement(() => SafeDom.getByTestId(APPROVE_TX_MODAL_SUBMIT_BTN_TEST_ID))
fireEvent.click(approveTxBtn)
// EXECUTE TX
fireEvent.click(SafeDom.getByTestId(EXECUTE_TX_BTN_TEST_ID))
await sleep(100)
fireEvent.click(SafeDom.getByTestId(APPROVE_TX_MODAL_SUBMIT_BTN_TEST_ID))
const executeTxBtn = await waitForElement(() => SafeDom.getByTestId(EXECUTE_TX_BTN_TEST_ID))
fireEvent.click(executeTxBtn)
const confirmReviewTxBtn = await waitForElement(() => SafeDom.getByTestId(APPROVE_TX_MODAL_SUBMIT_BTN_TEST_ID))
fireEvent.click(confirmReviewTxBtn)
await sleep(500)
// THEN

View File

@ -1,5 +1,5 @@
// @flow
import { fireEvent } from '@testing-library/react'
import { fireEvent, waitForElement } from '@testing-library/react'
import { aNewStore } from '~/store'
import { aMinedSafe } from '~/test/builder/safe.redux.builder'
import { renderSafeView } from '~/test/builder/safe.dom.utils'
@ -55,17 +55,15 @@ describe('DOM > Feature > Settings - Manage owners', () => {
await sleep(1300)
// Travel to settings
const settingsBtn = SafeDom.getByTestId(SETTINGS_TAB_BTN_TEST_ID)
const settingsBtn = await waitForElement(() => SafeDom.getByTestId(SETTINGS_TAB_BTN_TEST_ID))
fireEvent.click(settingsBtn)
await sleep(200)
// click on owners settings
const ownersSettingsBtn = SafeDom.getByTestId(OWNERS_SETTINGS_TAB_TEST_ID)
const ownersSettingsBtn = await waitForElement(() => SafeDom.getByTestId(OWNERS_SETTINGS_TAB_TEST_ID))
fireEvent.click(ownersSettingsBtn)
await sleep(200)
// open rename owner modal
const renameOwnerBtn = SafeDom.getByTestId(RENAME_OWNER_BTN_TEST_ID)
const renameOwnerBtn = await waitForElement(() => SafeDom.getByTestId(RENAME_OWNER_BTN_TEST_ID))
fireEvent.click(renameOwnerBtn)
// rename owner

View File

@ -0,0 +1,55 @@
// @flow
import { act, fireEvent } from '@testing-library/react'
import { aNewStore } from '~/store'
import { aMinedSafe } from '~/test/builder/safe.redux.builder'
import { renderSafeView } from '~/test/builder/safe.dom.utils'
import '@testing-library/jest-dom/extend-expect'
import { TOGGLE_SIDEBAR_BTN_TESTID } from '~/components/Header/component/SafeListHeader'
import { SIDEBAR_SAFELIST_ROW_TESTID } from '~/components/Sidebar/SafeList'
import { sleep } from '~/utils/timer'
describe('DOM > Feature > Sidebar', () => {
let store
let safeAddress: string
beforeEach(async () => {
store = aNewStore()
safeAddress = await aMinedSafe(store)
})
it('Shows "default" label for a single safe', async () => {
const SafeDom = await renderSafeView(store, safeAddress)
act(() => {
fireEvent.click(SafeDom.getByTestId(TOGGLE_SIDEBAR_BTN_TESTID))
})
const safes = SafeDom.getAllByTestId(SIDEBAR_SAFELIST_ROW_TESTID)
expect(safes.length).toBe(1)
expect(safes[0]).toContainElement(SafeDom.getByText('default'))
})
it('Changes default safe', async () => {
const SafeDom = await renderSafeView(store, safeAddress)
await aMinedSafe(store)
await sleep(2000)
act(() => {
fireEvent.click(SafeDom.getByTestId(TOGGLE_SIDEBAR_BTN_TESTID))
})
const safes = SafeDom.getAllByTestId(SIDEBAR_SAFELIST_ROW_TESTID)
expect(safes.length).toBe(2)
expect(safes[1]).toContainElement(SafeDom.getByText('default'))
expect(safes[0]).toContainElement(SafeDom.getByText('Make default'))
act(() => {
fireEvent.click(SafeDom.getByText('Make default'))
})
expect(safes[0]).toContainElement(SafeDom.getByText('default'))
expect(safes[1]).toContainElement(SafeDom.getByText('Make default'))
})
})

View File

@ -1,6 +1,6 @@
// @flow
import * as React from 'react'
import { fireEvent } from '@testing-library/react'
import { fireEvent, waitForElement } from '@testing-library/react'
import { sleep } from '~/utils/timer'
export const fillAndSubmitSendFundsForm = async (
@ -24,7 +24,7 @@ export const fillAndSubmitSendFundsForm = async (
fireEvent.click(reviewBtn)
// Submit the tx (Review Tx screen)
const submitBtn = SafeDom.getByTestId('submit-tx-btn')
const submitBtn = await waitForElement(() => SafeDom.getByTestId('submit-tx-btn'))
fireEvent.click(submitBtn)
await sleep(1000)
}

View File

@ -206,6 +206,11 @@ export default createMuiTheme({
color: primary,
},
},
MuiSvgIcon: {
colorSecondary: {
color: secondaryText,
},
},
MuiTab: {
root: {
fontFamily: 'Averta, monospace',

View File

@ -17,6 +17,7 @@ const lg = '24px'
const xl = '32px'
const xxl = '40px'
const marginButtonImg = '12px'
const headerHeight = '53px'
module.exports = {
primary,
@ -29,6 +30,7 @@ module.exports = {
warning: warningColor,
error: errorColor,
connected: connectedColor,
headerHeight,
xs,
sm,
md,

1092
yarn.lock

File diff suppressed because it is too large Load Diff