pull from manage-owners

This commit is contained in:
mmv 2019-07-10 17:59:22 +04:00
parent bc11d64d49
commit 9b709f8e29
77 changed files with 3141 additions and 1110 deletions

View File

@ -7,6 +7,7 @@
align-items: center;
border: solid 0.5px $border;
background-color: white;
margin-top: 50px;
}
@media only screen and (max-width: $(screenXs)px) {

View File

@ -22,6 +22,7 @@ type Props<K> = {
size: number,
defaultFixed?: boolean,
defaultOrder?: 'desc' | 'asc',
noBorder: boolean,
}
type State = {
@ -127,7 +128,7 @@ class GnoTable<K> extends React.Component<Props<K>, State> {
render() {
const {
data, label, columns, classes, children, size, defaultOrderBy, defaultOrder, defaultFixed,
data, label, columns, classes, children, size, defaultOrderBy, defaultOrder, defaultFixed, noBorder,
} = this.props
const {
order, orderBy, page, orderProp, rowsPerPage, fixed,
@ -138,7 +139,7 @@ class GnoTable<K> extends React.Component<Props<K>, State> {
const paginationClasses = {
selectRoot: classes.selectRoot,
root: classes.paginationRoot,
root: !noBorder && classes.paginationRoot,
input: classes.white,
}
@ -153,13 +154,16 @@ class GnoTable<K> extends React.Component<Props<K>, State> {
return (
<React.Fragment>
{!isEmpty && (
<Table aria-labelledby={label} className={classes.root}>
<TableHead columns={columns} order={orderParam} orderBy={orderByParam} onSort={this.onSort} />
<Table aria-labelledby={label} className={noBorder ? '' : classes.root}>
<TableHead columns={columns} order={order} orderBy={orderByParam} onSort={this.onSort} />
<TableBody>{children(sortedData)}</TableBody>
</Table>
)}
{isEmpty && (
<Row className={classNames(classes.loader, classes.root)} style={this.getEmptyStyle(emptyRows + 1)}>
<Row
className={classNames(classes.loader, !noBorder && classes.root)}
style={this.getEmptyStyle(emptyRows + 1)}
>
<CircularProgress size={60} />
</Row>
)}

View File

@ -2,9 +2,21 @@
import { type FieldValidator } from 'final-form'
import { getWeb3 } from '~/logic/wallets/getWeb3'
export const simpleMemoize = (fn: Function) => {
let lastArg
let lastResult
return (arg: any) => {
if (arg !== lastArg) {
lastArg = arg
lastResult = fn(arg)
}
return lastResult
}
}
type Field = boolean | string | null | typeof undefined
export const required = (value: Field) => (value ? undefined : 'Required')
export const required = simpleMemoize((value: Field) => (value ? undefined : 'Required'))
export const mustBeInteger = (value: string) => (!Number.isInteger(Number(value)) || value.includes('.') ? 'Must be an integer' : undefined)
@ -46,17 +58,17 @@ export const maxValue = (max: number) => (value: string) => {
export const ok = () => undefined
export const mustBeEthereumAddress = (address: Field) => {
export const mustBeEthereumAddress = simpleMemoize((address: Field) => {
const isAddress: boolean = getWeb3().utils.isAddress(address)
return isAddress ? undefined : 'Address should be a valid Ethereum address'
}
})
export const minMaxLength = (minLen: string | number, maxLen: string | number) => (value: string) => (value.length >= +minLen && value.length <= +maxLen ? undefined : `Should be ${minLen} to ${maxLen} symbols`)
export const ADDRESS_REPEATED_ERROR = 'Address already introduced'
export const uniqueAddress = (addresses: string[]) => (value: string) => (addresses.includes(value) ? ADDRESS_REPEATED_ERROR : undefined)
export const uniqueAddress = (addresses: string[]) => simpleMemoize((value: string) => (addresses.includes(value) ? ADDRESS_REPEATED_ERROR : undefined))
export const composeValidators = (...validators: Function[]): FieldValidator => (value: Field) => validators.reduce((error, validator) => error || validator(value), undefined)

View File

@ -10,15 +10,16 @@ type Props = {
fullwidth?: boolean,
bordered?: boolean,
className?: string,
style?: React.Node,
style?: Object,
testId?: string,
}
const Img = ({
fullwidth, alt, bordered, className, style, ...props
fullwidth, alt, bordered, className, style, testId = '', ...props
}: Props) => {
const classes = cx(styles.img, { fullwidth, bordered }, className)
return <img alt={alt} style={style} className={classes} {...props} />
return <img alt={alt} style={style} className={classes} data-testid={testId} {...props} />
}
export default Img

View File

@ -12,10 +12,11 @@ type Props = {
margin?: 'xs' | 'sm' | 'md' | 'lg' | 'xl',
align?: 'center' | 'end' | 'start',
grow?: boolean,
testId?: string,
}
const Row = ({
children, className, margin, align, grow, ...props
children, className, margin, align, grow, testId = '', ...props
}: Props) => {
const rowClassNames = cx(
styles.row,
@ -26,7 +27,7 @@ const Row = ({
)
return (
<div className={rowClassNames} {...props}>
<div className={rowClassNames} data-testid={testId} {...props}>
{children}
</div>
)

View File

@ -20,7 +20,7 @@ const buildWidthFrom = (size: number) => ({
})
const overflowStyle = {
overflowX: 'scroll',
overflowX: 'auto',
}
// see: https://css-tricks.com/responsive-data-tables/

View File

@ -28,7 +28,7 @@ export const saveSafes = async (safes: Object) => {
export const setOwners = async (safeAddress: string, owners: List<Owner>) => {
try {
const ownersAsMap = Map(owners.map((owner: Owner) => [owner.get('address').toLowerCase(), owner.get('name')]))
const ownersAsMap = Map(owners.map((owner: Owner) => [owner.address.toLowerCase(), owner.name]))
await saveToStorage(`${OWNERS_KEY}-${safeAddress}`, ownersAsMap)
} catch (err) {
// eslint-disable-next-line

View File

@ -5,7 +5,7 @@ import type { Dispatch as ReduxDispatch } from 'redux'
import StandardToken from '@gnosis.pm/util-contracts/build/contracts/GnosisStandardToken.json'
import HumanFriendlyToken from '@gnosis.pm/util-contracts/build/contracts/HumanFriendlyToken.json'
import { getWeb3 } from '~/logic/wallets/getWeb3'
import { type GlobalState } from '~/store/index'
import { type GlobalState } from '~/store'
import { makeToken, type TokenProps } from '~/logic/tokens/store/model/token'
import { fetchTokenList } from '~/logic/tokens/api'
import { ensureOnce } from '~/utils/singleton'

View File

@ -1,9 +1,9 @@
// @flow
import { createAction } from 'redux-actions'
import type { Dispatch as ReduxDispatch } from 'redux'
import { type Token } from '~/logic/tokens/store/model/token'
import { removeTokenFromStorage, removeFromActiveTokens } from '~/logic/tokens/utils/tokensStorage'
import { type GlobalState } from '~/store/index'
import type { Dispatch as ReduxDispatch } from 'redux'
export const REMOVE_TOKEN = 'REMOVE_TOKEN'

View File

@ -1,6 +1,6 @@
// @flow
import { getWeb3 } from '~/logic/wallets/getWeb3'
import { BigNumber } from 'bignumber.js'
import { getWeb3 } from '~/logic/wallets/getWeb3'
export const toNative = (amt: string | number | BigNumber, decimal: number): BigNumber => {
const web3 = getWeb3()

View File

@ -1,10 +1,10 @@
// @flow
import * as React from 'react'
import classNames from 'classnames'
import { getNamesFrom, getAccountsFrom } from '~/routes/open/utils/safeDataExtractor'
import Block from '~/components/layout/Block'
import { withStyles } from '@material-ui/core/styles'
import OpenInNew from '@material-ui/icons/OpenInNew'
import { getNamesFrom, getAccountsFrom } from '~/routes/open/utils/safeDataExtractor'
import Block from '~/components/layout/Block'
import Identicon from '~/components/Identicon'
import OpenPaper from '~/components/Stepper/OpenPaper'
import Col from '~/components/layout/Col'

View File

@ -74,8 +74,11 @@ const styles = () => ({
})
const getAddressValidators = (addresses: string[], position: number) => {
// thanks Rich Harris
// https://twitter.com/Rich_Harris/status/1125850391155965952
const copy = addresses.slice()
copy.splice(position, 1)
copy[position] = copy[copy.length - 1]
copy.pop()
return composeValidators(required, mustBeEthereumAddress, uniqueAddress(copy))
}
@ -97,7 +100,7 @@ export const calculateValuesAfterRemoving = (index: number, notRemovedOwners: nu
return initialValues
}
class SafeOwners extends React.Component<Props, State> {
class SafeOwners extends React.PureComponent<Props, State> {
state = {
numOwners: 1,
}

View File

@ -1,70 +0,0 @@
// @flow
import * as React from 'react'
import Field from '~/components/forms/Field'
import OpenPaper from '~/components/Stepper/OpenPaper'
import TextField from '~/components/forms/TextField'
import Checkbox from '~/components/forms/Checkbox'
import {
composeValidators, required, mustBeEthereumAddress, uniqueAddress,
} from '~/components/forms/validator'
import Block from '~/components/layout/Block'
import Heading from '~/components/layout/Heading'
export const CONFIRMATIONS_ERROR = 'Number of confirmations can not be higher than the number of owners'
export const NAME_PARAM = 'name'
export const OWNER_ADDRESS_PARAM = 'ownerAddress'
export const INCREASE_PARAM = 'increase'
export const safeFieldsValidation = (values: Object) => {
const errors = {}
if (Number.parseInt(values.owners, 10) < Number.parseInt(values.confirmations, 10)) {
errors.confirmations = CONFIRMATIONS_ERROR
}
return errors
}
type Props = {
numOwners: number,
threshold: number,
addresses: string[],
}
const AddOwnerForm = ({ addresses, numOwners, threshold }: Props) => (controls: React.Node) => (
<OpenPaper controls={controls}>
<Heading tag="h2" margin="lg">
Add Owner
</Heading>
<Heading tag="h4" margin="lg">
{`Actual number of owners: ${numOwners}, with threshold: ${threshold}`}
</Heading>
<Block margin="md">
<Field
name={NAME_PARAM}
component={TextField}
type="text"
validate={required}
placeholder="Owner Name*"
text="Owner Name*"
/>
</Block>
<Block margin="md">
<Field
name={OWNER_ADDRESS_PARAM}
component={TextField}
type="text"
validate={composeValidators(required, mustBeEthereumAddress, uniqueAddress(addresses))}
placeholder="Owner address*"
text="Owner address*"
/>
</Block>
<Block margin="md">
<Field name={INCREASE_PARAM} component={Checkbox} type="checkbox" />
<Block>Increase threshold?</Block>
</Block>
</OpenPaper>
)
export default AddOwnerForm

View File

@ -1,48 +0,0 @@
// @flow
import * as React from 'react'
import CircularProgress from '@material-ui/core/CircularProgress'
import OpenPaper from '~/components/Stepper/OpenPaper'
import Block from '~/components/layout/Block'
import Bold from '~/components/layout/Bold'
import Heading from '~/components/layout/Heading'
import Paragraph from '~/components/layout/Paragraph'
import { NAME_PARAM, OWNER_ADDRESS_PARAM, INCREASE_PARAM } from '~/routes/safe/components/AddOwner/AddOwnerForm'
type FormProps = {
values: Object,
submitting: boolean,
}
const spinnerStyle = {
minHeight: '50px',
}
const Review = () => (controls: React.Node, { values, submitting }: FormProps) => {
const text = values[INCREASE_PARAM]
? 'This operation will increase the threshold of the safe'
: 'This operation will not modify the threshold of the safe'
return (
<OpenPaper controls={controls}>
<Heading tag="h2">Review the Add Owner operation</Heading>
<Paragraph align="left">
<Bold>Owner Name: </Bold>
{' '}
{values[NAME_PARAM]}
</Paragraph>
<Paragraph align="left">
<Bold>Owner Address: </Bold>
{' '}
{values[OWNER_ADDRESS_PARAM]}
</Paragraph>
<Paragraph align="left">
<Bold>{text}</Bold>
</Paragraph>
<Block style={spinnerStyle}>
{ submitting && <CircularProgress size={50} /> }
</Block>
</OpenPaper>
)
}
export default Review

View File

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

View File

@ -1,105 +0,0 @@
// @flow
import * as React from 'react'
import { List } from 'immutable'
import Stepper from '~/components/Stepper'
import { connect } from 'react-redux'
import { type Safe } from '~/routes/safe/store/models/safe'
import { type Owner, makeOwner } from '~/routes/safe/store/models/owner'
import { setOwners } from '~/logic/safe/utils'
import { createTransaction } from '~/logic/safe/safeFrontendOperations'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
import AddOwnerForm, { NAME_PARAM, OWNER_ADDRESS_PARAM, INCREASE_PARAM } from './AddOwnerForm'
import Review from './Review'
import selector, { type SelectorProps } from './selector'
import actions, { type Actions } from './actions'
const getSteps = () => ['Fill Owner Form', 'Review Add order operation']
type Props = SelectorProps &
Actions & {
safe: Safe,
threshold: number,
}
type State = {
done: boolean,
}
export const ADD_OWNER_RESET_BUTTON_TEXT = 'RESET'
const getOwnerAddressesFrom = (owners: List<Owner>) => {
if (!owners) {
return []
}
return owners.map((owner: Owner) => owner.get('address'))
}
export const addOwner = async (values: Object, safe: Safe, threshold: number, executor: string) => {
const safeAddress = safe.get('address')
const gnosisSafe = await getGnosisSafeInstanceAt(safeAddress)
const nonce = await gnosisSafe.nonce()
const newThreshold = values[INCREASE_PARAM] ? threshold + 1 : threshold
const newOwnerAddress = values[OWNER_ADDRESS_PARAM]
const newOwnerName = values[NAME_PARAM]
const data = gnosisSafe.contract.methods.addOwnerWithThreshold(newOwnerAddress, newThreshold).encodeABI()
await createTransaction(safe, `Add Owner ${newOwnerName}`, safeAddress, '0', nonce, executor, data)
setOwners(safeAddress, safe.get('owners').push(makeOwner({ name: newOwnerName, address: newOwnerAddress })))
}
class AddOwner extends React.Component<Props, State> {
state = {
done: false,
}
onAddOwner = async (values: Object) => {
try {
const {
safe, threshold, userAddress, fetchTransactions,
} = this.props
await addOwner(values, safe, threshold, userAddress)
fetchTransactions(safe.get('address'))
this.setState({ done: true })
} catch (error) {
this.setState({ done: false })
// eslint-disable-next-line
console.log('Error while adding owner ' + error)
}
}
onReset = () => {
this.setState({ done: false })
}
render() {
const { safe } = this.props
const { done } = this.state
const steps = getSteps()
const finishedButton = <Stepper.FinishButton title={ADD_OWNER_RESET_BUTTON_TEXT} />
const addresses = getOwnerAddressesFrom(safe.get('owners'))
return (
<React.Fragment>
<Stepper
finishedTransaction={done}
finishedButton={finishedButton}
onSubmit={this.onAddOwner}
steps={steps}
onReset={this.onReset}
>
<Stepper.Page numOwners={safe.get('owners').count()} threshold={safe.get('threshold')} addresses={addresses}>
{AddOwnerForm}
</Stepper.Page>
<Stepper.Page>{Review}</Stepper.Page>
</Stepper>
</React.Fragment>
)
}
}
export default connect(
selector,
actions,
)(AddOwner)

View File

@ -1,11 +0,0 @@
// @flow
import { createStructuredSelector } from 'reselect'
import { userAccountSelector } from '~/logic/wallets/store/selectors'
export type SelectorProps = {
userAddress: userAccountSelector,
}
export default createStructuredSelector({
userAddress: userAccountSelector,
})

View File

@ -3,18 +3,8 @@ import { List } from 'immutable'
import { type Token } from '~/logic/tokens/store/model/token'
import { sameAddress } from '~/logic/wallets/ethAddresses'
import { isAddressAToken } from '~/logic/tokens/utils/tokenHelpers'
export const simpleMemoize = (fn: Function) => {
let lastArg
let lastResult
return (arg: any) => {
if (arg !== lastArg) {
lastArg = arg
lastResult = fn(arg)
}
return lastResult
}
}
import { simpleMemoize } from '~/components/forms/validator'
// import { getStandardTokenContract } from '~/logic/tokens/store/actions/fetchTokens'
// eslint-disable-next-line
export const addressIsTokenContract = simpleMemoize(async (tokenAddress: string) => {

View File

@ -183,6 +183,8 @@ class Layout extends React.Component<Props, State> {
updateSafe={updateSafe}
threshold={safe.threshold}
owners={safe.owners}
network={network}
userAddress={userAddress}
createTransaction={createTransaction}
/>
)}

View File

@ -1,50 +0,0 @@
// @flow
import * as React from 'react'
import Field from '~/components/forms/Field'
import SnackbarContent from '~/components/SnackbarContent'
import OpenPaper from '~/components/Stepper/OpenPaper'
import Checkbox from '~/components/forms/Checkbox'
import Block from '~/components/layout/Block'
import Heading from '~/components/layout/Heading'
export const DECREASE_PARAM = 'decrease'
type Props = {
numOwners: number,
threshold: number,
name: string,
disabled: boolean,
pendingTransactions: boolean,
}
const RemoveOwnerForm = ({
numOwners, threshold, name, disabled, pendingTransactions,
}: Props) => (
controls: React.Node,
) => (
<OpenPaper controls={controls}>
<Heading tag="h2" margin="lg">
Remove Owner
{' '}
{!!name && name}
</Heading>
<Heading tag="h4" margin="lg">
{`Actual number of owners: ${numOwners}, threhsold of safe: ${threshold}`}
</Heading>
{pendingTransactions && (
<SnackbarContent
variant="warning"
message="Be careful removing an owner might incur in some of the pending transactions could never be executed"
/>
)}
<Block margin="md">
<Field name={DECREASE_PARAM} component={Checkbox} type="checkbox" disabled={disabled} />
<Block>
{disabled && '(disabled) '}
Decrease threshold?
</Block>
</Block>
</OpenPaper>
)
export default RemoveOwnerForm

View File

@ -1,47 +0,0 @@
// @flow
import * as React from 'react'
import CircularProgress from '@material-ui/core/CircularProgress'
import Block from '~/components/layout/Block'
import OpenPaper from '~/components/Stepper/OpenPaper'
import Bold from '~/components/layout/Bold'
import Heading from '~/components/layout/Heading'
import Paragraph from '~/components/layout/Paragraph'
import { DECREASE_PARAM } from '~/routes/safe/components/RemoveOwner/RemoveOwnerForm'
type Props = {
name: string,
}
type FormProps = {
values: Object,
submitting: boolean,
}
const spinnerStyle = {
minHeight: '50px',
}
const Review = ({ name }: Props) => (controls: React.Node, { values, submitting }: FormProps) => {
const text = values[DECREASE_PARAM]
? 'This operation will decrease the threshold of the safe'
: 'This operation will not modify the threshold of the safe'
return (
<OpenPaper controls={controls}>
<Heading tag="h2">Review the Remove Owner operation</Heading>
<Paragraph align="left">
<Bold>Owner Name: </Bold>
{' '}
{name}
</Paragraph>
<Paragraph align="left">
<Bold>{text}</Bold>
</Paragraph>
<Block style={spinnerStyle}>
{ submitting && <CircularProgress size={50} /> }
</Block>
</OpenPaper>
)
}
export default Review

View File

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

View File

@ -1,121 +0,0 @@
// @flow
import * as React from 'react'
import Stepper from '~/components/Stepper'
import { connect } from 'react-redux'
import { type Safe } from '~/routes/safe/store/models/safe'
import { createTransaction } from '~/logic/safe/safeFrontendOperations'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
import RemoveOwnerForm, { DECREASE_PARAM } from './RemoveOwnerForm'
import Review from './Review'
import selector, { type SelectorProps } from './selector'
import actions, { type Actions } from './actions'
const getSteps = () => [
'Fill Owner Form', 'Review Remove order operation',
]
type Props = SelectorProps & Actions & {
safe: Safe,
threshold: number,
name: string,
userToRemove: string,
}
type State = {
done: boolean,
}
const SENTINEL_ADDRESS = '0x0000000000000000000000000000000000000001'
export const REMOVE_OWNER_RESET_BUTTON_TEXT = 'RESET'
export const initialValuesFrom = (decreaseMandatory: boolean = false) => ({
[DECREASE_PARAM]: decreaseMandatory,
})
export const shouldDecrease = (numOwners: number, threshold: number) => threshold === numOwners
export const removeOwner = async (
values: Object,
safe: Safe,
threshold: number,
userToRemove: string,
name: string,
executor: string,
) => {
const safeAddress = safe.get('address')
const gnosisSafe = await getGnosisSafeInstanceAt(safeAddress)
const nonce = await gnosisSafe.nonce()
const newThreshold = values[DECREASE_PARAM] ? threshold - 1 : threshold
const storedOwners = await gnosisSafe.getOwners()
const index = storedOwners.findIndex(ownerAddress => ownerAddress === userToRemove)
const prevAddress = index === 0 ? SENTINEL_ADDRESS : storedOwners[index - 1]
const data = gnosisSafe.contract.removeOwner(prevAddress, userToRemove, newThreshold).encodeABI()
const text = name || userToRemove
return createTransaction(safe, `Remove Owner ${text}`, safeAddress, '0', nonce, executor, data)
}
class RemoveOwner extends React.Component<Props, State> {
state = {
done: false,
}
onRemoveOwner = async (values: Object) => {
try {
const {
safe, threshold, executor, fetchTransactions, userToRemove, name,
} = this.props
await removeOwner(values, safe, threshold, userToRemove, name, executor)
fetchTransactions(safe.get('address'))
this.setState({ done: true })
} catch (error) {
this.setState({ done: false })
// eslint-disable-next-line
console.log('Error while adding owner ' + error)
}
}
onReset = () => {
this.setState({ done: false })
}
render() {
const { safe, name, pendingTransactions } = this.props
const { done } = this.state
const steps = getSteps()
const numOwners = safe.get('owners').count()
const threshold = safe.get('threshold')
const finishedButton = <Stepper.FinishButton title={REMOVE_OWNER_RESET_BUTTON_TEXT} />
const decrease = shouldDecrease(numOwners, threshold)
const initialValues = initialValuesFrom(decrease)
const disabled = decrease || threshold === 1
return (
<React.Fragment>
<Stepper
finishedTransaction={done}
finishedButton={finishedButton}
onSubmit={this.onRemoveOwner}
steps={steps}
onReset={this.onReset}
initialValues={initialValues}
>
<Stepper.Page
numOwners={numOwners}
threshold={threshold}
name={name}
disabled={disabled}
pendingTransactions={pendingTransactions}
>
{ RemoveOwnerForm }
</Stepper.Page>
<Stepper.Page name={name}>
{ Review }
</Stepper.Page>
</Stepper>
</React.Fragment>
)
}
}
export default connect(selector, actions)(RemoveOwner)

View File

@ -1,21 +0,0 @@
// @flow
import { List } from 'immutable'
import { createStructuredSelector, createSelector } from 'reselect'
import { userAccountSelector } from '~/logic/wallets/store/selectors'
import { type Transaction } from '~/routes/safe/store/models/transaction'
import { safeTransactionsSelector } from '~/routes/safe/store/selectors/index'
const pendingTransactionsSelector = createSelector(
safeTransactionsSelector,
(transactions: List<Transaction>) => transactions.findEntry((tx: Transaction) => tx.get('isExecuted')),
)
export type SelectorProps = {
executor: typeof userAccountSelector,
pendingTransactions: typeof pendingTransactionsSelector,
}
export default createStructuredSelector({
executor: userAccountSelector,
pendingTransactions: pendingTransactionsSelector,
})

View File

@ -1,21 +0,0 @@
// @flow
import * as React from 'react'
import ListItem from '@material-ui/core/ListItem'
import Avatar from '@material-ui/core/Avatar'
import Mail from '@material-ui/icons/Mail'
import ListItemText from '~/components/List/ListItemText'
type Props = {
address: string,
}
const Address = ({ address }: Props) => (
<ListItem>
<Avatar>
<Mail />
</Avatar>
<ListItemText primary="Safe Address" secondary={address} cut />
</ListItem>
)
export default Address

View File

@ -1,80 +0,0 @@
// @flow
import * as React from 'react'
import classNames from 'classnames'
import AccountBalance from '@material-ui/icons/AccountBalance'
import Avatar from '@material-ui/core/Avatar'
import Collapse from '@material-ui/core/Collapse'
import IconButton from '@material-ui/core/IconButton'
import List from '@material-ui/core/List'
import Img from '~/components/layout/Img'
import ListItem from '@material-ui/core/ListItem'
import ListItemIcon from '@material-ui/core/ListItemIcon'
import ListItemText from '@material-ui/core/ListItemText'
import { withStyles } from '@material-ui/core/styles'
import ExpandLess from '@material-ui/icons/ExpandLess'
import ExpandMore from '@material-ui/icons/ExpandMore'
import { Map } from 'immutable'
import Button from '~/components/layout/Button'
import openHoc, { type Open } from '~/components/hoc/OpenHoc'
import { type WithStyles } from '~/theme/mui'
import { type Token } from '~/logic/tokens/store/model/token'
type Props = Open & WithStyles & {
tokens: Map<string, Token>,
onMoveFunds: (token: Token) => void,
}
const styles = {
nested: {
paddingLeft: '40px',
},
}
export const MOVE_FUNDS_BUTTON_TEXT = 'Move'
const BalanceComponent = openHoc(({
open, toggle, tokens, classes, onMoveFunds,
}: Props) => {
const hasBalances = tokens.count() > 0
return (
<React.Fragment>
<ListItem onClick={hasBalances ? toggle : undefined}>
<Avatar>
<AccountBalance />
</Avatar>
<ListItemText primary="Balance" secondary="List of different token balances" />
<ListItemIcon>
{open
? <IconButton disableRipple><ExpandLess /></IconButton>
: <IconButton disabled={!hasBalances} disableRipple><ExpandMore /></IconButton>
}
</ListItemIcon>
</ListItem>
<Collapse in={open} timeout="auto">
<List component="div" disablePadding>
{tokens.valueSeq().map((token: Token) => {
const symbol = token.get('symbol')
const name = token.get('name')
const disabled = Number(token.get('funds')) === 0
const onMoveFundsClick = () => onMoveFunds(token)
return (
<ListItem key={symbol} className={classNames(classes.nested, symbol)}>
<ListItemIcon>
<Img src={token.get('logoUri')} height={30} alt={name} />
</ListItemIcon>
<ListItemText primary={name} secondary={`${token.get('funds')} ${symbol}`} />
<Button variant="contained" color="primary" onClick={onMoveFundsClick} disabled={disabled}>
{MOVE_FUNDS_BUTTON_TEXT}
</Button>
</ListItem>
)
})}
</List>
</Collapse>
</React.Fragment>
)
})
export default withStyles(styles)(BalanceComponent)

View File

@ -1,36 +0,0 @@
// @flow
import * as React from 'react'
import ListItem from '@material-ui/core/ListItem'
import Avatar from '@material-ui/core/Avatar'
import DoneAll from '@material-ui/icons/DoneAll'
import ListItemText from '~/components/List/ListItemText'
import Button from '~/components/layout/Button'
type Props = {
confirmations: number,
onEditThreshold: () => void,
}
const EDIT_THRESHOLD_BUTTON_TEXT = 'EDIT'
const Confirmations = ({ confirmations, onEditThreshold }: Props) => (
<ListItem>
<Avatar>
<DoneAll />
</Avatar>
<ListItemText
primary="Confirmations"
secondary={`${confirmations} required confirmations per transaction`}
cut
/>
<Button
variant="contained"
color="primary"
onClick={onEditThreshold}
>
{EDIT_THRESHOLD_BUTTON_TEXT}
</Button>
</ListItem>
)
export default Confirmations

View File

@ -1,35 +0,0 @@
// @flow
import * as React from 'react'
import ListItem from '@material-ui/core/ListItem'
import Avatar from '@material-ui/core/Avatar'
import AcoountBalanceWallet from '@material-ui/icons/AccountBalanceWallet'
import Button from '~/components/layout/Button'
import ListItemText from '~/components/List/ListItemText'
type Props = {
onSeeTxs: () => void,
}
export const SEE_MULTISIG_BUTTON_TEXT = 'TXs'
const MultisigTransactionsComponent = ({ onSeeTxs }: Props) => {
const text = 'See multisig txs executed on this Safe'
return (
<ListItem>
<Avatar>
<AcoountBalanceWallet />
</Avatar>
<ListItemText primary="Safe's Multisig Transaction" secondary={text} />
<Button
variant="contained"
color="primary"
onClick={onSeeTxs}
>
{SEE_MULTISIG_BUTTON_TEXT}
</Button>
</ListItem>
)
}
export default MultisigTransactionsComponent

View File

@ -1,91 +0,0 @@
// @flow
import * as React from 'react'
import openHoc, { type Open } from '~/components/hoc/OpenHoc'
import { withStyles } from '@material-ui/core/styles'
import Collapse from '@material-ui/core/Collapse'
import ListItemText from '~/components/List/ListItemText'
import List from '@material-ui/core/List'
import ListItem from '@material-ui/core/ListItem'
import ListItemIcon from '@material-ui/core/ListItemIcon'
import Avatar from '@material-ui/core/Avatar'
import IconButton from '@material-ui/core/IconButton'
import Button from '~/components/layout/Button'
import Group from '@material-ui/icons/Group'
import Delete from '@material-ui/icons/Delete'
import Person from '@material-ui/icons/Person'
import ExpandLess from '@material-ui/icons/ExpandLess'
import ExpandMore from '@material-ui/icons/ExpandMore'
import { type OwnerProps } from '~/routes/safe/store/models/owner'
import { type WithStyles } from '~/theme/mui'
import { sameAddress } from '~/logic/wallets/ethAddresses'
const styles = {
nested: {
paddingLeft: '40px',
},
}
type Props = Open & WithStyles & {
owners: List<OwnerProps>,
userAddress: string,
onAddOwner: () => void,
onRemoveOwner: (name: string, addres: string) => void,
}
export const ADD_OWNER_BUTTON_TEXT = 'Add'
export const REMOVE_OWNER_BUTTON_TEXT = 'Delete'
const Owners = openHoc(({
open, toggle, owners, classes, onAddOwner, userAddress, onRemoveOwner,
}: Props) => (
<React.Fragment>
<ListItem onClick={toggle}>
<Avatar>
<Group />
</Avatar>
<ListItemText primary="Owners" secondary={`${owners.size} owners`} />
<ListItemIcon>
{open
? <IconButton disableRipple><ExpandLess /></IconButton>
: <IconButton disableRipple><ExpandMore /></IconButton>
}
</ListItemIcon>
<Button
variant="contained"
color="primary"
onClick={onAddOwner}
>
{ADD_OWNER_BUTTON_TEXT}
</Button>
</ListItem>
<Collapse in={open} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{owners.map((owner) => {
const onRemoveIconClick = () => onRemoveOwner(owner.name, owner.address)
return (
<ListItem key={owner.address} className={classes.nested}>
<ListItemIcon>
<Person />
</ListItemIcon>
<ListItemText
cut
primary={owner.name}
secondary={owner.address}
/>
{ !sameAddress(userAddress, owner.address)
&& (
<IconButton aria-label="Delete" onClick={onRemoveIconClick}>
<Delete />
</IconButton>
)
}
</ListItem>
)
})}
</List>
</Collapse>
</React.Fragment>
))
export default withStyles(styles)(Owners)

View File

@ -1,17 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="500" height="500" viewBox="0 0 500 500">
<defs>
<linearGradient id="a" x1="0%" y1="50.001%" y2="50.001%">
<stop offset="0%" stop-color="#00B3CE"/>
<stop offset="100%" stop-color="#00C8DD"/>
</linearGradient>
</defs>
<g fill="none" fill-rule="nonzero">
<circle cx="250" cy="250" r="250" fill="url(#a)"/>
<g fill="#FFF">
<path d="M189.93 245.903l-49.95-49.674c-4.508 6.049-7.237 13.563-7.237 21.674 0 19.583 15.972 35.52 35.528 35.52 8.118 0 15.66-2.715 21.66-7.52z"/>
<path d="M248.639 71.028c-52.077 0-100.542 21.097-136.063 58.694l-5.708 6.042 143.84 144.48v25.923l-.555.562-37.007-37.326c-17.18 11.423-39.153 14.757-59.903 7.528-34.924-12.653-53-50.882-40.361-85.5 1.826-5.41 4.52-10.23 7.52-14.743l-15.937-15.959-3.014 5.118c-16.555 27.104-25.597 58.403-25.597 90.59-.285 96.042 77.965 174.577 173.965 174.577h29.396V71.056l-30.576-.028zm-118.292 64.729c31.945-31.02 73.743-47.854 118.611-47.854h.285c.5 0 .993 0 1.465.02v168.5l-120.36-120.666z"/>
<path d="M248.639 71.028c-52.077 0-100.542 21.097-136.063 58.694l-5.708 6.042 143.84 144.48v25.923l-.555.562-37.007-37.326c-17.18 11.423-39.153 14.757-59.903 7.528-34.924-12.653-53-50.882-40.361-85.5 1.826-5.41 4.52-10.23 7.52-14.743l-15.937-15.959-3.014 5.118c-16.555 27.104-25.597 58.403-25.597 90.59-.285 96.042 77.965 174.577 173.965 174.577h29.396V71.056l-30.576-.028zm-118.292 64.729c31.945-31.02 73.743-47.854 118.611-47.854h.285c.5 0 .993 0 1.465.02v168.5l-120.36-120.666zM423.611 250.41c0-79.618-64.764-144.417-144.389-144.473v17.3c70.104.027 127.125 57.069 127.125 127.166 0 70.11-57.02 127.166-127.125 127.194v17.278c79.625-.035 144.39-64.813 144.39-144.465z"/>
<path d="M314.549 250.486l-6.855 42.98h33.577l-6.854-42.98c7.646-3.743 12.944-11.59 12.944-20.708 0-12.702-10.236-23-22.868-23-12.632 0-22.875 10.298-22.875 23-.014 9.132 5.285 16.965 12.93 20.708zM303.25 152.986c9.028 2.333 12.507-11.201 3.48-13.528-9.015-2.312-12.508 11.223-3.48 13.528M342.868 173.549c7.139 5.972 16.111-4.73 8.972-10.702-7.11-6-16.104 4.715-8.972 10.702M368.333 206.944c4.105 8.362 16.646 2.223 12.549-6.138-4.09-8.369-16.646-2.209-12.549 6.138M303.25 347.618c9.028-2.326 12.507 11.23 3.48 13.549-9.015 2.305-12.508-11.23-3.48-13.549M342.868 327.07c7.139-5.98 16.111 4.729 8.972 10.687-7.11 6.014-16.104-4.701-8.972-10.688M368.333 293.66c4.105-8.361 16.646-2.202 12.549 6.166-4.09 8.34-16.646 2.202-12.549-6.166M378.424 250.826c0 9.334 13.958 9.334 13.958 0 0-9.312-13.958-9.312-13.958 0"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -1,135 +0,0 @@
// @flow
import ListComponent from '@material-ui/core/List'
import * as React from 'react'
import { List } from 'immutable'
import Block from '~/components/layout/Block'
import Col from '~/components/layout/Col'
import Bold from '~/components/layout/Bold'
import Img from '~/components/layout/Img'
import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row'
import { type Safe } from '~/routes/safe/store/models/safe'
import { type Token } from '~/logic/tokens/store/model/token'
import Transactions from '~/routes/safe/components/Transactions'
import Threshold from '~/routes/safe/components/Threshold'
import AddOwner from '~/routes/safe/components/AddOwner'
import RemoveOwner from '~/routes/safe/components/RemoveOwner'
import SendToken from '~/routes/safe/components/SendToken'
import Address from './Address'
import BalanceInfo from './BalanceInfo'
import Owners from './Owners'
import Confirmations from './Confirmations'
import MultisigTx from './MultisigTx'
const safeIcon = require('./assets/gnosis_safe.svg')
type SafeProps = {
safe: Safe,
tokens: List<Token>,
userAddress: string,
}
type State = {
component?: React.Node,
}
const listStyle = {
width: '100%',
}
class GnoSafe extends React.PureComponent<SafeProps, State> {
state = {
component: undefined,
}
onListTransactions = () => {
const { safe } = this.props
this.setState({
component: (
<Transactions threshold={safe.get('threshold')} safeName={safe.get('name')} safeAddress={safe.get('address')} />
),
})
}
onEditThreshold = () => {
const { safe } = this.props
this.setState({
component: <Threshold numOwners={safe.get('owners').count()} safe={safe} onReset={this.onListTransactions} />,
})
}
onAddOwner = (e: SyntheticEvent<HTMLButtonElement>) => {
const { safe } = this.props
e.stopPropagation()
this.setState({ component: <AddOwner threshold={safe.get('threshold')} safe={safe} /> })
}
onRemoveOwner = (name: string, address: string) => {
const { safe } = this.props
this.setState({
component: (
<RemoveOwner
safeAddress={safe.get('address')}
threshold={safe.get('threshold')}
safe={safe}
name={name}
userToRemove={address}
/>
),
})
}
onMoveTokens = (ercToken: Token) => {
const { safe } = this.props
this.setState({
component: (
<SendToken safe={safe} token={ercToken} key={ercToken.get('address')} onReset={this.onListTransactions} />
),
})
}
render() {
const { safe, tokens, userAddress } = this.props
const { component } = this.state
const address = safe.get('address')
return (
<Row grow>
<Col sm={12} top="xs" md={5} margin="xl" overflow>
<ListComponent style={listStyle}>
<BalanceInfo tokens={tokens} onMoveFunds={this.onMoveTokens} safeAddress={address} />
<Owners
owners={safe.owners}
onAddOwner={this.onAddOwner}
userAddress={userAddress}
onRemoveOwner={this.onRemoveOwner}
/>
<Confirmations confirmations={safe.get('threshold')} onEditThreshold={this.onEditThreshold} />
<Address address={address} />
<MultisigTx onSeeTxs={this.onListTransactions} />
</ListComponent>
</Col>
<Col sm={12} center="xs" md={7} margin="xl" layout="column">
<Block margin="xl">
<Paragraph size="lg" noMargin align="right">
<Bold>{safe.name.toUpperCase()}</Bold>
</Paragraph>
</Block>
<Row grow>
<Col sm={12} center={component ? undefined : 'sm'} middle={component ? undefined : 'sm'} layout="column">
{component || <Img alt="Safe Icon" src={safeIcon} height={330} />}
</Col>
</Row>
</Col>
</Row>
)
}
}
export default GnoSafe

View File

@ -4,6 +4,7 @@ import { withStyles } from '@material-ui/core/styles'
import Block from '~/components/layout/Block'
import Col from '~/components/layout/Col'
import Field from '~/components/forms/Field'
import Heading from '~/components/layout/Heading'
import { composeValidators, required, minMaxLength } from '~/components/forms/validator'
import TextField from '~/components/forms/TextField'
import GnoForm from '~/components/forms/GnoForm'
@ -44,10 +45,8 @@ const ChangeSafeName = (props: Props) => {
{() => (
<React.Fragment>
<Block className={classes.formContainer}>
<Paragraph noMargin className={classes.title} size="lg" weight="bolder">
Modify Safe name
</Paragraph>
<Paragraph size="sm">
<Heading tag="h3">Modify Safe name</Heading>
<Paragraph>
You can change the name of this Safe. This name is only stored locally and never shared with Gnosis or
any third parties.
</Paragraph>

View File

@ -2,12 +2,8 @@
import { lg, sm, boldFont } from '~/theme/variables'
export const styles = () => ({
title: {
padding: `${lg} 0 20px`,
fontSize: '16px',
},
formContainer: {
padding: '0 20px',
padding: lg,
minHeight: '369px',
},
root: {

View File

@ -0,0 +1,171 @@
// @flow
import React, { useState, useEffect } from 'react'
import { List } from 'immutable'
import { withStyles } from '@material-ui/core/styles'
import { SharedSnackbarConsumer } from '~/components/SharedSnackBar'
import Modal from '~/components/Modal'
import { type Owner, makeOwner } from '~/routes/safe/store/models/owner'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
import { getOwners } from '~/logic/safe/utils'
import OwnerForm from './screens/OwnerForm'
import ThresholdForm from './screens/ThresholdForm'
import ReviewAddOwner from './screens/Review'
const styles = () => ({
biggerModalWindow: {
width: '775px',
minHeight: '500px',
position: 'static',
},
})
type Props = {
onClose: () => void,
classes: Object,
isOpen: boolean,
safeAddress: string,
safeName: string,
owners: List<Owner>,
threshold: number,
network: string,
updateSafe: Function,
createTransaction: Function,
}
type ActiveScreen = 'selectOwner' | 'selectThreshold' | 'reviewAddOwner'
export const sendAddOwner = async (
values: Object,
safeAddress: string,
ownersOld: List<Owner>,
openSnackbar: Function,
createTransaction: Function,
updateSafe: Function,
) => {
const gnosisSafe = await getGnosisSafeInstanceAt(safeAddress)
const txData = gnosisSafe.contract.methods.addOwnerWithThreshold(values.ownerAddress, values.threshold).encodeABI()
const txHash = await createTransaction(safeAddress, safeAddress, 0, txData, openSnackbar)
const owners = []
const storedOwners = await getOwners(safeAddress)
storedOwners.forEach((value, key) => owners.push(makeOwner({
address: key,
name: (values.ownerAddress.toLowerCase() === key.toLowerCase()) ? values.ownerName : value,
})))
const newOwnerIndex = List(owners).findIndex(o => o.address.toLowerCase() === values.ownerAddress.toLowerCase())
if (newOwnerIndex < 0) {
owners.push(makeOwner({ address: values.ownerAddress, name: values.ownerName }))
}
if (txHash) {
updateSafe({
address: safeAddress,
owners: List(owners),
})
}
}
const AddOwner = ({
onClose,
isOpen,
classes,
safeAddress,
safeName,
owners,
threshold,
network,
createTransaction,
updateSafe,
}: Props) => {
const [activeScreen, setActiveScreen] = useState<ActiveScreen>('selectOwner')
const [values, setValues] = useState<Object>({})
useEffect(
() => () => {
setActiveScreen('selectOwner')
setValues({})
},
[isOpen],
)
const onClickBack = () => {
if (activeScreen === 'reviewAddOwner') {
setActiveScreen('selectThreshold')
} else if (activeScreen === 'selectThreshold') {
setActiveScreen('selectOwner')
}
}
const ownerSubmitted = (newValues: Object) => {
setValues(stateValues => ({
...stateValues,
ownerName: newValues.ownerName,
ownerAddress: newValues.ownerAddress,
}))
setActiveScreen('selectThreshold')
}
const thresholdSubmitted = (newValues: Object) => {
setValues(stateValues => ({
...stateValues,
threshold: newValues.threshold,
}))
setActiveScreen('reviewAddOwner')
}
return (
<React.Fragment>
<SharedSnackbarConsumer>
{({ openSnackbar }) => {
const onAddOwner = async () => {
onClose()
try {
sendAddOwner(values, safeAddress, owners, openSnackbar, createTransaction, updateSafe)
} catch (error) {
// eslint-disable-next-line
console.log('Error while removing an owner ' + error)
}
}
return (
<Modal
title="Add owner to Safe"
description="Add owner to Safe"
handleClose={onClose}
open={isOpen}
paperClassName={classes.biggerModalWindow}
>
<React.Fragment>
{activeScreen === 'selectOwner' && (
<OwnerForm onClose={onClose} onSubmit={ownerSubmitted} owners={owners} />
)}
{activeScreen === 'selectThreshold' && (
<ThresholdForm
onClose={onClose}
owners={owners}
threshold={threshold}
onClickBack={onClickBack}
onSubmit={thresholdSubmitted}
/>
)}
{activeScreen === 'reviewAddOwner' && (
<ReviewAddOwner
onClose={onClose}
safeName={safeName}
owners={owners}
network={network}
values={values}
onClickBack={onClickBack}
onSubmit={onAddOwner}
/>
)}
</React.Fragment>
</Modal>
)
}}
</SharedSnackbarConsumer>
</React.Fragment>
)
}
export default withStyles(styles)(AddOwner)

View File

@ -0,0 +1,116 @@
// @flow
import React from 'react'
import { List } from 'immutable'
import { withStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close'
import IconButton from '@material-ui/core/IconButton'
import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row'
import GnoForm from '~/components/forms/GnoForm'
import Col from '~/components/layout/Col'
import Button from '~/components/layout/Button'
import Block from '~/components/layout/Block'
import Hairline from '~/components/layout/Hairline'
import Field from '~/components/forms/Field'
import TextField from '~/components/forms/TextField'
import { type Owner } from '~/routes/safe/store/models/owner'
import {
composeValidators,
required,
mustBeEthereumAddress,
minMaxLength,
uniqueAddress,
} from '~/components/forms/validator'
import { styles } from './style'
export const ADD_OWNER_NAME_INPUT_TESTID = 'add-owner-name-input'
export const ADD_OWNER_ADDRESS_INPUT_TESTID = 'add-owner-address-testid'
export const ADD_OWNER_NEXT_BTN_TESTID = 'add-owner-next-btn'
type Props = {
onClose: () => void,
classes: Object,
onSubmit: Function,
owners: List<Owner>,
}
const OwnerForm = ({
classes, onClose, onSubmit, owners,
}: Props) => {
const handleSubmit = (values) => {
onSubmit(values)
}
const ownerDoesntExist = uniqueAddress(owners.map(o => o.address))
return (
<React.Fragment>
<Row align="center" grow className={classes.heading}>
<Paragraph weight="bolder" className={classes.manage} noMargin>
Add new owner
</Paragraph>
<Paragraph className={classes.annotation}>1 of 3</Paragraph>
<IconButton onClick={onClose} disableRipple>
<Close className={classes.closeIcon} />
</IconButton>
</Row>
<Hairline />
<GnoForm onSubmit={handleSubmit}>
{() => (
<React.Fragment>
<Block className={classes.formContainer}>
<Row margin="md">
<Paragraph>Add a new owner to the active Safe</Paragraph>
</Row>
<Row margin="md">
<Col xs={8}>
<Field
name="ownerName"
component={TextField}
type="text"
validate={composeValidators(required, minMaxLength(1, 50))}
placeholder="Owner name*"
text="Owner name*"
className={classes.addressInput}
testId={ADD_OWNER_NAME_INPUT_TESTID}
/>
</Col>
</Row>
<Row margin="md">
<Col xs={8}>
<Field
name="ownerAddress"
component={TextField}
type="text"
validate={composeValidators(required, mustBeEthereumAddress, ownerDoesntExist)}
placeholder="Owner address*"
text="Owner address*"
className={classes.addressInput}
testId={ADD_OWNER_ADDRESS_INPUT_TESTID}
/>
</Col>
</Row>
</Block>
<Hairline />
<Row align="center" className={classes.buttonRow}>
<Button className={classes.button} minWidth={140} onClick={onClose}>
Cancel
</Button>
<Button
type="submit"
className={classes.button}
variant="contained"
minWidth={140}
color="primary"
testId={ADD_OWNER_NEXT_BTN_TESTID}
>
Next
</Button>
</Row>
</React.Fragment>
)}
</GnoForm>
</React.Fragment>
)
}
export default withStyles(styles)(OwnerForm)

View File

@ -0,0 +1,32 @@
// @flow
import { lg, md, sm } from '~/theme/variables'
export const styles = () => ({
heading: {
padding: `${sm} ${lg}`,
justifyContent: 'flex-start',
boxSizing: 'border-box',
maxHeight: '75px',
},
annotation: {
letterSpacing: '-1px',
color: '#a2a8ba',
marginRight: 'auto',
marginLeft: '20px',
},
manage: {
fontSize: '24px',
},
closeIcon: {
height: '35px',
width: '35px',
},
formContainer: {
padding: `${md} ${lg}`,
minHeight: '340px',
},
buttonRow: {
height: '84px',
justifyContent: 'center',
},
})

View File

@ -0,0 +1,176 @@
// @flow
import React from 'react'
import { List } from 'immutable'
import classNames from 'classnames'
import { withStyles } from '@material-ui/core/styles'
import OpenInNew from '@material-ui/icons/OpenInNew'
import Close from '@material-ui/icons/Close'
import IconButton from '@material-ui/core/IconButton'
import Identicon from '~/components/Identicon'
import Link from '~/components/layout/Link'
import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row'
import Col from '~/components/layout/Col'
import Button from '~/components/layout/Button'
import Block from '~/components/layout/Block'
import Hairline from '~/components/layout/Hairline'
import type { Owner } from '~/routes/safe/store/models/owner'
import { getEtherScanLink } from '~/logic/wallets/getWeb3'
import { secondary } from '~/theme/variables'
import { styles } from './style'
export const ADD_OWNER_SUBMIT_BTN_TESTID = 'add-owner-submit-btn'
const openIconStyle = {
height: '16px',
color: secondary,
}
type Props = {
onClose: () => void,
classes: Object,
safeName: string,
owners: List<Owner>,
network: string,
values: Object,
onClickBack: Function,
onSubmit: Function,
}
const ReviewAddOwner = ({
classes, onClose, safeName, owners, network, values, onClickBack, onSubmit,
}: Props) => {
const handleSubmit = () => {
onSubmit()
}
return (
<React.Fragment>
<Row align="center" grow className={classes.heading}>
<Paragraph weight="bolder" className={classes.manage} noMargin>
Add new owner
</Paragraph>
<Paragraph className={classes.annotation}>3 of 3</Paragraph>
<IconButton onClick={onClose} disableRipple>
<Close className={classes.closeIcon} />
</IconButton>
</Row>
<Hairline />
<Block className={classes.formContainer}>
<Row className={classes.root}>
<Col xs={4} layout="column">
<Block className={classes.details}>
<Block margin="lg">
<Paragraph size="lg" color="primary" noMargin>
Details
</Paragraph>
</Block>
<Block margin="lg">
<Paragraph size="sm" color="disabled" noMargin>
Safe name
</Paragraph>
<Paragraph size="lg" color="primary" noMargin weight="bolder" className={classes.name}>
{safeName}
</Paragraph>
</Block>
<Block margin="lg">
<Paragraph size="sm" color="disabled" noMargin>
Any transaction requires the confirmation of:
</Paragraph>
<Paragraph size="lg" color="primary" noMargin weight="bolder" className={classes.name}>
{values.threshold}
{' '}
out of
{' '}
{owners.size + 1}
{' '}
owner(s)
</Paragraph>
</Block>
</Block>
</Col>
<Col xs={8} layout="column" className={classes.owners}>
<Row className={classes.ownersTitle}>
<Paragraph size="lg" color="primary" noMargin>
{owners.size + 1}
{' '}
Safe owner(s)
</Paragraph>
</Row>
<Hairline />
{owners.map(owner => (
<React.Fragment key={owner.address}>
<Row className={classes.owner}>
<Col xs={1} align="center">
<Identicon address={owner.address} diameter={32} />
</Col>
<Col xs={11}>
<Block className={classNames(classes.name, classes.userName)}>
<Paragraph weight="bolder" size="lg" noMargin>
{owner.name}
</Paragraph>
<Block align="center" className={classes.user}>
<Paragraph size="md" color="disabled" noMargin>
{owner.address}
</Paragraph>
<Link className={classes.open} to={getEtherScanLink(owner.address, network)} target="_blank">
<OpenInNew style={openIconStyle} />
</Link>
</Block>
</Block>
</Col>
</Row>
<Hairline />
</React.Fragment>
))}
<Row className={classes.info} align="center">
<Paragraph weight="bolder" noMargin color="primary" size="md">
ADDING NEW OWNER &darr;
</Paragraph>
</Row>
<Hairline />
<Row className={classes.selectedOwner}>
<Col xs={1} align="center">
<Identicon address={values.ownerAddress} diameter={32} />
</Col>
<Col xs={11}>
<Block className={classNames(classes.name, classes.userName)}>
<Paragraph weight="bolder" size="lg" noMargin>
{values.ownerName}
</Paragraph>
<Block align="center" className={classes.user}>
<Paragraph size="md" color="disabled" noMargin>
{values.ownerAddress}
</Paragraph>
<Link className={classes.open} to={getEtherScanLink(values.ownerAddress, network)} target="_blank">
<OpenInNew style={openIconStyle} />
</Link>
</Block>
</Block>
</Col>
</Row>
<Hairline />
</Col>
</Row>
</Block>
<Hairline />
<Row align="center" className={classes.buttonRow}>
<Button className={classes.button} minWidth={140} onClick={onClickBack}>
Back
</Button>
<Button
type="submit"
onClick={handleSubmit}
className={classes.button}
variant="contained"
minWidth={140}
color="primary"
testId={ADD_OWNER_SUBMIT_BTN_TESTID}
>
Submit
</Button>
</Row>
</React.Fragment>
)
}
export default withStyles(styles)(ReviewAddOwner)

View File

@ -0,0 +1,78 @@
// @flow
import {
lg, sm, border, background,
} from '~/theme/variables'
export const styles = () => ({
root: {
height: '372px',
},
heading: {
padding: `${sm} ${lg}`,
justifyContent: 'flex-start',
boxSizing: 'border-box',
maxHeight: '75px',
},
annotation: {
letterSpacing: '-1px',
color: '#a2a8ba',
marginRight: 'auto',
marginLeft: '20px',
},
manage: {
fontSize: '24px',
},
closeIcon: {
height: '35px',
width: '35px',
},
info: {
backgroundColor: background,
padding: sm,
justifyContent: 'center',
textAlign: 'center',
flexDirection: 'column',
},
buttonRow: {
height: '84px',
justifyContent: 'center',
},
details: {
padding: lg,
borderRight: `solid 1px ${border}`,
height: '100%',
},
owners: {
overflow: 'auto',
height: '100%',
},
ownersTitle: {
padding: lg,
},
owner: {
padding: sm,
alignItems: 'center',
},
name: {
textOverflow: 'ellipsis',
overflow: 'hidden',
},
userName: {
whiteSpace: 'nowrap',
},
selectedOwner: {
padding: sm,
alignItems: 'center',
backgroundColor: '#fff3e2',
},
user: {
justifyContent: 'left',
},
open: {
paddingLeft: sm,
width: 'auto',
'&:hover': {
cursor: 'pointer',
},
},
})

View File

@ -0,0 +1,125 @@
// @flow
import React from 'react'
import { List } from 'immutable'
import { withStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close'
import IconButton from '@material-ui/core/IconButton'
import MenuItem from '@material-ui/core/MenuItem'
import SelectField from '~/components/forms/SelectField'
import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row'
import GnoForm from '~/components/forms/GnoForm'
import Col from '~/components/layout/Col'
import Button from '~/components/layout/Button'
import Block from '~/components/layout/Block'
import Hairline from '~/components/layout/Hairline'
import Field from '~/components/forms/Field'
import type { Owner } from '~/routes/safe/store/models/owner'
import {
composeValidators, required, minValue, maxValue, mustBeInteger,
} from '~/components/forms/validator'
import { styles } from './style'
export const ADD_OWNER_THRESHOLD_NEXT_BTN_TESTID = 'add-owner-threshold-next-btn'
type Props = {
onClose: () => void,
classes: Object,
owners: List<Owner>,
threshold: number,
onClickBack: Function,
onSubmit: Function,
}
const ThresholdForm = ({
classes, onClose, owners, threshold, onClickBack, onSubmit,
}: Props) => {
const handleSubmit = (values) => {
onSubmit(values)
}
return (
<React.Fragment>
<Row align="center" grow className={classes.heading}>
<Paragraph weight="bolder" className={classes.manage} noMargin>
Add new owner
</Paragraph>
<Paragraph className={classes.annotation}>2 of 3</Paragraph>
<IconButton onClick={onClose} disableRipple>
<Close className={classes.closeIcon} />
</IconButton>
</Row>
<Hairline />
<GnoForm onSubmit={handleSubmit} initialValues={{ threshold: threshold.toString() }}>
{() => (
<React.Fragment>
<Block className={classes.formContainer}>
<Row>
<Paragraph weight="bolder" className={classes.headingText}>
Set the required owner confirmations:
</Paragraph>
</Row>
<Row>
<Paragraph weight="bolder">
Any transaction over any daily limit requires the confirmation of:
</Paragraph>
</Row>
<Row margin="xl" align="center" className={classes.inputRow}>
<Col xs={2}>
<Field
name="threshold"
render={props => (
<React.Fragment>
<SelectField {...props} disableError>
{[...Array(Number(owners.size + 1))].map((x, index) => (
<MenuItem key={index} value={`${index + 1}`}>
{index + 1}
</MenuItem>
))}
</SelectField>
{props.meta.error && props.meta.touched && (
<Paragraph className={classes.errorText} noMargin color="error">
{props.meta.error}
</Paragraph>
)}
</React.Fragment>
)}
validate={composeValidators(required, mustBeInteger, minValue(1), maxValue(owners.size + 1))}
data-testid="threshold-select-input"
/>
</Col>
<Col xs={10}>
<Paragraph size="lg" color="primary" noMargin className={classes.ownersText}>
out of
{' '}
{owners.size + 1}
{' '}
owner(s)
</Paragraph>
</Col>
</Row>
</Block>
<Hairline />
<Row align="center" className={classes.buttonRow}>
<Button className={classes.button} minWidth={140} onClick={onClickBack}>
Back
</Button>
<Button
type="submit"
className={classes.button}
variant="contained"
minWidth={140}
color="primary"
testId={ADD_OWNER_THRESHOLD_NEXT_BTN_TESTID}
>
Review
</Button>
</Row>
</React.Fragment>
)}
</GnoForm>
</React.Fragment>
)
}
export default withStyles(styles)(ThresholdForm)

View File

@ -0,0 +1,45 @@
// @flow
import { lg, md, sm } from '~/theme/variables'
export const styles = () => ({
heading: {
padding: `${sm} ${lg}`,
justifyContent: 'flex-start',
boxSizing: 'border-box',
maxHeight: '75px',
},
annotation: {
letterSpacing: '-1px',
color: '#a2a8ba',
marginRight: 'auto',
marginLeft: '20px',
},
manage: {
fontSize: '24px',
},
closeIcon: {
height: '35px',
width: '35px',
},
headingText: {
fontSize: '16px',
},
formContainer: {
padding: `${md} ${lg}`,
minHeight: '340px',
},
ownersText: {
marginLeft: sm,
},
buttonRow: {
height: '84px',
justifyContent: 'center',
},
inputRow: {
position: 'relative',
},
errorText: {
position: 'absolute',
bottom: '-25px',
},
})

View File

@ -0,0 +1,135 @@
// @flow
import React from 'react'
import { List } from 'immutable'
import { withStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close'
import OpenInNew from '@material-ui/icons/OpenInNew'
import IconButton from '@material-ui/core/IconButton'
import Row from '~/components/layout/Row'
import Link from '~/components/layout/Link'
import Block from '~/components/layout/Block'
import GnoForm from '~/components/forms/GnoForm'
import Button from '~/components/layout/Button'
import Hairline from '~/components/layout/Hairline'
import Field from '~/components/forms/Field'
import TextField from '~/components/forms/TextField'
import Paragraph from '~/components/layout/Paragraph'
import Identicon from '~/components/Identicon'
import { getEtherScanLink } from '~/logic/wallets/getWeb3'
import type { Owner } from '~/routes/safe/store/models/owner'
import { composeValidators, required, minMaxLength } from '~/components/forms/validator'
import Modal from '~/components/Modal'
import { styles } from './style'
import { secondary } from '~/theme/variables'
export const RENAME_OWNER_INPUT_TESTID = 'rename-owner-input'
export const SAVE_OWNER_CHANGES_BTN_TESTID = 'save-owner-changes-btn'
const openIconStyle = {
height: '16px',
color: secondary,
}
const stylesModal = () => ({
smallerModalWindow: {
height: 'auto',
position: 'static',
},
})
type Props = {
onClose: () => void,
classes: Object,
isOpen: boolean,
safeAddress: string,
ownerAddress: string,
owners: List<Owner>,
network: string,
selectedOwnerName: string,
updateSafe: Function,
}
const EditOwnerComponent = ({
onClose,
isOpen,
classes,
safeAddress,
ownerAddress,
selectedOwnerName,
updateSafe,
owners,
network,
}: Props) => {
const handleSubmit = (values) => {
const ownerToUpdateIndex = owners.findIndex(o => o.address === ownerAddress)
const updatedOwners = owners.update(ownerToUpdateIndex, owner => owner.set('name', values.ownerName))
updateSafe({ address: safeAddress, owners: updatedOwners })
onClose()
}
return (
<Modal
title="Edit owner from Safe"
description="Edit owner from Safe"
handleClose={onClose}
open={isOpen}
paperClassName={stylesModal.smallerModalWindow}
>
<Row align="center" grow className={classes.heading}>
<Paragraph className={classes.manage} noMargin weight="bolder">
Edit owner name
</Paragraph>
<IconButton onClick={onClose} disableRipple>
<Close className={classes.close} />
</IconButton>
</Row>
<Hairline />
<GnoForm onSubmit={handleSubmit}>
{() => (
<React.Fragment>
<Block className={classes.container}>
<Row margin="md">
<Field
name="ownerName"
component={TextField}
type="text"
validate={composeValidators(required, minMaxLength(1, 50))}
placeholder="Owner name*"
text="Owner name*"
initialValue={selectedOwnerName}
className={classes.addressInput}
testId={RENAME_OWNER_INPUT_TESTID}
/>
</Row>
<Row>
<Block align="center" className={classes.user}>
<Identicon address={ownerAddress} diameter={32} />
<Paragraph style={{ marginLeft: 10 }} size="md" color="disabled" noMargin>
{ownerAddress}
</Paragraph>
<Link className={classes.open} to={getEtherScanLink(ownerAddress, network)} target="_blank">
<OpenInNew style={openIconStyle} />
</Link>
</Block>
</Row>
</Block>
<Hairline />
<Row align="center" className={classes.buttonRow}>
<Button className={classes.button} minWidth={140} onClick={onClose}>
Cancel
</Button>
<Button type="submit" className={classes.button} variant="contained" minWidth={140} color="primary" testId={SAVE_OWNER_CHANGES_BTN_TESTID}>
Save
</Button>
</Row>
</React.Fragment>
)}
</GnoForm>
</Modal>
)
}
const EditOwnerModal = withStyles(styles)(EditOwnerComponent)
export default EditOwnerModal

View File

@ -0,0 +1,39 @@
// @flow
import {
lg, md, sm, error,
} from '~/theme/variables'
export const styles = () => ({
heading: {
padding: `${sm} ${lg}`,
justifyContent: 'space-between',
maxHeight: '75px',
boxSizing: 'border-box',
},
manage: {
fontSize: '24px',
},
container: {
padding: `${md} ${lg}`,
paddingBottom: '40px',
},
close: {
height: '35px',
width: '35px',
},
buttonRow: {
height: '84px',
justifyContent: 'center',
},
buttonEdit: {
color: '#fff',
backgroundColor: error,
},
open: {
paddingLeft: sm,
width: 'auto',
'&:hover': {
cursor: 'pointer',
},
},
})

View File

@ -0,0 +1,21 @@
// @flow
import * as React from 'react'
import Block from '~/components/layout/Block'
import Paragraph from '~/components/layout/Paragraph'
import Identicon from '~/components/Identicon'
type Props = {
address: string,
}
const OwnerAddressTableCell = (props: Props) => {
const { address } = props
return (
<Block align="left">
<Identicon address={address} diameter={32} />
<Paragraph style={{ marginLeft: 10 }}>{address}</Paragraph>
</Block>
)
}
export default OwnerAddressTableCell

View File

@ -0,0 +1,188 @@
// @flow
import React, { useState, useEffect } from 'react'
import { List } from 'immutable'
import { withStyles } from '@material-ui/core/styles'
import { SharedSnackbarConsumer } from '~/components/SharedSnackBar'
import Modal from '~/components/Modal'
import { type Owner, makeOwner } from '~/routes/safe/store/models/owner'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
import { getOwners } from '~/logic/safe/utils'
import CheckOwner from './screens/CheckOwner'
import ThresholdForm from './screens/ThresholdForm'
import ReviewRemoveOwner from './screens/Review'
const styles = () => ({
biggerModalWindow: {
width: '775px',
minHeight: '500px',
position: 'static',
},
})
type Props = {
onClose: () => void,
classes: Object,
isOpen: boolean,
safeAddress: string,
safeName: string,
ownerAddress: string,
ownerName: string,
owners: List<Owner>,
threshold: number,
network: string,
createTransaction: Function,
updateSafe: Function,
}
type ActiveScreen = 'checkOwner' | 'selectThreshold' | 'reviewRemoveOwner'
const SENTINEL_ADDRESS = '0x0000000000000000000000000000000000000001'
export const sendRemoveOwner = async (
values: Object,
safeAddress: string,
ownerAddressToRemove: string,
ownerNameToRemove: string,
ownersOld: List<Owner>,
openSnackbar: Function,
createTransaction: Function,
updateSafe: Function,
) => {
const gnosisSafe = await getGnosisSafeInstanceAt(safeAddress)
const safeOwners = await gnosisSafe.getOwners()
const index = safeOwners.findIndex(ownerAddress => ownerAddress.toLowerCase() === ownerAddressToRemove.toLowerCase())
const prevAddress = index === 0 ? SENTINEL_ADDRESS : safeOwners[index - 1]
const txData = gnosisSafe.contract.methods
.removeOwner(prevAddress, ownerAddressToRemove, values.threshold)
.encodeABI()
const txHash = await createTransaction(safeAddress, safeAddress, 0, txData, openSnackbar)
let owners = []
const storedOwners = await getOwners(safeAddress)
storedOwners.forEach((value, key) => owners.push(makeOwner({ address: key, name: value })))
owners = List(owners).filter(o => o.address.toLowerCase() !== ownerAddressToRemove.toLowerCase())
if (txHash) {
updateSafe({
address: safeAddress,
owners,
})
}
}
const RemoveOwner = ({
onClose,
isOpen,
classes,
safeAddress,
safeName,
ownerAddress,
ownerName,
owners,
threshold,
network,
createTransaction,
updateSafe,
}: Props) => {
const [activeScreen, setActiveScreen] = useState<ActiveScreen>('checkOwner')
const [values, setValues] = useState<Object>({})
useEffect(
() => () => {
setActiveScreen('checkOwner')
setValues({})
},
[isOpen],
)
const onClickBack = () => {
if (activeScreen === 'reviewRemoveOwner') {
setActiveScreen('selectThreshold')
} else if (activeScreen === 'selectThreshold') {
setActiveScreen('checkOwner')
}
}
const ownerSubmitted = () => {
setActiveScreen('selectThreshold')
}
const thresholdSubmitted = (newValues: Object) => {
values.threshold = newValues.threshold
setValues(values)
setActiveScreen('reviewRemoveOwner')
}
return (
<React.Fragment>
<SharedSnackbarConsumer>
{({ openSnackbar }) => {
const onRemoveOwner = () => {
onClose()
try {
sendRemoveOwner(
values,
safeAddress,
ownerAddress,
ownerName,
owners,
openSnackbar,
createTransaction,
updateSafe,
)
} catch (error) {
// eslint-disable-next-line
console.log('Error while removing an owner ' + error)
}
}
return (
<Modal
title="Remove owner from Safe"
description="Remove owner from Safe"
handleClose={onClose}
open={isOpen}
paperClassName={classes.biggerModalWindow}
>
<React.Fragment>
{activeScreen === 'checkOwner' && (
<CheckOwner
onClose={onClose}
ownerAddress={ownerAddress}
ownerName={ownerName}
network={network}
onSubmit={ownerSubmitted}
/>
)}
{activeScreen === 'selectThreshold' && (
<ThresholdForm
onClose={onClose}
owners={owners}
threshold={threshold}
onClickBack={onClickBack}
onSubmit={thresholdSubmitted}
/>
)}
{activeScreen === 'reviewRemoveOwner' && (
<ReviewRemoveOwner
onClose={onClose}
safeName={safeName}
owners={owners}
network={network}
values={values}
ownerAddress={ownerAddress}
ownerName={ownerName}
onClickBack={onClickBack}
onSubmit={onRemoveOwner}
/>
)}
</React.Fragment>
</Modal>
)
}}
</SharedSnackbarConsumer>
</React.Fragment>
)
}
export default withStyles(styles)(RemoveOwner)

View File

@ -0,0 +1,107 @@
// @flow
import React from 'react'
import classNames from 'classnames/bind'
import { withStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close'
import IconButton from '@material-ui/core/IconButton'
import OpenInNew from '@material-ui/icons/OpenInNew'
import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row'
import Col from '~/components/layout/Col'
import Button from '~/components/layout/Button'
import Block from '~/components/layout/Block'
import Hairline from '~/components/layout/Hairline'
import Link from '~/components/layout/Link'
import Identicon from '~/components/Identicon'
import { getEtherScanLink } from '~/logic/wallets/getWeb3'
import { styles } from './style'
import { secondary } from '~/theme/variables'
export const REMOVE_OWNER_MODAL_NEXT_BTN_TESTID = 'remove-owner-next-btn'
const openIconStyle = {
height: '16px',
color: secondary,
}
type Props = {
onClose: () => void,
classes: Object,
ownerAddress: string,
ownerName: string,
network: string,
onSubmit: Function,
}
const CheckOwner = ({
classes,
onClose,
ownerAddress,
ownerName,
network,
onSubmit,
}: Props) => {
const handleSubmit = (values) => {
onSubmit(values)
}
return (
<React.Fragment>
<Row align="center" grow className={classes.heading}>
<Paragraph weight="bolder" className={classes.manage} noMargin>
Remove owner
</Paragraph>
<Paragraph className={classes.annotation}>1 of 3</Paragraph>
<IconButton onClick={onClose} disableRipple>
<Close className={classes.closeIcon} />
</IconButton>
</Row>
<Hairline />
<Block className={classes.formContainer}>
<Row margin="md">
<Paragraph>
Review the owner you want to remove from the active Safe:
</Paragraph>
</Row>
<Row className={classes.owner}>
<Col xs={1} align="center">
<Identicon address={ownerAddress} diameter={32} />
</Col>
<Col xs={7}>
<Block className={classNames(classes.name, classes.userName)}>
<Paragraph size="lg" noMargin weight="bolder">
{ownerName}
</Paragraph>
<Block align="center" className={classes.user}>
<Paragraph size="md" color="disabled" noMargin>
{ownerAddress}
</Paragraph>
<Link className={classes.open} to={getEtherScanLink(ownerAddress, network)} target="_blank">
<OpenInNew style={openIconStyle} />
</Link>
</Block>
</Block>
</Col>
</Row>
</Block>
<Hairline />
<Row align="center" className={classes.buttonRow}>
<Button className={classes.button} minWidth={140} onClick={onClose}>
Cancel
</Button>
<Button
type="submit"
variant="contained"
minWidth={140}
color="primary"
onClick={handleSubmit}
testId={REMOVE_OWNER_MODAL_NEXT_BTN_TESTID}
>
Next
</Button>
</Row>
</React.Fragment>
)
}
export default withStyles(styles)(CheckOwner)

View File

@ -0,0 +1,45 @@
// @flow
import { lg, md, sm } from '~/theme/variables'
export const styles = () => ({
heading: {
padding: `${sm} ${lg}`,
justifyContent: 'flex-start',
boxSizing: 'border-box',
maxHeight: '75px',
},
annotation: {
letterSpacing: '-1px',
color: '#a2a8ba',
marginRight: 'auto',
marginLeft: '20px',
},
manage: {
fontSize: '24px',
},
closeIcon: {
height: '35px',
width: '35px',
},
formContainer: {
padding: `${md} ${lg}`,
minHeight: '340px',
},
buttonRow: {
height: '84px',
justifyContent: 'center',
},
name: {
textOverflow: 'ellipsis',
overflow: 'hidden',
},
userName: {
whiteSpace: 'nowrap',
},
owner: {
alignItems: 'center',
},
user: {
justifyContent: 'left',
},
})

View File

@ -0,0 +1,194 @@
// @flow
import React from 'react'
import { List } from 'immutable'
import classNames from 'classnames'
import { withStyles } from '@material-ui/core/styles'
import OpenInNew from '@material-ui/icons/OpenInNew'
import Close from '@material-ui/icons/Close'
import IconButton from '@material-ui/core/IconButton'
import Identicon from '~/components/Identicon'
import Link from '~/components/layout/Link'
import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row'
import Col from '~/components/layout/Col'
import Button from '~/components/layout/Button'
import Block from '~/components/layout/Block'
import Hairline from '~/components/layout/Hairline'
import type { Owner } from '~/routes/safe/store/models/owner'
import { getEtherScanLink } from '~/logic/wallets/getWeb3'
import { secondary } from '~/theme/variables'
import { styles } from './style'
export const REMOVE_OWNER_REVIEW_BTN_TESTID = 'remove-owner-review-btn'
const openIconStyle = {
height: '16px',
color: secondary,
}
type Props = {
onClose: () => void,
classes: Object,
safeName: string,
owners: List<Owner>,
network: string,
values: Object,
ownerAddress: string,
ownerName: string,
onClickBack: Function,
onSubmit: Function,
}
const ReviewRemoveOwner = ({
classes,
onClose,
safeName,
owners,
network,
values,
ownerAddress,
ownerName,
onClickBack,
onSubmit,
}: Props) => {
const handleSubmit = () => {
onSubmit()
}
return (
<React.Fragment>
<Row align="center" grow className={classes.heading}>
<Paragraph weight="bolder" className={classes.manage} noMargin>
Remove owner
</Paragraph>
<Paragraph className={classes.annotation}>3 of 3</Paragraph>
<IconButton onClick={onClose} disableRipple>
<Close className={classes.closeIcon} />
</IconButton>
</Row>
<Hairline />
<Block>
<Row className={classes.root}>
<Col xs={4} layout="column">
<Block className={classes.details}>
<Block margin="lg">
<Paragraph size="lg" color="primary" noMargin>
Details
</Paragraph>
</Block>
<Block margin="lg">
<Paragraph size="sm" color="disabled" noMargin>
Safe name
</Paragraph>
<Paragraph size="lg" color="primary" noMargin weight="bolder" className={classes.name}>
{safeName}
</Paragraph>
</Block>
<Block margin="lg">
<Paragraph size="sm" color="disabled" noMargin>
Any transaction requires the confirmation of:
</Paragraph>
<Paragraph size="lg" color="primary" noMargin weight="bolder" className={classes.name}>
{values.threshold}
{' '}
out of
{' '}
{owners.size - 1}
{' '}
owner(s)
</Paragraph>
</Block>
</Block>
</Col>
<Col xs={8} layout="column" className={classes.owners}>
<Row className={classes.ownersTitle}>
<Paragraph size="lg" color="primary" noMargin>
{owners.size - 1}
{' '}
Safe owner(s)
</Paragraph>
</Row>
<Hairline />
{owners.map(
owner => owner.address !== ownerAddress && (
<React.Fragment key={owner.address}>
<Row className={classes.owner}>
<Col xs={1} align="center">
<Identicon address={owner.address} diameter={32} />
</Col>
<Col xs={11}>
<Block className={classNames(classes.name, classes.userName)}>
<Paragraph weight="bolder" size="lg" noMargin>
{owner.name}
</Paragraph>
<Block align="center" className={classes.user}>
<Paragraph size="md" color="disabled" noMargin>
{owner.address}
</Paragraph>
<Link
className={classes.open}
to={getEtherScanLink(owner.address, network)}
target="_blank"
>
<OpenInNew style={openIconStyle} />
</Link>
</Block>
</Block>
</Col>
</Row>
<Hairline />
</React.Fragment>
),
)}
<Row className={classes.info} align="center">
<Paragraph weight="bolder" noMargin color="primary" size="md">
REMOVING OWNER &darr;
</Paragraph>
</Row>
<Hairline />
<Row className={classes.selectedOwner}>
<Col xs={1} align="center">
<Identicon address={ownerAddress} diameter={32} />
</Col>
<Col xs={11}>
<Block className={classNames(classes.name, classes.userName)}>
<Paragraph weight="bolder" size="lg" noMargin>
{ownerName}
</Paragraph>
<Block align="center" className={classes.user}>
<Paragraph size="md" color="disabled" noMargin>
{ownerAddress}
</Paragraph>
<Link className={classes.open} to={getEtherScanLink(ownerAddress, network)} target="_blank">
<OpenInNew style={openIconStyle} />
</Link>
</Block>
</Block>
</Col>
</Row>
<Hairline />
</Col>
</Row>
</Block>
<Hairline />
<Row align="center" className={classes.buttonRow}>
<Button className={classes.button} minWidth={140} onClick={onClickBack}>
Back
</Button>
<Button
type="submit"
onClick={handleSubmit}
className={classes.button}
variant="contained"
minWidth={140}
color="primary"
testId={REMOVE_OWNER_REVIEW_BTN_TESTID}
>
Submit
</Button>
</Row>
</React.Fragment>
)
}
export default withStyles(styles)(ReviewRemoveOwner)

View File

@ -0,0 +1,78 @@
// @flow
import {
lg, sm, border, background,
} from '~/theme/variables'
export const styles = () => ({
root: {
height: '372px',
},
heading: {
padding: `${sm} ${lg}`,
justifyContent: 'flex-start',
boxSizing: 'border-box',
maxHeight: '75px',
},
annotation: {
letterSpacing: '-1px',
color: '#a2a8ba',
marginRight: 'auto',
marginLeft: '20px',
},
manage: {
fontSize: '24px',
},
closeIcon: {
height: '35px',
width: '35px',
},
info: {
backgroundColor: background,
padding: sm,
justifyContent: 'center',
textAlign: 'center',
flexDirection: 'column',
},
buttonRow: {
height: '84px',
justifyContent: 'center',
},
details: {
padding: lg,
borderRight: `solid 1px ${border}`,
height: '100%',
},
owners: {
overflow: 'auto',
height: '100%',
},
ownersTitle: {
padding: lg,
},
owner: {
padding: sm,
alignItems: 'center',
},
name: {
textOverflow: 'ellipsis',
overflow: 'hidden',
},
userName: {
whiteSpace: 'nowrap',
},
selectedOwner: {
padding: sm,
alignItems: 'center',
backgroundColor: '#ffe6ea',
},
user: {
justifyContent: 'left',
},
open: {
paddingLeft: sm,
width: 'auto',
'&:hover': {
cursor: 'pointer',
},
},
})

View File

@ -0,0 +1,130 @@
// @flow
import React from 'react'
import { List } from 'immutable'
import { withStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close'
import MenuItem from '@material-ui/core/MenuItem'
import IconButton from '@material-ui/core/IconButton'
import SelectField from '~/components/forms/SelectField'
import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row'
import GnoForm from '~/components/forms/GnoForm'
import Col from '~/components/layout/Col'
import Button from '~/components/layout/Button'
import Block from '~/components/layout/Block'
import Hairline from '~/components/layout/Hairline'
import Field from '~/components/forms/Field'
import type { Owner } from '~/routes/safe/store/models/owner'
import {
composeValidators, required, minValue, maxValue, mustBeInteger,
} from '~/components/forms/validator'
import { styles } from './style'
export const REMOVE_OWNER_THRESHOLD_NEXT_BTN_TESTID = 'remove-owner-threshold-next-btn'
type Props = {
onClose: () => void,
classes: Object,
owners: List<Owner>,
threshold: number,
onClickBack: Function,
onSubmit: Function,
}
const ThresholdForm = ({
classes, onClose, owners, threshold, onClickBack, onSubmit,
}: Props) => {
const handleSubmit = (values) => {
onSubmit(values)
}
const defaultThreshold = threshold > 1 ? threshold - 1 : threshold
return (
<React.Fragment>
<Row align="center" grow className={classes.heading}>
<Paragraph weight="bolder" className={classes.manage} noMargin>
Remove owner
</Paragraph>
<Paragraph className={classes.annotation}>2 of 3</Paragraph>
<IconButton onClick={onClose} disableRipple>
<Close className={classes.closeIcon} />
</IconButton>
</Row>
<Hairline />
<GnoForm onSubmit={handleSubmit} initialValues={{ threshold: defaultThreshold.toString() }}>
{() => {
const numOptions = owners.size > 1 ? owners.size - 1 : 1
return (
<React.Fragment>
<Block className={classes.formContainer}>
<Row>
<Paragraph weight="bolder" className={classes.headingText}>
Set the required owner confirmations:
</Paragraph>
</Row>
<Row>
<Paragraph weight="bolder">
Any transaction over any daily limit requires the confirmation of:
</Paragraph>
</Row>
<Row margin="xl" align="center" className={classes.inputRow}>
<Col xs={2}>
<Field
name="threshold"
render={props => (
<React.Fragment>
<SelectField {...props} disableError>
{[...Array(Number(numOptions))].map((x, index) => (
<MenuItem key={index} value={`${index + 1}`}>
{index + 1}
</MenuItem>
))}
</SelectField>
{props.meta.error && props.meta.touched && (
<Paragraph className={classes.errorText} noMargin color="error">
{props.meta.error}
</Paragraph>
)}
</React.Fragment>
)}
validate={composeValidators(required, mustBeInteger, minValue(1), maxValue(numOptions))}
data-testid="threshold-select-input"
/>
</Col>
<Col xs={10}>
<Paragraph size="lg" color="primary" noMargin className={classes.ownersText}>
out of
{' '}
{owners.size - 1}
{' '}
owner(s)
</Paragraph>
</Col>
</Row>
</Block>
<Hairline />
<Row align="center" className={classes.buttonRow}>
<Button className={classes.button} minWidth={140} onClick={onClickBack}>
Back
</Button>
<Button
type="submit"
className={classes.button}
variant="contained"
minWidth={140}
color="primary"
data-testid={REMOVE_OWNER_THRESHOLD_NEXT_BTN_TESTID}
>
Review
</Button>
</Row>
</React.Fragment>
)
}}
</GnoForm>
</React.Fragment>
)
}
export default withStyles(styles)(ThresholdForm)

View File

@ -0,0 +1,45 @@
// @flow
import { lg, md, sm } from '~/theme/variables'
export const styles = () => ({
heading: {
padding: `${sm} ${lg}`,
justifyContent: 'flex-start',
boxSizing: 'border-box',
maxHeight: '75px',
},
annotation: {
letterSpacing: '-1px',
color: '#a2a8ba',
marginRight: 'auto',
marginLeft: '20px',
},
manage: {
fontSize: '24px',
},
closeIcon: {
height: '35px',
width: '35px',
},
headingText: {
fontSize: '16px',
},
formContainer: {
padding: `${md} ${lg}`,
minHeight: '340px',
},
ownersText: {
marginLeft: sm,
},
buttonRow: {
height: '84px',
justifyContent: 'center',
},
inputRow: {
position: 'relative',
},
errorText: {
position: 'absolute',
bottom: '-25px',
},
})

View File

@ -0,0 +1,178 @@
// @flow
import React, { useState, useEffect } from 'react'
import { List } from 'immutable'
import { withStyles } from '@material-ui/core/styles'
import { SharedSnackbarConsumer } from '~/components/SharedSnackBar'
import Modal from '~/components/Modal'
import { type Owner, makeOwner } from '~/routes/safe/store/models/owner'
import { getOwners } from '~/logic/safe/utils'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
import OwnerForm from './screens/OwnerForm'
import ReviewReplaceOwner from './screens/Review'
const styles = () => ({
biggerModalWindow: {
width: '775px',
minHeight: '500px',
position: 'static',
},
})
type Props = {
onClose: () => void,
classes: Object,
isOpen: boolean,
safeAddress: string,
safeName: string,
ownerAddress: string,
ownerName: string,
owners: List<Owner>,
network: string,
threshold: string,
createTransaction: Function,
updateSafe: Function,
}
type ActiveScreen = 'checkOwner' | 'reviewReplaceOwner'
const SENTINEL_ADDRESS = '0x0000000000000000000000000000000000000001'
export const sendReplaceOwner = async (
values: Object,
safeAddress: string,
ownerAddressToRemove: string,
ownerNameToRemove: string,
ownersOld: List<Owner>,
openSnackbar: Function,
createTransaction: Function,
updateSafe: Function,
) => {
const gnosisSafe = await getGnosisSafeInstanceAt(safeAddress)
const safeOwners = await gnosisSafe.getOwners()
const index = safeOwners.findIndex(ownerAddress => ownerAddress.toLowerCase() === ownerAddressToRemove.toLowerCase())
const prevAddress = index === 0 ? SENTINEL_ADDRESS : safeOwners[index - 1]
const txData = gnosisSafe.contract.methods
.swapOwner(prevAddress, ownerAddressToRemove, values.ownerAddress)
.encodeABI()
const txHash = await createTransaction(safeAddress, safeAddress, 0, txData, openSnackbar)
let owners = []
const storedOwners = await getOwners(safeAddress)
storedOwners.forEach((value, key) => owners.push(makeOwner({
address: key,
name: (values.ownerAddress.toLowerCase() === key.toLowerCase()) ? values.ownerName : value,
})))
const newOwnerIndex = List(owners).findIndex(o => o.address.toLowerCase() === values.ownerAddress.toLowerCase())
if (newOwnerIndex < 0) {
owners.push(makeOwner({ address: values.ownerAddress, name: values.ownerName }))
}
owners = List(owners).filter(o => o.address.toLowerCase() !== ownerAddressToRemove.toLowerCase())
if (txHash) {
updateSafe({
address: safeAddress,
owners,
})
}
}
const ReplaceOwner = ({
onClose,
isOpen,
classes,
safeAddress,
safeName,
ownerAddress,
ownerName,
owners,
network,
threshold,
createTransaction,
updateSafe,
}: Props) => {
const [activeScreen, setActiveScreen] = useState<ActiveScreen>('checkOwner')
const [values, setValues] = useState<Object>({})
useEffect(
() => () => {
setActiveScreen('checkOwner')
setValues({})
},
[isOpen],
)
const onClickBack = () => setActiveScreen('checkOwner')
const ownerSubmitted = (newValues: Object) => {
values.ownerName = newValues.ownerName
values.ownerAddress = newValues.ownerAddress
setValues(values)
setActiveScreen('reviewReplaceOwner')
}
return (
<React.Fragment>
<SharedSnackbarConsumer>
{({ openSnackbar }) => {
const onReplaceOwner = () => {
onClose()
try {
sendReplaceOwner(
values,
safeAddress,
ownerAddress,
ownerName,
owners,
openSnackbar,
createTransaction,
updateSafe,
)
} catch (error) {
// eslint-disable-next-line
console.log('Error while removing an owner ' + error)
}
}
return (
<Modal
title="Replace owner from Safe"
description="Replace owner from Safe"
handleClose={onClose}
open={isOpen}
paperClassName={classes.biggerModalWindow}
>
<React.Fragment>
{activeScreen === 'checkOwner' && (
<OwnerForm
onClose={onClose}
ownerAddress={ownerAddress}
ownerName={ownerName}
owners={owners}
network={network}
onSubmit={ownerSubmitted}
/>
)}
{activeScreen === 'reviewReplaceOwner' && (
<ReviewReplaceOwner
onClose={onClose}
safeName={safeName}
owners={owners}
network={network}
values={values}
ownerAddress={ownerAddress}
ownerName={ownerName}
onClickBack={onClickBack}
onSubmit={onReplaceOwner}
threshold={threshold}
/>
)}
</React.Fragment>
</Modal>
)
}}
</SharedSnackbarConsumer>
</React.Fragment>
)
}
export default withStyles(styles)(ReplaceOwner)

View File

@ -0,0 +1,159 @@
// @flow
import React from 'react'
import classNames from 'classnames/bind'
import { List } from 'immutable'
import { withStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close'
import IconButton from '@material-ui/core/IconButton'
import OpenInNew from '@material-ui/icons/OpenInNew'
import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row'
import GnoForm from '~/components/forms/GnoForm'
import Col from '~/components/layout/Col'
import Button from '~/components/layout/Button'
import Block from '~/components/layout/Block'
import Hairline from '~/components/layout/Hairline'
import Field from '~/components/forms/Field'
import TextField from '~/components/forms/TextField'
import Identicon from '~/components/Identicon'
import Link from '~/components/layout/Link'
import { getEtherScanLink } from '~/logic/wallets/getWeb3'
import { type Owner } from '~/routes/safe/store/models/owner'
import {
composeValidators,
required,
mustBeEthereumAddress,
minMaxLength,
uniqueAddress,
} from '~/components/forms/validator'
import { styles } from './style'
import { secondary } from '~/theme/variables'
export const REPLACE_OWNER_NAME_INPUT_TESTID = 'replace-owner-name-input'
export const REPLACE_OWNER_ADDRESS_INPUT_TESTID = 'replace-owner-address-testid'
export const REPLACE_OWNER_NEXT_BTN_TESTID = 'replace-owner-next-btn'
const openIconStyle = {
height: '16px',
color: secondary,
}
type Props = {
onClose: () => void,
classes: Object,
ownerAddress: string,
ownerName: string,
network: string,
onSubmit: Function,
owners: List<Owner>,
}
const OwnerForm = ({
classes, onClose, ownerAddress, ownerName, network, onSubmit, owners,
}: Props) => {
const handleSubmit = (values) => {
onSubmit(values)
}
const ownerDoesntExist = uniqueAddress(owners.map(o => o.address))
return (
<React.Fragment>
<Row align="center" grow className={classes.heading}>
<Paragraph weight="bolder" className={classes.manage} noMargin>
Replace owner
</Paragraph>
<Paragraph className={classes.annotation}>1 of 2</Paragraph>
<IconButton onClick={onClose} disableRipple>
<Close className={classes.closeIcon} />
</IconButton>
</Row>
<Hairline />
<GnoForm onSubmit={handleSubmit}>
{() => (
<React.Fragment>
<Block className={classes.formContainer}>
<Row>
<Paragraph>
Review the owner you want to replace from the active Safe. Then specify the new owner you want to
replace it with:
</Paragraph>
</Row>
<Row>
<Paragraph>Current owner</Paragraph>
</Row>
<Row className={classes.owner}>
<Col xs={1} align="center">
<Identicon address={ownerAddress} diameter={32} />
</Col>
<Col xs={7}>
<Block className={classNames(classes.name, classes.userName)}>
<Paragraph size="lg" noMargin weight="bolder">
{ownerName}
</Paragraph>
<Block align="center" className={classes.user}>
<Paragraph size="md" color="disabled" noMargin>
{ownerAddress}
</Paragraph>
<Link className={classes.open} to={getEtherScanLink(ownerAddress, network)} target="_blank">
<OpenInNew style={openIconStyle} />
</Link>
</Block>
</Block>
</Col>
</Row>
<Row>
<Paragraph>New owner</Paragraph>
</Row>
<Row margin="md">
<Col xs={8}>
<Field
name="ownerName"
component={TextField}
type="text"
validate={composeValidators(required, minMaxLength(1, 50))}
placeholder="Owner name*"
text="Owner name*"
className={classes.addressInput}
testId={REPLACE_OWNER_NAME_INPUT_TESTID}
/>
</Col>
</Row>
<Row margin="md">
<Col xs={8}>
<Field
name="ownerAddress"
component={TextField}
type="text"
validate={composeValidators(required, mustBeEthereumAddress, ownerDoesntExist)}
placeholder="Owner address*"
text="Owner address*"
className={classes.addressInput}
testId={REPLACE_OWNER_ADDRESS_INPUT_TESTID}
/>
</Col>
</Row>
</Block>
<Hairline />
<Row align="center" className={classes.buttonRow}>
<Button className={classes.button} minWidth={140} onClick={onClose}>
Cancel
</Button>
<Button
type="submit"
className={classes.button}
variant="contained"
minWidth={140}
color="primary"
testId={REPLACE_OWNER_NEXT_BTN_TESTID}
>
Next
</Button>
</Row>
</React.Fragment>
)}
</GnoForm>
</React.Fragment>
)
}
export default withStyles(styles)(OwnerForm)

View File

@ -0,0 +1,44 @@
// @flow
import { lg, md, sm } from '~/theme/variables'
export const styles = () => ({
heading: {
padding: `${sm} ${lg}`,
justifyContent: 'flex-start',
boxSizing: 'border-box',
maxHeight: '75px',
},
annotation: {
letterSpacing: '-1px',
color: '#a2a8ba',
marginRight: 'auto',
marginLeft: '20px',
},
manage: {
fontSize: '24px',
},
closeIcon: {
height: '35px',
width: '35px',
},
formContainer: {
padding: `${md} ${lg}`,
minHeight: '340px',
},
buttonRow: {
height: '84px',
justifyContent: 'center',
},
owner: {
alignItems: 'center',
},
user: {
justifyContent: 'left',
},
userName: {
whiteSpace: 'nowrap',
},
name: {
marginRight: `${sm}`,
},
})

View File

@ -0,0 +1,222 @@
// @flow
import React from 'react'
import { List } from 'immutable'
import classNames from 'classnames'
import { withStyles } from '@material-ui/core/styles'
import OpenInNew from '@material-ui/icons/OpenInNew'
import Close from '@material-ui/icons/Close'
import IconButton from '@material-ui/core/IconButton'
import Identicon from '~/components/Identicon'
import Link from '~/components/layout/Link'
import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row'
import Col from '~/components/layout/Col'
import Button from '~/components/layout/Button'
import Block from '~/components/layout/Block'
import Hairline from '~/components/layout/Hairline'
import type { Owner } from '~/routes/safe/store/models/owner'
import { getEtherScanLink } from '~/logic/wallets/getWeb3'
import { secondary } from '~/theme/variables'
import { styles } from './style'
export const REPLACE_OWNER_SUBMIT_BTN_TESTID = 'replace-owner-submit-btn'
const openIconStyle = {
height: '16px',
color: secondary,
}
type Props = {
onClose: () => void,
classes: Object,
safeName: string,
owners: List<Owner>,
network: string,
values: Object,
ownerAddress: string,
ownerName: string,
onClickBack: Function,
onSubmit: Function,
threshold: string,
}
const ReviewRemoveOwner = ({
classes,
onClose,
safeName,
owners,
network,
values,
ownerAddress,
ownerName,
onClickBack,
threshold,
onSubmit,
}: Props) => {
const handleSubmit = () => {
onSubmit()
}
return (
<React.Fragment>
<Row align="center" grow className={classes.heading}>
<Paragraph weight="bolder" className={classes.manage} noMargin>
Replace owner
</Paragraph>
<Paragraph className={classes.annotation}>2 of 2</Paragraph>
<IconButton onClick={onClose} disableRipple>
<Close className={classes.closeIcon} />
</IconButton>
</Row>
<Hairline />
<Block className={classes.formContainer}>
<Row className={classes.root}>
<Col xs={4} layout="column">
<Block className={classes.details}>
<Block margin="lg">
<Paragraph size="lg" color="primary" noMargin>
Details
</Paragraph>
</Block>
<Block margin="lg">
<Paragraph size="sm" color="disabled" noMargin>
Safe name
</Paragraph>
<Paragraph size="lg" color="primary" noMargin weight="bolder" className={classes.name}>
{safeName}
</Paragraph>
</Block>
<Block margin="lg">
<Paragraph size="sm" color="disabled" noMargin>
Any transaction requires the confirmation of:
</Paragraph>
<Paragraph size="lg" color="primary" noMargin weight="bolder" className={classes.name}>
{threshold}
{' '}
out of
{' '}
{owners.size}
{' '}
owner(s)
</Paragraph>
</Block>
</Block>
</Col>
<Col xs={8} layout="column" className={classes.owners}>
<Row className={classes.ownersTitle}>
<Paragraph size="lg" color="primary" noMargin>
{owners.size}
{' '}
Safe owner(s)
</Paragraph>
</Row>
<Hairline />
{owners.map(
owner => owner.address !== ownerAddress && (
<React.Fragment key={owner.address}>
<Row className={classes.owner}>
<Col xs={1} align="center">
<Identicon address={owner.address} diameter={32} />
</Col>
<Col xs={11}>
<Block className={classNames(classes.name, classes.userName)}>
<Paragraph weight="bolder" size="lg" noMargin>
{owner.name}
</Paragraph>
<Block align="center" className={classes.user}>
<Paragraph size="md" color="disabled" noMargin>
{owner.address}
</Paragraph>
<Link
className={classes.open}
to={getEtherScanLink(owner.address, network)}
target="_blank"
>
<OpenInNew style={openIconStyle} />
</Link>
</Block>
</Block>
</Col>
</Row>
<Hairline />
</React.Fragment>
),
)}
<Row className={classes.info} align="center">
<Paragraph weight="bolder" noMargin color="primary" size="md">
REMOVING OWNER &darr;
</Paragraph>
</Row>
<Hairline />
<Row className={classes.selectedOwnerRemoved}>
<Col xs={1} align="center">
<Identicon address={ownerAddress} diameter={32} />
</Col>
<Col xs={11}>
<Block className={classNames(classes.name, classes.userName)}>
<Paragraph weight="bolder" size="lg" noMargin>
{ownerName}
</Paragraph>
<Block align="center" className={classes.user}>
<Paragraph size="md" color="disabled" noMargin>
{ownerAddress}
</Paragraph>
<Link className={classes.open} to={getEtherScanLink(ownerAddress, network)} target="_blank">
<OpenInNew style={openIconStyle} />
</Link>
</Block>
</Block>
</Col>
</Row>
<Row className={classes.info} align="center">
<Paragraph weight="bolder" noMargin color="primary" size="md">
ADDING NEW OWNER &darr;
</Paragraph>
</Row>
<Hairline />
<Row className={classes.selectedOwnerAdded}>
<Col xs={1} align="center">
<Identicon address={values.ownerAddress} diameter={32} />
</Col>
<Col xs={11}>
<Block className={classNames(classes.name, classes.userName)}>
<Paragraph weight="bolder" size="lg" noMargin>
{values.ownerName}
</Paragraph>
<Block align="center" className={classes.user}>
<Paragraph size="md" color="disabled" noMargin>
{values.ownerAddress}
</Paragraph>
<Link className={classes.open} to={getEtherScanLink(values.ownerAddress, network)} target="_blank">
<OpenInNew style={openIconStyle} />
</Link>
</Block>
</Block>
</Col>
</Row>
<Hairline />
</Col>
</Row>
</Block>
<Hairline />
<Row align="center" className={classes.buttonRow}>
<Button className={classes.button} minWidth={140} onClick={onClickBack}>
Back
</Button>
<Button
type="submit"
onClick={handleSubmit}
className={classes.button}
variant="contained"
minWidth={140}
color="primary"
testId={REPLACE_OWNER_SUBMIT_BTN_TESTID}
>
Submit
</Button>
</Row>
</React.Fragment>
)
}
export default withStyles(styles)(ReviewRemoveOwner)

View File

@ -0,0 +1,83 @@
// @flow
import {
lg, sm, border, background,
} from '~/theme/variables'
export const styles = () => ({
root: {
height: '372px',
},
heading: {
padding: `${sm} ${lg}`,
justifyContent: 'flex-start',
boxSizing: 'border-box',
maxHeight: '75px',
},
annotation: {
letterSpacing: '-1px',
color: '#a2a8ba',
marginRight: 'auto',
marginLeft: '20px',
},
manage: {
fontSize: '24px',
},
closeIcon: {
height: '35px',
width: '35px',
},
info: {
backgroundColor: background,
padding: sm,
justifyContent: 'center',
textAlign: 'center',
flexDirection: 'column',
},
buttonRow: {
height: '84px',
justifyContent: 'center',
},
details: {
padding: lg,
borderRight: `solid 1px ${border}`,
height: '100%',
},
owners: {
overflow: 'auto',
height: '100%',
},
ownersTitle: {
padding: lg,
},
owner: {
padding: sm,
alignItems: 'center',
},
name: {
textOverflow: 'ellipsis',
overflow: 'hidden',
},
userName: {
whiteSpace: 'nowrap',
},
selectedOwnerRemoved: {
padding: sm,
alignItems: 'center',
backgroundColor: '#ffe6ea',
},
selectedOwnerAdded: {
padding: sm,
alignItems: 'center',
backgroundColor: '#fff3e2',
},
user: {
justifyContent: 'left',
},
open: {
paddingLeft: sm,
width: 'auto',
'&:hover': {
cursor: 'pointer',
},
},
})

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12">
<path fill="#4A5579" fill-rule="nonzero" d="M11.684 2.695a.669.669 0 0 0 0-.94L10.141.195a.652.652 0 0 0-.93 0l-1.215 1.22 2.475 2.5 1.213-1.22zM0 9.502v2.5h2.474l7.297-7.38-2.474-2.5L0 9.502z"/>
</svg>

After

Width:  |  Height:  |  Size: 291 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="12" viewBox="0 0 15 12">
<path fill="#4A5579" fill-rule="nonzero" d="M12.1 12v-1.412H9.278V9.176h2.824V7.765l2.117 2.117L12.101 12zM5.749 0a2.824 2.824 0 1 1 0 5.647 2.824 2.824 0 0 1 0-5.647zm0 7.059c.812 0 1.588.085 2.287.24a4.265 4.265 0 0 0-.635 3.995H.1V9.882c0-1.56 2.528-2.823 5.648-2.823z"/>
</svg>

After

Width:  |  Height:  |  Size: 370 B

View File

@ -0,0 +1,56 @@
// @flow
import { List } from 'immutable'
import type { Owner } from '~/routes/safe/store/models/owner'
import { type SortRow } from '~/components/Table/sorting'
import { type Column } from '~/components/Table/TableHead'
export const OWNERS_TABLE_NAME_ID = 'name'
export const OWNERS_TABLE_ADDRESS_ID = 'address'
export const OWNERS_TABLE_ACTIONS_ID = 'actions'
type OwnerData = {
name: string,
address: string,
}
export type OwnerRow = SortRow<OwnerData>
export const getOwnerData = (owners: List<Owner>): List<OwnerRow> => {
const rows = owners.map((owner: Owner) => ({
[OWNERS_TABLE_NAME_ID]: owner.name,
[OWNERS_TABLE_ADDRESS_ID]: owner.address,
}))
return rows
}
export const generateColumns = () => {
const nameColumn: Column = {
id: OWNERS_TABLE_NAME_ID,
order: false,
disablePadding: false,
label: 'Name',
width: 150,
custom: false,
align: 'left',
}
const addressColumn: Column = {
id: OWNERS_TABLE_ADDRESS_ID,
order: false,
disablePadding: false,
label: 'Address',
custom: false,
align: 'left',
}
const actionsColumn: Column = {
id: OWNERS_TABLE_ACTIONS_ID,
order: false,
disablePadding: false,
label: '',
custom: true,
}
return List([nameColumn, addressColumn, actionsColumn])
}

View File

@ -0,0 +1,259 @@
// @flow
import React from 'react'
import { List } from 'immutable'
import { withStyles } from '@material-ui/core/styles'
import TableRow from '@material-ui/core/TableRow'
import TableCell from '@material-ui/core/TableCell'
import Block from '~/components/layout/Block'
import Col from '~/components/layout/Col'
import Table from '~/components/Table'
import { type Column, cellWidth } from '~/components/Table/TableHead'
import Row from '~/components/layout/Row'
import Heading from '~/components/layout/Heading'
import Hairline from '~/components/layout/Hairline'
import Button from '~/components/layout/Button'
import Img from '~/components/layout/Img'
import AddOwnerModal from './AddOwnerModal'
import RemoveOwnerModal from './RemoveOwnerModal'
import ReplaceOwnerModal from './ReplaceOwnerModal'
import EditOwnerModal from './EditOwnerModal'
import OwnerAddressTableCell from './OwnerAddressTableCell'
import type { Owner } from '~/routes/safe/store/models/owner'
import {
getOwnerData, generateColumns, OWNERS_TABLE_NAME_ID, OWNERS_TABLE_ADDRESS_ID, type OwnerRow,
} from './dataFetcher'
import { lg, sm, boldFont } from '~/theme/variables'
import { styles } from './style'
import ReplaceOwnerIcon from './assets/icons/replace-owner.svg'
import RenameOwnerIcon from './assets/icons/rename-owner.svg'
import RemoveOwnerIcon from '../assets/icons/bin.svg'
export const RENAME_OWNER_BTN_TESTID = 'rename-owner-btn'
export const REMOVE_OWNER_BTN_TESTID = 'remove-owner-btn'
export const ADD_OWNER_BTN_TESTID = 'add-owner-btn'
export const REPLACE_OWNER_BTN_TESTID = 'replace-owner-btn'
export const OWNERS_ROW_TESTID = 'owners-row'
const controlsStyle = {
backgroundColor: 'white',
padding: sm,
}
const addOwnerButtonStyle = {
marginRight: sm,
fontWeight: boldFont,
}
const title = {
padding: lg,
}
type Props = {
classes: Object,
safeAddress: string,
safeName: string,
owners: List<Owner>,
network: string,
threshold: number,
userAddress: string,
createTransaction: Function,
updateSafe: Function,
granted: boolean,
}
type State = {
selectedOwnerAddress?: string,
selectedOwnerName?: string,
showAddOwner: boolean,
showRemoveOwner: boolean,
showReplaceOwner: boolean,
showEditOwner: boolean,
}
type Action = 'AddOwner' | 'EditOwner' | 'ReplaceOwner' | 'RemoveOwner'
class ManageOwners extends React.Component<Props, State> {
state = {
selectedOwnerAddress: undefined,
selectedOwnerName: undefined,
showAddOwner: false,
showRemoveOwner: false,
showReplaceOwner: false,
showEditOwner: false,
}
onShow = (action: Action, row?: Object) => () => {
this.setState({
[`show${action}`]: true,
selectedOwnerAddress: row && row.address,
selectedOwnerName: row && row.name,
})
}
onHide = (action: Action) => () => {
this.setState({
[`show${action}`]: false,
selectedOwnerAddress: undefined,
selectedOwnerName: undefined,
})
}
render() {
const {
classes,
safeAddress,
safeName,
owners,
threshold,
network,
userAddress,
createTransaction,
updateSafe,
granted,
} = this.props
const {
showAddOwner,
showRemoveOwner,
showReplaceOwner,
showEditOwner,
selectedOwnerName,
selectedOwnerAddress,
} = this.state
const columns = generateColumns()
const autoColumns = columns.filter(c => !c.custom)
const ownerData = getOwnerData(owners)
return (
<React.Fragment>
<Block className={classes.formContainer}>
<Heading tag="h3" style={title}>Manage Safe Owners</Heading>
<Table
label="Owners"
defaultOrderBy={OWNERS_TABLE_NAME_ID}
columns={columns}
data={ownerData}
size={ownerData.size}
defaultFixed
noBorder
>
{(sortedData: Array<OwnerRow>) => sortedData.map((row: any, index: number) => (
<TableRow tabIndex={-1} key={index} className={classes.hide} data-testid={OWNERS_ROW_TESTID}>
{autoColumns.map((column: Column) => (
<TableCell key={column.id} style={cellWidth(column.width)} align={column.align} component="td">
{column.id === OWNERS_TABLE_ADDRESS_ID ? (
<OwnerAddressTableCell address={row[column.id]} />
) : (
row[column.id]
)}
</TableCell>
))}
<TableCell component="td">
{granted && (
<Row align="end" className={classes.actions}>
<Img
alt="Edit owner"
className={classes.editOwnerIcon}
src={RenameOwnerIcon}
onClick={this.onShow('EditOwner', row)}
testId={RENAME_OWNER_BTN_TESTID}
/>
<Img
alt="Replace owner"
className={classes.replaceOwnerIcon}
src={ReplaceOwnerIcon}
onClick={this.onShow('ReplaceOwner', row)}
testId={REPLACE_OWNER_BTN_TESTID}
/>
{ownerData.size > 1 && (
<Img
alt="Remove owner"
className={classes.removeOwnerIcon}
src={RemoveOwnerIcon}
onClick={this.onShow('RemoveOwner', row)}
testId={REMOVE_OWNER_BTN_TESTID}
/>
)}
</Row>
)}
</TableCell>
</TableRow>
))
}
</Table>
</Block>
{granted && (
<React.Fragment>
<Hairline />
<Row style={controlsStyle} align="end" grow>
<Col end="xs">
<Button
style={addOwnerButtonStyle}
size="small"
variant="contained"
color="primary"
onClick={this.onShow('AddOwner')}
testId={ADD_OWNER_BTN_TESTID}
>
Add new owner
</Button>
</Col>
</Row>
</React.Fragment>
)}
<AddOwnerModal
onClose={this.onHide('AddOwner')}
isOpen={showAddOwner}
safeAddress={safeAddress}
safeName={safeName}
owners={owners}
threshold={threshold}
network={network}
userAddress={userAddress}
createTransaction={createTransaction}
updateSafe={updateSafe}
/>
<RemoveOwnerModal
onClose={this.onHide('RemoveOwner')}
isOpen={showRemoveOwner}
safeAddress={safeAddress}
safeName={safeName}
ownerAddress={selectedOwnerAddress}
ownerName={selectedOwnerName}
owners={owners}
threshold={threshold}
network={network}
userAddress={userAddress}
createTransaction={createTransaction}
updateSafe={updateSafe}
/>
<ReplaceOwnerModal
onClose={this.onHide('ReplaceOwner')}
isOpen={showReplaceOwner}
safeAddress={safeAddress}
safeName={safeName}
ownerAddress={selectedOwnerAddress}
ownerName={selectedOwnerName}
owners={owners}
network={network}
threshold={threshold}
userAddress={userAddress}
createTransaction={createTransaction}
updateSafe={updateSafe}
/>
<EditOwnerModal
onClose={this.onHide('EditOwner')}
isOpen={showEditOwner}
safeAddress={safeAddress}
ownerAddress={selectedOwnerAddress}
selectedOwnerName={selectedOwnerName}
owners={owners}
network={network}
updateSafe={updateSafe}
/>
</React.Fragment>
)
}
}
export default withStyles(styles)(ManageOwners)

View File

@ -0,0 +1,31 @@
// @flow
import { lg } from '~/theme/variables'
export const styles = () => ({
formContainer: {
minHeight: '369px',
},
hide: {
'&:hover': {
backgroundColor: '#fff3e2',
},
'&:hover $actions': {
visibility: 'initial',
},
},
actions: {
justifyContent: 'flex-end',
visibility: 'hidden',
},
editOwnerIcon: {
cursor: 'pointer',
},
replaceOwnerIcon: {
marginLeft: lg,
cursor: 'pointer',
},
removeOwnerIcon: {
marginLeft: lg,
cursor: 'pointer',
},
})

View File

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

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="12" viewBox="0 0 10 12">
<path fill="#FB4F62" fill-rule="nonzero" d="M.442 2.953l.375 7.51c0 .849.702 1.537 1.57 1.537H7.57c.867 0 1.57-.688 1.57-1.538l.36-7.509H.442zm9.498-.532l.003-.36c0-.825-.489-1.278-1.145-1.278L6.972.785C6.972.35 6.55 0 6.116 0H3.843c-.434 0-.87.351-.87.785L1.144.783C.42.783 0 1.335 0 2.062l.003.359H9.94zM6.542 4.927a.433.433 0 1 1 .867 0v4.965a.433.433 0 1 1-.867 0V4.927zm-2.004 0a.433.433 0 1 1 .867 0v4.965a.433.433 0 1 1-.867 0V4.927zm-2.003 0a.434.434 0 0 1 .867 0v4.965a.433.433 0 1 1-.867 0V4.927z"/>
</svg>

After

Width:  |  Height:  |  Size: 605 B

View File

@ -2,24 +2,32 @@
import * as React from 'react'
import cn from 'classnames'
import { List } from 'immutable'
import { connect } from 'react-redux'
import { withStyles } from '@material-ui/core/styles'
import Block from '~/components/layout/Block'
import Col from '~/components/layout/Col'
import Row from '~/components/layout/Row'
import Span from '~/components/layout/Span'
import Img from '~/components/layout/Img'
import RemoveSafeModal from './RemoveSafeModal'
import Paragraph from '~/components/layout/Paragraph'
import Hairline from '~/components/layout/Hairline'
import { type Owner } from '~/routes/safe/store/models/owner'
import ChangeSafeName from './ChangeSafeName'
import ThresholdSettings from './ThresholdSettings'
import ManageOwners from './ManageOwners'
import actions, { type Actions } from './actions'
import { styles } from './style'
import RemoveSafeIcon from './assets/icons/bin.svg'
export const OWNERS_SETTINGS_TAB_TESTID = 'owner-settings-tab'
type State = {
showRemoveSafe: boolean,
menuOptionIndex: number,
}
type Props = {
type Props = Actions & {
classes: Object,
granted: boolean,
etherScanLink: string,
@ -27,8 +35,10 @@ type Props = {
safeName: string,
owners: List<Owner>,
threshold: number,
network: string,
createTransaction: Function,
updateSafe: Function,
userAddress: string,
}
type Action = 'RemoveSafe'
@ -59,10 +69,12 @@ class Settings extends React.Component<Props, State> {
etherScanLink,
safeAddress,
safeName,
updateSafe,
owners,
threshold,
owners,
network,
userAddress,
createTransaction,
updateSafe,
} = this.props
return (
@ -74,8 +86,11 @@ class Settings extends React.Component<Props, State> {
</Paragraph>
</Col>
<Col xs={6} end="sm">
<Paragraph noMargin size="md" color="error" className={classes.links} onClick={this.onShow('RemoveSafe')}>
Remove Safe
<Paragraph noMargin size="md" color="error" onClick={this.onShow('RemoveSafe')}>
<Span className={cn(classes.links, classes.removeSafeText)}>
Remove Safe
</Span>
<Img alt="Trash Icon" className={classes.removeSafeIcon} src={RemoveSafeIcon} />
</Paragraph>
<RemoveSafeModal
onClose={this.onHide('RemoveSafe')}
@ -96,15 +111,18 @@ class Settings extends React.Component<Props, State> {
Safe name
</Row>
<Hairline />
<Row
className={cn(classes.menuOption, menuOptionIndex === 2 && classes.active)}
onClick={this.handleChange(2)}
testId={OWNERS_SETTINGS_TAB_TESTID}
>
Owners (
{owners.size}
)
</Row>
<Hairline />
{granted && (
<React.Fragment>
<Row
className={cn(classes.menuOption, menuOptionIndex === 2 && classes.active)}
onClick={this.handleChange(2)}
>
Owners
</Row>
<Hairline />
<Row
className={cn(classes.menuOption, menuOptionIndex === 3 && classes.active)}
onClick={this.handleChange(3)}
@ -112,13 +130,6 @@ class Settings extends React.Component<Props, State> {
Required confirmations
</Row>
<Hairline />
<Row
className={cn(classes.menuOption, menuOptionIndex === 4 && classes.active)}
onClick={this.handleChange(4)}
>
Modules
</Row>
<Hairline />
</React.Fragment>
)}
</Block>
@ -128,7 +139,19 @@ class Settings extends React.Component<Props, State> {
{menuOptionIndex === 1 && (
<ChangeSafeName safeAddress={safeAddress} safeName={safeName} updateSafe={updateSafe} />
)}
{granted && menuOptionIndex === 2 && <p>To be done</p>}
{menuOptionIndex === 2 && (
<ManageOwners
owners={owners}
threshold={threshold}
safeAddress={safeAddress}
safeName={safeName}
network={network}
createTransaction={createTransaction}
userAddress={userAddress}
updateSafe={updateSafe}
granted={granted}
/>
)}
{granted && menuOptionIndex === 3 && (
<ThresholdSettings
owners={owners}
@ -137,7 +160,6 @@ class Settings extends React.Component<Props, State> {
safeAddress={safeAddress}
/>
)}
{granted && menuOptionIndex === 4 && <p>To be done</p>}
</Block>
</Col>
</Block>
@ -146,4 +168,9 @@ class Settings extends React.Component<Props, State> {
}
}
export default withStyles(styles)(Settings)
const settingsComponent = withStyles(styles)(Settings)
export default connect(
undefined,
actions,
)(settingsComponent)

View File

@ -39,4 +39,14 @@ export const styles = () => ({
cursor: 'pointer',
},
},
removeSafeText: {
height: '16px',
lineHeight: '16px',
paddingRight: sm,
float: 'left',
},
removeSafeIcon: {
height: '16px',
cursor: 'pointer',
},
})

View File

@ -64,7 +64,7 @@ export const grantedSelector: Selector<GlobalState, RouterProps, boolean> = crea
return false
}
return owners.find((owner: Owner) => sameAddress(owner.get('address'), userAccount)) !== undefined
return owners.find((owner: Owner) => sameAddress(owner.address, userAccount)) !== undefined
},
)

View File

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

View File

@ -41,6 +41,12 @@ const safeStorageMware = (store: Store<GlobalState>) => (next: Function) => asyn
if (action.type === ADD_SAFE) {
const { safe } = action.payload
setOwners(safe.address, safe.owners)
} else if (action.type === UPDATE_SAFE) {
const { address, owners } = action.payload
if (address && owners) {
setOwners(address, owners)
}
} else if (action.type === REMOVE_SAFE) {
const safeAddress = action.payload
removeOwners(safeAddress)

View File

@ -3,14 +3,13 @@ import * as React from 'react'
import TestUtils from 'react-dom/test-utils'
import { type Store } from 'redux'
import { Provider } from 'react-redux'
import { ConnectedRouter } from 'connected-react-router'
import { render } from '@testing-library/react'
import { ConnectedRouter } from 'connected-react-router'
import PageFrame from '~/components/layout/PageFrame'
import ListItemText from '~/components/List/ListItemText/index'
import { SEE_MULTISIG_BUTTON_TEXT } from '~/routes/safe/components/Safe/MultisigTx'
import fetchTransactions from '~/routes/safe/store/actions/fetchTransactions'
import { sleep } from '~/utils/timer'
import { history } from '~/store'
import { history, type GlobalState } from '~/store'
import AppRoutes from '~/routes'
import { SAFELIST_ADDRESS } from '~/routes/routes'
import { EMPTY_DATA } from '~/logic/wallets/ethTransactions'
@ -23,16 +22,6 @@ export const EDIT_INDEX = 4
export const WITHDRAW_INDEX = 5
export const LIST_TXS_INDEX = 6
export const listTxsClickingOn = async (store: Store, seeTxsButton: Element, safeAddress: string) => {
await store.dispatch(fetchTransactions(safeAddress))
await sleep(1200)
expect(seeTxsButton.getElementsByTagName('span')[0].innerHTML).toEqual(SEE_MULTISIG_BUTTON_TEXT)
TestUtils.Simulate.click(seeTxsButton)
// give some time to expand the transactions
await sleep(800)
}
export const checkMinedTx = (Transaction: React.Component<any, any>, name: string) => {
const paragraphs = TestUtils.scryRenderedDOMComponentsWithTag(Transaction, 'p')

View File

@ -10,7 +10,7 @@ import { SAFE_NAME_INPUT_TESTID, SAFE_NAME_SUBMIT_BTN_TESTID } from '~/routes/sa
afterEach(cleanup)
describe('DOM > Feature > Settings', () => {
describe('DOM > Feature > Settings - Name', () => {
let store
let safeAddress
beforeEach(async () => {

View File

@ -0,0 +1,218 @@
// @flow
import { fireEvent, cleanup } from '@testing-library/react'
import { aNewStore } from '~/store'
import { aMinedSafe } from '~/test/builder/safe.redux.builder'
import { renderSafeView } from '~/test/builder/safe.dom.utils'
import { sleep } from '~/utils/timer'
import 'jest-dom/extend-expect'
import { SETTINGS_TAB_BTN_TESTID } from '~/routes/safe/components/Layout'
import { OWNERS_SETTINGS_TAB_TESTID } from '~/routes/safe/components/Settings'
import {
RENAME_OWNER_BTN_TESTID,
OWNERS_ROW_TESTID,
REMOVE_OWNER_BTN_TESTID,
ADD_OWNER_BTN_TESTID,
REPLACE_OWNER_BTN_TESTID,
} from '~/routes/safe/components/Settings/ManageOwners'
import {
RENAME_OWNER_INPUT_TESTID,
SAVE_OWNER_CHANGES_BTN_TESTID,
} from '~/routes/safe/components/Settings/ManageOwners/EditOwnerModal'
import { REMOVE_OWNER_MODAL_NEXT_BTN_TESTID } from '~/routes/safe/components/Settings/ManageOwners/RemoveOwnerModal/screens/CheckOwner'
import { REMOVE_OWNER_THRESHOLD_NEXT_BTN_TESTID } from '~/routes/safe/components/Settings/ManageOwners/RemoveOwnerModal/screens/ThresholdForm'
import { REMOVE_OWNER_REVIEW_BTN_TESTID } from '~/routes/safe/components/Settings/ManageOwners/RemoveOwnerModal/screens/Review'
import { ADD_OWNER_THRESHOLD_NEXT_BTN_TESTID } from '~/routes/safe/components/Settings/ManageOwners/AddOwnerModal/screens/ThresholdForm'
import {
ADD_OWNER_NAME_INPUT_TESTID,
ADD_OWNER_ADDRESS_INPUT_TESTID,
ADD_OWNER_NEXT_BTN_TESTID,
} from '~/routes/safe/components/Settings/ManageOwners/AddOwnerModal/screens/OwnerForm'
import { ADD_OWNER_SUBMIT_BTN_TESTID } from '~/routes/safe/components/Settings/ManageOwners/AddOwnerModal/screens/Review'
import {
REPLACE_OWNER_NEXT_BTN_TESTID,
REPLACE_OWNER_NAME_INPUT_TESTID,
REPLACE_OWNER_ADDRESS_INPUT_TESTID,
} from '~/routes/safe/components/Settings/ManageOwners/ReplaceOwnerModal/screens/OwnerForm'
import { REPLACE_OWNER_SUBMIT_BTN_TESTID } from '~/routes/safe/components/Settings/ManageOwners/ReplaceOwnerModal/screens/Review'
afterEach(cleanup)
describe('DOM > Feature > Settings - Manage owners', () => {
let store
let safeAddress
beforeEach(async () => {
store = aNewStore()
safeAddress = await aMinedSafe(store)
})
it("Changes owner's name", async () => {
const NEW_OWNER_NAME = 'NEW OWNER NAME'
const SafeDom = renderSafeView(store, safeAddress)
await sleep(1300)
// Travel to settings
const settingsBtn = SafeDom.getByTestId(SETTINGS_TAB_BTN_TESTID)
fireEvent.click(settingsBtn)
await sleep(200)
// click on owners settings
const ownersSettingsBtn = SafeDom.getByTestId(OWNERS_SETTINGS_TAB_TESTID)
fireEvent.click(ownersSettingsBtn)
await sleep(200)
// open rename owner modal
const renameOwnerBtn = SafeDom.getByTestId(RENAME_OWNER_BTN_TESTID)
fireEvent.click(renameOwnerBtn)
// rename owner
const ownerNameInput = SafeDom.getByTestId(RENAME_OWNER_INPUT_TESTID)
const saveOwnerChangesBtn = SafeDom.getByTestId(SAVE_OWNER_CHANGES_BTN_TESTID)
fireEvent.change(ownerNameInput, { target: { value: NEW_OWNER_NAME } })
fireEvent.click(saveOwnerChangesBtn)
await sleep(200)
// check if the name updated
const ownerRow = SafeDom.getByTestId(OWNERS_ROW_TESTID)
expect(ownerRow).toHaveTextContent(NEW_OWNER_NAME)
})
it('Removes an owner', async () => {
const twoOwnersSafeAddress = await aMinedSafe(store, 2)
const SafeDom = renderSafeView(store, twoOwnersSafeAddress)
await sleep(1300)
// Travel to settings
const settingsBtn = SafeDom.getByTestId(SETTINGS_TAB_BTN_TESTID)
fireEvent.click(settingsBtn)
await sleep(200)
// click on owners settings
const ownersSettingsBtn = SafeDom.getByTestId(OWNERS_SETTINGS_TAB_TESTID)
fireEvent.click(ownersSettingsBtn)
await sleep(200)
// check if there are 2 owners
let ownerRows = SafeDom.getAllByTestId(OWNERS_ROW_TESTID)
expect(ownerRows.length).toBe(2)
expect(ownerRows[0]).toHaveTextContent('Adol 1 Eth Account0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1')
expect(ownerRows[1]).toHaveTextContent('Adol 2 Eth Account0xFFcf8FDEE72ac11b5c542428B35EEF5769C409f0')
// click remove owner btn which opens the modal
const removeOwnerBtn = SafeDom.getAllByTestId(REMOVE_OWNER_BTN_TESTID)[1]
fireEvent.click(removeOwnerBtn)
// modal navigation
const nextBtnStep1 = SafeDom.getByTestId(REMOVE_OWNER_MODAL_NEXT_BTN_TESTID)
fireEvent.click(nextBtnStep1)
const nextBtnStep2 = SafeDom.getByTestId(REMOVE_OWNER_THRESHOLD_NEXT_BTN_TESTID)
fireEvent.click(nextBtnStep2)
const nextBtnStep3 = SafeDom.getByTestId(REMOVE_OWNER_REVIEW_BTN_TESTID)
fireEvent.click(nextBtnStep3)
await sleep(1300)
// check if owner was removed
ownerRows = SafeDom.getAllByTestId(OWNERS_ROW_TESTID)
expect(ownerRows.length).toBe(1)
expect(ownerRows[0]).toHaveTextContent('Adol 1 Eth Account0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1')
})
it('Adds a new owner', async () => {
const NEW_OWNER_NAME = 'I am a new owner'
const NEW_OWNER_ADDRESS = '0x0E329Fa8d6fCd1BA0cDA495431F1F7ca24F442c3'
const SafeDom = renderSafeView(store, safeAddress)
await sleep(1300)
// Travel to settings
const settingsBtn = SafeDom.getByTestId(SETTINGS_TAB_BTN_TESTID)
fireEvent.click(settingsBtn)
await sleep(200)
// click on owners settings
const ownersSettingsBtn = SafeDom.getByTestId(OWNERS_SETTINGS_TAB_TESTID)
fireEvent.click(ownersSettingsBtn)
await sleep(200)
// check if there is 1 owner
let ownerRows = SafeDom.getAllByTestId(OWNERS_ROW_TESTID)
expect(ownerRows.length).toBe(1)
expect(ownerRows[0]).toHaveTextContent('Adol 1 Eth Account0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1')
// click add owner btn
fireEvent.click(SafeDom.getByTestId(ADD_OWNER_BTN_TESTID))
await sleep(200)
// fill and travel add owner modal
const ownerNameInput = SafeDom.getByTestId(ADD_OWNER_NAME_INPUT_TESTID)
const ownerAddressInput = SafeDom.getByTestId(ADD_OWNER_ADDRESS_INPUT_TESTID)
const nextBtn = SafeDom.getByTestId(ADD_OWNER_NEXT_BTN_TESTID)
fireEvent.change(ownerNameInput, { target: { value: NEW_OWNER_NAME } })
fireEvent.change(ownerAddressInput, { target: { value: NEW_OWNER_ADDRESS } })
fireEvent.click(nextBtn)
await sleep(200)
fireEvent.click(SafeDom.getByTestId(ADD_OWNER_THRESHOLD_NEXT_BTN_TESTID))
await sleep(200)
fireEvent.click(SafeDom.getByTestId(ADD_OWNER_SUBMIT_BTN_TESTID))
await sleep(1000)
// check if owner was added
ownerRows = SafeDom.getAllByTestId(OWNERS_ROW_TESTID)
expect(ownerRows.length).toBe(2)
expect(ownerRows[0]).toHaveTextContent('Adol 1 Eth Account0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1')
expect(ownerRows[1]).toHaveTextContent(`${NEW_OWNER_NAME}${NEW_OWNER_ADDRESS}`)
})
it('Replaces an owner', async () => {
const NEW_OWNER_NAME = 'I replaced an old owner'
const NEW_OWNER_ADDRESS = '0x1dF62f291b2E969fB0849d99D9Ce41e2F137006e'
const twoOwnersSafeAddress = await aMinedSafe(store, 2)
const SafeDom = renderSafeView(store, twoOwnersSafeAddress)
await sleep(1300)
// Travel to settings
const settingsBtn = SafeDom.getByTestId(SETTINGS_TAB_BTN_TESTID)
fireEvent.click(settingsBtn)
await sleep(200)
// click on owners settings
const ownersSettingsBtn = SafeDom.getByTestId(OWNERS_SETTINGS_TAB_TESTID)
fireEvent.click(ownersSettingsBtn)
await sleep(200)
// check if there are 2 owners
let ownerRows = SafeDom.getAllByTestId(OWNERS_ROW_TESTID)
expect(ownerRows.length).toBe(2)
expect(ownerRows[0]).toHaveTextContent('Adol 1 Eth Account0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1')
expect(ownerRows[1]).toHaveTextContent('Adol 2 Eth Account0xFFcf8FDEE72ac11b5c542428B35EEF5769C409f0')
// click replace owner btn which opens the modal
const replaceOwnerBtn = SafeDom.getAllByTestId(REPLACE_OWNER_BTN_TESTID)[1]
fireEvent.click(replaceOwnerBtn)
// fill and travel add owner modal
const ownerNameInput = SafeDom.getByTestId(REPLACE_OWNER_NAME_INPUT_TESTID)
const ownerAddressInput = SafeDom.getByTestId(REPLACE_OWNER_ADDRESS_INPUT_TESTID)
const nextBtn = SafeDom.getByTestId(REPLACE_OWNER_NEXT_BTN_TESTID)
fireEvent.change(ownerNameInput, { target: { value: NEW_OWNER_NAME } })
fireEvent.change(ownerAddressInput, { target: { value: NEW_OWNER_ADDRESS } })
fireEvent.click(nextBtn)
await sleep(200)
const replaceSubmitBtn = SafeDom.getByTestId(REPLACE_OWNER_SUBMIT_BTN_TESTID)
fireEvent.click(replaceSubmitBtn)
await sleep(1000)
// check if the owner was replaced
ownerRows = SafeDom.getAllByTestId(OWNERS_ROW_TESTID)
expect(ownerRows.length).toBe(2)
expect(ownerRows[0]).toHaveTextContent('Adol 1 Eth Account0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1')
expect(ownerRows[1]).toHaveTextContent(`${NEW_OWNER_NAME}${NEW_OWNER_ADDRESS}`)
})
})

View File

@ -3,15 +3,9 @@
// TBD
describe('DOM > Feature > SAFE MULTISIG Transactions', () => {
it.only('mines correctly all multisig txs in a 1 owner & 1 threshold safe', async () => {
it.only('mines correctly all multisig txs in a 1 owner & 1 threshold safe', async () => {})
})
it.only('mines withdraw process correctly all multisig txs in a 2 owner & 2 threshold safe', async () => {})
it.only('mines withdraw process correctly all multisig txs in a 2 owner & 2 threshold safe', async () => {
})
it.only('approves and executes pending transactions', async () => {
})
it.only('approves and executes pending transactions', async () => {})
})

View File

@ -1,6 +1,6 @@
// @flow
import { getWeb3 } from '~/logic/wallets/getWeb3'
import { fireEvent } from '@testing-library/react'
import { getWeb3 } from '~/logic/wallets/getWeb3'
import { getFirstTokenContract } from '~/test/utils/tokenMovements'
import { aNewStore } from '~/store'
import { aMinedSafe } from '~/test/builder/safe.redux.builder'

View File

@ -1,7 +1,10 @@
// @flow
import { fireEvent } from '@testing-library/react'
import { MANAGE_TOKENS_BUTTON_TEST_ID } from '~/routes/safe/components/Balances'
import { ADD_CUSTOM_TOKEN_BUTTON_TEST_ID, TOGGLE_TOKEN_TEST_ID } from '~/routes/safe/components/Balances/Tokens/screens/TokenList'
import {
ADD_CUSTOM_TOKEN_BUTTON_TEST_ID,
TOGGLE_TOKEN_TEST_ID,
} from '~/routes/safe/components/Balances/Tokens/screens/TokenList'
import { MANAGE_TOKENS_MODAL_CLOSE_BUTTON_TEST_ID } from '~/routes/safe/components/Balances/Tokens'
export const clickOnManageTokens = (dom: any): void => {

View File

@ -1,9 +1,9 @@
// @flow
import React from 'react'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
import GnoStepper from '~/components/Stepper'
import Stepper from '@material-ui/core/Stepper'
import TestUtils from 'react-dom/test-utils'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
import GnoStepper from '~/components/Stepper'
export const printOutApprove = async (
subject: string,
@ -38,7 +38,10 @@ type FinsihedTx = {
finishedTransaction: boolean,
}
export const whenExecuted = (SafeDom: React.Component<any, any>, ParentComponent: React.ElementType): Promise<void> => new Promise((resolve, reject) => {
export const whenExecuted = (
SafeDom: React.Component<any, any>,
ParentComponent: React.ElementType,
): Promise<void> => new Promise((resolve, reject) => {
let times = 0
const interval = setInterval(() => {
if (times >= MAX_TIMES_EXECUTED) {

View File

@ -1,7 +1,7 @@
// @flow
import { type Match } from 'react-router-dom'
import { buildMatchPropsFrom } from '~/test/utils/buildReactRouterProps'
import { safeSelector } from '~/routes/safe/store/selectors/index'
import { type Match } from 'react-router-dom'
import { type GlobalState } from '~/store'
import { type Safe } from '~/routes/safe/store/models/safe'

View File

@ -1,53 +0,0 @@
// @flow
import TestUtils from 'react-dom/test-utils'
import { sleep } from '~/utils/timer'
import { checkMinedTx, checkPendingTx } from '~/test/builder/safe.dom.utils'
import { whenExecuted } from '~/test/utils/logTransactions'
import AddOwner from '~/routes/safe/components/AddOwner'
export const sendAddOwnerForm = async (
SafeDom: React.Component<any, any>,
addOwner: React.Component<any, any>,
ownerName: string,
ownerAddress: string,
increase: boolean = false,
) => {
// load add multisig form component
TestUtils.Simulate.click(addOwner)
// give time to re-render it
await sleep(400)
// fill the form
const inputs = TestUtils.scryRenderedDOMComponentsWithTag(SafeDom, 'input')
const nameInput = inputs[0]
const addressInput = inputs[1]
TestUtils.Simulate.change(nameInput, { target: { value: ownerName } })
TestUtils.Simulate.change(addressInput, { target: { value: ownerAddress } })
if (increase) {
const increaseInput = inputs[2]
TestUtils.Simulate.change(increaseInput, { target: { value: 'true' } })
}
// $FlowFixMe
const form = TestUtils.findRenderedDOMComponentWithTag(SafeDom, 'form')
// submit it
TestUtils.Simulate.submit(form)
TestUtils.Simulate.submit(form)
return whenExecuted(SafeDom, AddOwner)
}
export const checkMinedAddOwnerTx = (Transaction: React.Component<any, any>, name: string) => {
checkMinedTx(Transaction, name)
}
export const checkPendingAddOwnerTx = async (
Transaction: React.Component<any, any>,
safeThreshold: number,
name: string,
statusses: string[],
) => {
await checkPendingTx(Transaction, safeThreshold, name, statusses)
}

View File

@ -1,49 +0,0 @@
// @flow
import * as React from 'react'
import TestUtils from 'react-dom/test-utils'
import { sleep } from '~/utils/timer'
import { checkMinedTx, EXPAND_OWNERS_INDEX, checkPendingTx } from '~/test/builder/safe.dom.utils'
import { filterMoveButtonsFrom } from '~/test/builder/safe.dom.builder'
import { whenExecuted } from '~/test/utils/logTransactions'
import RemoveOwner from '~/routes/safe/components/RemoveOwner'
export const sendRemoveOwnerForm = async (
SafeDom: React.Component<any, any>,
expandOwners: React.Component<any, any>,
) => {
// Expand owners
TestUtils.Simulate.click(expandOwners)
await sleep(400)
// Get delete button user
const allButtons = TestUtils.scryRenderedDOMComponentsWithTag(SafeDom, 'button')
const buttons = filterMoveButtonsFrom(allButtons)
const removeUserButton = buttons[EXPAND_OWNERS_INDEX + 2] // + 2 one the Add and the next delete
expect(removeUserButton.getAttribute('aria-label')).toBe('Delete')
// render form for deleting the user
TestUtils.Simulate.click(removeUserButton)
await sleep(400)
// $FlowFixMe
const form = TestUtils.findRenderedDOMComponentWithTag(SafeDom, 'form')
// submit it
TestUtils.Simulate.submit(form)
TestUtils.Simulate.submit(form)
return whenExecuted(SafeDom, RemoveOwner)
}
export const checkMinedRemoveOwnerTx = (Transaction: React.Component<any, any>, name: string) => {
checkMinedTx(Transaction, name)
}
export const checkPendingRemoveOwnerTx = async (
Transaction: React.Component<any, any>,
safeThreshold: number,
name: string,
statusses: string[],
) => {
await checkPendingTx(Transaction, safeThreshold, name, statusses)
}