WA-280 - Feature open created safes (#12)

* WA-280 Added redux logic for safe route

* WA-280 Added tests including builders for safe's redux store classes

* WA-280 Improving Flow coverage in actions and reducers

* WA- 280 Mocking LocalStorage and Web3 in JEST

* WA-280 Generating view of Safe route and its logic to store and retrieve info from localstorage

* WA-280 Added run-with-testrpc for simulating a testnet in memory while executing tests
This commit is contained in:
Adolfo Panizo 2018-04-11 09:28:54 +02:00 committed by GitHub
parent d2b5131c9e
commit 88bfca0a0d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
71 changed files with 1273 additions and 258 deletions

View File

@ -4,7 +4,7 @@ node_js:
os:
- linux
before_script:
- yarn global add truffle
- yarn global add truffle@4.1.3
- yarn global add surge
- git clone https://github.com/gnosis/gnosis-safe-contracts.git
- cd gnosis-safe-contracts

View File

@ -0,0 +1,26 @@
// @flow
class LocalStorageMock {
store: Object
constructor() {
this.store = {}
}
clear() {
this.store = {}
}
getItem(key) {
return this.store[key] || null
}
setItem(key, value) {
this.store[key] = value.toString()
}
removeItem(key) {
delete this.store[key]
}
}
global.localStorage = new LocalStorageMock()

8
config/jest/Web3Mock.js Normal file
View File

@ -0,0 +1,8 @@
// @flow
import Web3 from 'web3'
const window = global.window || {}
window.web3 = window.web3 || {}
window.web3.currentProvider = new Web3.providers.HttpProvider('http://localhost:8545')
global.window = window

49
package-lock.json generated
View File

@ -2991,18 +2991,6 @@
"integrity": "sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg==",
"dev": true
},
"babel-eslint": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-7.2.3.tgz",
"integrity": "sha1-sv4tgBJkcPXBlELcdXJTqJdxCCc=",
"dev": true,
"requires": {
"babel-code-frame": "6.26.0",
"babel-traverse": "6.26.0",
"babel-types": "6.26.0",
"babylon": "6.18.0"
}
},
"babel-generator": {
"version": "6.26.1",
"resolved": "https://registry.npmjs.org/babel-generator/-/babel-generator-6.26.1.tgz",
@ -9745,6 +9733,33 @@
"integrity": "sha1-8ESOgGmFW/Kj5oPNwdMg5+KgfvQ=",
"dev": true
},
"ganache-cli": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ganache-cli/-/ganache-cli-6.1.0.tgz",
"integrity": "sha512-FdTeyk4uLRHGeFiMe+Qnh4Hc5KiTVqvRVVvLDFJEVVKC1P1yHhEgZeh9sp1KhuvxSrxToxgJS25UapYQwH4zHw==",
"dev": true,
"requires": {
"source-map-support": "0.5.4",
"webpack-cli": "2.0.10"
},
"dependencies": {
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true
},
"source-map-support": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.4.tgz",
"integrity": "sha512-PETSPG6BjY1AHs2t64vS2aqAgu6dMIMXJULWFBGbh2Gr8nVLbCFDo6i/RMMvviIQ2h1Z8+5gQhVKSn2je9nmdg==",
"dev": true,
"requires": {
"source-map": "0.6.1"
}
}
}
},
"gauge": {
"version": "2.7.4",
"resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz",
@ -18547,6 +18562,16 @@
"aproba": "1.2.0"
}
},
"run-with-testrpc": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/run-with-testrpc/-/run-with-testrpc-0.3.0.tgz",
"integrity": "sha512-G4mvZz0O9AaYyESVEWzaYTagooKtWajd6t4zC7qxPs06pIyhYYzNytmxapCH/5tfWgsRSNfZ+XJhYPBvYpIlaQ==",
"dev": true,
"requires": {
"colors": "1.1.2",
"ganache-cli": "6.1.0"
}
},
"rustbn.js": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/rustbn.js/-/rustbn.js-0.1.2.tgz",

View File

