pull from dev
This commit is contained in:
commit
556006a59a
46
package.json
46
package.json
|
@ -32,13 +32,13 @@
|
|||
"dependencies": {
|
||||
"@gnosis.pm/safe-contracts": "^1.0.0",
|
||||
"@gnosis.pm/util-contracts": "2.0.1",
|
||||
"@material-ui/core": "4.1.0",
|
||||
"@material-ui/icons": "4.1.0",
|
||||
"@welldone-software/why-did-you-render": "^3.0.9",
|
||||
"@material-ui/core": "4.1.3",
|
||||
"@material-ui/icons": "4.2.1",
|
||||
"@welldone-software/why-did-you-render": "3.2.1",
|
||||
"axios": "0.19.0",
|
||||
"bignumber.js": "9.0.0",
|
||||
"connected-react-router": "^6.3.1",
|
||||
"final-form": "4.14.1",
|
||||
"final-form": "4.16.1",
|
||||
"history": "^4.7.2",
|
||||
"immortal-db": "^1.0.2",
|
||||
"immutable": "^4.0.0-rc.9",
|
||||
|
@ -47,9 +47,9 @@
|
|||
"qrcode.react": "^0.9.3",
|
||||
"react": "^16.8.6",
|
||||
"react-dom": "^16.8.6",
|
||||
"react-final-form": "6.1.0",
|
||||
"react-final-form": "6.3.0",
|
||||
"react-final-form-listeners": "^1.0.2",
|
||||
"react-hot-loader": "4.11.0",
|
||||
"react-hot-loader": "4.11.1",
|
||||
"react-infinite-scroll-component": "^4.5.2",
|
||||
"react-redux": "7.1.0",
|
||||
"react-router-dom": "^4.3.1",
|
||||
|
@ -86,14 +86,14 @@
|
|||
"@babel/preset-flow": "^7.0.0-beta.40",
|
||||
"@babel/preset-react": "^7.0.0-beta.40",
|
||||
"@sambego/storybook-state": "^1.0.7",
|
||||
"@storybook/addon-actions": "5.1.4",
|
||||
"@storybook/addon-knobs": "5.1.4",
|
||||
"@storybook/addon-links": "5.1.4",
|
||||
"@storybook/react": "5.1.4",
|
||||
"@storybook/addon-actions": "5.1.9",
|
||||
"@storybook/addon-knobs": "5.1.9",
|
||||
"@storybook/addon-links": "5.1.9",
|
||||
"@storybook/react": "5.1.9",
|
||||
"@testing-library/react": "^8.0.1",
|
||||
"autoprefixer": "9.6.0",
|
||||
"babel-core": "^7.0.0-bridge.0",
|
||||
"babel-eslint": "^10.0.1",
|
||||
"babel-eslint": "10.0.2",
|
||||
"babel-jest": "24.8.0",
|
||||
"babel-loader": "8.0.6",
|
||||
"babel-plugin-dynamic-import-node": "^2.2.0",
|
||||
|
@ -104,15 +104,15 @@
|
|||
"detect-port": "^1.2.2",
|
||||
"eslint": "^5.16.0",
|
||||
"eslint-config-airbnb": "^17.1.0",
|
||||
"eslint-plugin-flowtype": "3.10.2",
|
||||
"eslint-plugin-import": "2.17.3",
|
||||
"eslint-plugin-jest": "22.6.4",
|
||||
"eslint-plugin-flowtype": "3.11.1",
|
||||
"eslint-plugin-import": "2.18.0",
|
||||
"eslint-plugin-jest": "22.7.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.0.3",
|
||||
"eslint-plugin-react": "7.13.0",
|
||||
"eslint-plugin-react": "7.14.2",
|
||||
"ethereumjs-abi": "^0.6.7",
|
||||
"extract-text-webpack-plugin": "^4.0.0-beta.0",
|
||||
"file-loader": "4.0.0",
|
||||
"flow-bin": "0.101.0",
|
||||
"flow-bin": "0.102.0",
|
||||
"fs-extra": "8.0.1",
|
||||
"html-loader": "^0.5.5",
|
||||
"html-webpack-plugin": "^3.0.4",
|
||||
|
@ -124,19 +124,19 @@
|
|||
"postcss-mixins": "^6.2.0",
|
||||
"postcss-simple-vars": "^5.0.2",
|
||||
"pre-commit": "^1.2.2",
|
||||
"prettier-eslint-cli": "^4.7.1",
|
||||
"prettier-eslint-cli": "5.0.0",
|
||||
"run-with-testrpc": "0.3.1",
|
||||
"storybook-host": "^5.0.3",
|
||||
"storybook-router": "^0.3.3",
|
||||
"style-loader": "^0.23.1",
|
||||
"truffle": "5.0.22",
|
||||
"truffle-contract": "4.0.20",
|
||||
"truffle-solidity-loader": "0.1.21",
|
||||
"truffle": "5.0.24",
|
||||
"truffle-contract": "4.0.21",
|
||||
"truffle-solidity-loader": "0.1.23",
|
||||
"uglifyjs-webpack-plugin": "2.1.3",
|
||||
"webpack": "4.34.0",
|
||||
"webpack": "4.35.0",
|
||||
"webpack-bundle-analyzer": "3.3.2",
|
||||
"webpack-cli": "3.3.4",
|
||||
"webpack-dev-server": "3.7.1",
|
||||
"webpack-cli": "3.3.5",
|
||||
"webpack-dev-server": "3.7.2",
|
||||
"webpack-manifest-plugin": "^2.0.0-rc.2"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -43,13 +43,12 @@ git clone https://github.com/gnosis/safe-contracts.git
|
|||
cd safe-contracts
|
||||
yarn
|
||||
ganache-cli -l 7000000
|
||||
npx truffle compile
|
||||
npx truffle migrate
|
||||
```
|
||||
2. Compiling Token Contracts for the tests:
|
||||
2. Migrate Token Contracts for the tests:
|
||||
Inside `safe-react` directory
|
||||
```
|
||||
npx truffle compile
|
||||
npx truffle migrate
|
||||
```
|
||||
3. Run the tests:
|
||||
```
|
||||
|
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
@ -53,7 +53,7 @@ class HeaderComponent extends React.PureComponent<Props, State> {
|
|||
let currentProvider: ProviderProps = await getProviderInfo()
|
||||
fetchProvider(currentProvider, openSnackbar)
|
||||
|
||||
this.providerListener = setInterval(async () => {
|
||||
this.providerListener = setInterval(async () => {
|
||||
const newProvider: ProviderProps = await getProviderInfo()
|
||||
if (JSON.stringify(currentProvider) !== JSON.stringify(newProvider)) {
|
||||
fetchProvider(newProvider, openSnackbar)
|
||||
|
|
|
@ -18,9 +18,10 @@ const SelectInput = ({
|
|||
formControlProps,
|
||||
classes,
|
||||
renderValue,
|
||||
disableError,
|
||||
...rest
|
||||
}: SelectFieldProps) => {
|
||||
const showError = ((meta.submitError && !meta.dirtySinceLastSubmit) || meta.error) && meta.touched
|
||||
const showError = ((meta.submitError && !meta.dirtySinceLastSubmit) || meta.error) && meta.touched && !disableError
|
||||
const inputProps = {
|
||||
...restInput,
|
||||
name,
|
||||
|
|
|
@ -70,4 +70,12 @@ export const inLimit = (limit: number, base: number, baseText: string, symbol: s
|
|||
return `Should not exceed ${max} ${symbol} (amount to reach ${baseText})`
|
||||
}
|
||||
|
||||
export const differentFrom = (diffValue: string) => (value: string) => {
|
||||
if (value === diffValue.toString()) {
|
||||
return `Value should be different than ${value}`
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const noErrorsOn = (name: string, errors: Object) => errors[name] === undefined
|
||||
|
|
|
@ -24,7 +24,7 @@ class Block extends PureComponent<Props> {
|
|||
const paddingStyle = padding ? capitalize(padding, 'padding') : undefined
|
||||
return (
|
||||
<div className={cx(className, 'block', margin, paddingStyle, align)} {...props}>
|
||||
{ children }
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ type Props = {
|
|||
type: 'button' | 'submit' | 'reset',
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | 'xxl',
|
||||
weight?: 'light' | 'regular' | 'bolder' | 'bold',
|
||||
color?: 'soft' | 'medium' | 'dark' | 'white' | 'fancy' | 'primary' | 'secondary' | 'warning' | 'disabled',
|
||||
color?: 'soft' | 'medium' | 'dark' | 'white' | 'fancy' | 'primary' | 'secondary' | 'warning' | 'disabled' | 'error',
|
||||
testId?: string,
|
||||
}
|
||||
|
||||
|
|
|
@ -74,6 +74,10 @@
|
|||
color: $disabled;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: $error;
|
||||
}
|
||||
|
||||
.white {
|
||||
color: white;
|
||||
}
|
|
@ -6,33 +6,27 @@ import styles from './index.scss'
|
|||
|
||||
const cx = classNames.bind(styles)
|
||||
|
||||
type HeadingTag = 'h1' | 'h2' | 'h3' | 'h4';
|
||||
type HeadingTag = 'h1' | 'h2' | 'h3' | 'h4'
|
||||
|
||||
type Props = {
|
||||
align?: 'left' | 'center' | 'right',
|
||||
margin?: 'sm' | 'md' | 'lg' | 'xl',
|
||||
color?: 'soft' | 'medium' | 'dark' | 'white' | 'fancy' | 'primary' | 'secondary' | 'warning' | 'disabled',
|
||||
color?: 'soft' | 'medium' | 'dark' | 'white' | 'fancy' | 'primary' | 'secondary' | 'warning' | 'disabled' | 'error',
|
||||
tag: HeadingTag,
|
||||
truncate?: boolean,
|
||||
children: React.Node,
|
||||
testId?: string,
|
||||
}
|
||||
|
||||
class Heading extends React.PureComponent<Props> {
|
||||
render() {
|
||||
const {
|
||||
align, tag, truncate, margin, color, children, ...props
|
||||
align, tag, truncate, margin, color, children, testId, ...props
|
||||
} = this.props
|
||||
|
||||
const className = cx(
|
||||
'heading',
|
||||
align,
|
||||
tag,
|
||||
margin ? capitalize(margin, 'margin') : undefined,
|
||||
color,
|
||||
{ truncate },
|
||||
)
|
||||
const className = cx('heading', align, tag, margin ? capitalize(margin, 'margin') : undefined, color, { truncate })
|
||||
|
||||
return React.createElement(tag, { ...props, className }, children)
|
||||
return React.createElement(tag, { ...props, className, 'data-testid': testId || '' }, children)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -95,6 +95,10 @@
|
|||
color: $disabled;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: $error;
|
||||
}
|
||||
|
||||
.white {
|
||||
color: white;
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ type Props = {
|
|||
children: React.Node,
|
||||
color?: 'regular' | 'white',
|
||||
className?: string,
|
||||
innerRef: React.ElementRef<any>,
|
||||
innerRef?: React.ElementRef<any>,
|
||||
}
|
||||
|
||||
const GnosisLink = ({
|
||||
|
|
|
@ -11,7 +11,7 @@ type Props = {
|
|||
noPadding?: boolean,
|
||||
weight?: 'light' | 'regular' | 'bolder' | 'bold',
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | 'xxl',
|
||||
color?: 'soft' | 'medium' | 'dark' | 'white' | 'fancy' | 'primary' | 'secondary' | 'warning' | 'disabled',
|
||||
color?: 'soft' | 'medium' | 'dark' | 'white' | 'fancy' | 'primary' | 'secondary' | 'warning' | 'disabled' | 'error',
|
||||
transform?: 'capitalize' | 'lowercase' | 'uppercase',
|
||||
children: React.Node,
|
||||
dot?: boolean,
|
||||
|
|
|
@ -35,6 +35,10 @@
|
|||
color: $disabled;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: $error;
|
||||
}
|
||||
|
||||
.white {
|
||||
color: white;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
// @flow
|
||||
import classNames from 'classnames/bind'
|
||||
import React from 'react'
|
||||
import * as React from 'react'
|
||||
import { capitalize } from '~/utils/css'
|
||||
import styles from './index.scss'
|
||||
|
||||
|
@ -27,7 +27,7 @@ const Row = ({
|
|||
|
||||
return (
|
||||
<div className={rowClassNames} {...props}>
|
||||
{ children }
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import {
|
|||
} from '~/config/names'
|
||||
|
||||
const devConfig = {
|
||||
[TX_SERVICE_HOST]: 'https://safe-transaction-history.dev.gnosisdev.com/api/v1/',
|
||||
[TX_SERVICE_HOST]: 'https://safe-transaction-service.dev.gnosisdev.com/api/v1/',
|
||||
[ENABLED_TX_SERVICE_REMOVAL_SENDER]: false,
|
||||
[SIGNATURES_VIA_METAMASK]: false,
|
||||
[RELAY_API_URL]: 'https://safe-relay.staging.gnosisdev.com/api/v1/',
|
||||
|
|
|
@ -7,7 +7,7 @@ import {
|
|||
} from '~/config/names'
|
||||
|
||||
const prodConfig = {
|
||||
[TX_SERVICE_HOST]: 'https://safe-transaction-history.dev.gnosisdev.com/api/v1/',
|
||||
[TX_SERVICE_HOST]: 'https://safe-transaction-service.dev.gnosisdev.com/api/v1/',
|
||||
[ENABLED_TX_SERVICE_REMOVAL_SENDER]: false,
|
||||
[SIGNATURES_VIA_METAMASK]: false,
|
||||
[RELAY_API_URL]: 'https://safe-relay.staging.gnosisdev.com/api/v1/',
|
||||
|
|
|
@ -7,7 +7,7 @@ import {
|
|||
} from '~/config/names'
|
||||
|
||||
const testConfig = {
|
||||
[TX_SERVICE_HOST]: 'http://localhost:8000/api/v1/',
|
||||
[TX_SERVICE_HOST]: 'https://safe-transaction-service.dev.gnosisdev.com/api/v1/',
|
||||
[ENABLED_TX_SERVICE_REMOVAL_SENDER]: false,
|
||||
[SIGNATURES_VIA_METAMASK]: false,
|
||||
[RELAY_API_URL]: 'https://safe-relay.staging.gnosisdev.com/api/v1',
|
||||
|
|
|
@ -14,7 +14,7 @@ export const TX_TYPE_EXECUTION = 'execution'
|
|||
export const executeTransaction = async (
|
||||
safeInstance: any,
|
||||
to: string,
|
||||
valueInWei: number,
|
||||
valueInWei: number | string,
|
||||
data: string,
|
||||
operation: number | string,
|
||||
nonce: string | number,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// @flow
|
||||
import { type Owner } from '~/routes/safe/store/models/owner'
|
||||
import { List, Map } from 'immutable'
|
||||
import { loadFromStorage, saveToStorage } from '~/utils/storage'
|
||||
import { loadFromStorage, saveToStorage, removeFromStorage } from '~/utils/storage'
|
||||
|
||||
export const SAFES_KEY = 'SAFES'
|
||||
export const TX_KEY = 'TX'
|
||||
|
@ -36,8 +36,17 @@ export const setOwners = async (safeAddress: string, owners: List<Owner>) => {
|
|||
}
|
||||
}
|
||||
|
||||
export const getOwners = async (safeAddress: string): Map<string, string> => {
|
||||
export const getOwners = async (safeAddress: string): Promise<Map<string, string>> => {
|
||||
const data: Object = await loadFromStorage(`${OWNERS_KEY}-${safeAddress}`)
|
||||
console.log(data)
|
||||
|
||||
return data ? Map(data) : Map()
|
||||
}
|
||||
|
||||
export const removeOwners = async (safeAddress: string): Promise<void> => {
|
||||
try {
|
||||
await removeFromStorage(`${OWNERS_KEY}-${safeAddress}`)
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line
|
||||
console.log('Error removing owners from localstorage')
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,9 +8,6 @@ type AddTokenProps = {
|
|||
token: Token,
|
||||
}
|
||||
|
||||
export const addToken = createAction<string, *, *>(
|
||||
ADD_TOKEN,
|
||||
(token: Token): AddTokenProps => ({
|
||||
token,
|
||||
}),
|
||||
)
|
||||
export const addToken = createAction<string, *, *>(ADD_TOKEN, (token: Token): AddTokenProps => ({
|
||||
token,
|
||||
}))
|
||||
|
|
|
@ -9,11 +9,8 @@ type TokenProps = {
|
|||
tokens: Map<string, Token>,
|
||||
}
|
||||
|
||||
const addTokens = createAction<string, *, *>(
|
||||
ADD_TOKENS,
|
||||
(tokens: Map<string, Token>): TokenProps => ({
|
||||
tokens,
|
||||
}),
|
||||
)
|
||||
const addTokens = createAction<string, *, *>(ADD_TOKENS, (tokens: Map<string, Token>): TokenProps => ({
|
||||
tokens,
|
||||
}))
|
||||
|
||||
export default addTokens
|
||||
|
|
|
@ -7,12 +7,13 @@ import Heading from '~/components/layout/Heading'
|
|||
import Row from '~/components/layout/Row'
|
||||
import IconButton from '@material-ui/core/IconButton'
|
||||
import ReviewInformation from '~/routes/load/components/ReviewInformation'
|
||||
import OwnerList from '~/routes/load/components/OwnerList'
|
||||
import DetailsForm, { safeFieldsValidation } from '~/routes/load/components/DetailsForm'
|
||||
import { history } from '~/store'
|
||||
import { secondary } from '~/theme/variables'
|
||||
import { type SelectorProps } from '~/routes/load/container/selector'
|
||||
|
||||
const getSteps = () => ['Details', 'Review']
|
||||
const getSteps = () => ['Details', 'Owners', 'Review']
|
||||
|
||||
type Props = SelectorProps & {
|
||||
onLoadSafeSubmit: (values: Object) => Promise<void>,
|
||||
|
@ -32,6 +33,7 @@ const Layout = ({
|
|||
provider, onLoadSafeSubmit, network, userAddress,
|
||||
}: Props) => {
|
||||
const steps = getSteps()
|
||||
const initialValues = {}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
|
@ -43,15 +45,16 @@ const Layout = ({
|
|||
</IconButton>
|
||||
<Heading tag="h2">Load existing Safe</Heading>
|
||||
</Row>
|
||||
<Stepper onSubmit={onLoadSafeSubmit} steps={steps} testId="load-safe-form">
|
||||
<Stepper onSubmit={onLoadSafeSubmit} steps={steps} initialValues={initialValues} testId="load-safe-form">
|
||||
<Stepper.Page validate={safeFieldsValidation}>{DetailsForm}</Stepper.Page>
|
||||
<Stepper.Page network={network}>{OwnerList}</Stepper.Page>
|
||||
<Stepper.Page network={network} userAddress={userAddress}>
|
||||
{ReviewInformation}
|
||||
</Stepper.Page>
|
||||
</Stepper>
|
||||
</Block>
|
||||
) : (
|
||||
<div>No metamask detected</div>
|
||||
<div>No account detected</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
|
|
|
@ -0,0 +1,176 @@
|
|||
// @flow
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { withStyles } from '@material-ui/core/styles'
|
||||
import OpenInNew from '@material-ui/icons/OpenInNew'
|
||||
import Block from '~/components/layout/Block'
|
||||
import Field from '~/components/forms/Field'
|
||||
import { required } from '~/components/forms/validator'
|
||||
import TextField from '~/components/forms/TextField'
|
||||
import Identicon from '~/components/Identicon'
|
||||
import OpenPaper from '~/components/Stepper/OpenPaper'
|
||||
import Col from '~/components/layout/Col'
|
||||
import Row from '~/components/layout/Row'
|
||||
import Link from '~/components/layout/Link'
|
||||
import Paragraph from '~/components/layout/Paragraph'
|
||||
import Hairline from '~/components/layout/Hairline'
|
||||
import {
|
||||
sm, md, lg, border, secondary,
|
||||
} from '~/theme/variables'
|
||||
import { getOwnerNameBy, getOwnerAddressBy } from '~/routes/open/components/fields'
|
||||
import { getEtherScanLink } from '~/logic/wallets/getWeb3'
|
||||
import { FIELD_LOAD_ADDRESS, THRESHOLD } from '~/routes/load/components/fields'
|
||||
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
|
||||
|
||||
const openIconStyle = {
|
||||
height: '16px',
|
||||
color: secondary,
|
||||
}
|
||||
|
||||
const styles = () => ({
|
||||
details: {
|
||||
padding: lg,
|
||||
borderRight: `solid 1px ${border}`,
|
||||
height: '100%',
|
||||
},
|
||||
owners: {
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
ownerNames: {
|
||||
maxWidth: '400px',
|
||||
},
|
||||
ownerAddresses: {
|
||||
alignItems: 'center',
|
||||
marginLeft: `${sm}`,
|
||||
},
|
||||
address: {
|
||||
paddingLeft: '6px',
|
||||
},
|
||||
open: {
|
||||
paddingLeft: sm,
|
||||
width: 'auto',
|
||||
'&:hover': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
},
|
||||
title: {
|
||||
padding: `${md} ${lg}`,
|
||||
},
|
||||
owner: {
|
||||
padding: `0 ${lg}`,
|
||||
marginBottom: '12px',
|
||||
},
|
||||
header: {
|
||||
padding: `${sm} ${lg}`,
|
||||
},
|
||||
name: {
|
||||
marginRight: `${sm}`,
|
||||
},
|
||||
})
|
||||
|
||||
type LayoutProps = {
|
||||
network: string,
|
||||
}
|
||||
|
||||
type Props = LayoutProps & {
|
||||
values: Object,
|
||||
classes: Object,
|
||||
updateInitialProps: (initialValues: Object) => void,
|
||||
}
|
||||
|
||||
const calculateSafeValues = (owners: Array<string>, threshold: Number, values: Object) => {
|
||||
const initialValues = { ...values }
|
||||
for (let i = 0; i < owners.length; i += 1) {
|
||||
initialValues[getOwnerAddressBy(i)] = owners[i]
|
||||
}
|
||||
initialValues[THRESHOLD] = threshold
|
||||
return initialValues
|
||||
}
|
||||
|
||||
const OwnerListComponent = (props: Props) => {
|
||||
const [owners, setOwners] = useState<Array<string>>([])
|
||||
const {
|
||||
values, updateInitialProps, network, classes,
|
||||
} = props
|
||||
|
||||
useEffect(() => {
|
||||
let isCurrent = true
|
||||
|
||||
const fetchSafe = async () => {
|
||||
const safeAddress = values[FIELD_LOAD_ADDRESS]
|
||||
const gnosisSafe = await getGnosisSafeInstanceAt(safeAddress)
|
||||
const safeOwners = await gnosisSafe.getOwners()
|
||||
const threshold = await gnosisSafe.getThreshold()
|
||||
|
||||
if (isCurrent && owners) {
|
||||
const sortedOwners = safeOwners.sort()
|
||||
const initialValues = calculateSafeValues(sortedOwners, threshold, values)
|
||||
updateInitialProps(initialValues)
|
||||
setOwners(sortedOwners)
|
||||
}
|
||||
}
|
||||
|
||||
fetchSafe()
|
||||
|
||||
return () => {
|
||||
isCurrent = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Block className={classes.title}>
|
||||
<Paragraph noMargin size="md" color="primary">
|
||||
{`This Safe has ${owners.length} owners. Optional: Provide a name for each owner.`}
|
||||
</Paragraph>
|
||||
</Block>
|
||||
<Hairline />
|
||||
<Row className={classes.header}>
|
||||
<Col xs={4}>NAME</Col>
|
||||
<Col xs={8}>ADDRESS</Col>
|
||||
</Row>
|
||||
<Hairline />
|
||||
<Block margin="md" padding="md">
|
||||
{owners.map((address, index) => (
|
||||
<Row key={address} className={classes.owner}>
|
||||
<Col xs={4}>
|
||||
<Field
|
||||
className={classes.name}
|
||||
name={getOwnerNameBy(index)}
|
||||
component={TextField}
|
||||
type="text"
|
||||
validate={required}
|
||||
initialValue={`Owner #${index + 1}`}
|
||||
placeholder="Owner Name*"
|
||||
text="Owner Name"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={7}>
|
||||
<Row className={classes.ownerAddresses}>
|
||||
<Identicon address={address} diameter={32} />
|
||||
<Paragraph size="md" color="disabled" noMargin className={classes.address}>
|
||||
{address}
|
||||
</Paragraph>
|
||||
<Link className={classes.open} to={getEtherScanLink(address, network)} target="_blank">
|
||||
<OpenInNew style={openIconStyle} />
|
||||
</Link>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
))}
|
||||
</Block>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
const OwnerListPage = withStyles(styles)(OwnerListComponent)
|
||||
|
||||
const OwnerList = ({ updateInitialProps }: Object, network: string) => (controls: React$Node, { values }: Object) => (
|
||||
<React.Fragment>
|
||||
<OpenPaper controls={controls} padding={false}>
|
||||
<OwnerListPage network={network} updateInitialProps={updateInitialProps} values={values} />
|
||||
</OpenPaper>
|
||||
</React.Fragment>
|
||||
)
|
||||
|
||||
export default OwnerList
|
|
@ -1,20 +1,24 @@
|
|||
// @flow
|
||||
import * as React from 'react'
|
||||
import classNames from 'classnames'
|
||||
import Block from '~/components/layout/Block'
|
||||
import { withStyles } from '@material-ui/core/styles'
|
||||
import OpenInNew from '@material-ui/icons/OpenInNew'
|
||||
import Identicon from '~/components/Identicon'
|
||||
import OpenPaper from '~/components/Stepper/OpenPaper'
|
||||
import Col from '~/components/layout/Col'
|
||||
import Row from '~/components/layout/Row'
|
||||
import Link from '~/components/layout/Link'
|
||||
import Paragraph from '~/components/layout/Paragraph'
|
||||
import Hairline from '~/components/layout/Hairline'
|
||||
import {
|
||||
xs, sm, lg, border, secondary,
|
||||
} from '~/theme/variables'
|
||||
import { getEtherScanLink, getWeb3 } from '~/logic/wallets/getWeb3'
|
||||
import { FIELD_LOAD_NAME, FIELD_LOAD_ADDRESS } from '~/routes/load/components/fields'
|
||||
import { sameAddress } from '~/logic/wallets/ethAddresses'
|
||||
import { getGnosisSafeContract } from '~/logic/contracts/safeContracts'
|
||||
import { shortVersionOf } from '~/logic/wallets/ethAddresses'
|
||||
import { getAccountsFrom } from '~/routes/open/utils/safeDataExtractor'
|
||||
import { getOwnerNameBy, getOwnerAddressBy, getNumOwnersFrom } from '~/routes/open/components/fields'
|
||||
import { getEtherScanLink } from '~/logic/wallets/getWeb3'
|
||||
import { FIELD_LOAD_NAME, FIELD_LOAD_ADDRESS, THRESHOLD } from '~/routes/load/components/fields'
|
||||
|
||||
const openIconStyle = {
|
||||
height: '16px',
|
||||
|
@ -22,20 +26,31 @@ const openIconStyle = {
|
|||
}
|
||||
|
||||
const styles = () => ({
|
||||
root: {
|
||||
minHeight: '300px',
|
||||
},
|
||||
details: {
|
||||
padding: lg,
|
||||
borderRight: `solid 1px ${border}`,
|
||||
height: '100%',
|
||||
},
|
||||
name: {
|
||||
letterSpacing: '-0.6px',
|
||||
owners: {
|
||||
padding: lg,
|
||||
},
|
||||
container: {
|
||||
marginTop: xs,
|
||||
name: {
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
userName: {
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
owner: {
|
||||
padding: sm,
|
||||
paddingLeft: lg,
|
||||
alignItems: 'center',
|
||||
},
|
||||
address: {
|
||||
paddingLeft: '6px',
|
||||
user: {
|
||||
justifyContent: 'left',
|
||||
},
|
||||
open: {
|
||||
paddingLeft: sm,
|
||||
|
@ -44,6 +59,13 @@ const styles = () => ({
|
|||
cursor: 'pointer',
|
||||
},
|
||||
},
|
||||
container: {
|
||||
marginTop: xs,
|
||||
alignItems: 'center',
|
||||
},
|
||||
address: {
|
||||
paddingLeft: '6px',
|
||||
},
|
||||
})
|
||||
|
||||
type LayoutProps = {
|
||||
|
@ -60,77 +82,113 @@ type State = {
|
|||
isOwner: boolean,
|
||||
}
|
||||
|
||||
const checkUserAddressOwner = (values: Object, userAddress: string): boolean => {
|
||||
let isOwner: boolean = false
|
||||
|
||||
for (let i = 0; i < getNumOwnersFrom(values); i += 1) {
|
||||
if (values[getOwnerAddressBy(i)] === userAddress) {
|
||||
isOwner = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return isOwner
|
||||
}
|
||||
|
||||
class ReviewComponent extends React.PureComponent<Props, State> {
|
||||
state = {
|
||||
isOwner: false,
|
||||
}
|
||||
|
||||
mounted = false
|
||||
|
||||
componentDidMount = async () => {
|
||||
this.mounted = true
|
||||
|
||||
const { values, userAddress } = this.props
|
||||
const safeAddress = values[FIELD_LOAD_ADDRESS]
|
||||
const web3 = getWeb3()
|
||||
|
||||
const GnosisSafe = getGnosisSafeContract(web3)
|
||||
const gnosisSafe = await GnosisSafe.at(safeAddress)
|
||||
const owners = await gnosisSafe.getOwners()
|
||||
if (!owners) {
|
||||
return
|
||||
}
|
||||
|
||||
const isOwner = owners.find((owner: string) => sameAddress(owner, userAddress)) !== undefined
|
||||
if (this.mounted) {
|
||||
this.setState(() => ({ isOwner }))
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.mounted = false
|
||||
}
|
||||
|
||||
render() {
|
||||
const { values, classes, network } = this.props
|
||||
const { isOwner } = this.state
|
||||
const {
|
||||
values, classes, network, userAddress,
|
||||
} = this.props
|
||||
|
||||
const isOwner = checkUserAddressOwner(values, userAddress)
|
||||
const owners = getAccountsFrom(values)
|
||||
const safeAddress = values[FIELD_LOAD_ADDRESS]
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Block className={classes.details}>
|
||||
<Block margin="lg">
|
||||
<Paragraph size="sm" color="disabled" noMargin>
|
||||
Name of the Safe
|
||||
</Paragraph>
|
||||
<Paragraph size="lg" color="primary" noMargin weight="bolder" className={classes.name}>
|
||||
{values[FIELD_LOAD_NAME]}
|
||||
</Paragraph>
|
||||
</Block>
|
||||
<Block margin="lg">
|
||||
<Paragraph size="sm" color="disabled" noMargin>
|
||||
Safe address
|
||||
</Paragraph>
|
||||
<Row className={classes.container}>
|
||||
<Identicon address={safeAddress} diameter={32} />
|
||||
<Paragraph size="md" color="disabled" noMargin className={classes.address}>
|
||||
{safeAddress}
|
||||
<Row className={classes.root}>
|
||||
<Col xs={4} layout="column">
|
||||
<Block className={classes.details}>
|
||||
<Block margin="lg">
|
||||
<Paragraph size="lg" color="primary" noMargin>
|
||||
Review details
|
||||
</Paragraph>
|
||||
</Block>
|
||||
<Block margin="lg">
|
||||
<Paragraph size="sm" color="disabled" noMargin>
|
||||
Name of the Safe
|
||||
</Paragraph>
|
||||
<Paragraph size="lg" color="primary" noMargin weight="bolder" className={classes.name}>
|
||||
{values[FIELD_LOAD_NAME]}
|
||||
</Paragraph>
|
||||
</Block>
|
||||
<Block margin="lg">
|
||||
<Paragraph size="sm" color="disabled" noMargin>
|
||||
Safe address
|
||||
</Paragraph>
|
||||
<Row className={classes.container}>
|
||||
<Identicon address={safeAddress} diameter={32} />
|
||||
<Paragraph size="md" color="disabled" noMargin className={classes.address}>
|
||||
{shortVersionOf(safeAddress, 4)}
|
||||
</Paragraph>
|
||||
<Link className={classes.open} to={getEtherScanLink(safeAddress, network)} target="_blank">
|
||||
<OpenInNew style={openIconStyle} />
|
||||
</Link>
|
||||
</Row>
|
||||
</Block>
|
||||
<Block margin="lg">
|
||||
<Paragraph size="sm" color="disabled" noMargin>
|
||||
Connected wallet client is owner?
|
||||
</Paragraph>
|
||||
<Paragraph size="lg" color="primary" noMargin weight="bolder" className={classes.name}>
|
||||
{isOwner ? 'Yes' : 'No (read-only)'}
|
||||
</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 ${getNumOwnersFrom(values)} owners`}
|
||||
</Paragraph>
|
||||
</Block>
|
||||
</Block>
|
||||
</Col>
|
||||
<Col xs={8} layout="column">
|
||||
<Block className={classes.owners}>
|
||||
<Paragraph size="lg" color="primary" noMargin>
|
||||
{`${getNumOwnersFrom(values)} Safe owners`}
|
||||
</Paragraph>
|
||||
<Link className={classes.open} to={getEtherScanLink(safeAddress, network)} target="_blank">
|
||||
<OpenInNew style={openIconStyle} />
|
||||
</Link>
|
||||
</Row>
|
||||
</Block>
|
||||
<Block margin="lg">
|
||||
<Paragraph size="sm" color="disabled" noMargin>
|
||||
Connected wallet client is owner?
|
||||
</Paragraph>
|
||||
<Paragraph size="lg" color="primary" noMargin weight="bolder" className={classes.name}>
|
||||
{isOwner ? 'Yes' : 'No (read-only)'}
|
||||
</Paragraph>
|
||||
</Block>
|
||||
</Block>
|
||||
</Block>
|
||||
<Hairline />
|
||||
{owners.map((x, index) => (
|
||||
<React.Fragment key={owners[index].address}>
|
||||
<Row className={classes.owner}>
|
||||
<Col xs={1} align="center">
|
||||
<Identicon address={owners[index]} diameter={32} />
|
||||
</Col>
|
||||
<Col xs={11}>
|
||||
<Block className={classNames(classes.name, classes.userName)}>
|
||||
<Paragraph size="lg" noMargin>
|
||||
{values[getOwnerNameBy(index)]}
|
||||
</Paragraph>
|
||||
<Block align="center" className={classes.user}>
|
||||
<Paragraph size="md" color="disabled" noMargin>
|
||||
{owners[index]}
|
||||
</Paragraph>
|
||||
<Link className={classes.open} to={getEtherScanLink(owners[index], network)} target="_blank">
|
||||
<OpenInNew style={openIconStyle} />
|
||||
</Link>
|
||||
</Block>
|
||||
</Block>
|
||||
</Col>
|
||||
</Row>
|
||||
<Hairline />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Col>
|
||||
</Row>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
// @flow
|
||||
export const FIELD_LOAD_NAME: string = 'name'
|
||||
export const FIELD_LOAD_ADDRESS: string = 'address'
|
||||
export const THRESHOLD: string = 'threshold'
|
||||
|
|
|
@ -10,13 +10,20 @@ import { history } from '~/store'
|
|||
import selector, { type SelectorProps } from './selector'
|
||||
import actions, { type Actions } from './actions'
|
||||
import Layout from '../components/Layout'
|
||||
import { getNamesFrom, getOwnersFrom } from '~/routes/open/utils/safeDataExtractor'
|
||||
import { FIELD_LOAD_NAME, FIELD_LOAD_ADDRESS } from '../components/fields'
|
||||
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
|
||||
|
||||
type Props = SelectorProps & Actions
|
||||
|
||||
export const loadSafe = async (safeName: string, safeAddress: string, addSafe: Function) => {
|
||||
export const loadSafe = async (
|
||||
safeName: string,
|
||||
safeAddress: string,
|
||||
owners: Array,
|
||||
addSafe: Function
|
||||
) => {
|
||||
const safeProps = await buildSafe(safeAddress, safeName)
|
||||
|
||||
safeProps.owners = owners
|
||||
await addSafe(safeProps)
|
||||
|
||||
const storedSafes = (await loadFromStorage(SAFES_KEY)) || {}
|
||||
|
@ -31,8 +38,13 @@ class Load extends React.Component<Props> {
|
|||
const { addSafe } = this.props
|
||||
const safeName = values[FIELD_LOAD_NAME]
|
||||
const safeAddress = values[FIELD_LOAD_ADDRESS]
|
||||
const ownerNames = getNamesFrom(values)
|
||||
|
||||
await loadSafe(safeName, safeAddress, addSafe)
|
||||
const gnosisSafe = await getGnosisSafeInstanceAt(safeAddress)
|
||||
const ownerAddresses = await gnosisSafe.getOwners()
|
||||
const owners = getOwnersFrom(ownerNames, ownerAddresses.sort())
|
||||
|
||||
await loadSafe(safeName, safeAddress, owners, addSafe)
|
||||
|
||||
const url = `${SAFELIST_ADDRESS}/${safeAddress}`
|
||||
history.push(url)
|
||||
|
|
|
@ -23,8 +23,7 @@ import OpenPaper from '~/components/Stepper/OpenPaper'
|
|||
import { getAccountsFrom } from '~/routes/open/utils/safeDataExtractor'
|
||||
import Hairline from '~/components/layout/Hairline'
|
||||
import { md, lg, sm } from '~/theme/variables'
|
||||
|
||||
const trash = require('../../assets/trash.svg')
|
||||
import trash from '~/assets/icons/trash.svg'
|
||||
|
||||
type Props = {
|
||||
classes: Object,
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
// @flow
|
||||
import { makeOwner } from '~/routes/safe/store/models/owner'
|
||||
|
||||
export const getAccountsFrom = (values: Object): string[] => {
|
||||
const accounts = Object.keys(values)
|
||||
.sort()
|
||||
|
@ -15,6 +17,12 @@ export const getNamesFrom = (values: Object): string[] => {
|
|||
return accounts.map(account => values[account]).slice(0, values.owners)
|
||||
}
|
||||
|
||||
export const getOwnersFrom = (names: string[], addresses: string[]): Array<string, string> => {
|
||||
const owners = names.map((name: string, index: number) => makeOwner({ name, address: addresses[index] }))
|
||||
|
||||
return owners
|
||||
}
|
||||
|
||||
export const getThresholdFrom = (values: Object): number => Number(values.confirmations)
|
||||
|
||||
export const getSafeNameFrom = (values: Object): string => values.name
|
||||
|
|
|
@ -17,8 +17,12 @@ import { copyToClipboard } from '~/utils/clipboard'
|
|||
import Hairline from '~/components/layout/Hairline'
|
||||
import SafeInfo from '~/routes/safe/components/Balances/SendModal/SafeInfo'
|
||||
import { setImageToPlaceholder } from '~/routes/safe/components/Balances/utils'
|
||||
import { getStandardTokenContract } from '~/logic/tokens/store/actions/fetchTokens'
|
||||
import { EMPTY_DATA } from '~/logic/wallets/ethTransactions'
|
||||
import { getWeb3 } from '~/logic/wallets/getWeb3'
|
||||
import ArrowDown from '../assets/arrow-down.svg'
|
||||
import { secondary } from '~/theme/variables'
|
||||
import { isEther } from '~/logic/tokens/utils/tokenHelpers'
|
||||
import { styles } from './style'
|
||||
|
||||
type Props = {
|
||||
|
@ -50,87 +54,109 @@ const ReviewTx = ({
|
|||
createTransaction,
|
||||
}: Props) => (
|
||||
<SharedSnackbarConsumer>
|
||||
{({ openSnackbar }) => (
|
||||
<React.Fragment>
|
||||
<Row align="center" grow className={classes.heading}>
|
||||
<Paragraph weight="bolder" className={classes.headingText} noMargin>
|
||||
Send Funds
|
||||
</Paragraph>
|
||||
<Paragraph className={classes.annotation}>2 of 2</Paragraph>
|
||||
<IconButton onClick={onClose} disableRipple>
|
||||
<Close className={classes.closeIcon} />
|
||||
</IconButton>
|
||||
</Row>
|
||||
<Hairline />
|
||||
<Block className={classes.container}>
|
||||
<SafeInfo
|
||||
safeAddress={safeAddress}
|
||||
etherScanLink={etherScanLink}
|
||||
safeName={safeName}
|
||||
ethBalance={ethBalance}
|
||||
/>
|
||||
<Row margin="md">
|
||||
<Col xs={1}>
|
||||
<img src={ArrowDown} alt="Arrow Down" style={{ marginLeft: '8px' }} />
|
||||
</Col>
|
||||
<Col xs={11} center="xs" layout="column">
|
||||
<Hairline />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row margin="xs">
|
||||
<Paragraph size="md" color="disabled" style={{ letterSpacing: '-0.5px' }} noMargin>
|
||||
Recipient
|
||||
{({ openSnackbar }) => {
|
||||
const submitTx = async () => {
|
||||
const web3 = getWeb3()
|
||||
const isSendingETH = isEther(tx.token.symbol)
|
||||
const txRecipient = isSendingETH ? tx.recipientAddress : tx.token.address
|
||||
let txData = EMPTY_DATA
|
||||
let txAmount = web3.utils.toWei(tx.amount, 'ether')
|
||||
|
||||
|
||||
if (!isSendingETH) {
|
||||
const StandardToken = await getStandardTokenContract()
|
||||
const tokenInstance = await StandardToken.at(tx.token.address)
|
||||
|
||||
txData = tokenInstance.contract.methods.transfer(tx.recipientAddress, txAmount).encodeABI()
|
||||
// txAmount should be 0 if we send tokens
|
||||
// the real value is encoded in txData and will be used by the contract
|
||||
// if txAmount > 0 it would send ETH from the safe
|
||||
txAmount = 0
|
||||
}
|
||||
|
||||
createTransaction(safeAddress, txRecipient, txAmount, txData, openSnackbar)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Row align="center" grow className={classes.heading}>
|
||||
<Paragraph weight="bolder" className={classes.headingText} noMargin>
|
||||
Send Funds
|
||||
</Paragraph>
|
||||
<Paragraph className={classes.annotation}>2 of 2</Paragraph>
|
||||
<IconButton onClick={onClose} disableRipple>
|
||||
<Close className={classes.closeIcon} />
|
||||
</IconButton>
|
||||
</Row>
|
||||
<Row margin="md" align="center">
|
||||
<Col xs={1}>
|
||||
<Identicon address={tx.recipientAddress} diameter={32} />
|
||||
</Col>
|
||||
<Col xs={11} layout="column">
|
||||
<Paragraph weight="bolder" onClick={copyToClipboard} noMargin>
|
||||
{tx.recipientAddress}
|
||||
<Link to={etherScanLink} target="_blank">
|
||||
<OpenInNew style={openIconStyle} />
|
||||
</Link>
|
||||
<Hairline />
|
||||
<Block className={classes.container}>
|
||||
<SafeInfo
|
||||
safeAddress={safeAddress}
|
||||
etherScanLink={etherScanLink}
|
||||
safeName={safeName}
|
||||
ethBalance={ethBalance}
|
||||
/>
|
||||
<Row margin="md">
|
||||
<Col xs={1}>
|
||||
<img src={ArrowDown} alt="Arrow Down" style={{ marginLeft: '8px' }} />
|
||||
</Col>
|
||||
<Col xs={11} center="xs" layout="column">
|
||||
<Hairline />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row margin="xs">
|
||||
<Paragraph size="md" color="disabled" style={{ letterSpacing: '-0.5px' }} noMargin>
|
||||
Recipient
|
||||
</Paragraph>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row margin="md" align="center">
|
||||
<Col xs={1}>
|
||||
<Identicon address={tx.recipientAddress} diameter={32} />
|
||||
</Col>
|
||||
<Col xs={11} layout="column">
|
||||
<Paragraph weight="bolder" onClick={copyToClipboard} noMargin>
|
||||
{tx.recipientAddress}
|
||||
<Link to={etherScanLink} target="_blank">
|
||||
<OpenInNew style={openIconStyle} />
|
||||
</Link>
|
||||
</Paragraph>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row margin="xs">
|
||||
<Paragraph size="md" color="disabled" style={{ letterSpacing: '-0.5px' }} noMargin>
|
||||
Amount
|
||||
</Paragraph>
|
||||
</Row>
|
||||
<Row margin="md" align="center">
|
||||
<Img src={tx.token.logoUri} height={28} alt={tx.token.name} onError={setImageToPlaceholder} />
|
||||
<Paragraph size="md" noMargin className={classes.amount}>
|
||||
{tx.amount}
|
||||
{' '}
|
||||
{tx.token.symbol}
|
||||
</Paragraph>
|
||||
</Row>
|
||||
</Block>
|
||||
<Hairline style={{ position: 'absolute', bottom: 85 }} />
|
||||
<Row align="center" className={classes.buttonRow}>
|
||||
<Button className={classes.button} minWidth={140} onClick={onClickBack}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className={classes.button}
|
||||
onClick={submitTx}
|
||||
variant="contained"
|
||||
minWidth={140}
|
||||
color="primary"
|
||||
data-testid="submit-tx-btn"
|
||||
>
|
||||
SUBMIT
|
||||
</Button>
|
||||
</Row>
|
||||
<Row margin="xs">
|
||||
<Paragraph size="md" color="disabled" style={{ letterSpacing: '-0.5px' }} noMargin>
|
||||
Amount
|
||||
</Paragraph>
|
||||
</Row>
|
||||
<Row margin="md" align="center">
|
||||
<Img src={tx.token.logoUri} height={28} alt={tx.token.name} onError={setImageToPlaceholder} />
|
||||
<Paragraph size="md" noMargin className={classes.amount}>
|
||||
{tx.amount}
|
||||
{' '}
|
||||
{tx.token.symbol}
|
||||
</Paragraph>
|
||||
</Row>
|
||||
</Block>
|
||||
<Hairline style={{ position: 'absolute', bottom: 85 }} />
|
||||
<Row align="center" className={classes.buttonRow}>
|
||||
<Button className={classes.button} minWidth={140} onClick={onClickBack}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className={classes.button}
|
||||
onClick={() => {
|
||||
createTransaction(safeAddress, tx.recipientAddress, tx.amount, tx.token, openSnackbar)
|
||||
onClose()
|
||||
}}
|
||||
variant="contained"
|
||||
minWidth={140}
|
||||
color="primary"
|
||||
data-testid="submit-tx-btn"
|
||||
>
|
||||
SUBMIT
|
||||
</Button>
|
||||
</Row>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
}}
|
||||
</SharedSnackbarConsumer>
|
||||
)
|
||||
|
||||
|
|
|
@ -42,7 +42,7 @@ const Tokens = (props: Props) => {
|
|||
return (
|
||||
<React.Fragment>
|
||||
<Row align="center" grow className={classes.heading}>
|
||||
<Paragraph className={classes.manage} noMargin>
|
||||
<Paragraph className={classes.manage} noMargin weight="bolder">
|
||||
Manage Tokens
|
||||
</Paragraph>
|
||||
<IconButton onClick={onClose} disableRipple data-testid={MANAGE_TOKENS_MODAL_CLOSE_BUTTON_TEST_ID}>
|
||||
|
|
|
@ -3,10 +3,10 @@ import * as React from 'react'
|
|||
import OpenInNew from '@material-ui/icons/OpenInNew'
|
||||
import Tabs from '@material-ui/core/Tabs'
|
||||
import Tab from '@material-ui/core/Tab'
|
||||
import { withStyles } from '@material-ui/core/styles'
|
||||
import Hairline from '~/components/layout/Hairline'
|
||||
import Block from '~/components/layout/Block'
|
||||
import Identicon from '~/components/Identicon'
|
||||
import { withStyles } from '@material-ui/core/styles'
|
||||
import Heading from '~/components/layout/Heading'
|
||||
import Row from '~/components/layout/Row'
|
||||
import Link from '~/components/layout/Link'
|
||||
|
@ -20,6 +20,10 @@ import {
|
|||
import { copyToClipboard } from '~/utils/clipboard'
|
||||
import Balances from './Balances'
|
||||
import Transactions from './TransactionsNew'
|
||||
import Settings from './Settings'
|
||||
|
||||
export const SETTINGS_TAB_BTN_TESTID = 'settings-tab-btn'
|
||||
export const SAFE_VIEW_NAME_HEADING_TESTID = 'safe-name-heading'
|
||||
|
||||
type Props = SelectorProps & {
|
||||
classes: Object,
|
||||
|
@ -28,10 +32,6 @@ type Props = SelectorProps & {
|
|||
fetchTransactions: Function,
|
||||
}
|
||||
|
||||
type State = {
|
||||
tabIndex: number,
|
||||
}
|
||||
|
||||
const openIconStyle = {
|
||||
height: '16px',
|
||||
color: secondary,
|
||||
|
@ -100,6 +100,7 @@ class Layout extends React.Component<Props, State> {
|
|||
activeTokens,
|
||||
createTransaction,
|
||||
fetchTransactions,
|
||||
updateSafe,
|
||||
} = this.props
|
||||
const { tabIndex } = this.state
|
||||
|
||||
|
@ -116,7 +117,7 @@ class Layout extends React.Component<Props, State> {
|
|||
<Identicon address={address} diameter={50} />
|
||||
<Block className={classes.name}>
|
||||
<Row>
|
||||
<Heading tag="h2" color="secondary">
|
||||
<Heading tag="h2" color="secondary" testId={SAFE_VIEW_NAME_HEADING_TESTID}>
|
||||
{name}
|
||||
</Heading>
|
||||
{!granted && <Block className={classes.readonly}>Read Only</Block>}
|
||||
|
@ -135,7 +136,7 @@ class Layout extends React.Component<Props, State> {
|
|||
<Tabs value={tabIndex} onChange={this.handleChange} indicatorColor="secondary" textColor="secondary">
|
||||
<Tab label="Balances" />
|
||||
<Tab label="Transactions" />
|
||||
<Tab label="Settings" />
|
||||
<Tab label="Settings" data-testid={SETTINGS_TAB_BTN_TESTID} />
|
||||
</Tabs>
|
||||
</Row>
|
||||
<Hairline color="#c8ced4" />
|
||||
|
@ -152,6 +153,18 @@ class Layout extends React.Component<Props, State> {
|
|||
/>
|
||||
)}
|
||||
{tabIndex === 1 && <Transactions fetchTransactions={fetchTransactions} safeAddress={address} />}
|
||||
{tabIndex === 2 && (
|
||||
<Settings
|
||||
granted={granted}
|
||||
safeAddress={address}
|
||||
safeName={name}
|
||||
etherScanLink={etherScanLink}
|
||||
updateSafe={updateSafe}
|
||||
threshold={safe.threshold}
|
||||
owners={safe.owners}
|
||||
createTransaction={createTransaction}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,94 @@
|
|||
// @flow
|
||||
import React from 'react'
|
||||
import { withStyles } from '@material-ui/core/styles'
|
||||
import Block from '~/components/layout/Block'
|
||||
import Col from '~/components/layout/Col'
|
||||
import Field from '~/components/forms/Field'
|
||||
import { composeValidators, required, minMaxLength } from '~/components/forms/validator'
|
||||
import TextField from '~/components/forms/TextField'
|
||||
import GnoForm from '~/components/forms/GnoForm'
|
||||
import Row from '~/components/layout/Row'
|
||||
import Paragraph from '~/components/layout/Paragraph'
|
||||
import Hairline from '~/components/layout/Hairline'
|
||||
import Button from '~/components/layout/Button'
|
||||
import { sm, boldFont } from '~/theme/variables'
|
||||
import { styles } from './style'
|
||||
|
||||
const controlsStyle = {
|
||||
backgroundColor: 'white',
|
||||
padding: sm,
|
||||
}
|
||||
|
||||
const saveButtonStyle = {
|
||||
marginRight: sm,
|
||||
fontWeight: boldFont,
|
||||
}
|
||||
|
||||
export const SAFE_NAME_INPUT_TESTID = 'safe-name-input'
|
||||
export const SAFE_NAME_SUBMIT_BTN_TESTID = 'change-safe-name-btn'
|
||||
|
||||
type Props = {
|
||||
classes: Object,
|
||||
safeAddress: string,
|
||||
safeName: string,
|
||||
updateSafe: Function,
|
||||
}
|
||||
|
||||
const ChangeSafeName = (props: Props) => {
|
||||
const {
|
||||
classes, safeAddress, safeName, updateSafe,
|
||||
} = props
|
||||
|
||||
const handleSubmit = (values) => {
|
||||
updateSafe({ address: safeAddress, name: values.safeName })
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<GnoForm onSubmit={handleSubmit}>
|
||||
{() => (
|
||||
<React.Fragment>
|
||||
<Block className={classes.formContainer}>
|
||||
<Paragraph noMargin className={classes.title} size="lg" weight="bolder">
|
||||
Modify Safe name
|
||||
</Paragraph>
|
||||
<Paragraph size="sm">
|
||||
You can change the name of this Safe. This name is only stored locally and never shared with Gnosis or
|
||||
any third parties.
|
||||
</Paragraph>
|
||||
<Block className={classes.root}>
|
||||
<Field
|
||||
name="safeName"
|
||||
component={TextField}
|
||||
type="text"
|
||||
validate={composeValidators(required, minMaxLength(1, 50))}
|
||||
placeholder="Safe name*"
|
||||
text="Safe name*"
|
||||
defaultValue={safeName}
|
||||
testId={SAFE_NAME_INPUT_TESTID}
|
||||
/>
|
||||
</Block>
|
||||
</Block>
|
||||
<Hairline />
|
||||
<Row style={controlsStyle} align="end" grow>
|
||||
<Col end="xs">
|
||||
<Button
|
||||
type="submit"
|
||||
style={saveButtonStyle}
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
testId={SAFE_NAME_SUBMIT_BTN_TESTID}
|
||||
>
|
||||
SAVE
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</GnoForm>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
export default withStyles(styles)(ChangeSafeName)
|
|
@ -0,0 +1,17 @@
|
|||
// @flow
|
||||
import { lg } from '~/theme/variables'
|
||||
|
||||
export const styles = () => ({
|
||||
title: {
|
||||
padding: `${lg} 0 20px`,
|
||||
fontSize: '16px',
|
||||
},
|
||||
formContainer: {
|
||||
padding: '0 20px',
|
||||
minHeight: '369px',
|
||||
},
|
||||
root: {
|
||||
display: 'flex',
|
||||
maxWidth: '460px',
|
||||
},
|
||||
})
|
|
@ -0,0 +1,10 @@
|
|||
// @flow
|
||||
import removeSafe from '~/routes/safe/store/actions/removeSafe'
|
||||
|
||||
export type Actions = {
|
||||
removeSafe: Function,
|
||||
}
|
||||
|
||||
export default {
|
||||
removeSafe,
|
||||
}
|
|
@ -0,0 +1,108 @@
|
|||
// @flow
|
||||
import React from 'react'
|
||||
import classNames from 'classnames'
|
||||
import { connect } from 'react-redux'
|
||||
import { history } from '~/store'
|
||||
import { SAFELIST_ADDRESS } from '~/routes/routes'
|
||||
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 Block from '~/components/layout/Block'
|
||||
import Modal from '~/components/Modal'
|
||||
import Identicon from '~/components/Identicon'
|
||||
import Col from '~/components/layout/Col'
|
||||
import Row from '~/components/layout/Row'
|
||||
import Button from '~/components/layout/Button'
|
||||
import Link from '~/components/layout/Link'
|
||||
import Paragraph from '~/components/layout/Paragraph'
|
||||
import Hairline from '~/components/layout/Hairline'
|
||||
import actions, { type Actions } from './actions'
|
||||
import { secondary } from '~/theme/variables'
|
||||
import { styles } from './style'
|
||||
|
||||
const openIconStyle = {
|
||||
height: '16px',
|
||||
color: secondary,
|
||||
}
|
||||
|
||||
type Props = Actions & {
|
||||
onClose: () => void,
|
||||
classes: Object,
|
||||
isOpen: boolean,
|
||||
safeAddress: string,
|
||||
etherScanLink: string,
|
||||
safeName: string,
|
||||
}
|
||||
|
||||
const RemoveSafeComponent = ({
|
||||
onClose, isOpen, classes, safeAddress, etherScanLink, safeName, removeSafe,
|
||||
}: Props) => (
|
||||
<Modal title="Remove Safe" description="Remove the selected Safe" handleClose={onClose} open={isOpen}>
|
||||
<Row align="center" grow className={classes.heading}>
|
||||
<Paragraph className={classes.manage} noMargin weight="bolder">
|
||||
Remove Safe
|
||||
</Paragraph>
|
||||
<IconButton onClick={onClose} disableRipple>
|
||||
<Close className={classes.close} />
|
||||
</IconButton>
|
||||
</Row>
|
||||
<Hairline />
|
||||
<Block className={classes.container}>
|
||||
<Row className={classes.owner}>
|
||||
<Col xs={1} align="center">
|
||||
<Identicon address={safeAddress} diameter={32} />
|
||||
</Col>
|
||||
<Col xs={11}>
|
||||
<Block className={classNames(classes.name, classes.userName)}>
|
||||
<Paragraph size="lg" noMargin weight="bolder">
|
||||
{safeName}
|
||||
</Paragraph>
|
||||
<Block align="center" className={classes.user}>
|
||||
<Paragraph size="md" color="disabled" noMargin>
|
||||
{safeAddress}
|
||||
</Paragraph>
|
||||
<Link className={classes.open} to={etherScanLink} target="_blank">
|
||||
<OpenInNew style={openIconStyle} />
|
||||
</Link>
|
||||
</Block>
|
||||
</Block>
|
||||
</Col>
|
||||
</Row>
|
||||
<Hairline />
|
||||
<Row className={classes.description}>
|
||||
<Paragraph noMargin>
|
||||
Removing a Safe only removes it from your interface.
|
||||
<b>It does not delete the Safe</b>
|
||||
. You can always add it back using the Safe's address.
|
||||
</Paragraph>
|
||||
</Row>
|
||||
</Block>
|
||||
<Hairline />
|
||||
<Row align="center" className={classes.buttonRow}>
|
||||
<Button className={classes.button} minWidth={140} onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className={classes.buttonRemove}
|
||||
onClick={() => {
|
||||
removeSafe(safeAddress)
|
||||
onClose()
|
||||
history.push(SAFELIST_ADDRESS)
|
||||
}}
|
||||
variant="contained"
|
||||
minWidth={140}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Row>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
const RemoveSafeModal = withStyles(styles)(RemoveSafeComponent)
|
||||
|
||||
export default connect(
|
||||
undefined,
|
||||
actions,
|
||||
)(RemoveSafeModal)
|
|
@ -0,0 +1,56 @@
|
|||
// @flow
|
||||
import {
|
||||
lg, md, sm, error, background,
|
||||
} from '~/theme/variables'
|
||||
|
||||
export const styles = (theme: Object) => ({
|
||||
heading: {
|
||||
padding: `${sm} ${lg}`,
|
||||
justifyContent: 'space-between',
|
||||
maxHeight: '75px',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
container: {
|
||||
minHeight: '369px',
|
||||
},
|
||||
manage: {
|
||||
fontSize: '24px',
|
||||
},
|
||||
close: {
|
||||
height: '35px',
|
||||
width: '35px',
|
||||
},
|
||||
buttonRow: {
|
||||
height: '84px',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
buttonRemove: {
|
||||
color: '#fff',
|
||||
backgroundColor: error,
|
||||
},
|
||||
name: {
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
userName: {
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
owner: {
|
||||
backgroundColor: background,
|
||||
padding: md,
|
||||
alignItems: 'center',
|
||||
},
|
||||
user: {
|
||||
justifyContent: 'left',
|
||||
},
|
||||
description: {
|
||||
padding: md,
|
||||
},
|
||||
open: {
|
||||
paddingLeft: sm,
|
||||
width: 'auto',
|
||||
'&:hover': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
},
|
||||
})
|
|
@ -0,0 +1,120 @@
|
|||
// @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 SelectField from '~/components/forms/SelectField'
|
||||
import MenuItem from '@material-ui/core/MenuItem'
|
||||
import {
|
||||
composeValidators, minValue, mustBeInteger, required, differentFrom,
|
||||
} from '~/components/forms/validator'
|
||||
import Field from '~/components/forms/Field'
|
||||
import GnoForm from '~/components/forms/GnoForm'
|
||||
import Hairline from '~/components/layout/Hairline'
|
||||
import Paragraph from '~/components/layout/Paragraph'
|
||||
import Button from '~/components/layout/Button'
|
||||
import Block from '~/components/layout/Block'
|
||||
import Row from '~/components/layout/Row'
|
||||
import Col from '~/components/layout/Col'
|
||||
import type { Owner } from '~/routes/safe/store/models/owner'
|
||||
import { styles } from './style'
|
||||
|
||||
type Props = {
|
||||
onClose: () => void,
|
||||
classes: Object,
|
||||
threshold: number,
|
||||
owners: List<Owner>,
|
||||
onChangeThreshold: Function,
|
||||
}
|
||||
|
||||
const THRESHOLD_FIELD_NAME = 'threshold'
|
||||
|
||||
const ChangeThreshold = ({
|
||||
onClose, owners, threshold, classes, onChangeThreshold,
|
||||
}: Props) => {
|
||||
const handleSubmit = async (values) => {
|
||||
const newThreshold = values[THRESHOLD_FIELD_NAME]
|
||||
|
||||
await onChangeThreshold(newThreshold)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Row align="center" grow className={classes.heading}>
|
||||
<Paragraph className={classes.headingText} weight="bolder" noMargin>
|
||||
Change required confirmations
|
||||
</Paragraph>
|
||||
<IconButton onClick={onClose} disableRipple>
|
||||
<Close className={classes.close} />
|
||||
</IconButton>
|
||||
</Row>
|
||||
<Hairline />
|
||||
<GnoForm onSubmit={handleSubmit} initialValues={{ threshold: threshold.toString() }}>
|
||||
{() => (
|
||||
<React.Fragment>
|
||||
<Block className={classes.modalContent}>
|
||||
<Row>
|
||||
<Paragraph>
|
||||
Every transaction outside any specified daily limits, needs to be confirmed by all specified owners.
|
||||
If no daily limits are set, all owners will need to sign for transactions.
|
||||
</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_FIELD_NAME}
|
||||
render={props => (
|
||||
<>
|
||||
<SelectField {...props} disableError>
|
||||
{[...Array(Number(owners.size))].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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
validate={composeValidators(required, mustBeInteger, minValue(1), differentFrom(threshold))}
|
||||
data-testid="threshold-select-input"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={10}>
|
||||
<Paragraph size="lg" color="primary" noMargin className={classes.ownersText}>
|
||||
out of
|
||||
{' '}
|
||||
{owners.size}
|
||||
{' '}
|
||||
owner(s)
|
||||
</Paragraph>
|
||||
</Col>
|
||||
</Row>
|
||||
</Block>
|
||||
<Hairline style={{ position: 'absolute', bottom: 85 }} />
|
||||
<Row align="center" className={classes.buttonRow}>
|
||||
<Button className={classes.button} minWidth={140} onClick={onClose}>
|
||||
BACK
|
||||
</Button>
|
||||
<Button type="submit" color="primary" className={classes.button} minWidth={140} variant="contained">
|
||||
CHANGE
|
||||
</Button>
|
||||
</Row>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</GnoForm>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
export default withStyles(styles)(ChangeThreshold)
|
|
@ -0,0 +1,44 @@
|
|||
// @flow
|
||||
import { lg, md, sm } from '~/theme/variables'
|
||||
|
||||
export const styles = () => ({
|
||||
heading: {
|
||||
padding: `${sm} ${lg}`,
|
||||
justifyContent: 'space-between',
|
||||
boxSizing: 'border-box',
|
||||
maxHeight: '75px',
|
||||
},
|
||||
annotation: {
|
||||
letterSpacing: '-1px',
|
||||
color: '#a2a8ba',
|
||||
marginRight: 'auto',
|
||||
marginLeft: '20px',
|
||||
},
|
||||
headingText: {
|
||||
fontSize: '20px',
|
||||
},
|
||||
close: {
|
||||
height: '35px',
|
||||
width: '35px',
|
||||
},
|
||||
modalContent: {
|
||||
padding: `${md} ${lg}`,
|
||||
},
|
||||
ownersText: {
|
||||
marginLeft: sm,
|
||||
},
|
||||
buttonRow: {
|
||||
height: '84px',
|
||||
justifyContent: 'center',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
},
|
||||
inputRow: {
|
||||
position: 'relative',
|
||||
},
|
||||
errorText: {
|
||||
position: 'absolute',
|
||||
bottom: '-25px',
|
||||
},
|
||||
})
|
|
@ -0,0 +1,100 @@
|
|||
// @flow
|
||||
import React, { useState } from 'react'
|
||||
import { withStyles } from '@material-ui/core/styles'
|
||||
import { List } from 'immutable'
|
||||
import { SharedSnackbarConsumer } from '~/components/SharedSnackBar'
|
||||
import Heading from '~/components/layout/Heading'
|
||||
import Button from '~/components/layout/Button'
|
||||
import Bold from '~/components/layout/Bold'
|
||||
import Block from '~/components/layout/Block'
|
||||
import Row from '~/components/layout/Row'
|
||||
import Modal from '~/components/Modal'
|
||||
import Paragraph from '~/components/layout/Paragraph'
|
||||
import ChangeThreshold from './ChangeThreshold'
|
||||
import type { Owner } from '~/routes/safe/store/models/owner'
|
||||
import { styles } from './style'
|
||||
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
|
||||
|
||||
type Props = {
|
||||
owners: List<Owner>,
|
||||
threshold: number,
|
||||
classes: Object,
|
||||
createTransaction: Function,
|
||||
safeAddress: string,
|
||||
}
|
||||
|
||||
const ThresholdSettings = ({
|
||||
owners, threshold, classes, createTransaction, safeAddress,
|
||||
}: Props) => {
|
||||
const [isModalOpen, setModalOpen] = useState(false)
|
||||
|
||||
const toggleModal = () => {
|
||||
setModalOpen(prevOpen => !prevOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<SharedSnackbarConsumer>
|
||||
{({ openSnackbar }) => {
|
||||
const onChangeThreshold = async (newThreshold) => {
|
||||
const safeInstance = await getGnosisSafeInstanceAt(safeAddress)
|
||||
const txData = safeInstance.contract.methods.changeThreshold(newThreshold).encodeABI()
|
||||
|
||||
createTransaction(safeAddress, safeAddress, 0, txData, openSnackbar)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Block className={classes.container}>
|
||||
<Heading tag="h3">Required confirmations</Heading>
|
||||
<Paragraph>
|
||||
Any transaction over any daily limit
|
||||
<br />
|
||||
{' '}
|
||||
requires the confirmation of:
|
||||
</Paragraph>
|
||||
<Paragraph size="xxl" className={classes.ownersText}>
|
||||
<Bold>{threshold}</Bold>
|
||||
{' '}
|
||||
out of
|
||||
{' '}
|
||||
<Bold>{owners.size}</Bold>
|
||||
{' '}
|
||||
owners
|
||||
</Paragraph>
|
||||
{owners.size > 1 && (
|
||||
<Row align="center" className={classes.buttonRow}>
|
||||
<Button
|
||||
color="primary"
|
||||
minWidth={120}
|
||||
className={classes.modifyBtn}
|
||||
onClick={toggleModal}
|
||||
variant="contained"
|
||||
>
|
||||
Modify
|
||||
</Button>
|
||||
</Row>
|
||||
)}
|
||||
</Block>
|
||||
<Modal
|
||||
title="Change Required Confirmations"
|
||||
description="Change Required Confirmations Form"
|
||||
handleClose={toggleModal}
|
||||
open={isModalOpen}
|
||||
>
|
||||
<ChangeThreshold
|
||||
onClose={toggleModal}
|
||||
owners={owners}
|
||||
threshold={threshold}
|
||||
onChangeThreshold={onChangeThreshold}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</SharedSnackbarConsumer>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
export default withStyles(styles)(ThresholdSettings)
|
|
@ -0,0 +1,35 @@
|
|||
// @flow
|
||||
import {
|
||||
fontColor, lg, smallFontSize, md,
|
||||
} from '~/theme/variables'
|
||||
|
||||
export const styles = () => ({
|
||||
ownersText: {
|
||||
fontSize: '26px',
|
||||
color: '#8896b6',
|
||||
'& b': {
|
||||
color: fontColor,
|
||||
},
|
||||
},
|
||||
container: {
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
padding: lg,
|
||||
},
|
||||
buttonRow: {
|
||||
position: 'absolute',
|
||||
bottom: '51px',
|
||||
left: 0,
|
||||
height: '51px',
|
||||
width: '100%',
|
||||
paddingRight: md,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
borderTop: 'solid 1px #e4e8f1',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
modifyBtn: {
|
||||
height: '32px',
|
||||
fontSize: smallFontSize,
|
||||
},
|
||||
})
|
|
@ -0,0 +1,149 @@
|
|||
// @flow
|
||||
import * as React from 'react'
|
||||
import cn from 'classnames'
|
||||
import { List } from 'immutable'
|
||||
import { withStyles } from '@material-ui/core/styles'
|
||||
import Block from '~/components/layout/Block'
|
||||
import Col from '~/components/layout/Col'
|
||||
import Row from '~/components/layout/Row'
|
||||
import RemoveSafeModal from './RemoveSafeModal'
|
||||
import Paragraph from '~/components/layout/Paragraph'
|
||||
import Hairline from '~/components/layout/Hairline'
|
||||
import { type Owner } from '~/routes/safe/store/models/owner'
|
||||
import ChangeSafeName from './ChangeSafeName'
|
||||
import ThresholdSettings from './ThresholdSettings'
|
||||
import { styles } from './style'
|
||||
|
||||
type State = {
|
||||
showRemoveSafe: boolean,
|
||||
menuOptionIndex: number,
|
||||
}
|
||||
|
||||
type Props = {
|
||||
classes: Object,
|
||||
granted: boolean,
|
||||
etherScanLink: string,
|
||||
safeAddress: string,
|
||||
safeName: string,
|
||||
owners: List<Owner>,
|
||||
threshold: number,
|
||||
createTransaction: Function,
|
||||
updateSafe: Function,
|
||||
}
|
||||
|
||||
type Action = 'RemoveSafe'
|
||||
|
||||
class Settings extends React.Component<Props, State> {
|
||||
state = {
|
||||
showRemoveSafe: false,
|
||||
menuOptionIndex: 1,
|
||||
}
|
||||
|
||||
handleChange = menuOptionIndex => () => {
|
||||
this.setState({ menuOptionIndex })
|
||||
}
|
||||
|
||||
onShow = (action: Action) => () => {
|
||||
this.setState(() => ({ [`show${action}`]: true }))
|
||||
}
|
||||
|
||||
onHide = (action: Action) => () => {
|
||||
this.setState(() => ({ [`show${action}`]: false }))
|
||||
}
|
||||
|
||||
render() {
|
||||
const { showRemoveSafe, menuOptionIndex } = this.state
|
||||
const {
|
||||
classes,
|
||||
granted,
|
||||
etherScanLink,
|
||||
safeAddress,
|
||||
safeName,
|
||||
updateSafe,
|
||||
owners,
|
||||
threshold,
|
||||
createTransaction,
|
||||
} = this.props
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Row align="center" className={classes.message}>
|
||||
<Col xs={6}>
|
||||
<Paragraph className={classes.settings} size="lg" weight="bolder">
|
||||
Settings
|
||||
</Paragraph>
|
||||
</Col>
|
||||
<Col xs={6} end="sm">
|
||||
<Paragraph noMargin size="md" color="error" className={classes.links} onClick={this.onShow('RemoveSafe')}>
|
||||
Remove Safe
|
||||
</Paragraph>
|
||||
<RemoveSafeModal
|
||||
onClose={this.onHide('RemoveSafe')}
|
||||
isOpen={showRemoveSafe}
|
||||
etherScanLink={etherScanLink}
|
||||
safeAddress={safeAddress}
|
||||
safeName={safeName}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Block className={classes.root}>
|
||||
<Col xs={3} layout="column">
|
||||
<Block className={classes.menu}>
|
||||
<Row
|
||||
className={cn(classes.menuOption, menuOptionIndex === 1 && classes.active)}
|
||||
onClick={this.handleChange(1)}
|
||||
>
|
||||
Safe name
|
||||
</Row>
|
||||
<Hairline />
|
||||
{granted && (
|
||||
<React.Fragment>
|
||||
<Row
|
||||
className={cn(classes.menuOption, menuOptionIndex === 2 && classes.active)}
|
||||
onClick={this.handleChange(2)}
|
||||
>
|
||||
Owners
|
||||
</Row>
|
||||
<Hairline />
|
||||
<Row
|
||||
className={cn(classes.menuOption, menuOptionIndex === 3 && classes.active)}
|
||||
onClick={this.handleChange(3)}
|
||||
>
|
||||
Required confirmations
|
||||
</Row>
|
||||
<Hairline />
|
||||
<Row
|
||||
className={cn(classes.menuOption, menuOptionIndex === 4 && classes.active)}
|
||||
onClick={this.handleChange(4)}
|
||||
>
|
||||
Modules
|
||||
</Row>
|
||||
<Hairline />
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Block>
|
||||
</Col>
|
||||
<Col xs={9} layout="column">
|
||||
<Block className={classes.container}>
|
||||
{menuOptionIndex === 1 && (
|
||||
<ChangeSafeName safeAddress={safeAddress} safeName={safeName} updateSafe={updateSafe} />
|
||||
)}
|
||||
{granted && menuOptionIndex === 2 && <p>To be done</p>}
|
||||
{granted && menuOptionIndex === 3 && (
|
||||
<ThresholdSettings
|
||||
owners={owners}
|
||||
threshold={threshold}
|
||||
createTransaction={createTransaction}
|
||||
safeAddress={safeAddress}
|
||||
/>
|
||||
)}
|
||||
{granted && menuOptionIndex === 4 && <p>To be done</p>}
|
||||
</Block>
|
||||
</Col>
|
||||
</Block>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default withStyles(styles)(Settings)
|
|
@ -0,0 +1,42 @@
|
|||
// @flow
|
||||
import {
|
||||
sm, lg, border, secondary, bolderFont,
|
||||
} from '~/theme/variables'
|
||||
|
||||
export const styles = () => ({
|
||||
root: {
|
||||
backgroundColor: 'white',
|
||||
boxShadow: '0 -1px 4px 0 rgba(74, 85, 121, 0.5)',
|
||||
minHeight: '400px',
|
||||
display: 'flex',
|
||||
},
|
||||
settings: {
|
||||
letterSpacing: '-0.5px',
|
||||
},
|
||||
menu: {
|
||||
borderRight: `solid 1px ${border}`,
|
||||
height: '100%',
|
||||
},
|
||||
menuOption: {
|
||||
padding: lg,
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
active: {
|
||||
backgroundColor: '#f4f4f9',
|
||||
color: secondary,
|
||||
fontWeight: bolderFont,
|
||||
},
|
||||
container: {
|
||||
height: '100%',
|
||||
},
|
||||
message: {
|
||||
margin: `${sm} 0`,
|
||||
},
|
||||
links: {
|
||||
textDecoration: 'underline',
|
||||
'&:hover': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
},
|
||||
})
|
|
@ -3,12 +3,14 @@ import fetchSafe from '~/routes/safe/store/actions/fetchSafe'
|
|||
import fetchTokenBalances from '~/routes/safe/store/actions/fetchTokenBalances'
|
||||
import createTransaction from '~/routes/safe/store/actions/createTransaction'
|
||||
import fetchTransactions from '~/routes/safe/store/actions/fetchTransactions'
|
||||
import updateSafe from '~/routes/safe/store/actions/updateSafe'
|
||||
|
||||
export type Actions = {
|
||||
fetchSafe: typeof fetchSafe,
|
||||
fetchTokenBalances: typeof fetchTokenBalances,
|
||||
createTransaction: typeof createTransaction,
|
||||
fetchTransactions: typeof fetchTransactions,
|
||||
updateSafe: typeof updateSafe,
|
||||
}
|
||||
|
||||
export default {
|
||||
|
@ -16,4 +18,5 @@ export default {
|
|||
fetchTokenBalances,
|
||||
createTransaction,
|
||||
fetchTransactions,
|
||||
updateSafe,
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ export type Props = Actions &
|
|||
granted: boolean,
|
||||
}
|
||||
|
||||
const TIMEOUT = process.env.NODE_ENV === 'test' ? 1500 : 15000
|
||||
const TIMEOUT = process.env.NODE_ENV === 'test' ? 1500 : 5000
|
||||
|
||||
class SafeView extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
|
@ -29,8 +29,9 @@ class SafeView extends React.Component<Props> {
|
|||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { activeTokens } = this.props
|
||||
const oldActiveTokensSize = prevProps.activeTokens.size
|
||||
|
||||
if (activeTokens.size > prevProps.activeTokens.size) {
|
||||
if (oldActiveTokensSize > 0 && activeTokens.size > oldActiveTokensSize) {
|
||||
this.checkForUpdates()
|
||||
}
|
||||
}
|
||||
|
@ -61,6 +62,7 @@ class SafeView extends React.Component<Props> {
|
|||
tokens,
|
||||
createTransaction,
|
||||
fetchTransactions,
|
||||
updateSafe,
|
||||
} = this.props
|
||||
|
||||
return (
|
||||
|
@ -75,6 +77,7 @@ class SafeView extends React.Component<Props> {
|
|||
granted={granted}
|
||||
createTransaction={createTransaction}
|
||||
fetchTransactions={fetchTransactions}
|
||||
updateSafe={updateSafe}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
|
|
|
@ -18,12 +18,9 @@ type ActionReturn = {
|
|||
safe: Safe,
|
||||
}
|
||||
|
||||
export const addSafe = createAction<string, Function, ActionReturn>(
|
||||
ADD_SAFE,
|
||||
(safe: Safe): ActionReturn => ({
|
||||
safe,
|
||||
}),
|
||||
)
|
||||
export const addSafe = createAction<string, Function, ActionReturn>(ADD_SAFE, (safe: Safe): ActionReturn => ({
|
||||
safe,
|
||||
}))
|
||||
|
||||
const saveSafe = (name: string, address: string, threshold: number, ownersName: string[], ownersAddress: string[]) => (
|
||||
dispatch: ReduxDispatch<GlobalState>,
|
||||
|
|
|
@ -1,52 +1,31 @@
|
|||
// @flow
|
||||
import type { Dispatch as ReduxDispatch, GetState } from 'redux'
|
||||
import { createAction } from 'redux-actions'
|
||||
import { getWeb3 } from '~/logic/wallets/getWeb3'
|
||||
import { EMPTY_DATA } from '~/logic/wallets/ethTransactions'
|
||||
import { type Token } from '~/logic/tokens/store/model/token'
|
||||
import { userAccountSelector } from '~/logic/wallets/store/selectors'
|
||||
import { type GlobalState } from '~/store'
|
||||
import { isEther } from '~/logic/tokens/utils/tokenHelpers'
|
||||
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
|
||||
import { executeTransaction, CALL } from '~/logic/safe/transactions'
|
||||
import { getStandardTokenContract } from '~/logic/tokens/store/actions/fetchTokens'
|
||||
|
||||
const createTransaction = (
|
||||
safeAddress: string,
|
||||
to: string,
|
||||
valueInEth: string,
|
||||
token: Token,
|
||||
valueInWei: string,
|
||||
txData: string = EMPTY_DATA,
|
||||
openSnackbar: Function,
|
||||
) => async (dispatch: ReduxDispatch<GlobalState>, getState: GetState<GlobalState>) => {
|
||||
const isSendingETH = isEther(token.symbol)
|
||||
const state: GlobalState = getState()
|
||||
|
||||
const safeInstance = await getGnosisSafeInstanceAt(safeAddress)
|
||||
const web3 = getWeb3()
|
||||
const from = userAccountSelector(state)
|
||||
const threshold = await safeInstance.getThreshold()
|
||||
const nonce = (await safeInstance.nonce()).toString()
|
||||
const txRecipient = isSendingETH ? to : token.address
|
||||
const valueInWei = web3.utils.toWei(valueInEth, 'ether')
|
||||
let txAmount = valueInWei
|
||||
const nonce = await safeInstance.nonce()
|
||||
const isExecution = threshold.toNumber() === 1
|
||||
|
||||
let txData = EMPTY_DATA
|
||||
if (!isSendingETH) {
|
||||
const StandardToken = await getStandardTokenContract()
|
||||
const sendToken = await StandardToken.at(token.address)
|
||||
|
||||
txData = sendToken.contract.methods.transfer(to, valueInWei).encodeABI()
|
||||
// txAmount should be 0 if we send tokens
|
||||
// the real value is encoded in txData and will be used by the contract
|
||||
// if txAmount > 0 it would send ETH from the safe
|
||||
txAmount = 0
|
||||
}
|
||||
|
||||
let txHash
|
||||
if (isExecution) {
|
||||
openSnackbar('Transaction has been submitted', 'success')
|
||||
txHash = await executeTransaction(safeInstance, txRecipient, txAmount, txData, CALL, nonce, from)
|
||||
txHash = await executeTransaction(safeInstance, to, valueInWei, txData, CALL, nonce, from)
|
||||
openSnackbar('Transaction has been confirmed', 'success')
|
||||
} else {
|
||||
// txHash = await approveTransaction(safeAddress, to, valueInWei, txData, CALL, nonce)
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
// @flow
|
||||
import { createAction } from 'redux-actions'
|
||||
|
||||
export const REMOVE_SAFE = 'REMOVE_SAFE'
|
||||
|
||||
const removeSafe = createAction<string, *>(REMOVE_SAFE)
|
||||
|
||||
export default removeSafe
|
|
@ -1,9 +1,10 @@
|
|||
// @flow
|
||||
import { ADD_SAFE } from '~/routes/safe/store/actions/addSafe'
|
||||
import { UPDATE_SAFE } from '~/routes/safe/store/actions/updateSafe'
|
||||
import { REMOVE_SAFE } from '~/routes/safe/store/actions/removeSafe'
|
||||
import type { Store, AnyAction } from 'redux'
|
||||
import { type GlobalState } from '~/store/'
|
||||
import { saveSafes, setOwners } from '~/logic/safe/utils'
|
||||
import { saveSafes, setOwners, removeOwners } from '~/logic/safe/utils'
|
||||
import { safesMapSelector } from '~/routes/safeList/store/selectors'
|
||||
import { getActiveTokensAddressesForAllSafes } from '~/routes/safe/store/selectors'
|
||||
import { tokensSelector } from '~/logic/tokens/store/selectors'
|
||||
|
@ -11,7 +12,7 @@ import type { Token } from '~/logic/tokens/store/model/token'
|
|||
import { saveActiveTokens } from '~/logic/tokens/utils/tokensStorage'
|
||||
import { ACTIVATE_TOKEN_FOR_ALL_SAFES } from '~/routes/safe/store/actions/activateTokenForAllSafes'
|
||||
|
||||
const watchedActions = [ADD_SAFE, UPDATE_SAFE, ACTIVATE_TOKEN_FOR_ALL_SAFES]
|
||||
const watchedActions = [ADD_SAFE, UPDATE_SAFE, REMOVE_SAFE, ACTIVATE_TOKEN_FOR_ALL_SAFES]
|
||||
|
||||
const safeStorageMware = (store: Store<GlobalState>) => (next: Function) => async (action: AnyAction) => {
|
||||
const handledAction = next(action)
|
||||
|
@ -40,6 +41,9 @@ const safeStorageMware = (store: Store<GlobalState>) => (next: Function) => asyn
|
|||
if (action.type === ADD_SAFE) {
|
||||
const { safe } = action.payload
|
||||
setOwners(safe.address, safe.owners)
|
||||
} else if (action.type === REMOVE_SAFE) {
|
||||
const safeAddress = action.payload
|
||||
removeOwners(safeAddress)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ import { loadFromStorage } from '~/utils/storage'
|
|||
import { SAFES_KEY } from '~/logic/safe/utils'
|
||||
import { UPDATE_SAFE } from '~/routes/safe/store/actions/updateSafe'
|
||||
import { ACTIVATE_TOKEN_FOR_ALL_SAFES } from '~/routes/safe/store/actions/activateTokenForAllSafes'
|
||||
import { REMOVE_SAFE } from '~/routes/safe/store/actions/removeSafe'
|
||||
|
||||
export const SAFE_REDUCER_ID = 'safes'
|
||||
|
||||
|
@ -91,6 +92,11 @@ export default handleActions<State, *>(
|
|||
|
||||
return state.set(safe.address, SafeRecord(safe))
|
||||
},
|
||||
[REMOVE_SAFE]: (state: State, action: ActionType<Function>): State => {
|
||||
const safeAddress = action.payload
|
||||
|
||||
return state.delete(safeAddress)
|
||||
},
|
||||
},
|
||||
Map(),
|
||||
)
|
||||
|
|
|
@ -12,7 +12,7 @@ import fetchTransactions from '~/routes/safe/store/actions/fetchTransactions'
|
|||
import { sleep } from '~/utils/timer'
|
||||
import { history } from '~/store'
|
||||
import AppRoutes from '~/routes'
|
||||
import { SAFELIST_ADDRESS, SETTINS_ADDRESS } from '~/routes/routes'
|
||||
import { SAFELIST_ADDRESS } from '~/routes/routes'
|
||||
import { EMPTY_DATA } from '~/logic/wallets/ethTransactions'
|
||||
|
||||
export const EXPAND_BALANCE_INDEX = 0
|
||||
|
|
|
@ -61,6 +61,10 @@ describe('DOM > Feature > LOAD a safe', () => {
|
|||
fireEvent.submit(form)
|
||||
await sleep(400)
|
||||
|
||||
// submit form with owners names
|
||||
fireEvent.submit(form)
|
||||
await sleep(400)
|
||||
|
||||
// Submit
|
||||
fireEvent.submit(form)
|
||||
const deployedAddress = await whenSafeDeployed()
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
// @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, SAFE_VIEW_NAME_HEADING_TESTID } from '~/routes/safe/components/Layout'
|
||||
import { SAFE_NAME_INPUT_TESTID, SAFE_NAME_SUBMIT_BTN_TESTID } from '~/routes/safe/components/Settings/ChangeSafeName'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('DOM > Feature > Settings', () => {
|
||||
let store
|
||||
let safeAddress
|
||||
beforeEach(async () => {
|
||||
store = aNewStore()
|
||||
// using 4th account because other accounts were used in other tests and paid gas
|
||||
safeAddress = await aMinedSafe(store)
|
||||
})
|
||||
|
||||
it('Changes safe name', async () => {
|
||||
const INITIAL_NAME = 'Safe Name'
|
||||
const NEW_NAME = 'NEW SAFE NAME'
|
||||
|
||||
const SafeDom = renderSafeView(store, safeAddress)
|
||||
await sleep(1300)
|
||||
|
||||
const safeNameHeading = SafeDom.getByTestId(SAFE_VIEW_NAME_HEADING_TESTID)
|
||||
expect(safeNameHeading).toHaveTextContent(INITIAL_NAME)
|
||||
|
||||
// Open settings tab
|
||||
// Safe name setting screen should be pre-selected
|
||||
const settingsBtn = SafeDom.getByTestId(SETTINGS_TAB_BTN_TESTID)
|
||||
fireEvent.click(settingsBtn)
|
||||
|
||||
// Change the name
|
||||
const safeNameInput = SafeDom.getByTestId(SAFE_NAME_INPUT_TESTID)
|
||||
const submitBtn = SafeDom.getByTestId(SAFE_NAME_SUBMIT_BTN_TESTID)
|
||||
fireEvent.change(safeNameInput, { target: { value: NEW_NAME } })
|
||||
fireEvent.click(submitBtn)
|
||||
|
||||
// Check if the name changed
|
||||
expect(safeNameHeading).toHaveTextContent(NEW_NAME)
|
||||
})
|
||||
})
|
|
@ -1,3 +1,3 @@
|
|||
// @flow
|
||||
|
||||
export * from './tokens'
|
||||
export * from './tokens'
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
disabled,
|
||||
primary,
|
||||
secondary,
|
||||
error,
|
||||
md,
|
||||
lg,
|
||||
bolderFont,
|
||||
|
@ -27,7 +28,7 @@ const palette = {
|
|||
main: secondary,
|
||||
},
|
||||
error: {
|
||||
main: '#FB4F62',
|
||||
main: error,
|
||||
},
|
||||
contrastThreshold: 3,
|
||||
tonalOffset: 0.2,
|
||||
|
|
|
@ -7,6 +7,7 @@ const tertiary = '#f6f9fc'
|
|||
const fontColor = '#4a5579'
|
||||
const fancyColor = '#fd7890'
|
||||
const warningColor = '#ffc05f'
|
||||
const errorColor = '#fb4f62'
|
||||
const connectedColor = '#00c4c4'
|
||||
const disabled = '#65707e'
|
||||
const xs = '4px'
|
||||
|
@ -28,6 +29,7 @@ module.exports = Object.assign(
|
|||
fontColor,
|
||||
fancy: fancyColor,
|
||||
warning: warningColor,
|
||||
error: errorColor,
|
||||
connected: connectedColor,
|
||||
xs,
|
||||
sm,
|
||||
|
|
|
@ -31,3 +31,11 @@ export const saveToStorage = async (key: string, value: *): Promise<*> => {
|
|||
console.error(`Failed to save ${key} in the storage:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
export const removeFromStorage = async (key: string): Promise<*> => {
|
||||
try {
|
||||
await storage.remove(`${PREFIX}__${key}`)
|
||||
} catch (err) {
|
||||
console.error(`Failed to remove ${key} from the storage:`, err)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue