WA-279 - Create safe in a three step process (#11)

* WA-279 Creating Stepper component for simulating a Wizard Form with multiple steps

* WA-279 Introduced a review middle step when creating a new Safe

* WA-279 Added more Layout components (Bold, Span, GnoForm...)

* WA-279 Added validation to final-form's create safe form, including isEthAddress validator to form's owners addresses

* WA-279 Updated storybook Header and Footer
This commit is contained in:
Adolfo Panizo 2018-03-29 11:52:58 +02:00 committed by GitHub
parent 135cbd1568
commit d2b5131c9e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 923 additions and 262 deletions

View File

@ -3,7 +3,9 @@ import { addDecorator, configure } from '@storybook/react'
import { withKnobs } from '@storybook/addon-knobs'
import { MuiThemeProvider } from 'material-ui/styles'
import * as React from 'react'
import { Provider } from 'react-redux'
import StoryRouter from 'storybook-router'
import { store } from '~/store'
import theme from '~/theme/mui'
import 'index.scss'
@ -14,22 +16,14 @@ import 'index.scss'
addDecorator(withKnobs);
addDecorator(StoryRouter())
// Adding Material UI Theme
addDecorator(story => (
<MuiThemeProvider theme={theme}>
{ story() }
</MuiThemeProvider>
addDecorator((story) => (
<Provider store={store}>
<MuiThemeProvider theme={theme}>
{ story() }
</MuiThemeProvider>
</Provider>
))
/*
https://storybook.js.org/addons/introduction/
addDecorator((story) => (
<div>
{ story() }
</div>
));
*/
const components = require.context('../src/components', true, /\.stories\.((js|ts)x?)$/)
const routes = require.context('../src/routes', true, /\.stories\.((js|ts)x?)$/)
@ -38,4 +32,4 @@ function loadStories() {
routes.keys().forEach((filename) => routes(filename))
}
configure(loadStories, module);
configure(loadStories, module)

View File

@ -36,6 +36,7 @@
"@babel/preset-flow": "^7.0.0-beta.40",
"@babel/preset-react": "^7.0.0-beta.40",
"@babel/preset-stage-0": "^7.0.0-beta.40",
"@sambego/storybook-state": "^1.0.7",
"@storybook/addon-actions": "^3.3.15",
"@storybook/addon-knobs": "^3.3.15",
"@storybook/addon-links": "^3.3.15",

View File

@ -1,14 +1,16 @@
// @flow
import { storiesOf } from '@storybook/react'
import * as React from 'react'
import { host } from 'storybook-host'
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', module)
.addDecorator(host({
title: 'Footer',
align: 'center',
height: 250,
width: '100%',
}))
.addDecorator(FrameDecorator)
.add('Footer', () => <Component />)

View File

@ -2,14 +2,13 @@
import { select } from '@storybook/addon-knobs'
import { storiesOf } from '@storybook/react'
import * as React from 'react'
import Block from '~/components/layout/Block'
import styles from '~/components/layout/PageFrame/index.scss'
import Component from './Layout'
const FrameDecorator = story => (
<Block className={styles.frame}>
<div className={styles.frame}>
{ story() }
</Block>
</div>
)
storiesOf('Components', module)

View File

@ -0,0 +1,74 @@
// @flow
import * as React from 'react'
import Button from '~/components/layout/Button'
import Link from '~/components/layout/Link'
type NextButtonProps = {
text: string,
disabled: boolean,
}
const NextButton = ({ text, disabled }: NextButtonProps) => (
<Button
variant="raised"
color="primary"
type="submit"
disabled={disabled}
>
{text}
</Button>
)
const GoButton = () => (
<Link to="/welcome">
<NextButton text="GO" disabled={false} />
</Link>
)
type ControlProps = {
next: string,
onPrevious: () => void,
firstPage: boolean,
submitting: boolean,
}
const ControlButtons = ({
next, firstPage, onPrevious, submitting,
}: ControlProps) => (
<React.Fragment>
<Button
type="button"
disabled={firstPage || submitting}
onClick={onPrevious}
>
Back
</Button>
<NextButton text={next} disabled={submitting} />
</React.Fragment>
)
type Props = {
finishedTx: boolean,
onPrevious: () => void,
firstPage: boolean,
lastPage: boolean,
submitting: boolean,
}
const Controls = ({
finishedTx, onPrevious, firstPage, lastPage, submitting,
}: Props) => (
<React.Fragment>
{ finishedTx
? <GoButton />
: <ControlButtons
submitting={submitting}
next={lastPage ? 'Finish' : 'Next'}
firstPage={firstPage}
onPrevious={onPrevious}
/>
}
</React.Fragment>
)
export default Controls

View File

@ -0,0 +1,14 @@
// @flow
import * as React from 'react'
type Props = {
children: React$Node,
}
const Step = ({ children }: Props) => (
<div>
{children}
</div>
)
export default Step

View File

@ -0,0 +1,118 @@
// @flow
import Stepper, { Step as FormStep, StepLabel } from 'material-ui/Stepper'
import * as React from 'react'
import type { FormApi } from 'react-final-form'
import GnoForm from '~/components/forms/GnoForm'
import Col from '~/components/layout/Col'
import Row from '~/components/layout/Row'
import Controls from './Controls'
export { default as Step } from './Step'
type Props = {
steps: string[],
finishedTransaction: boolean,
initialValues?: Object,
children: React$Node,
onSubmit: (values: Object, form: FormApi, callback: ?(errors: ?Object) => void) => ?Object | Promise<?Object> | void,
}
type State = {
page: number,
values: Object,
}
type PageProps = {
children: Function,
}
class GnoStepper extends React.PureComponent<Props, State> {
static Page = ({ children }: PageProps) => children
constructor(props: Props) {
super(props)
this.state = {
page: 0,
values: props.initialValues || {},
}
}
getActivePageFrom = (pages: React$Node) => {
const activePageProps = React.Children.toArray(pages)[this.state.page].props
const { children, ...props } = activePageProps
return children(props)
}
validate = (values: Object) => {
const activePage = React.Children.toArray(this.props.children)[
this.state.page
]
return activePage.props.validate ? activePage.props.validate(values) : {}
}
next = (values: Object) =>
this.setState(state => ({
page: Math.min(state.page + 1, React.Children.count(this.props.children) - 1),
values,
}))
previous = () =>
this.setState(state => ({
page: Math.max(state.page - 1, 0),
}))
handleSubmit = (values: Object) => {
const { children, onSubmit } = this.props
const { page } = this.state
const isLastPage = page === React.Children.count(children) - 1
if (isLastPage) {
return onSubmit(values)
}
return this.next(values)
}
render() {
const { steps, children, finishedTransaction } = this.props
const { page, values } = this.state
const activePage = this.getActivePageFrom(children)
const isLastPage = page === steps.length - 1
return (
<React.Fragment>
<Stepper activeStep={page} alternativeLabel>
{steps.map(label => (
<FormStep key={label}>
<StepLabel>{label}</StepLabel>
</FormStep>
))}
</Stepper>
<GnoForm
onSubmit={this.handleSubmit}
initialValues={values}
padding={15}
validation={this.validate}
render={activePage}
>
{(submitting: boolean) => (
<Row align="end" margin="lg" grow>
<Col xs={12} center="xs">
<Controls
submitting={submitting}
finishedTx={finishedTransaction}
onPrevious={this.previous}
firstPage={page === 0}
lastPage={isLastPage}
/>
</Col>
</Row>
)}
</GnoForm>
</React.Fragment>
)
}
}
export default GnoStepper

View File

@ -6,25 +6,30 @@ import type { FormApi } from 'react-final-form'
type Props = {
onSubmit: (values: Object, form: FormApi, callback: ?(errors: ?Object) => void) => ?Object | Promise<?Object> | void,
children: Function,
width: string,
padding: number,
validation?: (values: Object) => Object | Promise<Object>,
initialValues?: Object,
render: Function,
}
const calculateWidth = (width: string): $Shape<CSSStyleDeclaration> => ({
maxWidth: `${width}px`,
const stylesBasedOn = (padding: number): $Shape<CSSStyleDeclaration> => ({
padding: `0 ${padding}%`,
display: 'flex',
flexDirection: 'column',
flex: '1 0 auto',
})
const GnoForm = ({
onSubmit, validation, initialValues, children, width,
onSubmit, validation, initialValues, children, padding, render,
}: Props) => (
<Form
validate={validation}
onSubmit={onSubmit}
initialValues={initialValues}
render={({ handleSubmit, ...rest }) => (
<form onSubmit={handleSubmit} style={calculateWidth(width)}>
{children(rest)}
<form onSubmit={handleSubmit} style={stylesBasedOn(padding)}>
{render(rest)}
{children(rest.submitting)}
</form>
)}
/>

View File

@ -1,4 +1,6 @@
// @flow
import { getWeb3 } from '~/wallets/getWeb3'
type Field = boolean | number | string
export const required = (value: Field) => (value ? undefined : 'Required')
@ -24,5 +26,11 @@ export const maxValue = (max: number) => (value: number) => {
export const ok = () => undefined
export const mustBeEthereumAddress = (address: Field) => {
const isAddress: boolean = getWeb3().isAddress(address)
return isAddress ? undefined : 'Address should be a valid Ethereum address'
}
export const composeValidators = (...validators: Function[]) => (value: Field) =>
validators.reduce((error, validator) => error || validator(value), undefined)

View File

@ -1,6 +1,7 @@
.block {
display: inline-block;
width: 100%;
overflow-x: hidden;
}
.sm {

View File

@ -0,0 +1,20 @@
// @flow
import * as React from 'react'
type Props = {
children: React$Node,
}
class Bold extends React.PureComponent<Props> {
render() {
const { children, ...props } = this.props
return (
<b {...props}>
{ children }
</b>
)
}
}
export default Bold

View File

@ -196,4 +196,4 @@
@media only screen and (min-width: $(screenLg)px) {
@mixin row Lg;
}
}

View File

@ -11,7 +11,7 @@ type Props = {
}
const Page = ({ children, align }: Props) => (
<main className={cx(styles.container, align)}>
<main className={cx(styles.page, align)}>
{children}
</main>
)

View File

@ -1,8 +1,9 @@
.page {
margin: 0px 30px;
align-self: flex-end;
display: flex;
flex-direction: column;
flex: 1 0 auto;
}
.center {
align-self: center;
}
}

View File

@ -1,6 +1,7 @@
.frame {
display: grid;
grid-template-rows: auto 1fr auto;
background-color: $tertiary;
display: flex;
flex-direction: column;
flex: 1 0 auto;
background-color: white;
padding: $xl;
}

View File

@ -1,15 +1,15 @@
// @flow
import * as React from 'react'
import classNames from 'classnames/bind'
import * as css from './index.scss'
import styles from './index.scss'
const cx = classNames.bind(css)
const cx = classNames.bind(styles)
type Props = {
center?: boolean,
noMargin?: boolean,
bold?: boolean,
size?: 'sm',
size?: 'sm' | 'md' | 'lg',
color?: 'soft' | 'medium' | 'dark' | 'primary',
children: React$Node
}
@ -21,7 +21,7 @@ class Paragraph extends React.PureComponent<Props> {
} = this.props
return (
<p className={cx(color, { bold }, { noMargin }, size, { center })} {...props}>
<p className={cx(styles.paragraph, { bold }, { noMargin }, size, { center })} {...props}>
{ children }
</p>
)

View File

@ -1,3 +1,8 @@
.paragraph {
text-overflow: ellipsis;
overflow-x: inherit;
}
.soft {
color: #888888;
}
@ -26,6 +31,14 @@
font-size: $smallFontSize;
}
.md {
font-size: $mediumFontSize;
}
.lg {
font-size: $largeFontSize;
}
.bold {
font-weight: bold;
}

View File

@ -0,0 +1,18 @@
// @flow
import classNames from 'classnames/bind'
import * as React from 'react'
import styles from './index.scss'
const cx = classNames.bind(styles)
type Props = {
children: React$Node,
}
const Pre = ({ children, ...props }: Props) => (
<pre className={cx(styles.pre)} {...props}>
{children}
</pre>
)
export default Pre

View File

@ -0,0 +1,4 @@
.pre {
text-overflow: ellipsis;
overflow-x: hidden;
}

View File

@ -1,6 +1,7 @@
// @flow
import classNames from 'classnames/bind'
import React from 'react'
import { capitalize } from '~/utils/css'
import styles from './index.scss'
const cx = classNames.bind(styles)
@ -8,12 +9,27 @@ const cx = classNames.bind(styles)
type Props = {
className?: string,
children: React$Node,
margin?: 'sm' | 'md' | 'lg' | 'xl',
align?: 'center' | 'end' | 'start',
grow?: boolean,
}
const Row = ({ children, className, ...props }: Props) => (
<div className={cx(styles.row, className)} {...props}>
{ children }
</div>
)
const Row = ({
children, className, margin, align, grow, ...props
}: Props) => {
const rowClassNames = cx(
styles.row,
margin ? capitalize(margin, 'margin') : undefined,
align ? capitalize(align, 'align') : undefined,
{ grow },
className,
)
return (
<div className={rowClassNames} {...props}>
{ children }
</div>
)
}
export default Row

View File

@ -4,3 +4,33 @@
flex-direction: row;
flex-wrap: wrap;
}
.grow {
flex: 1 1 auto;
}
.marginSm {
margin-bottom: $sm;
}
.marginMd {
margin-bottom: $md;
}
.marginLg {
margin-bottom: $xl;
}
.marginXl {
margin-bottom: $xl;
}
.alignStart {
align-items: flex-start;
}
.alignEnd {
align-items: flex-end;
}
.alignCenter {
align-items: center;
}

View File

@ -1,33 +1,16 @@
// @flow
import 'babel-polyfill'
import { createBrowserHistory } from 'history'
import { MuiThemeProvider } from 'material-ui/styles'
import React from 'react'
import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import { ConnectedRouter, routerMiddleware, routerReducer } from 'react-router-redux'
import { combineReducers, createStore, applyMiddleware, compose } from 'redux'
import thunk from 'redux-thunk'
import provider, { REDUCER_ID } from '~/wallets/store/reducer/provider'
import { ConnectedRouter } from 'react-router-redux'
import { history, store } from '~/store'
import theme from '~/theme/mui'
import AppRoutes from '~/routes'
import './index.scss'
const history = createBrowserHistory()
// eslint-disable-next-line
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
const finalCreateStore = composeEnhancers(applyMiddleware(
thunk,
routerMiddleware(history),
))
const reducers = combineReducers({
routing: routerReducer,
[REDUCER_ID]: provider,
})
const store = createStore(reducers, finalCreateStore)
const Root = () => (
<Provider store={store}>
<MuiThemeProvider theme={theme}>

View File

@ -4,18 +4,23 @@ html, body {
}
body {
position: absolute;
bottom: 0;
top: 0;
left: 0;
right: 0;
overflow-x: hidden;
color: #1f5f76;
display: grid;
font-family: 'Montserrat', sans-serif;
font-size: $regularFontSize;
grid-template-columns:1fr;
grid-template-rows:1fr;
height: 100%;
font-size: $mediumFontSize;
margin: 0;
}
body>div:first-child {
display: grid;
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: calc(100% - (2 * $xl));
padding: $xl;
background-image: linear-gradient(to bottom, $primary, #1a829d, #1a829d, #1f5f76);
}

View File

@ -0,0 +1,54 @@
// @flow
import * as React from 'react'
import { CircularProgress } from 'material-ui/Progress'
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 Pre from '~/components/layout/Pre'
import Row from '~/components/layout/Row'
type FormProps = {
submitting: boolean,
}
type Props = {
address: string,
tx: Object,
}
const Deployment = ({ address, tx }: Props) => (
<Block>
<Paragraph><Bold>Deployed safe to: </Bold>{address}</Paragraph>
<Pre>
{JSON.stringify(tx, null, 2) }
</Pre>
</Block>
)
export default ({ address, tx }: Props) => ({ submitting }: FormProps) => {
const txFinished = !!address
return (
<Block>
{ !txFinished &&
<React.Fragment>
<Paragraph center size="lg">
You are about to create a Safe for keeping your funds more secure.
</Paragraph>
<Paragraph center size="lg">
Remember to check you have enough funds in your wallet.
</Paragraph>
</React.Fragment>
}
<Row>
<Col xs={12} center={submitting ? 'xs' : undefined} margin="lg">
{ submitting
? <CircularProgress size={50} />
: txFinished && <Deployment address={address} tx={tx} />
}
</Col>
</Row>
</Block>
)
}

View File

@ -1,87 +1,59 @@
// @flow
import Button from 'material-ui/Button'
import * as React from 'react'
import { Field } from 'react-final-form'
import Block from '~/components/layout/Block'
import Heading from '~/components/layout/Heading'
import TextField from '~/components/forms/TextField'
import GnoForm from '~/components/forms/GnoForm'
import Name from './Name'
import Owners from './Owners'
import Confirmations from './Confirmations'
import type { FormApi } from 'react-final-form'
import Stepper from '~/components/Stepper'
import Confirmation from '~/routes/open/components/FormConfirmation'
import Review from '~/routes/open/components/ReviewInformation'
import SafeFields, { safeFieldsValidation } from '~/routes/open/components/SafeForm'
type Props = {
onCallSafeContractSubmit: Function,
onAddFunds: Function,
safeAddress: string,
funds: number,
userAccount: string,
}
const validation = (values) => {
const errors = {}
if (values.owners < values.confirmations) {
errors.confirmations = 'Number of confirmations can not be higher than the number of owners'
}
return errors
}
const NewSafe = ({ values }: Object) => (
<Block margin="md">
<Heading tag="h2" margin="lg">Deploy a new Safe</Heading>
<Name />
<Owners numOwners={values.owners} />
<Confirmations />
<Block margin="xl">
<Button variant="raised" color="primary" type="submit">
Create Safe
</Button>
</Block>
</Block>
)
const getSteps = () => [
'Fill Safe Form', 'Review Information', 'Deploy it',
]
const initialValuesFrom = (userAccount: string) => ({
owner0Address: userAccount,
})
const Open = ({
funds, safeAddress, onCallSafeContractSubmit, onAddFunds, userAccount,
}: Props) => (
<React.Fragment>
<GnoForm
onSubmit={onCallSafeContractSubmit}
initialValues={initialValuesFrom(userAccount)}
width="500"
validation={validation}
>
{ NewSafe }
</GnoForm>
<GnoForm onSubmit={onAddFunds} width="500">
{(pristine, invalid) => (
<Block margin="md">
<Heading tag="h2" margin="lg">Add Funds to the safe</Heading>
<div style={{ margin: '10px 0px' }}>
<label style={{ marginRight: '10px' }}>{safeAddress || 'Not safe detected'}</label>
</div>
{ safeAddress &&
<div>
<Field name="fundsToAdd" component={TextField} type="text" placeholder="ETH to add" />
<Button type="submit" disabled={!safeAddress || pristine || invalid}>
Add funds
</Button>
</div>
}
{ safeAddress &&
<div style={{ margin: '15px 0px' }}>
Total funds in this safe: { funds || 0 } ETH
</div>
}
</Block>
)}
</GnoForm>
</React.Fragment>
)
type Props = {
provider: string,
userAccount: string,
safeAddress: string,
safeTx: string,
onCallSafeContractSubmit: (values: Object, form: FormApi, callback: ?(errors: ?Object) => void)
=> ?Object | Promise<?Object> | void,
}
export default Open
const Layout = ({
provider, userAccount, safeAddress, safeTx, onCallSafeContractSubmit,
}: Props) => {
const steps = getSteps()
const initialValues = initialValuesFrom(userAccount)
return (
<React.Fragment>
{ provider
? (
<Stepper
onSubmit={onCallSafeContractSubmit}
finishedTransaction={!!safeAddress}
steps={steps}
initialValues={initialValues}
>
<Stepper.Page validate={safeFieldsValidation}>
{ SafeFields }
</Stepper.Page>
<Stepper.Page>
{ Review }
</Stepper.Page>
<Stepper.Page address={safeAddress} tx={safeTx}>
{ Confirmation }
</Stepper.Page>
</Stepper>
)
: <div>No metamask detected</div>
}
</React.Fragment>
)
}
export default Layout

View File

@ -0,0 +1,60 @@
// @flow
import { storiesOf } from '@storybook/react'
import { State, Store } from '@sambego/storybook-state'
import * as React from 'react'
import styles from '~/components/layout/PageFrame/index.scss'
import { getAccountsFrom, getThresholdFrom } from '~/routes/open/utils/safeDataExtractor'
import { getProviderInfo } from '~/wallets/getWeb3'
import { sleep } from '~/utils/timer'
import Component from './Layout'
const FrameDecorator = story => (
<div className={styles.frame}>
{ story() }
</div>
)
const store = new Store({
safeAddress: '',
safeTx: '',
})
storiesOf('Routes', module)
.addDecorator(FrameDecorator)
.add('Open safe with all props set', () => {
getProviderInfo()
const provider = 'METAMASK'
const userAccount = '0x03db1a8b26d08df23337e9276a36b474510f0023'
const onCallSafeContractSubmit = async (values: Object) => {
const accounts = getAccountsFrom(values)
const numConfirmations = getThresholdFrom(values)
const data = {
userAccount,
accounts,
requiredConfirmations: numConfirmations,
}
// eslint-disable-next-line
console.log(`Generating and sending a eth tx based on: ${JSON.stringify(data, null, 2)}`)
await sleep(3000)
store.set({
safeAddress: '0x03db1a8b26d08df23337e9276a36b474510f0025',
// eslint-disable-next-line
safeTx: {"transactionHash":"0x4603de1ab6a92b4ee1fd67189089f5c02f5df5d135bf85af84083c27808c0544","transactionIndex":0,"blockHash":"0x593ce7d85fef2a492e8f759f485c8b66ff803773e77182c68dd45c439b7a956d","blockNumber":19,"gasUsed":3034193,"cumulativeGasUsed":3034193,"contractAddress":"0xfddda33736fb95b587cbfecc1ff4a50f717adc00","logs":[],"status":"0x01","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"},
})
}
return (
<State store={store}>
<Component
provider={provider}
userAccount={userAccount}
safeAddress={store.get('safeAddress')}
safeTx={store.get('safeTx')}
onCallSafeContractSubmit={onCallSafeContractSubmit}
/>
</State>
)
})

View File

@ -0,0 +1,47 @@
// @flow
import * as React from 'react'
import { getNamesFrom, getAccountsFrom } from '~/routes/open/utils/safeDataExtractor'
import Block from '~/components/layout/Block'
import Bold from '~/components/layout/Bold'
import Col from '~/components/layout/Col'
import Heading from '~/components/layout/Heading'
import Row from '~/components/layout/Row'
import Paragraph from '~/components/layout/Paragraph'
type FormProps = {
values: Object,
}
const ReviewInformation = () => ({ values }: FormProps) => {
const names = getNamesFrom(values)
const addresses = getAccountsFrom(values)
return (
<Block>
<Heading tag="h2">Review the Safe information</Heading>
<Paragraph>
<Bold>Safe Name: </Bold> {values.name}
</Paragraph>
<Paragraph>
<Bold>Required confirmations: </Bold> {values.confirmations}
</Paragraph>
<Heading tag="h3">Owners</Heading>
{ names.map((name, index) => (
<Row key={`name${(index)}`} margin="md">
<Col xs={11} xsOffset={1}margin="sm">
<Block>
<Paragraph noMargin>{name}</Paragraph>
</Block>
</Col>
<Col xs={11} xsOffset={1} margin="sm">
<Block>
<Paragraph noMargin>{addresses[index]}</Paragraph>
</Block>
</Col>
</Row>
))}
</Block>
)
}
export default ReviewInformation

View File

@ -2,7 +2,7 @@
import * as React from 'react'
import Field from '~/components/forms/Field'
import TextField from '~/components/forms/TextField'
import { composeValidators, minValue, mustBeNumber, required } from '~/components/forms/validator'
import { composeValidators, minValue, mustBeNumber, mustBeEthereumAddress, required } from '~/components/forms/validator'
import Block from '~/components/layout/Block'
import Col from '~/components/layout/Col'
import Heading from '~/components/layout/Heading'
@ -46,7 +46,7 @@ const Owners = ({ numOwners }: Props) => (
name={`owner${index}Address`}
component={TextField}
type="text"
validate={required}
validate={composeValidators(required, mustBeEthereumAddress)}
placeholder="Owner Address*"
text="Owner Address"
/>

View File

@ -0,0 +1,26 @@
// @flow
import * as React from 'react'
import Block from '~/components/layout/Block'
import Heading from '~/components/layout/Heading'
import Name from './Name'
import Owners from './Owners'
import Confirmations from './Confirmations'
export const safeFieldsValidation = (values: Object) => {
const errors = {}
if (values.owners < values.confirmations) {
errors.confirmations = 'Number of confirmations can not be higher than the number of owners'
}
return errors
}
export default () => ({ values }: Object) => (
<Block margin="md">
<Heading tag="h2" margin="lg">Deploy a new Safe</Heading>
<Name />
<Owners numOwners={values.owners} />
<Confirmations />
</Block>
)

View File

@ -1,14 +1,15 @@
// @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 { 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'
import Layout from '../components/Layout'
type Props = {
provider: string,
@ -17,7 +18,7 @@ type Props = {
type State = {
safeAddress: string,
funds: number,
safeTx: string,
}
class Open extends React.Component<Props, State> {
@ -26,30 +27,12 @@ class Open extends React.Component<Props, State> {
this.state = {
safeAddress: '',
funds: 0,
safeTx: '',
}
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
@ -65,7 +48,7 @@ class Open extends React.Component<Props, State> {
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 })
this.setState({ safeAddress: safeInstance.address, safeTx: transactionReceipt })
} catch (error) {
// eslint-disable-next-line
console.log('Error while creating the Safe' + error)
@ -75,20 +58,18 @@ class Open extends React.Component<Props, State> {
safe: any
render() {
const { safeAddress, safeTx } = this.state
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>
}
<Layout
provider={provider}
userAccount={userAccount}
safeAddress={safeAddress}
safeTx={safeTx}
onCallSafeContractSubmit={this.onCallSafeContractSubmit}
/>
</PageFrame>
)
}

View File

@ -0,0 +1,97 @@
// @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,45 @@
// @flow
/*
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}`)
}
}
<GnoForm onSubmit={onAddFunds} width="500">
{(pristine, invalid) => (
<Block margin="md">
<Heading tag="h2" margin="lg">Add Funds to the safe</Heading>
<div style={{ margin: '10px 0px' }}>
<label style={{ marginRight: '10px' }}>{safeAddress || 'Not safe detected'}</label>
</div>
{ safeAddress &&
<div>
<Field name="fundsToAdd" component={TextField} type="text" placeholder="ETH to add" />
<Button type="submit" disabled={!safeAddress || pristine || invalid}>
Add funds
</Button>
</div>
}
{ safeAddress &&
<div style={{ margin: '15px 0px' }}>
Total funds in this safe: { funds || 0 } ETH
</div>
}
</Block>
)}
</GnoForm>
*/

View File

@ -1,10 +0,0 @@
// @flow
export const getAccountsFrom = (values: Object): string[] => {
const accounts = Object.keys(values).filter(key => /^owner\d+Address$/.test(key))
return accounts.map(account => values[account])
}
export const getThresholdFrom = (values: Object): number => {
return Number(values.confirmations)
}

View File

@ -1,21 +0,0 @@
// @flow
import { getAccountsFrom, getThresholdFrom } from './safe'
describe('Test JS', () => {
it('return the addresses of owners', () => {
const safe = {
owner0Address: 'foo',
owner1Address: 'bar',
owner2Address: 'baz',
}
expect(['foo', 'bar', 'baz']).toEqual(getAccountsFrom(safe))
})
it('return the number of required confirmations', () => {
const safe = {
confirmations: '1',
}
expect(1).toEqual(getThresholdFrom(safe))
})
})

View File

@ -0,0 +1,14 @@
// @flow
export const getAccountsFrom = (values: Object): string[] => {
const accounts = Object.keys(values).sort().filter(key => /^owner\d+Address$/.test(key))
return accounts.map(account => values[account]).slice(0, values.owners)
}
export const getNamesFrom = (values: Object): string[] => {
const accounts = Object.keys(values).sort().filter(key => /^owner\d+Name$/.test(key))
return accounts.map(account => values[account]).slice(0, values.owners)
}
export const getThresholdFrom = (values: Object): number => Number(values.confirmations)

View File

@ -0,0 +1,60 @@
// @flow
import { getAccountsFrom, getNamesFrom, getThresholdFrom } from './safeDataExtractor'
describe('Test JS', () => {
it('return the addresses of owners', () => {
const safe = {
owner0Address: 'foo',
owner1Address: 'bar',
owner2Address: 'baz',
owners: 3,
}
expect(getAccountsFrom(safe)).toEqual(['foo', 'bar', 'baz'])
})
it('return the names of owners', () => {
const safe = {
owner0Name: 'foo',
owner1Name: 'bar',
owner2Name: 'baz',
owners: 3,
}
expect(getNamesFrom(safe)).toEqual(['foo', 'bar', 'baz'])
})
it('return first number of owners info based on owners property', () => {
const safe = {
owner0Name: 'fooName',
owner0Address: 'fooAddress',
owner1Name: 'barName',
owner1Address: 'barAddress',
owner2Name: 'bazName',
owner2Address: 'bazAddress',
owners: 1,
}
expect(getNamesFrom(safe)).toEqual(['fooName'])
expect(getAccountsFrom(safe)).toEqual(['fooAddress'])
})
it('return name and address ordered alphabetically', () => {
const safe = {
owner1Name: 'barName',
owner1Address: 'barAddress',
owner0Name: 'fooName',
owner2Name: 'bazName',
owner2Address: 'bazAddress',
owner0Address: 'fooAddress',
owners: 1,
}
expect(getNamesFrom(safe)).toEqual(['fooName'])
expect(getAccountsFrom(safe)).toEqual(['fooAddress'])
})
it('return the number of required confirmations', () => {
const safe = {
confirmations: '1',
}
expect(getThresholdFrom(safe)).toEqual(1)
})
})

View File

@ -1,7 +1,6 @@
// @flow
import React from 'react'
import Block from '~/components/layout/Block'
import PageFrame from '~/components/layout/PageFrame'
import Img from '~/components/layout/Img'
import Button from '~/components/layout/Button'
import Link from '~/components/layout/Link'
@ -14,25 +13,23 @@ type Props = {
}
const Welcome = ({ provider }: Props) => (
<PageFrame align="center">
<Block className={styles.safe}>
<Img alt="Safe Box" src={vault} height={330} />
<Block className={styles.safeActions}>
{ provider &&
<Link to="/open">
<Button variant="raised" color="primary">
Create a new Safe
</Button>
</Link>
}
<Link to="/transactions">
<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">
Open a Safe
Create a new Safe
</Button>
</Link>
</Block>
}
<Link to="/transactions">
<Button variant="raised" color="primary">
Open a Safe
</Button>
</Link>
</Block>
</PageFrame>
</Block>
)
export default Welcome

View File

@ -0,0 +1,25 @@
// @flow
import { select } from '@storybook/addon-knobs'
import { storiesOf } from '@storybook/react'
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', module)
.addDecorator(FrameDecorator)
.add('Welcome with Metamask connected', () => {
const provider = select('Status by Provider', ['', 'UNKNOWN', 'METAMASK', 'PARITY'], 'METAMASK')
return (
<Component
provider={provider}
fetchProvider={() => { }}
/>
)
})

View File

@ -1,24 +0,0 @@
// @flow
import { storiesOf } from '@storybook/react'
import * as React from 'react'
import Component from './Layout'
storiesOf('Routes', module)
.add('Welcome with Metamask connected', () => (
<Component
provider="METAMASK"
fetchProvider={() => { }}
/>
))
.add('Welcome with Parity connected', () => (
<Component
provider="PARITY"
fetchProvider={() => { }}
/>
))
.add('Welcome without provider', () => (
<Component
provider=""
fetchProvider={() => { }}
/>
))

View File

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

21
src/store/index.js Normal file
View File

@ -0,0 +1,21 @@
// @flow
import { createBrowserHistory } from 'history'
import { routerMiddleware, routerReducer } from 'react-router-redux'
import { combineReducers, createStore, applyMiddleware, compose } from 'redux'
import thunk from 'redux-thunk'
import provider, { REDUCER_ID } from '~/wallets/store/reducer/provider'
export const history = createBrowserHistory()
// eslint-disable-next-line
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
const finalCreateStore = composeEnhancers(applyMiddleware(
thunk,
routerMiddleware(history),
))
const reducers = combineReducers({
routing: routerReducer,
[REDUCER_ID]: provider,
})
export const store = createStore(reducers, finalCreateStore)

View File

@ -22,7 +22,8 @@ module.exports = Object.assign({}, {
regularFontWeight: 400,
boldFontWeight: 700,
smallFontSize: '12px',
regularFontSize: '14px',
mediumFontSize: '14px',
largeFontSize: '18px',
screenXs: 480,
screenXsMax: 767,
screenSm: 768,

View File

@ -1,7 +1,7 @@
// @flow
export const upperFirst = (value: string) => value.charAt(0).toUpperCase() + value.slice(1)
type Value = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | number | boolean
type Value = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'center' | 'end' | 'start' | number | boolean
export const capitalize = (value: Value, prefix?: string) => {
if (!value) {

View File

@ -688,6 +688,12 @@
lodash "^4.2.0"
to-fast-properties "^2.0.0"
"@sambego/storybook-state@^1.0.7":
version "1.0.7"
resolved "https://registry.yarnpkg.com/@sambego/storybook-state/-/storybook-state-1.0.7.tgz#4409793c0d34f1d351af1c8045a2764b5af574c0"
dependencies:
uuid "^3.1.0"
"@sindresorhus/is@^0.7.0":
version "0.7.0"
resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.7.0.tgz#9a06f4f137ee84d7df0460c1fdb1135ffa6c50fd"