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; align-items: center;
border: solid 0.5px $border; border: solid 0.5px $border;
background-color: white; background-color: white;
margin-top: 50px;
} }
@media only screen and (max-width: $(screenXs)px) { @media only screen and (max-width: $(screenXs)px) {

View File

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

View File

@ -2,9 +2,21 @@
import { type FieldValidator } from 'final-form' import { type FieldValidator } from 'final-form'
import { getWeb3 } from '~/logic/wallets/getWeb3' 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 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) 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 ok = () => undefined
export const mustBeEthereumAddress = (address: Field) => { export const mustBeEthereumAddress = simpleMemoize((address: Field) => {
const isAddress: boolean = getWeb3().utils.isAddress(address) const isAddress: boolean = getWeb3().utils.isAddress(address)
return isAddress ? undefined : 'Address should be a valid Ethereum 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 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 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) 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, fullwidth?: boolean,
bordered?: boolean, bordered?: boolean,
className?: string, className?: string,
style?: React.Node, style?: Object,
testId?: string,
} }
const Img = ({ const Img = ({
fullwidth, alt, bordered, className, style, ...props fullwidth, alt, bordered, className, style, testId = '', ...props
}: Props) => { }: Props) => {
const classes = cx(styles.img, { fullwidth, bordered }, className) 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 export default Img

View File

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

View File

@ -20,7 +20,7 @@ const buildWidthFrom = (size: number) => ({
}) })
const overflowStyle = { const overflowStyle = {
overflowX: 'scroll', overflowX: 'auto',
} }
// see: https://css-tricks.com/responsive-data-tables/ // 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>) => { export const setOwners = async (safeAddress: string, owners: List<Owner>) => {
try { 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) await saveToStorage(`${OWNERS_KEY}-${safeAddress}`, ownersAsMap)
} catch (err) { } catch (err) {
// eslint-disable-next-line // 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 StandardToken from '@gnosis.pm/util-contracts/build/contracts/GnosisStandardToken.json'
import HumanFriendlyToken from '@gnosis.pm/util-contracts/build/contracts/HumanFriendlyToken.json' import HumanFriendlyToken from '@gnosis.pm/util-contracts/build/contracts/HumanFriendlyToken.json'
import { getWeb3 } from '~/logic/wallets/getWeb3' 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 { makeToken, type TokenProps } from '~/logic/tokens/store/model/token'
import { fetchTokenList } from '~/logic/tokens/api' import { fetchTokenList } from '~/logic/tokens/api'
import { ensureOnce } from '~/utils/singleton' import { ensureOnce } from '~/utils/singleton'

View File

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

View File

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

View File

@ -1,10 +1,10 @@
// @flow // @flow
import * as React from 'react' import * as React from 'react'
import classNames from 'classnames' 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 { withStyles } from '@material-ui/core/styles'
import OpenInNew from '@material-ui/icons/OpenInNew' 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 Identicon from '~/components/Identicon'
import OpenPaper from '~/components/Stepper/OpenPaper' import OpenPaper from '~/components/Stepper/OpenPaper'
import Col from '~/components/layout/Col' import Col from '~/components/layout/Col'

View File

@ -74,8 +74,11 @@ const styles = () => ({
}) })
const getAddressValidators = (addresses: string[], position: number) => { const getAddressValidators = (addresses: string[], position: number) => {
// thanks Rich Harris
// https://twitter.com/Rich_Harris/status/1125850391155965952
const copy = addresses.slice() const copy = addresses.slice()
copy.splice(position, 1) copy[position] = copy[copy.length - 1]
copy.pop()
return composeValidators(required, mustBeEthereumAddress, uniqueAddress(copy)) return composeValidators(required, mustBeEthereumAddress, uniqueAddress(copy))
} }
@ -97,7 +100,7 @@ export const calculateValuesAfterRemoving = (index: number, notRemovedOwners: nu
return initialValues return initialValues
} }
class SafeOwners extends React.Component<Props, State> { class SafeOwners extends React.PureComponent<Props, State> {
state = { state = {
numOwners: 1, 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 { type Token } from '~/logic/tokens/store/model/token'
import { sameAddress } from '~/logic/wallets/ethAddresses' import { sameAddress } from '~/logic/wallets/ethAddresses'
import { isAddressAToken } from '~/logic/tokens/utils/tokenHelpers' import { isAddressAToken } from '~/logic/tokens/utils/tokenHelpers'
import { simpleMemoize } from '~/components/forms/validator'
export const simpleMemoize = (fn: Function) => { // import { getStandardTokenContract } from '~/logic/tokens/store/actions/fetchTokens'
let lastArg
let lastResult
return (arg: any) => {
if (arg !== lastArg) {
lastArg = arg
lastResult = fn(arg)
}
return lastResult
}
}
// eslint-disable-next-line // eslint-disable-next-line
export const addressIsTokenContract = simpleMemoize(async (tokenAddress: string) => { export const addressIsTokenContract = simpleMemoize(async (tokenAddress: string) => {

View File

@ -183,6 +183,8 @@ class Layout extends React.Component<Props, State> {
updateSafe={updateSafe} updateSafe={updateSafe}
threshold={safe.threshold} threshold={safe.threshold}
owners={safe.owners} owners={safe.owners}
network={network}
userAddress={userAddress}
createTransaction={createTransaction} 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 Block from '~/components/layout/Block'
import Col from '~/components/layout/Col' import Col from '~/components/layout/Col'
import Field from '~/components/forms/Field' import Field from '~/components/forms/Field'
import Heading from '~/components/layout/Heading'
import { composeValidators, required, minMaxLength } from '~/components/forms/validator' import { composeValidators, required, minMaxLength } from '~/components/forms/validator'
import TextField from '~/components/forms/TextField' import TextField from '~/components/forms/TextField'
import GnoForm from '~/components/forms/GnoForm' import GnoForm from '~/components/forms/GnoForm'
@ -44,10 +45,8 @@ const ChangeSafeName = (props: Props) => {
{() => ( {() => (
<React.Fragment> <React.Fragment>
<Block className={classes.formContainer}> <Block className={classes.formContainer}>
<Paragraph noMargin className={classes.title} size="lg" weight="bolder"> <Heading tag="h3">Modify Safe name</Heading>
Modify Safe name <Paragraph>
</Paragraph>
<Paragraph size="sm">
You can change the name of this Safe. This name is only stored locally and never shared with Gnosis or You can change the name of this Safe. This name is only stored locally and never shared with Gnosis or
any third parties. any third parties.
</Paragraph> </Paragraph>

View File

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

View File

@ -3,14 +3,13 @@ import * as React from 'react'
import TestUtils from 'react-dom/test-utils' import TestUtils from 'react-dom/test-utils'
import { type Store } from 'redux' import { type Store } from 'redux'
import { Provider } from 'react-redux' import { Provider } from 'react-redux'
import { ConnectedRouter } from 'connected-react-router'
import { render } from '@testing-library/react' import { render } from '@testing-library/react'
import { ConnectedRouter } from 'connected-react-router'
import PageFrame from '~/components/layout/PageFrame' import PageFrame from '~/components/layout/PageFrame'
import ListItemText from '~/components/List/ListItemText/index' 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 fetchTransactions from '~/routes/safe/store/actions/fetchTransactions'
import { sleep } from '~/utils/timer' import { sleep } from '~/utils/timer'
import { history } from '~/store' import { history, type GlobalState } from '~/store'
import AppRoutes from '~/routes' import AppRoutes from '~/routes'
import { SAFELIST_ADDRESS } from '~/routes/routes' import { SAFELIST_ADDRESS } from '~/routes/routes'
import { EMPTY_DATA } from '~/logic/wallets/ethTransactions' import { EMPTY_DATA } from '~/logic/wallets/ethTransactions'
@ -23,16 +22,6 @@ export const EDIT_INDEX = 4
export const WITHDRAW_INDEX = 5 export const WITHDRAW_INDEX = 5
export const LIST_TXS_INDEX = 6 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) => { export const checkMinedTx = (Transaction: React.Component<any, any>, name: string) => {
const paragraphs = TestUtils.scryRenderedDOMComponentsWithTag(Transaction, 'p') 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) afterEach(cleanup)
describe('DOM > Feature > Settings', () => { describe('DOM > Feature > Settings - Name', () => {
let store let store
let safeAddress let safeAddress
beforeEach(async () => { 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 // TBD
describe('DOM > Feature > SAFE MULTISIG Transactions', () => { 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 // @flow
import { getWeb3 } from '~/logic/wallets/getWeb3'
import { fireEvent } from '@testing-library/react' import { fireEvent } from '@testing-library/react'
import { getWeb3 } from '~/logic/wallets/getWeb3'
import { getFirstTokenContract } from '~/test/utils/tokenMovements' import { getFirstTokenContract } from '~/test/utils/tokenMovements'
import { aNewStore } from '~/store' import { aNewStore } from '~/store'
import { aMinedSafe } from '~/test/builder/safe.redux.builder' import { aMinedSafe } from '~/test/builder/safe.redux.builder'

View File

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

View File

@ -1,9 +1,9 @@
// @flow // @flow
import React from 'react' import React from 'react'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
import GnoStepper from '~/components/Stepper'
import Stepper from '@material-ui/core/Stepper' import Stepper from '@material-ui/core/Stepper'
import TestUtils from 'react-dom/test-utils' import TestUtils from 'react-dom/test-utils'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
import GnoStepper from '~/components/Stepper'
export const printOutApprove = async ( export const printOutApprove = async (
subject: string, subject: string,
@ -38,7 +38,10 @@ type FinsihedTx = {
finishedTransaction: boolean, 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 let times = 0
const interval = setInterval(() => { const interval = setInterval(() => {
if (times >= MAX_TIMES_EXECUTED) { if (times >= MAX_TIMES_EXECUTED) {

View File

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