@ -8,7 +8,8 @@
"scripts": {
"start": "node scripts/start.js",
"build": "node scripts/build.js",
"test": "node scripts/test.js --env=jsdom",
"test": "run-with-testrpc -l 40000000 'node scripts/test.js --env=jsdom'",
"test-local": "node scripts/test.js --env=jsdom",
"precommit": "./precommit.sh",
"flow": "flow",
"storybook": "start-storybook -p 6006",
@ -82,6 +83,7 @@
"redux-actions": "^2.3.0",
"redux-thunk": "^2.2.0",
"reselect": "^3.0.1",
"run-with-testrpc": "^0.3.0",
"storybook-host": "^4.1.5",
"storybook-router": "^0.3.3",
"style-loader": "^0.20.2",
@ -105,12 +107,15 @@
"react-router-dom": "^4.2.2"
},
"jest": {
"verbose": true,
"collectCoverageFrom": [
"src/**/*.{js,jsx}"
],
"setupFiles": [
"<rootDir>/config/webpack.config.test.js",
"<rootDir>/config/polyfills.js"
"<rootDir>/config/polyfills.js",
"<rootDir>/config/jest/LocalStorageMock.js",
"<rootDir>/config/jest/Web3Mock.js"
],
"testMatch": [
"<rootDir>/src/**/__tests__/**/*.js?(x)",

View File

@ -2,13 +2,17 @@
import React from 'react'
import Block from '~/components/layout/Block'
import Link from '~/components/layout/Link'
import { WELCOME_ADDRESS, SAFELIST_ADDRESS } from '~/routes/routes'
import styles from './index.scss'
const Footer = () => (
<Block className={styles.footer}>
<Link to="/welcome">
<Link padding="md" to={WELCOME_ADDRESS}>
Welcome
</Link>
<Link to={SAFELIST_ADDRESS}>
Safe List
</Link>
</Block>
)

View File

@ -1,7 +1,18 @@
.footer {
display: grid;
grid-template-rows: 100%;
grid-template-columns: auto;
justify-content: end;
grid-column-gap: $md;
grid-template-columns: 1fr auto auto;
justify-items: end;
}
@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

@ -2,7 +2,7 @@
import React from 'react'
import Col from '~/components/layout/Col'
import Img from '~/components/layout/Img'
import Loader from '~/components/Loader'
import Refresh from '~/components/Refresh'
import Row from '~/components/layout/Row'
import Connected from './Connected'
@ -22,7 +22,7 @@ const Header = ({ provider, reloadWallet }: Props) => (
</Col>
<Col xs={12} center="xs" sm={6} end="sm" margin="lg">
{ provider ? <Connected provider={provider} /> : <NotConnected /> }
<Loader callback={reloadWallet} />
<Refresh callback={reloadWallet} />
</Col>
</Row>
)

View File

@ -1,47 +1,7 @@
// @flow
import * as React from 'react'
import { CircularProgress } from 'material-ui/Progress'
import RefreshIcon from 'material-ui-icons/Refresh'
import { sm, secondary } from '~/theme/variables'
type Props = {
callback: () => void,
}
type State = {
loading: boolean,
}
const loaderStyle = {
margin: `0px 0px 0px ${sm}`,
color: secondary,
}
class Loader extends React.Component<Props, State> {
constructor() {
super()
this.state = {
loading: false,
}
}
onReload = async () => {
this.setState({ loading: true }, await this.props.callback())
this.setState({ loading: false })
}
render() {
const { loading } = this.state
return (
<div style={loaderStyle}>
{loading
? <CircularProgress color="inherit" size={24} />
: <RefreshIcon color="inherit" onClick={this.onReload} /> }
</div>
)
}
}
const Loader = () => <CircularProgress size={50} />
export default Loader

View File

@ -0,0 +1,30 @@
// @flow
import * as React from 'react'
import Bold from '~/components/layout/Bold'
import Button from '~/components/layout/Button'
import Col from '~/components/layout/Col'
import Row from '~/components/layout/Row'
import Link from '~/components/layout/Link'
import Paragraph from '~/components/layout/Paragraph/index'
import { OPEN_ADDRESS } from '~/routes/routes'
type Props = {
text: string
}
const NoSafe = ({ text }: Props) => (
<Row>
<Col xs={12} center="xs" sm={10} smOffset={2} start="sm" margin="md">
<Paragraph size="lg">
<Bold>{text}</Bold>
</Paragraph>
</Col>
<Col xs={12} center="xs" sm={10} smOffset={2} start="sm" margin="md">
<Link to={OPEN_ADDRESS}>
<Button variant="raised" size="small" color="primary">CREATE A NEW SAFE</Button>
</Link>
</Col>
</Row>
)
export default NoSafe

View File

@ -0,0 +1,47 @@
// @flow
import * as React from 'react'
import { CircularProgress } from 'material-ui/Progress'
import RefreshIcon from 'material-ui-icons/Refresh'
import { sm, secondary } from '~/theme/variables'
type Props = {
callback: () => void,
}
type State = {
loading: boolean,
}
const loaderStyle = {
margin: `0px 0px 0px ${sm}`,
color: secondary,
}
class Loader extends React.Component<Props, State> {
constructor() {
super()
this.state = {
loading: false,
}
}
onReload = async () => {
this.setState({ loading: true }, await this.props.callback())
this.setState({ loading: false })
}
render() {
const { loading } = this.state
return (
<div style={loaderStyle}>
{loading
? <CircularProgress color="inherit" size={24} />
: <RefreshIcon color="inherit" onClick={this.onReload} /> }
</div>
)
}
}
export default Loader

View File

@ -2,6 +2,7 @@
import * as React from 'react'
import Button from '~/components/layout/Button'
import Link from '~/components/layout/Link'
import { SAFELIST_ADDRESS } from '~/routes/routes'
type NextButtonProps = {
text: string,
@ -20,8 +21,8 @@ const NextButton = ({ text, disabled }: NextButtonProps) => (
)
const GoButton = () => (
<Link to="/welcome">
<NextButton text="GO" disabled={false} />
<Link to={SAFELIST_ADDRESS}>
<NextButton text="VISIT SAFES" disabled={false} />
</Link>
)

View File

@ -1,17 +1,33 @@
// @flow
import classNames from 'classnames/bind'
import React from 'react'
import { Link } from 'react-router-dom'
import { capitalize } from '~/utils/css'
import styles from './index.scss'
const cx = classNames.bind(styles)
type Props = {
padding?: 'xs' | 'sm' | 'md',
to: string,
children: React$Node,
className?: string,
}
const GnosisLink = ({ to, children, ...props }: Props) => (
<Link className={styles.link} to={to} {...props}>
{ children }
</Link>
)
const GnosisLink = ({
to, children, className, padding, ...props
}: Props) => {
const classes = cx(
styles.link,
padding ? capitalize(padding, 'padding') : undefined,
className,
)
return (
<Link className={classes} to={to} {...props}>
{ children }
</Link>
)
}
export default GnosisLink

View File

@ -1,4 +1,16 @@
.link {
text-decoration: none;
color: $secondary;
}
}
.paddingXs {
padding-right: $xs;
}
.paddingSm {
padding-right: $sm;
}
.paddingMd {
padding-right: $md;
}

View File

@ -8,10 +8,11 @@ const cx = classNames.bind(styles)
type Props = {
children: React$Node,
align?: 'center',
overflow?: boolean
}
const Page = ({ children, align }: Props) => (
<main className={cx(styles.page, align)}>
const Page = ({ children, align, overflow }: Props) => (
<main className={cx(styles.page, align, { overflow })}>
{children}
</main>
)

View File

@ -7,3 +7,7 @@
.center {
align-self: center;
}
.overflow {
overflow-x: scroll;
}

View File

@ -1,21 +1,17 @@
// @flow
import React from 'react'
import Page from '~/components/layout/Page'
import Footer from '~/components/Footer'
import Header from '~/components/Header'
import styles from './index.scss'
type Props = {
children: React$Node,
align?: "center",
}
const PageFrame = ({ children, align }: Props) => (
const PageFrame = ({ children }: Props) => (
<div className={styles.frame}>
<Header />
<Page align={align}>
{children}
</Page>
{children}
<Footer />
</div>
)

View File

@ -9,7 +9,7 @@ type Props = {
center?: boolean,
noMargin?: boolean,
bold?: boolean,
size?: 'sm' | 'md' | 'lg',
size?: 'sm' | 'md' | 'lg' | 'xl',
color?: 'soft' | 'medium' | 'dark' | 'primary',
children: React$Node
}

View File

@ -39,6 +39,10 @@
font-size: $largeFontSize;
}
.lg {
font-size: $extraLargeFontSize;
}
.bold {
font-weight: bold;
}

View File

@ -0,0 +1,28 @@
// @flow
import * as React from 'react'
import Table, { TableBody, TableCell, TableHead, TableRow } from 'material-ui/Table'
export { TableBody, TableCell, TableHead, TableRow }
type Props = {
children: React$Node,
size?: number
}
const buildWidthFrom = (size: number) => ({
minWidth: `${size}px`,
})
// see: https://css-tricks.com/responsive-data-tables/
const GnoTable = ({ size, children }: Props) => {
const style = size ? buildWidthFrom(size) : undefined
return (
<Table style={style}>
{children}
</Table>
)
}
export default GnoTable

View File

@ -6,6 +6,7 @@ import React from 'react'
import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import { ConnectedRouter } from 'react-router-redux'
import PageFrame from '~/components/layout/PageFrame'
import { history, store } from '~/store'
import theme from '~/theme/mui'
import AppRoutes from '~/routes'
@ -15,7 +16,9 @@ const Root = () => (
<Provider store={store}>
<MuiThemeProvider theme={theme}>
<ConnectedRouter history={history}>
<AppRoutes />
<PageFrame>
<AppRoutes />
</PageFrame>
</ConnectedRouter>
</MuiThemeProvider>
</Provider>

View File

@ -1,28 +1,35 @@
// @flow
import { CircularProgress } from 'material-ui/Progress'
import React from 'react'
import Loadable from 'react-loadable'
import { Switch, Redirect, Route } from 'react-router-dom'
import Loader from '~/components/Loader'
import Welcome from './welcome/container'
import { SAFELIST_ADDRESS, OPEN_ADDRESS, SAFE_PARAM_ADDRESS, WELCOME_ADDRESS } from './routes'
const Loading = () => <CircularProgress size={50} />
const Safe = Loadable({
loader: () => import('./safe/container'),
loading: Loader,
})
const Transactions = Loadable({
loader: () => import('./transactions/components/Layout'),
loading: Loading,
const SafeList = Loadable({
loader: () => import('./safeList/container'),
loading: Loader,
})
const Open = Loadable({
loader: () => import('./open/container/Open'),
loading: Loading,
loading: Loader,
})
const SAFE_ADDRESS = `${SAFELIST_ADDRESS}/:${SAFE_PARAM_ADDRESS}`
const Routes = () => (
<Switch>
<Redirect exact from="/" to="/welcome" />
<Route exact path="/welcome" component={Welcome} />
<Route exact path="/open" component={Open} />
<Route exact path="/transactions" component={Transactions} />
<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} />
</Switch>
)

View File

@ -17,8 +17,10 @@ type Props = {
tx: Object,
}
export const DEPLOYED_COMPONENT_ID = 'deployedSafeComponent'
const Deployment = ({ address, tx }: Props) => (
<Block>
<Block className={DEPLOYED_COMPONENT_ID}>
<Paragraph><Bold>Deployed safe to: </Bold>{address}</Paragraph>
<Pre>
{JSON.stringify(tx, null, 2) }

View File

@ -20,7 +20,7 @@ const store = new Store({
})
storiesOf('Routes', module)
storiesOf('Routes /open', module)
.addDecorator(FrameDecorator)
.add('Open safe with all props set', () => {
getProviderInfo()

View File

@ -0,0 +1,81 @@
// @flow
import * as React from 'react'
import TestUtils from 'react-dom/test-utils'
import Open from '~/routes/open/container/Open'
import { Provider } from 'react-redux'
import { ConnectedRouter } from 'react-router-redux'
import { FIELD_NAME, FIELD_OWNERS, FIELD_CONFIRMATIONS, getOwnerNameBy, getOwnerAddressBy } from '~/routes/open/components/fields'
import { DEPLOYED_COMPONENT_ID } from '~/routes/open/components/FormConfirmation'
import { history, store } from '~/store'
import { sleep } from '~/utils/timer'
import { getProviderInfo } from '~/wallets/getWeb3'
import addProvider from '~/wallets/store/actions/addProvider'
import { makeProvider } from '~/wallets/store/model/provider'
describe('React DOM TESTS > Create Safe form', () => {
let open
let provider
beforeEach(async () => {
// init app web3 instance
provider = await getProviderInfo()
const walletRecord = makeProvider(provider)
store.dispatch(addProvider(walletRecord))
open = TestUtils.renderIntoDocument((
<Provider store={store}>
<ConnectedRouter history={history}>
<Open />
</ConnectedRouter>
</Provider>
))
})
it('should create a 1 owner safe after rendering correctly the form', async () => {
const inputs = TestUtils.scryRenderedDOMComponentsWithTag(open, 'input')
const fieldName = inputs[0]
expect(fieldName.name).toEqual(FIELD_NAME)
const fieldOwners = inputs[1]
expect(fieldOwners.name).toEqual(FIELD_OWNERS)
const fieldConfirmations = inputs[2]
expect(fieldConfirmations.name).toEqual(FIELD_CONFIRMATIONS)
TestUtils.Simulate.change(fieldOwners, { target: { value: '1' } })
const inputsExpanded = TestUtils.scryRenderedDOMComponentsWithTag(open, 'input')
const ownerName = inputsExpanded[2]
expect(ownerName.name).toEqual(getOwnerNameBy(0))
const ownerAddress = inputsExpanded[3]
expect(ownerAddress.name).toEqual(getOwnerAddressBy(0))
expect(ownerAddress.value).toEqual(provider.account)
// WHEN
TestUtils.Simulate.change(fieldName, { target: { value: 'Adolfo Safe' } })
TestUtils.Simulate.change(fieldConfirmations, { target: { value: '1' } })
TestUtils.Simulate.change(ownerName, { target: { value: 'Adolfo Eth Account' } })
const form = TestUtils.findRenderedDOMComponentWithTag(open, 'form')
// One submit per step when creating a safe
TestUtils.Simulate.submit(form) // fill the form
TestUtils.Simulate.submit(form) // confirming data
TestUtils.Simulate.submit(form) // Executing transaction
// giving some time to the component for updating its state with safe
// before destroying its context
await sleep(1500)
// THEN
const Deployed = TestUtils.findRenderedDOMComponentWithClass(open, DEPLOYED_COMPONENT_ID)
const addressHtml = Deployed.getElementsByTagName('p')[0].innerHTML
const contractAddress = addressHtml.slice(addressHtml.lastIndexOf('>') + 1)
const transactionHash = JSON.parse(Deployed.getElementsByTagName('pre')[0].innerHTML)
delete transactionHash.logsBloom
// eslint-disable-next-line
console.log('Deployed safe address is: ' + contractAddress)
// eslint-disable-next-line
console.log(transactionHash)
})
})

View File

@ -4,11 +4,12 @@ import { Field } from 'react-final-form'
import TextField from '~/components/forms/TextField'
import { composeValidators, minValue, mustBeNumber, required } from '~/components/forms/validator'
import Block from '~/components/layout/Block'
import { FIELD_CONFIRMATIONS } from '~/routes/open/components/fields'
const Confirmations = () => (
<Block margin="md">
<Field
name="confirmations"
name={FIELD_CONFIRMATIONS}
component={TextField}
type="text"
validate={composeValidators(

View File

@ -4,11 +4,12 @@ import Field from '~/components/forms/Field'
import TextField from '~/components/forms/TextField'
import { required } from '~/components/forms/validator'
import Block from '~/components/layout/Block'
import { FIELD_NAME } from '~/routes/open/components/fields'
const Name = () => (
<Block margin="md">
<Field
name="name"
name={FIELD_NAME}
component={TextField}
type="text"
validate={required}

View File

@ -8,6 +8,7 @@ import Col from '~/components/layout/Col'
import Heading from '~/components/layout/Heading'
import Row from '~/components/layout/Row'
import Paragraph from '~/components/layout/Paragraph'
import { FIELD_OWNERS, getOwnerNameBy, getOwnerAddressBy } from '~/routes/open/components/fields'
type Props = {
numOwners: number,
@ -18,7 +19,7 @@ const Owners = ({ numOwners }: Props) => (
<Heading tag="h3">Owners</Heading>
<Block margin="sm">
<Field
name="owners"
name={FIELD_OWNERS}
component={TextField}
type="text"
validate={composeValidators(required, mustBeNumber, minValue(1))}
@ -33,7 +34,7 @@ const Owners = ({ numOwners }: Props) => (
<Paragraph bold>Owner {index + 1}</Paragraph>
<Block margin="sm">
<Field
name={`owner${index}Name`}
name={getOwnerNameBy(index)}
component={TextField}
type="text"
validate={required}
@ -43,7 +44,7 @@ const Owners = ({ numOwners }: Props) => (
</Block>
<Block margin="sm">
<Field
name={`owner${index}Address`}
name={getOwnerAddressBy(index)}
component={TextField}
type="text"
validate={composeValidators(required, mustBeEthereumAddress)}

View File

@ -0,0 +1,7 @@
// @flow
export const FIELD_NAME: string = 'name'
export const FIELD_CONFIRMATIONS: string = 'confirmations'
export const FIELD_OWNERS: string = 'owners'
export const getOwnerNameBy = (index: number) => `owner${index}Name`
export const getOwnerAddressBy = (index: number) => `owner${index}Address`

View File

@ -1,17 +1,17 @@
// @flow
import * as React from 'react'
import { connect } from 'react-redux'
import contract from 'truffle-contract'
import PageFrame from '~/components/layout/PageFrame'
import { getAccountsFrom, getThresholdFrom } from '~/routes/open/utils/safeDataExtractor'
import Page from '~/components/layout/Page'
import { getAccountsFrom, getThresholdFrom, getNamesFrom, getSafeNameFrom } from '~/routes/open/utils/safeDataExtractor'
import { getWeb3 } from '~/wallets/getWeb3'
import { promisify } from '~/utils/promisify'
import Safe from '#/GnosisSafe.json'
import selector from './selector'
import actions, { type Actions } from './actions'
import Layout from '../components/Layout'
type Props = {
type Props = Actions & {
provider: string,
userAccount: string,
}
@ -35,20 +35,21 @@ class Open extends React.Component<Props, State> {
onCallSafeContractSubmit = async (values) => {
try {
const { userAccount } = this.props
const { userAccount, addSafe } = this.props
const accounts = getAccountsFrom(values)
const numConfirmations = getThresholdFrom(values)
const name = getSafeNameFrom(values)
const owners = getNamesFrom(values)
const web3 = getWeb3()
this.safe.setProvider(web3.currentProvider)
const safeInstance = await this.safe.new(accounts, numConfirmations, 0, 0, { from: userAccount, gas: '5000000' })
const { transactionHash } = safeInstance
const { address, transactionHash } = safeInstance
const transactionReceipt = await promisify(cb => web3.eth.getTransactionReceipt(transactionHash, cb))
// eslint-disable-next-line
console.log(`Transaction Receipt${JSON.stringify(transactionReceipt)}`)
this.setState({ safeAddress: safeInstance.address, safeTx: transactionReceipt })
addSafe(name, address, numConfirmations, owners, accounts)
this.setState({ safeAddress: address, safeTx: transactionReceipt })
} catch (error) {
// eslint-disable-next-line
console.log('Error while creating the Safe' + error)
@ -62,7 +63,7 @@ class Open extends React.Component<Props, State> {
const { provider, userAccount } = this.props
return (
<PageFrame>
<Page>
<Layout
provider={provider}
userAccount={userAccount}
@ -70,9 +71,9 @@ class Open extends React.Component<Props, State> {
safeTx={safeTx}
onCallSafeContractSubmit={this.onCallSafeContractSubmit}
/>
</PageFrame>
</Page>
)
}
}
export default connect(selector)(Open)
export default connect(selector, actions)(Open)

View File

@ -1,97 +0,0 @@
// @flow
import * as React from 'react'
import { connect } from 'react-redux'
import contract from 'truffle-contract'
import PageFrame from '~/components/layout/PageFrame'
import { getWeb3 } from '~/wallets/getWeb3'
import { promisify } from '~/utils/promisify'
import Safe from '#/GnosisSafe.json'
import Layout from '../components/Layout'
import selector from './selector'
import { getAccountsFrom, getThresholdFrom } from './safe'
type Props = {
provider: string,
userAccount: string,
}
type State = {
safeAddress: string,
funds: number,
}
class Open extends React.Component<Props, State> {
constructor() {
super()
this.state = {
safeAddress: '',
funds: 0,
}
this.safe = contract(Safe)
}
onAddFunds = async (values: Object) => {
const { fundsToAdd } = values
const { safeAddress } = this.state
try {
const web3 = getWeb3()
const accounts = await promisify(cb => web3.eth.getAccounts(cb))
const txData = { from: accounts[0], to: safeAddress, value: web3.toWei(fundsToAdd, 'ether') }
await promisify(cb => web3.eth.sendTransaction(txData, cb))
const funds = await promisify(cb => web3.eth.getBalance(safeAddress, cb))
const fundsInEther = funds ? web3.fromWei(funds.toNumber(), 'ether') : 0
this.setState({ funds: fundsInEther })
} catch (error) {
// eslint-disable-next-line
console.log(`Errog adding funds to safe${error}`)
}
}
onCallSafeContractSubmit = async (values) => {
try {
const { userAccount } = this.props
const accounts = getAccountsFrom(values)
const numConfirmations = getThresholdFrom(values)
const web3 = getWeb3()
this.safe.setProvider(web3.currentProvider)
const safeInstance = await this.safe.new(accounts, numConfirmations, 0, 0, { from: userAccount, gas: '5000000' })
const { transactionHash } = safeInstance
const transactionReceipt = await promisify(cb => web3.eth.getTransactionReceipt(transactionHash, cb))
// eslint-disable-next-line
console.log(`Transaction Receipt${JSON.stringify(transactionReceipt)}`)
this.setState({ safeAddress: safeInstance.address })
} catch (error) {
// eslint-disable-next-line
console.log('Error while creating the Safe' + error)
}
}
safe: any
render() {
const { provider, userAccount } = this.props
const { safeAddress, funds } = this.state
return (
<PageFrame>
{ provider
? <Layout
userAccount={userAccount}
safeAddress={safeAddress}
onAddFunds={this.onAddFunds}
funds={funds}
onCallSafeContractSubmit={this.onCallSafeContractSubmit}
/>
: <div>No metamask detected</div>
}
</PageFrame>
)
}
}
export default connect(selector)(Open)

View File

@ -0,0 +1,10 @@
// @flow
import addSafe from '~/routes/safe/store/actions/addSafe'
export type Actions = {
addSafe: typeof addSafe,
}
export default {
addSafe,
}

View File

@ -12,3 +12,5 @@ export const getNamesFrom = (values: Object): string[] => {
}
export const getThresholdFrom = (values: Object): number => Number(values.confirmations)
export const getSafeNameFrom = (values: Object): string => values.name

5
src/routes/routes.js Normal file
View File

@ -0,0 +1,5 @@
// @flow
export const SAFE_PARAM_ADDRESS = 'address'
export const SAFELIST_ADDRESS = '/safes'
export const OPEN_ADDRESS = '/open'
export const WELCOME_ADDRESS = '/welcome'

View File

@ -0,0 +1,18 @@
// @flow
import * as React from 'react'
import NoSafe from '~/components/NoSafe'
import { type SelectorProps } from '~/routes/safe/container/selector'
import GnoSafe from './Safe'
type Props = SelectorProps
const Layout = ({ safe }: Props) => (
<React.Fragment>
{ safe
? <GnoSafe safe={safe} />
: <NoSafe text="Not found safe" />
}
</React.Fragment>
)
export default Layout

View File

@ -0,0 +1,24 @@
// @flow
import { storiesOf } from '@storybook/react'
import * as React from 'react'
import styles from '~/components/layout/PageFrame/index.scss'
import { SafeFactory } from '~/routes/safe/store/test/builder/index.builder'
import Component from './Layout'
const FrameDecorator = story => (
<div className={styles.frame}>
{ story() }
</div>
)
storiesOf('Routes /safe:address', module)
.addDecorator(FrameDecorator)
.add('Safe undefined', () => <Component safe={undefined} />)
.add('Safe with 2 owners', () => {
const safe = SafeFactory.twoOwnersSafe
return (
<Component safe={safe} />
)
})

View File

@ -0,0 +1,73 @@
// @flow
import * as React from 'react'
import Block from '~/components/layout/Block'
import Bold from '~/components/layout/Bold'
import Col from '~/components/layout/Col'
import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row'
import Table, { TableBody, TableCell, TableHead, TableRow } from '~/components/layout/Table'
import { type Safe } from '~/routes/safe/store/model/safe'
type SafeProps = {
safe: Safe,
}
const GnoSafe = ({ safe }: SafeProps) => (
<React.Fragment>
<Row>
<Col xs={12}>
<Paragraph size="lg">
<Bold>{safe.name.toUpperCase()}</Bold>
</Paragraph>
</Col>
</Row>
<Row>
<Paragraph size="lg">
<Bold>Address</Bold>
</Paragraph>
</Row>
<Row>
<Block>
<Paragraph>
{safe.address}
</Paragraph>
</Block>
</Row>
<Row>
<Paragraph size="lg">
<Bold>Number of required confirmations per transaction</Bold>
</Paragraph>
</Row>
<Row>
<Paragraph>
{safe.get('confirmations')}
</Paragraph>
</Row>
<Row>
<Paragraph size="lg">
<Bold>Owners</Bold>
</Paragraph>
</Row>
<Row margin="lg">
<Table>
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Adress</TableCell>
</TableRow>
</TableHead>
<TableBody>
{safe.owners.map(owner => (
<TableRow key={safe.address}>
<TableCell>{owner.name}</TableCell>
<TableCell>{owner.address}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div />
</Row>
</React.Fragment>
)
export default GnoSafe

View File

@ -0,0 +1,22 @@
// @flow
import * as React from 'react'
import { connect } from 'react-redux'
import Page from '~/components/layout/Page'
import Layout from '~/routes/safe/component/Layout'
import selector, { type SelectorProps } from './selector'
type Props = SelectorProps
class SafeView extends React.PureComponent<Props> {
render() {
const { safe } = this.props
return (
<Page>
<Layout safe={safe} />
</Page>
)
}
}
export default connect(selector)(SafeView)

View File

@ -0,0 +1,11 @@
// @flow
import { createStructuredSelector } from 'reselect'
import { safeSelector, type SafeSelectorProps } from '~/routes/safe/store/selectors'
export type SelectorProps = {
safe: SafeSelectorProps,
}
export default createStructuredSelector({
safe: safeSelector,
})

View File

@ -0,0 +1,29 @@
// @flow
import { List } from 'immutable'
import { createAction } from 'redux-actions'
import { type SafeProps } from '~/routes/safe/store/model/safe'
import { makeOwner, type Owner } from '~/routes/safe/store/model/owner'
export const ADD_SAFE = 'ADD_SAFE'
export const buildOwnersFrom = (names: string[], addresses: string[]) => {
const owners = names.map((name: string, index: number) => makeOwner({ name, address: addresses[index] }))
return List(owners)
}
const addSafe = createAction(
ADD_SAFE,
(
name: string, address: string, confirmations: number,
ownersName: string[], ownersAddress: string[],
): SafeProps => {
const owners: List<Owner> = buildOwnersFrom(ownersName, ownersAddress)
return ({
address, name, confirmations, owners,
})
},
)
export default addSafe

View File

@ -0,0 +1,3 @@
// @flow
export * from './addSafe'
export { default as addSafe } from './addSafe'

View File

@ -0,0 +1,17 @@
// @flow
import { Record } from 'immutable'
import type { RecordFactory, RecordOf } from 'immutable'
export type OwnerProps = {
name: string,
address: string,
}
export const makeOwner: RecordFactory<OwnerProps> = Record({
name: '',
address: '',
})
export type Owner = RecordOf<OwnerProps>
// Useage const someRecord: Owner = makeOwner({ name: ... })

View File

@ -0,0 +1,22 @@
// @flow
import { List, Record } from 'immutable'
import type { RecordFactory, RecordOf } from 'immutable'
import type { Owner } from '~/routes/safe/store/model/owner'
export type SafeProps = {
name: string,
address: string,
confirmations: number,
owners: List<Owner>,
}
export const makeSafe: RecordFactory<SafeProps> = Record({
name: '',
address: '',
confirmations: 0,
owners: List([]),
})
export type Safe = RecordOf<SafeProps>
// Useage const someRecord: Safe = makeSafe({ name: ... })

View File

@ -0,0 +1,48 @@
// @flow
import { Map, List } from 'immutable'
import { handleActions, type ActionType } from 'redux-actions'
import addSafe, { ADD_SAFE } from '~/routes/safe/store/actions/addSafe'
import { makeOwner } from '~/routes/safe/store/model/owner'
import { type Safe, makeSafe } from '~/routes/safe/store/model/safe'
import { loadSafes, saveSafes } from '~/utils/localStorage'
export const SAFE_REDUCER_ID = 'safes'
export type State = Map<string, Safe>
const buildSafesFrom = (loadedSafes: Object): State => {
const safes: State = Map()
return safes.withMutations((map: State) => {
Object.keys(loadedSafes).forEach((address) => {
const safe = loadedSafes[address]
safe.owners = List(safe.owners.map((owner => makeOwner(owner))))
return map.set(address, makeSafe(safe))
})
})
}
export const calculateInitialState = (): State => {
const storedSafes = loadSafes()
return storedSafes ? buildSafesFrom(storedSafes) : Map()
}
/*
type Action<T> = {
key: string,
payload: T,
};
type AddSafeType = Action<SafeProps>
action: AddSafeType
*/
export default handleActions({
[ADD_SAFE]: (state: State, action: ActionType<typeof addSafe>): State => {
const safes = state.set(action.payload.address, makeSafe(action.payload))
saveSafes(safes.toJSON())
return safes
},
}, Map())

View File

@ -0,0 +1,32 @@
// @flow
import { Map } from 'immutable'
import { type Match } from 'react-router-dom'
import { createSelector, createStructuredSelector, type Selector } from 'reselect'
import { type GlobalState } from '~/store/index'
import { SAFE_PARAM_ADDRESS } from '~/routes/routes'
import { type Safe } from '~/routes/safe/store/model/safe'
import { safesMapSelector } from '~/routes/safeList/store/selectors'
type RouterProps = {
match: Match,
}
const safeAddessSelector = (state: GlobalState, props: RouterProps) => props.match.params[SAFE_PARAM_ADDRESS] || ''
export type SafeSelectorProps = Safe | typeof undefined
export const safeSelector: Selector<GlobalState, RouterProps, SafeSelectorProps> = createSelector(
safesMapSelector,
safeAddessSelector,
(safes: Map<string, Safe>, address: string) => {
if (!address) {
return undefined
}
return safes.get(address)
},
)
export default createStructuredSelector({
safe: safeSelector,
})

View File

@ -0,0 +1,59 @@
// @flow
import { makeSafe, type Safe } from '~/routes/safe/store/model/safe'
import { buildOwnersFrom } from '~/routes/safe/store/actions'
class SafeBuilder {
safe: Safe
constructor() {
this.safe = makeSafe()
}
withAddress(address: string) {
this.safe = this.safe.set('address', address)
return this
}
withName(name: string) {
this.safe = this.safe.set('name', name)
return this
}
withConfirmations(confirmations: number) {
this.safe = this.safe.set('confirmations', confirmations)
return this
}
withOwner(names: string[], adresses: string[]) {
const owners = buildOwnersFrom(names, adresses)
this.safe = this.safe.set('owners', owners)
return this
}
get() {
return this.safe
}
}
const aSafe = () => new SafeBuilder()
export class SafeFactory {
static oneOwnerSafe = aSafe()
.withAddress('0x03db1a8b26d08df23337e9276a36b474510f0025')
.withName('Adol ICO Safe')
.withConfirmations(1)
.withOwner(['Adol Metamask'], ['0x03db1a8b26d08df23337e9276a36b474510f0023'])
.get()
static twoOwnersSafe = aSafe()
.withAddress('0x03db1a8b26d08df23337e9276a36b474510f0026')
.withName('Adol & Tobias Safe')
.withConfirmations(2)
.withOwner(
['Adol Metamask', 'Tobias Metamask'],
['0x03db1a8b26d08df23337e9276a36b474510f0023', '0x03db1a8b26d08df23337e9276a36b474510f0024'],
)
.get()
}
export default aSafe

View File

@ -0,0 +1,86 @@
// @flow
import { combineReducers, createStore, applyMiddleware, compose } from 'redux'
import thunk from 'redux-thunk'
import safeReducer, { calculateInitialState, SAFE_REDUCER_ID } from '~/routes/safe/store/reducer/safe'
import addSafe from '~/routes/safe/store/actions/addSafe'
import * as SafeFields from '~/routes/open/components/fields'
import { getAccountsFrom, getNamesFrom } from '~/routes/open/utils/safeDataExtractor'
import { SafeFactory } from './builder/index.builder'
const aStore = (initState) => {
const reducers = combineReducers({
[SAFE_REDUCER_ID]: safeReducer,
})
const middlewares = [
thunk,
]
const enhancers = [
applyMiddleware(...middlewares),
]
return createStore(reducers, initState, compose(...enhancers))
}
const providerReducerTests = () => {
describe('Safe Actions[addSafe]', () => {
let store
beforeEach(() => {
store = aStore()
})
it('reducer should return SafeRecord from form values', () => {
// GIVEN
const address = '0x03db1a8b26d08df23337e9276a36b474510f0025'
const formValues = {
[SafeFields.FIELD_NAME]: 'Adol ICO Safe',
[SafeFields.FIELD_CONFIRMATIONS]: 1,
[SafeFields.FIELD_OWNERS]: 1,
[SafeFields.getOwnerAddressBy(0)]: '0x03db1a8b26d08df23337e9276a36b474510f0023',
[SafeFields.getOwnerNameBy(0)]: 'Adol Metamask',
address,
}
// WHEN
store.dispatch(addSafe(
formValues[SafeFields.FIELD_NAME],
formValues.address,
formValues[SafeFields.FIELD_CONFIRMATIONS],
getNamesFrom(formValues),
getAccountsFrom(formValues),
))
const safes = store.getState()[SAFE_REDUCER_ID]
// THEN
expect(safes.get(address)).toEqual(SafeFactory.oneOwnerSafe)
})
it('reducer loads information from localStorage', async () => {
// GIVEN
const address = '0x03db1a8b26d08df23337e9276a36b474510f0025'
const formValues = {
[SafeFields.FIELD_NAME]: 'Adol ICO Safe',
[SafeFields.FIELD_CONFIRMATIONS]: 1,
[SafeFields.FIELD_OWNERS]: 1,
[SafeFields.getOwnerAddressBy(0)]: '0x03db1a8b26d08df23337e9276a36b474510f0023',
[SafeFields.getOwnerNameBy(0)]: 'Adol Metamask',
address,
}
// WHEN
store.dispatch(addSafe(
formValues[SafeFields.FIELD_NAME],
formValues.address,
formValues[SafeFields.FIELD_CONFIRMATIONS],
getNamesFrom(formValues),
getAccountsFrom(formValues),
))
const anotherStore = aStore({ [SAFE_REDUCER_ID]: calculateInitialState() })
const safes = anotherStore.getState()[SAFE_REDUCER_ID]
// THEN
expect(calculateInitialState()).toEqual(safes)
})
})
}
export default providerReducerTests

View File

@ -0,0 +1,54 @@
// @flow
import { Map } from 'immutable'
import { type Match } from 'react-router-dom'
import { SAFE_REDUCER_ID } from '~/routes/safe/store/reducer/safe'
import { type Safe } from '~/routes/safe/store/model/safe'
import { SafeFactory } from '~/routes/safe/store/test/builder/index.builder'
import { safeSelector } from '../selectors'
const buildMathPropsFrom = (address): Match => ({
params: {
address,
},
isExact: true,
path: '',
url: '',
})
const safeSelectorTests = () => {
describe('Safe Selector[safeSelector]', () => {
it('should return empty list when no safes', () => {
// GIVEN
const reduxStore = { [SAFE_REDUCER_ID]: Map(), providers: undefined }
const match: Match = buildMathPropsFrom('fooAddress')
// WHEN
const safes = safeSelector(reduxStore, { match })
// THEN
expect(safes).toBe(undefined)
})
it('should return a list of size 2 when 2 safes are created', () => {
// GIVEN
let map: Map<string, Safe> = Map()
map = map.set('fooAddress', SafeFactory.oneOwnerSafe)
map = map.set('barAddress', SafeFactory.twoOwnersSafe)
const match: Match = buildMathPropsFrom('fooAddress')
const undefMatch: Match = buildMathPropsFrom('inventedAddress')
const reduxStore = { [SAFE_REDUCER_ID]: map, providers: undefined }
// WHEN
const oneOwnerSafe = safeSelector(reduxStore, { match })
const undefinedSafe = safeSelector(reduxStore, { match: undefMatch })
// THEN
expect(oneOwnerSafe).toEqual(SafeFactory.oneOwnerSafe)
expect(undefinedSafe).toBe(undefined)
})
})
}
export default safeSelectorTests

View File

@ -0,0 +1,11 @@
// @flow
import safeReducerTests from './safe.reducer'
import safeSelectorTests from './safe.selector'
describe('Safe Test suite', () => {
// ACTIONS AND REDUCERS
safeReducerTests()
// SAFE SELECTOR
safeSelectorTests()
})

View File

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

View File

@ -0,0 +1,24 @@
// @flow
import { storiesOf } from '@storybook/react'
import { List } from 'immutable'
import * as React from 'react'
import styles from '~/components/layout/PageFrame/index.scss'
import { SafeFactory } from '~/routes/safe/store/test/builder/index.builder'
import Component from './Layout'
const FrameDecorator = story => (
<div className={styles.frame}>
{ story() }
</div>
)
storiesOf('Routes /safes', module)
.addDecorator(FrameDecorator)
.add('Safe List whithout safes', () => <Component safes={List([])} />)
.add('Safe List whith 2 safes', () => {
const safes = List([SafeFactory.oneOwnerSafe, SafeFactory.twoOwnersSafe])
return (
<Component safes={safes} />
)
})

View File

@ -0,0 +1,42 @@
// @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/model/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 numeric>Confirmations</TableCell>
<TableCell numeric>Number of owners</TableCell>
</TableRow>
</TableHead>
<TableBody>
{safes.map(safe => (
<TableRow key={safe.address}>
<TableCell>
<Link to={`${SAFELIST_ADDRESS}/${safe.address}`}>
<Button variant="raised" size="small" color="primary">Open</Button>
</Link>
</TableCell>
<TableCell padding="none">{safe.name}</TableCell>
<TableCell padding="none">{safe.address}</TableCell>
<TableCell padding="none" numeric>{safe.confirmations}</TableCell>
<TableCell padding="none" numeric>{safe.owners.count()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
export default SafeTable

View File

@ -0,0 +1,20 @@
// @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/model/safe'
import Layout from '../components/Layout'
import selector from './selector'
type Props = {
safes: List<Safe>
}
const SafeList = ({ safes }: Props) => (
<Page overflow>
<Layout safes={safes} />
</Page>
)
export default connect(selector)(SafeList)

View File

@ -0,0 +1,7 @@
// @flow
import { createStructuredSelector } from 'reselect'
import { safesListSelector } from '~/routes/safeList/store/selectors'
export default createStructuredSelector({
safes: safesListSelector,
})

View File

@ -0,0 +1,7 @@
// @flow
import { List, Map } from 'immutable'
import { type GlobalState } from '~/store/index'
import { type Safe } from '~/routes/safe/store/model/safe'
export const safesMapSelector = (state: GlobalState): Map<string, Safe> => state.safes
export const safesListSelector = (state: GlobalState): List<Safe> => state.safes.toList()

View File

@ -0,0 +1,7 @@
// @flow
import safesSelectorTests from './safes.selector'
describe('SafeList Test suite', () => {
// safesSelector SELECTOR
safesSelectorTests()
})

View File

@ -0,0 +1,40 @@
// @flow
import { List, Map } from 'immutable'
import { SAFE_REDUCER_ID } from '~/routes/safe/store/reducer/safe'
import { type Safe } from '~/routes/safe/store/model/safe'
import { SafeFactory } from '~/routes/safe/store/test/builder/index.builder'
import { safesListSelector } from '../selectors'
const safesListSelectorTests = () => {
describe('Safes Selector[safesSelector]', () => {
it('should return empty list when no safes', () => {
// GIVEN
const reduxStore = { [SAFE_REDUCER_ID]: Map(), providers: undefined }
const emptyList = List([])
// WHEN
const safes = safesListSelector(reduxStore)
// THEN
expect(safes).toEqual(emptyList)
})
it('should return a list of size 2 when 2 safes are created', () => {
// GIVEN
let map: Map<string, Safe> = Map()
map = map.set('fooAddress', SafeFactory.oneOwnerSafe)
map = map.set('barAddress', SafeFactory.twoOwnersSafe)
const reduxStore = { [SAFE_REDUCER_ID]: map, providers: undefined }
// WHEN
const safes = safesListSelector(reduxStore)
// THEN
expect(safes.count()).toEqual(2)
expect(safes.get(0)).not.toEqual(safes.get(1))
})
})
}
export default safesListSelectorTests

View File

@ -1,11 +0,0 @@
import React from 'react'
class Layout extends React.Component {
render() {
return (
<div>I am transactions Layout</div>
)
}
}
export default Layout

View File

@ -4,6 +4,7 @@ import Block from '~/components/layout/Block'
import Img from '~/components/layout/Img'
import Button from '~/components/layout/Button'
import Link from '~/components/layout/Link'
import { OPEN_ADDRESS, SAFELIST_ADDRESS } from '~/routes/routes'
import styles from './Layout.scss'
const vault = require('../assets/vault.svg')
@ -12,20 +13,26 @@ type Props = {
provider: string
}
type SafeProps = {
size?: 'small' | 'medium',
}
export const CreateSafe = ({ size }: SafeProps) => (
<Link to={OPEN_ADDRESS}>
<Button variant="raised" size={size || 'medium'} color="primary">
Create a new Safe
</Button>
</Link>
)
const Welcome = ({ provider }: Props) => (
<Block className={styles.safe}>
<Img alt="Safe Box" src={vault} height={330} />
<Block className={styles.safeActions} margin="md">
{ provider &&
<Link to="/open">
<Button variant="raised" color="primary">
Create a new Safe
</Button>
</Link>
}
<Link to="/transactions">
{ provider && <CreateSafe /> }
<Link to={SAFELIST_ADDRESS}>
<Button variant="raised" color="primary">
Open a Safe
See Safe list
</Button>
</Link>
</Block>

View File

@ -12,7 +12,7 @@ const FrameDecorator = story => (
)
storiesOf('Routes', module)
storiesOf('Routes /welcome', module)
.addDecorator(FrameDecorator)
.add('Welcome with Metamask connected', () => {
const provider = select('Status by Provider', ['', 'UNKNOWN', 'METAMASK', 'PARITY'], 'METAMASK')
@ -23,3 +23,21 @@ storiesOf('Routes', module)
/>
)
})
.add('Welcome with unknown wallet', () => {
const provider = select('Status by Provider', ['', 'UNKNOWN', 'METAMASK', 'PARITY'], 'UNKNOWN')
return (
<Component
provider={provider}
fetchProvider={() => { }}
/>
)
})
.add('Welcome without wallet connected', () => {
const provider = select('Status by Provider', ['', 'UNKNOWN', 'METAMASK', 'PARITY'], '')
return (
<Component
provider={provider}
fetchProvider={() => { }}
/>
)
})

View File

@ -1,7 +1,7 @@
// @flow
import * as React from 'react'
import { connect } from 'react-redux'
import PageFrame from '~/components/layout/PageFrame'
import Page from '~/components/layout/Page'
import Layout from '../components/Layout'
import selector from './selector'
@ -10,9 +10,9 @@ type Props = {
}
const Welcome = ({ provider }: Props) => (
<PageFrame align="center">
<Page align="center">
<Layout provider={provider} />
</PageFrame>
</Page>
)
export default connect(selector)(Welcome)

View File

@ -1,11 +1,13 @@
// @flow
import { createBrowserHistory } from 'history'
import { routerMiddleware, routerReducer } from 'react-router-redux'
import { combineReducers, createStore, applyMiddleware, compose } from 'redux'
import { combineReducers, createStore, applyMiddleware, compose, type Reducer, type Store } from 'redux'
import thunk from 'redux-thunk'
import provider, { REDUCER_ID } from '~/wallets/store/reducer/provider'
import provider, { PROVIDER_REDUCER_ID, type State as ProviderState } from '~/wallets/store/reducer/provider'
import safe, { SAFE_REDUCER_ID, calculateInitialState, type State as SafeState } from '~/routes/safe/store/reducer/safe'
export const history = createBrowserHistory()
// eslint-disable-next-line
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
const finalCreateStore = composeEnhancers(applyMiddleware(
@ -13,9 +15,17 @@ const finalCreateStore = composeEnhancers(applyMiddleware(
routerMiddleware(history),
))
const reducers = combineReducers({
export type GlobalState = {
providers: ProviderState,
safes: SafeState,
}
const reducers: Reducer<GlobalState> = combineReducers({
routing: routerReducer,
[REDUCER_ID]: provider,
[PROVIDER_REDUCER_ID]: provider,
[SAFE_REDUCER_ID]: safe,
})
export const store = createStore(reducers, finalCreateStore)
const initialState = { [SAFE_REDUCER_ID]: calculateInitialState() }
export const store: Store<GlobalState> = createStore(reducers, initialState, finalCreateStore)

View File

@ -2,6 +2,7 @@
const primary = '#1798cc'
const secondary = '#13222b'
const tertiary = '#f6f9fc'
const xs = '4px'
const sm = '8px'
const md = '16px'
const lg = '24px'
@ -11,6 +12,7 @@ module.exports = Object.assign({}, {
primary,
secondary,
tertiary,
xs,
sm,
md,
lg,
@ -24,6 +26,7 @@ module.exports = Object.assign({}, {
smallFontSize: '12px',
mediumFontSize: '14px',
largeFontSize: '18px',
extraLargeFontSize: '24px',
screenXs: 480,
screenXsMax: 767,
screenSm: 768,

28
src/utils/localStorage.js Normal file
View File

@ -0,0 +1,28 @@
// @flow
const SAFES_KEY = 'SAFES'
export const loadSafes = () => {
try {
const serializedState = localStorage.getItem(SAFES_KEY)
if (serializedState === null) {
return undefined
}
if (serializedState === undefined) {
return undefined
}
return JSON.parse(serializedState)
} catch (err) {
return undefined
}
}
export const saveSafes = (safes: Object) => {
try {
const serializedState = JSON.stringify(safes)
localStorage.setItem(SAFES_KEY, serializedState)
} catch (err) {
// Ignore write errors
}
}

View File

@ -1,6 +1,9 @@
// @flow
import { createAction } from 'redux-actions'
import { type Provider } from '~/wallets/store/model/provider'
export const ADD_PROVIDER = 'ADD_PROVIDER'
export default createAction(ADD_PROVIDER)
const addProvider = createAction(ADD_PROVIDER, (provider: Provider) => provider)
export default addProvider

View File

@ -1,11 +1,13 @@
// @flow
import { handleActions } from 'redux-actions'
import { makeProvider } from '~/wallets/store/model/provider'
import { ADD_PROVIDER } from '~/wallets/store/actions/addProvider'
import { handleActions, type ActionType } from 'redux-actions'
import { makeProvider, type Provider } from '~/wallets/store/model/provider'
import addProvider, { ADD_PROVIDER } from '~/wallets/store/actions/addProvider'
export const REDUCER_ID = 'providers'
export const PROVIDER_REDUCER_ID = 'providers'
export type State = Provider
export default handleActions({
[ADD_PROVIDER]: (state, { payload }) =>
[ADD_PROVIDER]: (state: State, { payload }: ActionType<typeof addProvider>) =>
makeProvider(payload),
}, makeProvider())

View File

@ -1,9 +1,9 @@
// @flow
import { createSelector } from 'reselect'
import type { Provider } from '~/wallets/store/model/provider'
import { REDUCER_ID } from '~/wallets/store/reducer/provider'
import { PROVIDER_REDUCER_ID } from '~/wallets/store/reducer/provider'
const providerSelector = (state: any): Provider => state[REDUCER_ID]
const providerSelector = (state: any): Provider => state[PROVIDER_REDUCER_ID]
export const userAccountSelector = createSelector(
providerSelector,

View File

@ -1,5 +1,5 @@
// @flow
import { REDUCER_ID } from '~/wallets/store/reducer/provider'
import { PROVIDER_REDUCER_ID } from '~/wallets/store/reducer/provider'
import { userAccountSelector } from '../selectors'
import { ProviderFactory } from './builder/index.builder'
@ -7,7 +7,7 @@ const providerReducerTests = () => {
describe('Provider Name Selector[userAccountSelector]', () => {
it('should return empty when no provider is loaded', () => {
// GIVEN
const reduxStore = { [REDUCER_ID]: ProviderFactory.noProvider }
const reduxStore = { [PROVIDER_REDUCER_ID]: ProviderFactory.noProvider }
// WHEN
const providerName = userAccountSelector(reduxStore)
@ -18,7 +18,7 @@ const providerReducerTests = () => {
it('should return empty when Metamask is loaded but not available', () => {
// GIVEN
const reduxStore = { [REDUCER_ID]: ProviderFactory.metamaskLoaded }
const reduxStore = { [PROVIDER_REDUCER_ID]: ProviderFactory.metamaskLoaded }
// WHEN
const providerName = userAccountSelector(reduxStore)
@ -29,7 +29,7 @@ const providerReducerTests = () => {
it('should return account when Metamask is loaded and available', () => {
// GIVEN
const reduxStore = { [REDUCER_ID]: ProviderFactory.metamaskAvailable }
const reduxStore = { [PROVIDER_REDUCER_ID]: ProviderFactory.metamaskAvailable }
// WHEN
const providerName = userAccountSelector(reduxStore)

View File

@ -1,5 +1,5 @@
// @flow
import { REDUCER_ID } from '~/wallets/store/reducer/provider'
import { PROVIDER_REDUCER_ID } from '~/wallets/store/reducer/provider'
import { providerNameSelector } from '../selectors'
import { ProviderFactory } from './builder/index.builder'
@ -7,7 +7,7 @@ const providerReducerTests = () => {
describe('Provider Name Selector[providerNameSelector]', () => {
it('should return undefined when no provider is loaded', () => {
// GIVEN
const reduxStore = { [REDUCER_ID]: ProviderFactory.noProvider }
const reduxStore = { [PROVIDER_REDUCER_ID]: ProviderFactory.noProvider }
// WHEN
const providerName = providerNameSelector(reduxStore)
@ -18,7 +18,7 @@ const providerReducerTests = () => {
it('should return undefined when Metamask is loaded but not available', () => {
// GIVEN
const reduxStore = { [REDUCER_ID]: ProviderFactory.metamaskLoaded }
const reduxStore = { [PROVIDER_REDUCER_ID]: ProviderFactory.metamaskLoaded }
// WHEN
const providerName = providerNameSelector(reduxStore)
@ -29,7 +29,7 @@ const providerReducerTests = () => {
it('should return METAMASK when Metamask is loaded and available', () => {
// GIVEN
const reduxStore = { [REDUCER_ID]: ProviderFactory.metamaskAvailable }
const reduxStore = { [PROVIDER_REDUCER_ID]: ProviderFactory.metamaskAvailable }
// WHEN
const providerName = providerNameSelector(reduxStore)

View File

@ -1,7 +1,7 @@
// @flow
import { combineReducers, createStore, applyMiddleware, compose } from 'redux'
import thunk from 'redux-thunk'
import providerReducer, { REDUCER_ID } from '~/wallets/store/reducer/provider'
import providerReducer, { PROVIDER_REDUCER_ID } from '~/wallets/store/reducer/provider'
import type { ProviderProps } from '~/wallets/store/model/provider'
import { makeProvider } from '~/wallets/store/model/provider'
import { processProviderResponse } from '../actions/fetchProvider'
@ -11,7 +11,7 @@ const providerReducerTests = () => {
let store
beforeEach(() => {
const reducers = combineReducers({
[REDUCER_ID]: providerReducer,
[PROVIDER_REDUCER_ID]: providerReducer,
})
const middlewares = [
thunk,
@ -30,7 +30,7 @@ const providerReducerTests = () => {
// WHEN
processProviderResponse(store.dispatch, emptyResponse)
const provider = store.getState()[REDUCER_ID]
const provider = store.getState()[PROVIDER_REDUCER_ID]
// THEN
expect(makeProvider(emptyResponse)).toEqual(provider)
@ -44,7 +44,7 @@ const providerReducerTests = () => {
// WHEN
processProviderResponse(store.dispatch, metamaskLoaded)
const provider = store.getState()[REDUCER_ID]
const provider = store.getState()[PROVIDER_REDUCER_ID]
// THEN
expect(makeProvider(metamaskLoaded)).toEqual(provider)
@ -58,7 +58,7 @@ const providerReducerTests = () => {
// WHEN
processProviderResponse(store.dispatch, metamask)
const provider = store.getState()[REDUCER_ID]
const provider = store.getState()[PROVIDER_REDUCER_ID]
// THEN
expect(makeProvider(metamask)).toEqual(provider)

View File

@ -4177,6 +4177,10 @@ entities@~1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.1.tgz#6e5c2d0a5621b5dadaecef80b90edfb5cd7772f0"
envinfo@^4.4.2:
version "4.4.2"
resolved "https://registry.yarnpkg.com/envinfo/-/envinfo-4.4.2.tgz#472c49f3a8b9bca73962641ce7cb692bf623cd1c"
errno@^0.1.3, errno@~0.1.1, errno@~0.1.7:
version "0.1.7"
resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.7.tgz#4684d71779ad39af177e3f007996f7c67c852618"
@ -5175,6 +5179,13 @@ fuse.js@^3.0.1, fuse.js@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-3.2.0.tgz#f0448e8069855bf2a3e683cdc1d320e7e2a07ef4"
ganache-cli@^6.0.3:
version "6.1.0"
resolved "https://registry.yarnpkg.com/ganache-cli/-/ganache-cli-6.1.0.tgz#486c846497204b644166b5f0f74c9b41d02bdc25"
dependencies:
source-map-support "^0.5.3"
webpack-cli "^2.0.9"
gauge@~2.7.3:
version "2.7.4"
resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"
@ -9942,6 +9953,13 @@ run-queue@^1.0.0, run-queue@^1.0.3:
dependencies:
aproba "^1.1.1"
run-with-testrpc@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/run-with-testrpc/-/run-with-testrpc-0.3.0.tgz#77205fc63e44e62202e0e5de51596038d82ba519"
dependencies:
colors "^1.1.2"
ganache-cli "^6.0.3"
rustbn.js@~0.1.1:
version "0.1.2"
resolved "https://registry.yarnpkg.com/rustbn.js/-/rustbn.js-0.1.2.tgz#979fa0f9562216dd667c9d2cd179ae5d13830eff"
@ -10435,6 +10453,12 @@ source-map-support@^0.5.0:
dependencies:
source-map "^0.6.0"
source-map-support@^0.5.3:
version "0.5.4"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.4.tgz#54456efa89caa9270af7cd624cc2f123e51fbae8"
dependencies:
source-map "^0.6.0"
source-map-url@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3"
@ -11698,6 +11722,37 @@ webpack-cli@^2.0.8:
yeoman-environment "^2.0.0"
yeoman-generator "^2.0.3"
webpack-cli@^2.0.9:
version "2.0.14"
resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-2.0.14.tgz#71d03d8c10547c1dfd674f71ff3b0457c33a74cd"
dependencies:
chalk "^2.3.2"
cross-spawn "^6.0.5"
diff "^3.5.0"
enhanced-resolve "^4.0.0"
envinfo "^4.4.2"
glob-all "^3.1.0"
global-modules "^1.0.0"
got "^8.2.0"
import-local "^1.0.0"
inquirer "^5.1.0"
interpret "^1.0.4"
jscodeshift "^0.5.0"
listr "^0.13.0"
loader-utils "^1.1.0"
lodash "^4.17.5"
log-symbols "^2.2.0"
mkdirp "^0.5.1"
p-each-series "^1.0.0"
p-lazy "^1.0.0"
prettier "^1.5.3"
supports-color "^5.3.0"
v8-compile-cache "^1.1.2"
webpack-addons "^1.1.5"
yargs "^11.1.0"
yeoman-environment "^2.0.0"
yeoman-generator "^2.0.3"
webpack-dev-middleware@3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-3.0.1.tgz#7ffd6d0192883c83d3f262e8d7dec822493c6166"
@ -12099,6 +12154,23 @@ yargs@^11.0.0:
y18n "^3.2.1"
yargs-parser "^9.0.2"
yargs@^11.1.0:
version "11.1.0"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-11.1.0.tgz#90b869934ed6e871115ea2ff58b03f4724ed2d77"
dependencies:
cliui "^4.0.0"
decamelize "^1.1.1"
find-up "^2.1.0"
get-caller-file "^1.0.1"
os-locale "^2.0.0"
require-directory "^2.1.1"
require-main-filename "^1.0.1"
set-blocking "^2.0.0"
string-width "^2.0.0"
which-module "^2.0.0"
y18n "^3.2.1"
yargs-parser "^9.0.2"
yargs@^3.27.0, yargs@^3.29.0:
version "3.32.0"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-3.32.0.tgz#03088e9ebf9e756b69751611d2a5ef591482c995"