Merge branch 'development' of github.com:gnosis/safe-react into feature/#512-network-switching

This commit is contained in:
Mati Dastugue 2021-06-07 12:59:23 -03:00
commit 31180d974a
110 changed files with 1918 additions and 1680 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "safe-react", "name": "safe-react",
"version": "3.6.7", "version": "3.7.0",
"description": "Allowing crypto users manage funds in a safer way", "description": "Allowing crypto users manage funds in a safer way",
"website": "https://github.com/gnosis/safe-react#readme", "website": "https://github.com/gnosis/safe-react#readme",
"bugs": { "bugs": {
@ -161,7 +161,7 @@
"@gnosis.pm/safe-apps-sdk": "3.1.0-alpha.0", "@gnosis.pm/safe-apps-sdk": "3.1.0-alpha.0",
"@gnosis.pm/safe-apps-sdk-v1": "npm:@gnosis.pm/safe-apps-sdk@0.4.2", "@gnosis.pm/safe-apps-sdk-v1": "npm:@gnosis.pm/safe-apps-sdk@0.4.2",
"@gnosis.pm/safe-contracts": "1.1.1-dev.2", "@gnosis.pm/safe-contracts": "1.1.1-dev.2",
"@gnosis.pm/safe-react-components": "https://github.com/gnosis/safe-react-components.git#4864ebb", "@gnosis.pm/safe-react-components": "https://github.com/gnosis/safe-react-components.git#0e4fcd6",
"@gnosis.pm/util-contracts": "2.0.6", "@gnosis.pm/util-contracts": "2.0.6",
"@ledgerhq/hw-transport-node-hid-singleton": "5.51.1", "@ledgerhq/hw-transport-node-hid-singleton": "5.51.1",
"@material-ui/core": "^4.11.0", "@material-ui/core": "^4.11.0",
@ -214,6 +214,7 @@
"react-ga": "3.3.0", "react-ga": "3.3.0",
"react-hot-loader": "4.13.0", "react-hot-loader": "4.13.0",
"react-intersection-observer": "^8.31.0", "react-intersection-observer": "^8.31.0",
"react-papaparse": "^3.14.0",
"react-qr-reader": "^2.2.1", "react-qr-reader": "^2.2.1",
"react-redux": "7.2.3", "react-redux": "7.2.3",
"react-router-dom": "5.2.0", "react-router-dom": "5.2.0",
@ -221,6 +222,7 @@
"react-window": "^1.8.6", "react-window": "^1.8.6",
"redux": "4.0.5", "redux": "4.0.5",
"redux-actions": "^2.6.5", "redux-actions": "^2.6.5",
"redux-localstorage-simple": "^2.4.0",
"redux-thunk": "^2.3.0", "redux-thunk": "^2.3.0",
"reselect": "^4.0.0", "reselect": "^4.0.0",
"semver": "^7.3.2", "semver": "^7.3.2",

View File

@ -20,15 +20,11 @@ import { getNetworkId } from 'src/config'
import { ETHEREUM_NETWORK } from 'src/config/networks/network.d' import { ETHEREUM_NETWORK } from 'src/config/networks/network.d'
import { networkSelector } from 'src/logic/wallets/store/selectors' import { networkSelector } from 'src/logic/wallets/store/selectors'
import { SAFELIST_ADDRESS, WELCOME_ADDRESS } from 'src/routes/routes' import { SAFELIST_ADDRESS, WELCOME_ADDRESS } from 'src/routes/routes'
import { import { safeTotalFiatBalanceSelector, safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
safeTotalFiatBalanceSelector,
safeNameSelector,
safeParamAddressFromStateSelector,
safeLoadedViaUrlSelector,
} from 'src/logic/safe/store/selectors'
import { currentCurrencySelector } from 'src/logic/currencyValues/store/selectors' import { currentCurrencySelector } from 'src/logic/currencyValues/store/selectors'
import Modal from 'src/components/Modal' import Modal from 'src/components/Modal'
import SendModal from 'src/routes/safe/components/Balances/SendModal' import SendModal from 'src/routes/safe/components/Balances/SendModal'
import { useSafeName } from 'src/logic/addressBook/hooks/useSafeName'
import { useLoadSafe } from 'src/logic/safe/hooks/useLoadSafe' import { useLoadSafe } from 'src/logic/safe/hooks/useLoadSafe'
import { useSafeScheduledUpdates } from 'src/logic/safe/hooks/useSafeScheduledUpdates' import { useSafeScheduledUpdates } from 'src/logic/safe/hooks/useSafeScheduledUpdates'
import useSafeActions from 'src/logic/safe/hooks/useSafeActions' import useSafeActions from 'src/logic/safe/hooks/useSafeActions'
@ -72,14 +68,13 @@ const App: React.FC = ({ children }) => {
const matchSafe = useRouteMatch({ path: `${SAFELIST_ADDRESS}`, strict: false }) const matchSafe = useRouteMatch({ path: `${SAFELIST_ADDRESS}`, strict: false })
const history = useHistory() const history = useHistory()
const safeAddress = useSelector(safeParamAddressFromStateSelector) const safeAddress = useSelector(safeParamAddressFromStateSelector)
const safeName = useSelector(safeNameSelector) ?? '' const safeName = useSafeName(safeAddress)
const { safeActionsState, onShow, onHide, showSendFunds, hideSendFunds } = useSafeActions() const { safeActionsState, onShow, onHide, showSendFunds, hideSendFunds } = useSafeActions()
const currentSafeBalance = useSelector(safeTotalFiatBalanceSelector) const currentSafeBalance = useSelector(safeTotalFiatBalanceSelector)
const currentCurrency = useSelector(currentCurrencySelector) const currentCurrency = useSelector(currentCurrencySelector)
const granted = useSelector(grantedSelector) const granted = useSelector(grantedSelector)
const sidebarItems = useSidebarItems() const sidebarItems = useSidebarItems()
const isSafeLoadedViaUrl = useSelector(safeLoadedViaUrlSelector) const safeLoaded = useLoadSafe(safeAddress)
const safeLoaded = useLoadSafe(safeAddress, isSafeLoadedViaUrl)
useSafeScheduledUpdates(safeLoaded, safeAddress) useSafeScheduledUpdates(safeLoaded, safeAddress)
const sendFunds = safeActionsState.sendFunds const sendFunds = safeActionsState.sendFunds

View File

@ -56,7 +56,7 @@ const useSidebarItems = (): ListItemType[] => {
href: `${matchSafeWithAddress?.url}/transactions`, href: `${matchSafeWithAddress?.url}/transactions`,
}, },
{ {
label: 'AddressBook', label: 'ADDRESS BOOK',
icon: <ListIcon type="addressBook" />, icon: <ListIcon type="addressBook" />,
selected: matchSafeWithAction?.params.safeAction === 'address-book', selected: matchSafeWithAction?.params.safeAction === 'address-book',
href: `${matchSafeWithAddress?.url}/address-book`, href: `${matchSafeWithAddress?.url}/address-book`,

View File

@ -9,7 +9,6 @@ import { COOKIES_KEY } from 'src/logic/cookies/model/cookie'
import { openCookieBanner } from 'src/logic/cookies/store/actions/openCookieBanner' import { openCookieBanner } from 'src/logic/cookies/store/actions/openCookieBanner'
import { cookieBannerOpen } from 'src/logic/cookies/store/selectors' import { cookieBannerOpen } from 'src/logic/cookies/store/selectors'
import { loadFromCookie, saveCookie } from 'src/logic/cookies/utils' import { loadFromCookie, saveCookie } from 'src/logic/cookies/utils'
import { useSafeAppUrl } from 'src/logic/hooks/useSafeAppUrl'
import { mainFontFamily, md, primary, screenSm } from 'src/theme/variables' import { mainFontFamily, md, primary, screenSm } from 'src/theme/variables'
import { loadGoogleAnalytics, removeCookies } from 'src/utils/googleAnalytics' import { loadGoogleAnalytics, removeCookies } from 'src/utils/googleAnalytics'
import { closeIntercom, isIntercomLoaded, loadIntercom } from 'src/utils/intercom' import { closeIntercom, isIntercomLoaded, loadIntercom } from 'src/utils/intercom'
@ -98,7 +97,7 @@ interface CookiesBannerFormProps {
const CookiesBanner = (): ReactElement => { const CookiesBanner = (): ReactElement => {
const classes = useStyles() const classes = useStyles()
const dispatch = useRef(useDispatch()) const dispatch = useRef(useDispatch())
const { url: appUrl } = useSafeAppUrl()
const [showAnalytics, setShowAnalytics] = useState(false) const [showAnalytics, setShowAnalytics] = useState(false)
const [showIntercom, setShowIntercom] = useState(false) const [showIntercom, setShowIntercom] = useState(false)
const [localNecessary, setLocalNecessary] = useState(true) const [localNecessary, setLocalNecessary] = useState(true)
@ -107,12 +106,6 @@ const CookiesBanner = (): ReactElement => {
const showBanner = useSelector(cookieBannerOpen) const showBanner = useSelector(cookieBannerOpen)
useEffect(() => {
if (appUrl) {
setTimeout(closeIntercom, 50)
}
}, [appUrl])
useEffect(() => { useEffect(() => {
async function fetchCookiesFromStorage() { async function fetchCookiesFromStorage() {
const cookiesState = await loadFromCookie(COOKIES_KEY) const cookiesState = await loadFromCookie(COOKIES_KEY)
@ -178,7 +171,7 @@ const CookiesBanner = (): ReactElement => {
dispatch.current(openCookieBanner({ cookieBannerOpen: false })) dispatch.current(openCookieBanner({ cookieBannerOpen: false }))
} }
if (showIntercom && !appUrl) { if (showIntercom) {
loadIntercom() loadIntercom()
} }

View File

@ -1,15 +1,17 @@
import React from 'react'
import styled from 'styled-components'
import { ButtonLink, EthHashInfo, Text } from '@gnosis.pm/safe-react-components' import { ButtonLink, EthHashInfo, Text } from '@gnosis.pm/safe-react-components'
import { makeStyles } from '@material-ui/core/styles'
import React, { ReactElement } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import styled from 'styled-components'
import { safeNameSelector } from 'src/logic/safe/store/selectors'
import { formatAmount } from 'src/logic/tokens/utils/formatAmount' import { formatAmount } from 'src/logic/tokens/utils/formatAmount'
import { sameAddress } from 'src/logic/wallets/ethAddresses' import { sameAddress } from 'src/logic/wallets/ethAddresses'
import DefaultBadge from './DefaultBadge' import DefaultBadge from './DefaultBadge'
import { SafeRecordProps } from 'src/logic/safe/store/models/safe' import { SafeRecordProps } from 'src/logic/safe/store/models/safe'
import { DefaultSafe } from 'src/routes/safe/store/reducer/types/safe' import { DefaultSafe } from 'src/routes/safe/store/reducer/types/safe'
import setDefaultSafe from 'src/logic/safe/store/actions/setDefaultSafe' import setDefaultSafe from 'src/logic/safe/store/actions/setDefaultSafe'
import { makeStyles } from '@material-ui/core/styles'
import { getNetworkInfo } from 'src/config' import { getNetworkInfo } from 'src/config'
import { useDispatch } from 'react-redux'
const StyledButtonLink = styled(ButtonLink)` const StyledButtonLink = styled(ButtonLink)`
visibility: hidden; visibility: hidden;
@ -51,10 +53,10 @@ type Props = {
const { nativeCoin } = getNetworkInfo() const { nativeCoin } = getNetworkInfo()
export const AddressWrapper = (props: Props): React.ReactElement => { export const AddressWrapper = ({ safe, defaultSafe }: Props): ReactElement => {
const classes = useStyles() const classes = useStyles()
const { safe, defaultSafe } = props
const dispatch = useDispatch() const dispatch = useDispatch()
const safeName = useSelector((state) => safeNameSelector(state, safe.address))
const setDefaultSafeAction = (safeAddress: string) => { const setDefaultSafeAction = (safeAddress: string) => {
dispatch(setDefaultSafe(safeAddress)) dispatch(setDefaultSafe(safeAddress))
@ -62,7 +64,7 @@ export const AddressWrapper = (props: Props): React.ReactElement => {
return ( return (
<div className={classes.wrapper}> <div className={classes.wrapper}>
<EthHashInfo hash={safe.address} name={safe.name} showAvatar shortenHash={4} /> <EthHashInfo hash={safe.address} name={safeName} showAvatar shortenHash={4} />
<div className={classes.addressDetails}> <div className={classes.addressDetails}>
<Text size="xl">{`${formatAmount(safe.ethBalance)} ${nativeCoin.name}`}</Text> <Text size="xl">{`${formatAmount(safe.ethBalance)} ${nativeCoin.name}`}</Text>

View File

@ -39,7 +39,7 @@ type Props = {
export const SafeListSidebar = ({ children }: Props): ReactElement => { export const SafeListSidebar = ({ children }: Props): ReactElement => {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [filter, setFilter] = useState('') const [filter, setFilter] = useState('')
const safes = useSelector(sortedSafeListSelector).filter((safe) => !safe.loadedViaUrl) const safes = useSelector(sortedSafeListSelector)
const defaultSafe = useSelector(defaultSafeSelector) const defaultSafe = useSelector(defaultSafeSelector)
const currentSafe = useSelector(safeParamAddressFromStateSelector) const currentSafe = useSelector(safeParamAddressFromStateSelector)

View File

@ -1,7 +1,10 @@
import { createSelector } from 'reselect' import { createSelector } from 'reselect'
import { safesListSelector } from 'src/logic/safe/store/selectors' import { safesListWithAddressBookNameSelector } from 'src/logic/safe/store/selectors'
export const sortedSafeListSelector = createSelector(safesListSelector, (safes) => /**
safes.sort((a, b) => (a.name > b.name ? 1 : -1)), * Sort safe list by the name in the address book
*/
export const sortedSafeListSelector = createSelector([safesListWithAddressBookNameSelector], (safes) =>
safes.sort((a, b) => (a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1)),
) )

View File

@ -14,6 +14,7 @@ import {
addressIsNotCurrentSafe, addressIsNotCurrentSafe,
OWNER_ADDRESS_IS_SAFE_ADDRESS_ERROR, OWNER_ADDRESS_IS_SAFE_ADDRESS_ERROR,
mustBeHexData, mustBeHexData,
validAddressBookName,
} from 'src/components/forms/validator' } from 'src/components/forms/validator'
describe('Forms > Validators', () => { describe('Forms > Validators', () => {
@ -249,4 +250,26 @@ describe('Forms > Validators', () => {
expect(differentFrom('a')('a')).toEqual(getDifferentFromErrMsg('a')) expect(differentFrom('a')('a')).toEqual(getDifferentFromErrMsg('a'))
}) })
}) })
describe('validAddressBookName validator', () => {
it('Returns error for an empty string', () => {
expect(validAddressBookName('')).toBe('Should be 1 to 50 symbols')
})
it('Returns error for a name longer than 50 chars', () => {
expect(validAddressBookName('abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabc')).toBe(
'Should be 1 to 50 symbols',
)
})
it('Returns error for a blacklisted name', () => {
const blacklistedErrorMessage = 'Name should not include: UNKNOWN, OWNER #, MY WALLET'
expect(validAddressBookName('unknown')).toBe(blacklistedErrorMessage)
expect(validAddressBookName('unknown a')).toBe(blacklistedErrorMessage)
expect(validAddressBookName('owner #1')).toBe(blacklistedErrorMessage)
expect(validAddressBookName('My Wallet')).toBe(blacklistedErrorMessage)
})
it('Returns undefined for a non-blacklisted name', () => {
expect(validAddressBookName('A valid name')).toBeUndefined()
})
})
}) })

View File

@ -1,10 +1,11 @@
import { List } from 'immutable'
import memoize from 'lodash.memoize' import memoize from 'lodash.memoize'
import { sameAddress } from 'src/logic/wallets/ethAddresses' import { sameAddress } from 'src/logic/wallets/ethAddresses'
import { getWeb3 } from 'src/logic/wallets/getWeb3' import { getWeb3 } from 'src/logic/wallets/getWeb3'
import { isFeatureEnabled } from 'src/config' import { isFeatureEnabled } from 'src/config'
import { FEATURES } from 'src/config/networks/network.d' import { FEATURES } from 'src/config/networks/network.d'
import { isValidAddress } from 'src/utils/isValidAddress'
import { ADDRESS_BOOK_INVALID_NAMES, isValidAddressBookName } from 'src/logic/addressBook/utils'
type ValidatorReturnType = string | undefined type ValidatorReturnType = string | undefined
export type GenericValidatorType = (...args: unknown[]) => ValidatorReturnType export type GenericValidatorType = (...args: unknown[]) => ValidatorReturnType
@ -74,9 +75,7 @@ export const mustBeHexData = (data: string): ValidatorReturnType => {
export const mustBeAddressHash = memoize( export const mustBeAddressHash = memoize(
(address: string): ValidatorReturnType => { (address: string): ValidatorReturnType => {
const errorMessage = 'Must be a valid address' const errorMessage = 'Must be a valid address'
const startsWith0x = address?.startsWith('0x') return isValidAddress(address) ? undefined : errorMessage
const isAddress = getWeb3().utils.isAddress(address)
return startsWith0x && isAddress ? undefined : errorMessage
}, },
) )
@ -101,8 +100,12 @@ export const mustBeEthereumContractAddress = memoize(
}, },
) )
export const minMaxLength = (minLen: number, maxLen: number) => (value: string): ValidatorReturnType => export const minMaxLength = (minLen: number, maxLen: number) => (value: string): ValidatorReturnType => {
value.length >= +minLen && value.length <= +maxLen ? undefined : `Should be ${minLen} to ${maxLen} symbols` const testValue = value || ''
return testValue.length >= +minLen && testValue.length <= +maxLen
? undefined
: `Should be ${minLen} to ${maxLen} symbols`
}
export const minMaxDecimalsLength = (minLen: number, maxLen: number) => (value: string): ValidatorReturnType => { export const minMaxDecimalsLength = (minLen: number, maxLen: number) => (value: string): ValidatorReturnType => {
const decimals = value.split('.')[1] || '0' const decimals = value.split('.')[1] || '0'
@ -113,7 +116,7 @@ export const minMaxDecimalsLength = (minLen: number, maxLen: number) => (value:
export const ADDRESS_REPEATED_ERROR = 'Address already introduced' export const ADDRESS_REPEATED_ERROR = 'Address already introduced'
export const OWNER_ADDRESS_IS_SAFE_ADDRESS_ERROR = 'Cannot use Safe itself as owner.' export const OWNER_ADDRESS_IS_SAFE_ADDRESS_ERROR = 'Cannot use Safe itself as owner.'
export const uniqueAddress = (addresses: string[] | List<string> = []) => (address?: string): string | undefined => { export const uniqueAddress = (addresses: string[] = []) => (address?: string): string | undefined => {
const addressExists = addresses.some((addressFromList) => sameAddress(addressFromList, address)) const addressExists = addresses.some((addressFromList) => sameAddress(addressFromList, address))
return addressExists ? ADDRESS_REPEATED_ERROR : undefined return addressExists ? ADDRESS_REPEATED_ERROR : undefined
} }
@ -136,3 +139,15 @@ export const differentFrom = (diffValue: number | string) => (value: string): Va
} }
export const noErrorsOn = (name: string, errors: Record<string, unknown>): boolean => errors[name] === undefined export const noErrorsOn = (name: string, errors: Record<string, unknown>): boolean => errors[name] === undefined
export const validAddressBookName = (name: string): string | undefined => {
const lengthError = minMaxLength(1, 50)(name)
if (lengthError === undefined) {
return isValidAddressBookName(name)
? undefined
: `Name should not include: ${ADDRESS_BOOK_INVALID_NAMES.join(', ')}`
}
return lengthError
}

View File

@ -34,6 +34,7 @@ type Token = {
} }
export enum ETHEREUM_NETWORK { export enum ETHEREUM_NETWORK {
UNKNOWN = 0,
MAINNET = 1, MAINNET = 1,
MORDEN = 2, MORDEN = 2,
ROPSTEN = 3, ROPSTEN = 3,
@ -42,9 +43,8 @@ export enum ETHEREUM_NETWORK {
KOVAN = 42, KOVAN = 42,
XDAI = 100, XDAI = 100,
ENERGY_WEB_CHAIN = 246, ENERGY_WEB_CHAIN = 246,
VOLTA = 73799,
UNKNOWN = 0,
LOCAL = 4447, LOCAL = 4447,
VOLTA = 73799,
} }
export type NetworkSettings = { export type NetworkSettings = {

View File

@ -0,0 +1,10 @@
import { useSelector } from 'react-redux'
import { getNameFromAddressBook } from 'src/logic/addressBook/utils'
import { addressBookSelector } from 'src/logic/addressBook/store/selectors'
export const useSafeName = (safeAddress: string): string => {
const addressBook = useSelector(addressBookSelector)
return getNameFromAddressBook(addressBook, safeAddress) || ''
}

View File

@ -1,17 +1,28 @@
import { ETHEREUM_NETWORK } from 'src/config/networks/network.d'
import { getNetworkId } from 'src/config'
export const ADDRESS_BOOK_DEFAULT_NAME = 'UNKNOWN'
export type AddressBookEntry = { export type AddressBookEntry = {
address: string address: string // the contact address
name: string name: string // human-readable name
chainId: ETHEREUM_NETWORK // see https://chainid.network
} }
const networkId = getNetworkId()
export const makeAddressBookEntry = ({ export const makeAddressBookEntry = ({
address = '', address,
name = '', name,
chainId = networkId,
}: { }: {
address: string address: string
name?: string name: string
chainId?: number
}): AddressBookEntry => ({ }): AddressBookEntry => ({
address, address,
name, name,
chainId,
}) })
export type AddressBookState = AddressBookEntry[] export type AddressBookState = AddressBookEntry[]

View File

@ -1,22 +0,0 @@
import { createAction } from 'redux-actions'
import { AddressBookEntry } from 'src/logic/addressBook/model/addressBook'
export const ADD_ENTRY = 'ADD_ENTRY'
type addAddressBookEntryOptions = {
notifyEntryUpdate: boolean
}
export const addAddressBookEntry = createAction(
ADD_ENTRY,
(entry: AddressBookEntry, options?: addAddressBookEntryOptions) => {
let notifyEntryUpdate = true
if (options) {
notifyEntryUpdate = options.notifyEntryUpdate
}
return {
entry,
shouldAvoidUpdatesNotifications: !notifyEntryUpdate,
}
},
)

View File

@ -1,8 +0,0 @@
import { createAction } from 'redux-actions'
import { AddressBookEntry } from 'src/logic/addressBook/model/addressBook'
export const ADD_OR_UPDATE_ENTRY = 'ADD_OR_UPDATE_ENTRY'
export const addOrUpdateAddressBookEntry = createAction(ADD_OR_UPDATE_ENTRY, (entry: AddressBookEntry) => ({
entry,
}))

View File

@ -0,0 +1,17 @@
import { createAction } from 'redux-actions'
import { AddressBookEntry, AddressBookState } from 'src/logic/addressBook/model/addressBook'
// following the suggested naming convention at
// https://redux.js.org/style-guide/style-guide#write-action-types-as-domaineventname
export enum ADDRESS_BOOK_ACTIONS {
ADD_OR_UPDATE = 'addressBook/addOrUpdate',
REMOVE = 'addressBook/remove',
IMPORT = 'addressBook/import',
SAFE_LOAD = 'addressBook/safeLoad',
}
export const addressBookAddOrUpdate = createAction<AddressBookEntry>(ADDRESS_BOOK_ACTIONS.ADD_OR_UPDATE)
export const addressBookRemove = createAction<AddressBookEntry>(ADDRESS_BOOK_ACTIONS.REMOVE)
export const addressBookSafeLoad = createAction<AddressBookState>(ADDRESS_BOOK_ACTIONS.SAFE_LOAD)
export const addressBookImport = createAction<AddressBookState>(ADDRESS_BOOK_ACTIONS.IMPORT)

View File

@ -1,8 +0,0 @@
import { createAction } from 'redux-actions'
import { AddressBookState } from 'src/logic/addressBook/model/addressBook'
export const LOAD_ADDRESS_BOOK = 'LOAD_ADDRESS_BOOK'
export const loadAddressBook = createAction(LOAD_ADDRESS_BOOK, (addressBook: AddressBookState) => ({
addressBook,
}))

View File

@ -1,22 +0,0 @@
import { loadAddressBook } from 'src/logic/addressBook/store/actions/loadAddressBook'
import { buildAddressBook } from 'src/logic/addressBook/store/reducer/addressBook'
import { getAddressBookFromStorage } from 'src/logic/addressBook/utils'
import { Dispatch } from 'redux'
const loadAddressBookFromStorage = () => async (dispatch: Dispatch): Promise<void> => {
try {
let storedAdBk = await getAddressBookFromStorage()
if (!storedAdBk) {
storedAdBk = []
}
const addressBook = buildAddressBook(storedAdBk)
dispatch(loadAddressBook(addressBook))
} catch (err) {
// eslint-disable-next-line
console.error('Error while loading active tokens from storage:', err)
}
}
export default loadAddressBookFromStorage

View File

@ -1,7 +0,0 @@
import { createAction } from 'redux-actions'
export const REMOVE_ENTRY = 'REMOVE_ENTRY'
export const removeAddressBookEntry = createAction(REMOVE_ENTRY, (entryAddress: string) => ({
entryAddress,
}))

View File

@ -1,8 +0,0 @@
import { createAction } from 'redux-actions'
import { AddressBookEntry } from 'src/logic/addressBook/model/addressBook'
export const UPDATE_ENTRY = 'UPDATE_ENTRY'
export const updateAddressBookEntry = createAction(UPDATE_ENTRY, (entry: AddressBookEntry) => ({
entry,
}))

View File

@ -1,61 +0,0 @@
import { ADD_ENTRY } from 'src/logic/addressBook/store/actions/addAddressBookEntry'
import { ADD_OR_UPDATE_ENTRY } from 'src/logic/addressBook/store/actions/addOrUpdateAddressBookEntry'
import { REMOVE_ENTRY } from 'src/logic/addressBook/store/actions/removeAddressBookEntry'
import { UPDATE_ENTRY } from 'src/logic/addressBook/store/actions/updateAddressBookEntry'
import { addressBookSelector } from 'src/logic/addressBook/store/selectors'
import { saveAddressBook } from 'src/logic/addressBook/utils'
import { enhanceSnackbarForAction, getNotificationsFromTxType } from 'src/logic/notifications'
import enqueueSnackbar from 'src/logic/notifications/store/actions/enqueueSnackbar'
import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
import { safesListSelector } from 'src/logic/safe/store/selectors'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
import { updateSafe } from 'src/logic/safe/store/actions/updateSafe'
const watchedActions = [ADD_ENTRY, REMOVE_ENTRY, UPDATE_ENTRY, ADD_OR_UPDATE_ENTRY]
const addressBookMiddleware = (store) => (next) => async (action) => {
const handledAction = next(action)
if (watchedActions.includes(action.type)) {
const state = store.getState()
const { dispatch } = store
const addressBook = addressBookSelector(state)
const safes = safesListSelector(state)
if (addressBook.length) {
await saveAddressBook(addressBook)
}
switch (action.type) {
case ADD_ENTRY: {
const { shouldAvoidUpdatesNotifications } = action.payload
if (!shouldAvoidUpdatesNotifications) {
const notification = getNotificationsFromTxType(TX_NOTIFICATION_TYPES.ADDRESSBOOK_NEW_ENTRY)
dispatch(enqueueSnackbar(enhanceSnackbarForAction(notification.afterExecution.noMoreConfirmationsNeeded)))
}
break
}
case REMOVE_ENTRY: {
const notification = getNotificationsFromTxType(TX_NOTIFICATION_TYPES.ADDRESSBOOK_DELETE_ENTRY)
dispatch(enqueueSnackbar(enhanceSnackbarForAction(notification.afterExecution.noMoreConfirmationsNeeded)))
break
}
case UPDATE_ENTRY: {
const { entry } = action.payload
const notification = getNotificationsFromTxType(TX_NOTIFICATION_TYPES.ADDRESSBOOK_EDIT_ENTRY)
dispatch(enqueueSnackbar(enhanceSnackbarForAction(notification.afterExecution.noMoreConfirmationsNeeded)))
const safeFound = safes.find((safe) => sameAddress(safe.address, entry.address))
if (safeFound) {
dispatch(updateSafe({ address: safeFound.address, name: entry.name }))
}
break
}
default:
break
}
}
return handledAction
}
export default addressBookMiddleware

View File

@ -0,0 +1,37 @@
import { ADDRESS_BOOK_ACTIONS } from 'src/logic/addressBook/store/actions'
import { enhanceSnackbarForAction, getNotificationsFromTxType } from 'src/logic/notifications'
import enqueueSnackbar from 'src/logic/notifications/store/actions/enqueueSnackbar'
import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
const watchedActions = Object.values(ADDRESS_BOOK_ACTIONS)
export const addressBookMiddleware = (store) => (next) => async (action) => {
const handledAction = next(action)
if (watchedActions.includes(action.type)) {
const { dispatch } = store
switch (action.type) {
case ADDRESS_BOOK_ACTIONS.ADD_OR_UPDATE: {
const { shouldAvoidUpdatesNotifications } = action.payload
if (!shouldAvoidUpdatesNotifications) {
const notification = getNotificationsFromTxType(TX_NOTIFICATION_TYPES.ADDRESS_BOOK_NEW_ENTRY)
dispatch(enqueueSnackbar(enhanceSnackbarForAction(notification.afterExecution.noMoreConfirmationsNeeded)))
}
break
}
case ADDRESS_BOOK_ACTIONS.REMOVE: {
const notification = getNotificationsFromTxType(TX_NOTIFICATION_TYPES.ADDRESS_BOOK_DELETE_ENTRY)
dispatch(enqueueSnackbar(enhanceSnackbarForAction(notification.afterExecution.noMoreConfirmationsNeeded)))
break
}
case ADDRESS_BOOK_ACTIONS.IMPORT: {
const notification = getNotificationsFromTxType(TX_NOTIFICATION_TYPES.ADDRESS_BOOK_IMPORT_ENTRIES)
dispatch(enqueueSnackbar(enhanceSnackbarForAction(notification.afterExecution.noMoreConfirmationsNeeded)))
break
}
default:
break
}
}
return handledAction
}

View File

@ -1,78 +0,0 @@
import { Action, handleActions } from 'redux-actions'
import { AddressBookEntry, AddressBookState, makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { ADD_ENTRY } from 'src/logic/addressBook/store/actions/addAddressBookEntry'
import { ADD_OR_UPDATE_ENTRY } from 'src/logic/addressBook/store/actions/addOrUpdateAddressBookEntry'
import { LOAD_ADDRESS_BOOK } from 'src/logic/addressBook/store/actions/loadAddressBook'
import { REMOVE_ENTRY } from 'src/logic/addressBook/store/actions/removeAddressBookEntry'
import { UPDATE_ENTRY } from 'src/logic/addressBook/store/actions/updateAddressBookEntry'
import { AppReduxState } from 'src/store'
import { checksumAddress } from 'src/utils/checksumAddress'
import { getValidAddressBookName } from 'src/logic/addressBook/utils'
export const ADDRESS_BOOK_REDUCER_ID = 'addressBook'
export const buildAddressBook = (storedAddressBook: AddressBookState): AddressBookState => {
return storedAddressBook.map((addressBookEntry) => {
const { address, name } = addressBookEntry
return makeAddressBookEntry({ address: checksumAddress(address), name })
})
}
type AddressBookPayload = { addressBook: AddressBookState }
type EntryPayload = { entry: AddressBookEntry }
type RemoveEntryPayload = { entryAddress: string }
type Payloads = AddressBookPayload | EntryPayload | RemoveEntryPayload
export default handleActions<AppReduxState['addressBook'], Payloads>(
{
[LOAD_ADDRESS_BOOK]: (state, action: Action<AddressBookPayload>) => {
const { addressBook } = action.payload
return addressBook
},
[ADD_ENTRY]: (state, action: Action<EntryPayload>) => {
const { entry } = action.payload
const entryFound = state.find((oldEntry) => oldEntry.address === entry.address)
if (!entryFound) {
state.push(entry)
}
return state
},
[UPDATE_ENTRY]: (state, action: Action<EntryPayload>) => {
const { entry } = action.payload
const entryIndex = state.findIndex((oldEntry) => oldEntry.address === entry.address)
if (entryIndex >= 0) {
state[entryIndex] = entry
}
return state
},
[REMOVE_ENTRY]: (state, action: Action<RemoveEntryPayload>) => {
const { entryAddress } = action.payload
const entryIndex = state.findIndex((oldEntry) => oldEntry.address === entryAddress)
state.splice(entryIndex, 1)
return state
},
[ADD_OR_UPDATE_ENTRY]: (state, action: Action<EntryPayload>) => {
const { entry } = action.payload
// Only updates entries with valid names
const validName = getValidAddressBookName(entry.name)
if (!validName) {
return state
}
const entryIndex = state.findIndex((oldEntry) => oldEntry.address === entry.address)
if (entryIndex >= 0) {
state[entryIndex] = entry
} else {
state.push(entry)
}
return state
},
},
[],
)

View File

@ -0,0 +1,66 @@
import { Action, handleActions } from 'redux-actions'
import { AddressBookEntry, AddressBookState } from 'src/logic/addressBook/model/addressBook'
import { ADDRESS_BOOK_ACTIONS } from 'src/logic/addressBook/store/actions'
import { getEntryIndex, isValidAddressBookName } from 'src/logic/addressBook/utils'
import { AppReduxState } from 'src/store'
export const ADDRESS_BOOK_REDUCER_ID = 'addressBook'
type Payloads = AddressBookEntry | AddressBookState
const batchLoadEntries = (state, action: Action<AddressBookState>): AddressBookState => {
const newState = [...state]
const addressBookEntries = action.payload
addressBookEntries
// exclude those entries with invalid name
.filter(({ name }) => isValidAddressBookName(name))
.forEach((addressBookEntry) => {
const entryIndex = getEntryIndex(newState, addressBookEntry)
if (entryIndex >= 0) {
// update
newState[entryIndex] = addressBookEntry
} else {
// add
newState.push(addressBookEntry)
}
})
return newState
}
export default handleActions<AppReduxState['addressBook'], Payloads>(
{
[ADDRESS_BOOK_ACTIONS.ADD_OR_UPDATE]: (state, action: Action<AddressBookEntry>) => {
const newState = [...state]
const addressBookEntry = action.payload
const entryIndex = getEntryIndex(newState, addressBookEntry)
// update
if (entryIndex >= 0) {
newState[entryIndex] = addressBookEntry
return newState
}
// add
return [...newState, addressBookEntry]
},
[ADDRESS_BOOK_ACTIONS.REMOVE]: (state, action: Action<AddressBookEntry>) => {
const newState = [...state]
const addressBookEntry = action.payload
const entryIndex = getEntryIndex(newState, addressBookEntry)
if (entryIndex >= 0) {
newState.splice(entryIndex, 1)
return newState
}
return newState
},
[ADDRESS_BOOK_ACTIONS.SAFE_LOAD]: batchLoadEntries,
[ADDRESS_BOOK_ACTIONS.IMPORT]: batchLoadEntries,
},
[],
)

View File

@ -1,27 +1,56 @@
import { AppReduxState } from 'src/store'
import { createSelector } from 'reselect' import { createSelector } from 'reselect'
import { ADDRESS_BOOK_REDUCER_ID } from 'src/logic/addressBook/store/reducer/addressBook' import { getNetworkId } from 'src/config'
import { ADDRESS_BOOK_DEFAULT_NAME, AddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { AppReduxState } from 'src/store'
import { Overwrite } from 'src/types/helpers'
import { AddressBookState } from 'src/logic/addressBook/model/addressBook' const networkId = getNetworkId()
export const addressBookSelector = (state: AppReduxState): AddressBookState => state[ADDRESS_BOOK_REDUCER_ID] export const addressBookSelector = (state: AppReduxState): AppReduxState['addressBook'] => state['addressBook']
export const addressBookAddressesListSelector = (state: AppReduxState): string[] => { type AddressBookMap = {
const addressBook = addressBookSelector(state) [chainId: number]: {
return addressBook.map((entry) => entry.address) [address: string]: AddressBookEntry
}
} }
export const getNameFromAddressBookSelector = createSelector( export const addressBookMapSelector = createSelector(
addressBookSelector, [addressBookSelector],
(_, address) => address, (addressBook): AddressBookMap => {
(addressBook, address) => { const addressBookMap = {}
const adbkEntry = addressBook.find((addressBookItem) => addressBookItem.address === address)
if (adbkEntry) { addressBook.forEach((entry) => {
return adbkEntry.name const { address, chainId } = entry
if (!addressBookMap[chainId]) {
addressBookMap[chainId] = { [address]: entry }
} else {
addressBookMap[chainId][address] = entry
} }
return 'UNKNOWN' })
return addressBookMap
}, },
) )
export const addressBookAddressesListSelector = createSelector([addressBookSelector], (addressBook): string[] =>
addressBook.map(({ address }) => address),
)
type GetNameParams = Overwrite<
AddressBookEntry,
{ chainId?: AddressBookEntry['chainId']; name?: AddressBookEntry['name'] }
>
type GetNameReturnObject = Overwrite<GetNameParams, { chainId: AddressBookEntry['chainId'] }>
export const getNameFromAddressBookSelector = createSelector(
[
addressBookMapSelector,
(_, { address, chainId = networkId }: GetNameParams): GetNameReturnObject => ({
address,
chainId,
}),
],
(addressBook, entry) => addressBook?.[entry.chainId]?.[entry.address]?.name ?? ADDRESS_BOOK_DEFAULT_NAME,
)

View File

@ -1,16 +1,8 @@
import { List } from 'immutable'
import { import {
checkIfEntryWasDeletedFromAddressBook, checkIfEntryWasDeletedFromAddressBook,
getAddressBookFromStorage,
getNameFromAddressBook, getNameFromAddressBook,
getOwnersWithNameFromAddressBook,
isValidAddressBookName, isValidAddressBookName,
migrateOldAddressBook, } from 'src/logic/addressBook/utils'
OldAddressBookEntry,
OldAddressBookType,
saveAddressBook,
} from 'src/logic/addressBook/utils/index'
import { buildAddressBook } from 'src/logic/addressBook/store/reducer/addressBook'
import { AddressBookEntry, AddressBookState, makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook' import { AddressBookEntry, AddressBookState, makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook'
const getMockAddressBookEntry = (address: string, name: string = 'test'): AddressBookEntry => const getMockAddressBookEntry = (address: string, name: string = 'test'): AddressBookEntry =>
@ -19,14 +11,6 @@ const getMockAddressBookEntry = (address: string, name: string = 'test'): Addres
name, name,
}) })
const getMockOldAddressBookEntry = ({ address = '', name = '', isOwner = false }): OldAddressBookEntry => {
return {
address,
name,
isOwner,
}
}
describe('getNameFromSafeAddressBook', () => { describe('getNameFromSafeAddressBook', () => {
const entry1 = getMockAddressBookEntry('123456', 'test1') const entry1 = getMockAddressBookEntry('123456', 'test1')
const entry2 = getMockAddressBookEntry('78910', 'test2') const entry2 = getMockAddressBookEntry('78910', 'test2')
@ -44,155 +28,6 @@ describe('getNameFromSafeAddressBook', () => {
}) })
}) })
describe('getOwnersWithNameFromAddressBook', () => {
const entry1 = getMockAddressBookEntry('123456', 'test1')
const entry2 = getMockAddressBookEntry('78910', 'test2')
const entry3 = getMockAddressBookEntry('4781321', 'test3')
it('It should returns the list of owners with their names given a safeAddressBook and a list of owners', () => {
// given
const safeAddressBook = [entry1, entry2, entry3]
const ownerList = List([
{ address: entry1.address, name: '' },
{ address: entry2.address, name: '' },
])
const expectedResult = List([
{ address: entry1.address, name: entry1.name },
{ address: entry2.address, name: entry2.name },
])
// when
const result = getOwnersWithNameFromAddressBook(safeAddressBook, ownerList)
// then
expect(result).toStrictEqual(expectedResult)
})
})
jest.mock('src/utils/storage/index')
describe('saveAddressBook', () => {
const mockAdd1 = '0x696fd93D725d84acfFf6c62a1fe8C94E1c9E934A'
const mockAdd2 = '0x2C7aC78b01Be0FC66AD29b684ffAb0C93B381D00'
const mockAdd3 = '0x537BD452c3505FC07bA242E437bD29D4E1DC9126'
const entry1 = getMockAddressBookEntry(mockAdd1, 'test1')
const entry2 = getMockAddressBookEntry(mockAdd2, 'test2')
const entry3 = getMockAddressBookEntry(mockAdd3, 'test3')
afterAll(() => {
jest.unmock('src/utils/storage/index')
})
it('It should save a given addressBook to the localStorage', async () => {
// given
const addressBook: AddressBookState = [entry1, entry2, entry3]
// when
await saveAddressBook(addressBook)
const storageUtils = require('src/utils/storage/index')
const spy = storageUtils.loadFromStorage.mockImplementationOnce(() => JSON.stringify(addressBook))
const storedAddressBook = await getAddressBookFromStorage()
// @ts-ignore
let result = buildAddressBook(storedAddressBook)
// then
expect(result).toStrictEqual(addressBook)
expect(spy).toHaveBeenCalled()
})
})
describe('migrateOldAddressBook', () => {
const safeAddress1 = '0x696fd93D725d84acfFf6c62a1fe8C94E1c9E934A'
const safeAddress2 = '0x2C7aC78b01Be0FC66AD29b684ffAb0C93B381D00'
const mockAdd1 = '0x9163c2F4452E3399CB60AAf737231Af87548DA91'
const mockAdd2 = '0xC4e446Da9C3D37385C86488294C6758c4e25dbD8'
it('It should receive an addressBook in old format and return the same addressBook in new format', () => {
// given
const entry1 = getMockOldAddressBookEntry({ name: 'test1', address: mockAdd1 })
const entry2 = getMockOldAddressBookEntry({ name: 'test2', address: mockAdd2 })
const oldAddressBook: OldAddressBookType = {
[safeAddress1]: [entry1],
[safeAddress2]: [entry2],
}
const expectedEntry1 = getMockAddressBookEntry(mockAdd1, 'test1')
const expectedEntry2 = getMockAddressBookEntry(mockAdd2, 'test2')
const expectedResult = [expectedEntry1, expectedEntry2]
// when
const result = migrateOldAddressBook(oldAddressBook)
// then
expect(result).toStrictEqual(expectedResult)
})
})
describe('getAddressBookFromStorage', () => {
const safeAddress1 = '0x696fd93D725d84acfFf6c62a1fe8C94E1c9E934A'
const safeAddress2 = '0x2C7aC78b01Be0FC66AD29b684ffAb0C93B381D00'
const mockAdd1 = '0x9163c2F4452E3399CB60AAf737231Af87548DA91'
const mockAdd2 = '0xC4e446Da9C3D37385C86488294C6758c4e25dbD8'
beforeAll(() => {
jest.mock('src/utils/storage/index')
})
afterAll(() => {
jest.unmock('src/utils/storage/index')
})
it('It should return null if no addressBook in storage', async () => {
// given
const expectedResult = null
const storageUtils = require('src/utils/storage/index')
const spy = storageUtils.loadFromStorage.mockImplementationOnce(() => null)
// when
const result = await getAddressBookFromStorage()
// then
expect(result).toStrictEqual(expectedResult)
expect(spy).toHaveBeenCalled()
})
it('It should return migrated addressBook if old addressBook in storage', async () => {
// given
const expectedEntry1 = getMockAddressBookEntry(mockAdd1, 'test1')
const expectedEntry2 = getMockAddressBookEntry(mockAdd2, 'test2')
const entry1 = getMockOldAddressBookEntry({ name: 'test1', address: mockAdd1 })
const entry2 = getMockOldAddressBookEntry({ name: 'test2', address: mockAdd2 })
const oldAddressBook: OldAddressBookType = {
[safeAddress1]: [entry1],
[safeAddress2]: [entry2],
}
const expectedResult = [expectedEntry1, expectedEntry2]
const storageUtils = require('src/utils/storage/index')
const spy = storageUtils.loadFromStorage.mockImplementationOnce(() => oldAddressBook)
// when
const result = await getAddressBookFromStorage()
// then
expect(result).toStrictEqual(expectedResult)
expect(spy).toHaveBeenCalled()
})
it('It should return addressBook if addressBook in storage', async () => {
// given
const expectedEntry1 = getMockAddressBookEntry(mockAdd1, 'test1')
const expectedEntry2 = getMockAddressBookEntry(mockAdd2, 'test2')
const expectedResult = [expectedEntry1, expectedEntry2]
const storageUtils = require('src/utils/storage/index')
const spy = storageUtils.loadFromStorage.mockImplementationOnce(() => JSON.stringify(expectedResult))
// when
const result = await getAddressBookFromStorage()
// then
expect(result).toStrictEqual(expectedResult)
expect(spy).toHaveBeenCalled()
})
})
describe('isValidAddressBookName', () => { describe('isValidAddressBookName', () => {
it('It should return false if given a blacklisted name like UNKNOWN', () => { it('It should return false if given a blacklisted name like UNKNOWN', () => {
// given // given

View File

@ -1,11 +1,9 @@
import { List } from 'immutable'
import { mustBeEthereumContractAddress } from 'src/components/forms/validator' import { mustBeEthereumContractAddress } from 'src/components/forms/validator'
import { AddressBookEntry, AddressBookState, makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook' import { ETHEREUM_NETWORK } from 'src/config/networks/network.d'
import { SafeOwner } from 'src/logic/safe/store/models/safe' import { AddressBookEntry, AddressBookState } from 'src/logic/addressBook/model/addressBook'
import { sameAddress } from 'src/logic/wallets/ethAddresses' import { sameAddress } from 'src/logic/wallets/ethAddresses'
import { loadFromStorage, saveToStorage } from 'src/utils/storage' import { AppReduxState } from 'src/store'
import { Overwrite } from 'src/types/helpers'
const ADDRESS_BOOK_STORAGE_KEY = 'ADDRESS_BOOK_STORAGE_KEY'
export type OldAddressBookEntry = { export type OldAddressBookEntry = {
address: string address: string
@ -17,44 +15,7 @@ export type OldAddressBookType = {
[safeAddress: string]: [OldAddressBookEntry] [safeAddress: string]: [OldAddressBookEntry]
} }
const ADDRESSBOOK_INVALID_NAMES = ['UNKNOWN', 'OWNER #', 'MY WALLET'] export const ADDRESS_BOOK_INVALID_NAMES = ['UNKNOWN', 'OWNER #', 'MY WALLET']
export const migrateOldAddressBook = (oldAddressBook: OldAddressBookType): AddressBookState => {
const values: AddressBookState = []
const adbkValues = Object.values(oldAddressBook)
for (const safeIterator of adbkValues) {
for (const safeAddressBook of safeIterator) {
if (!values.find((entry) => sameAddress(entry.address, safeAddressBook.address))) {
values.push(makeAddressBookEntry({ address: safeAddressBook.address, name: safeAddressBook.name }))
}
}
}
return values
}
export const getAddressBookFromStorage = async (): Promise<AddressBookState | null> => {
const result: OldAddressBookType | string | undefined = await loadFromStorage(ADDRESS_BOOK_STORAGE_KEY)
if (!result) {
return null
}
if (typeof result === 'string') {
return JSON.parse(result)
}
return migrateOldAddressBook(result as OldAddressBookType)
}
export const saveAddressBook = async (addressBook: AddressBookState): Promise<void> => {
try {
await saveToStorage(ADDRESS_BOOK_STORAGE_KEY, JSON.stringify(addressBook))
} catch (err) {
console.error('Error storing addressBook in localstorage', err)
}
}
type GetNameFromAddressBookOptions = { type GetNameFromAddressBookOptions = {
filterOnlyValidName: boolean filterOnlyValidName: boolean
@ -73,32 +34,17 @@ export const getNameFromAddressBook = (
} }
export const isValidAddressBookName = (addressBookName: string): boolean => { export const isValidAddressBookName = (addressBookName: string): boolean => {
const hasInvalidName = ADDRESSBOOK_INVALID_NAMES.find((invalidName) => const hasInvalidName = ADDRESS_BOOK_INVALID_NAMES.find((invalidName) =>
addressBookName.toUpperCase().includes(invalidName), addressBookName?.toUpperCase().includes(invalidName),
) )
return !hasInvalidName return !hasInvalidName
} }
// TODO: is this really required?
export const getValidAddressBookName = (addressBookName: string): string | null => { export const getValidAddressBookName = (addressBookName: string): string | null => {
return isValidAddressBookName(addressBookName) ? addressBookName : null return isValidAddressBookName(addressBookName) ? addressBookName : null
} }
export const getOwnersWithNameFromAddressBook = (
addressBook: AddressBookState,
ownerList: List<SafeOwner>,
): List<SafeOwner> => {
if (!ownerList) {
return List([])
}
return ownerList.map((owner) => {
const ownerName = getNameFromAddressBook(addressBook, owner.address)
return {
address: owner.address,
name: ownerName || owner.name,
}
})
}
export const formatAddressListToAddressBookNames = ( export const formatAddressListToAddressBookNames = (
addressBook: AddressBookState, addressBook: AddressBookState,
addresses: string[], addresses: string[],
@ -111,12 +57,13 @@ export const formatAddressListToAddressBookNames = (
return { return {
address: address, address: address,
name: ownerName || '', name: ownerName || '',
chainId: ETHEREUM_NETWORK.UNKNOWN,
} }
}) })
} }
/** /**
* If the safe is not loaded, the owner wasn't not deleted * If the safe is not loaded, the owner wasn't deleted
* If the safe is already loaded and the owner has a valid name, will return true if the address is not already on the addressBook * If the safe is already loaded and the owner has a valid name, will return true if the address is not already on the addressBook
* @param name * @param name
* @param address * @param address
@ -172,3 +119,11 @@ export const filterAddressEntries = (
return foundName || foundAddress return foundName || foundAddress
}) })
export const getEntryIndex = (
state: AppReduxState['addressBook'],
addressBookEntry: Overwrite<AddressBookEntry, { name?: string }>,
): number =>
state.findIndex(
({ address, chainId }) => chainId === addressBookEntry.chainId && sameAddress(address, addressBookEntry.address),
)

View File

@ -0,0 +1,133 @@
import { AddressBookEntry, AddressBookState, makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { saveSafes, StoredSafes } from 'src/logic/safe/utils'
import { removeFromStorage } from 'src/utils/storage'
import { getNetworkName } from 'src/config'
import { getWeb3 } from 'src/logic/wallets/getWeb3'
import { Errors, logError } from 'src/logic/exceptions/CodedException'
import { getEntryIndex, isValidAddressBookName } from '.'
interface StorageConfig {
states: string[]
namespace: string
namespaceSeparator: string
}
/**
* Migrates the safes names from the Safe Object to the Address Book
*
* Works on the persistence layer, reads from the localStorage and stores back the mutated safe info through immortalDB.
*
* @note If the Safe name is an invalid AB name, it's renamed to "Migrated from: {safe.name}"
*/
const migrateSafeNames = ({ states, namespace, namespaceSeparator }: StorageConfig): void => {
const prefix = `v2_${getNetworkName()}`
const safesKey = `_immortal|${prefix}__SAFES`
const storedSafes = localStorage.getItem(safesKey)
if (!storedSafes) {
// nothing left to migrate
return
}
const parsedStoredSafes = JSON.parse(storedSafes) as Record<string, any>
if (Object.entries(parsedStoredSafes).every(([, { name }]) => name === undefined)) {
// no name key, safes already migrated
return
}
// make address book entries from the safe names & addresses
const safeAbEntries: AddressBookState = Object.values(parsedStoredSafes)
.filter(({ name }) => name && isValidAddressBookName(name))
.map(({ address, name }) => makeAddressBookEntry({ address, name }))
// remove names from the safes in place
Object.values(parsedStoredSafes).forEach((item) => {
item.owners = item.owners.map((owner: any) => owner.address)
delete item.name
})
const migratedSafes = parsedStoredSafes as StoredSafes
const [state] = states
const addressBookKey = `${namespace}${namespaceSeparator}${state}`
const storedAddressBook = localStorage.getItem(addressBookKey)
const addressBookToStore: AddressBookState = storedAddressBook ? JSON.parse(storedAddressBook) : []
// mutate `addressBookToStore` by adding safes' information
safeAbEntries.forEach((entry) => {
const safeIndex = getEntryIndex(addressBookToStore, entry)
if (safeIndex >= 0) {
// update AB entry with what was stored in the safe object
addressBookToStore[safeIndex] = entry
} else {
// add the safe entry to the AB
addressBookToStore.push(entry)
}
})
// store the mutated address book
localStorage.setItem(addressBookKey, JSON.stringify(addressBookToStore))
// update stored safe
localStorage.setItem(safesKey, JSON.stringify(migratedSafes))
saveSafes(migratedSafes).then(() => console.info('Safe objects migrated'))
}
/**
* Migrates the AddressBook from a per-network to a global storage under the key `ADDRESS_BOOK` in `localStorage`
*
* The migrated structure will be `{ address, name, chainId }`
*
* @note Also, adds `chainId` to every entry in the AddressBook list.
*/
const migrateAddressBook = ({ states, namespace, namespaceSeparator }: StorageConfig): void => {
const [state] = states
const prefix = `v2_${getNetworkName()}`
const newKey = `${namespace}${namespaceSeparator}${state}`
const oldKey = 'ADDRESS_BOOK_STORAGE_KEY'
const storageKey = `_immortal|${prefix}__${oldKey}`
if (localStorage.getItem(newKey)) {
// already migrated
return
}
const storedAddressBook = localStorage.getItem(storageKey)
if (!storedAddressBook) {
// nothing to migrate
return
}
const parsedAddressBook = JSON.parse(JSON.parse(storedAddressBook as string))
const migratedAddressBook = (parsedAddressBook as Omit<AddressBookEntry, 'chainId'>[])
// exclude those addresses with invalid names and addresses
.filter((item) => {
return isValidAddressBookName(item.name) && getWeb3().utils.isAddress(item.address)
})
.map(({ address, ...entry }) =>
makeAddressBookEntry({
address,
...entry,
}),
)
localStorage.setItem(newKey, JSON.stringify(migratedAddressBook))
// Remove the old Address Book storage
localStorage.removeItem(storageKey)
removeFromStorage(oldKey).then(() => console.info('Legacy Address Book removed'))
}
const migrate = (storageConfig: StorageConfig): void => {
try {
migrateAddressBook(storageConfig)
migrateSafeNames(storageConfig)
} catch (e) {
logError(Errors._200, e.message)
}
}
export default migrate

View File

@ -7,6 +7,7 @@
enum ErrorCodes { enum ErrorCodes {
___0 = '0: No such error code', ___0 = '0: No such error code',
_100 = '100: Invalid input in the address field', _100 = '100: Invalid input in the address field',
_200 = '200: Failed migrating to the address book v2',
_600 = '600: Error fetching token list', _600 = '600: Error fetching token list',
_601 = '601: Error fetching balances', _601 = '601: Error fetching balances',
_900 = '900: Error loading Safe App', _900 = '900: Error loading Safe App',

View File

@ -1,22 +0,0 @@
import { useLocation } from 'react-router-dom'
import { useEffect, useState } from 'react'
type AppUrlReturnType = {
url: string | null
}
export const useSafeAppUrl = (): AppUrlReturnType => {
const [url, setUrl] = useState<string | null>(null)
const { search } = useLocation()
useEffect(() => {
if (search !== url) {
const query = new URLSearchParams(search)
setUrl(query.get('appUrl'))
}
}, [search, url])
return {
url,
}
}

View File

@ -142,6 +142,17 @@ const addressBookEditEntry = {
afterExecutionError: null, afterExecutionError: null,
} }
const addressBookImportEntries = {
beforeExecution: null,
afterRejection: null,
waitingConfirmation: null,
afterExecution: {
noMoreConfirmationsNeeded: NOTIFICATIONS.ADDRESS_BOOK_IMPORT_ENTRIES_SUCCESS,
moreConfirmationsNeeded: null,
},
afterExecutionError: null,
}
const addressBookDeleteEntry = { const addressBookDeleteEntry = {
beforeExecution: null, beforeExecution: null,
afterRejection: null, afterRejection: null,
@ -153,6 +164,17 @@ const addressBookDeleteEntry = {
afterExecutionError: null, afterExecutionError: null,
} }
const addressBookExportEntries = {
beforeExecution: null,
afterRejection: null,
waitingConfirmation: null,
afterExecution: {
noMoreConfirmationsNeeded: NOTIFICATIONS.ADDRESS_BOOK_EXPORT_ENTRIES_SUCCESS,
moreConfirmationsNeeded: null,
},
afterExecutionError: NOTIFICATIONS.ADDRESS_BOOK_EXPORT_ENTRIES_ERROR,
}
export const getNotificationsFromTxType: any = (txType, origin) => { export const getNotificationsFromTxType: any = (txType, origin) => {
let notificationsQueue let notificationsQueue
@ -193,18 +215,26 @@ export const getNotificationsFromTxType: any = (txType, origin) => {
notificationsQueue = waitingTransactionNotificationsQueue notificationsQueue = waitingTransactionNotificationsQueue
break break
} }
case TX_NOTIFICATION_TYPES.ADDRESSBOOK_NEW_ENTRY: { case TX_NOTIFICATION_TYPES.ADDRESS_BOOK_NEW_ENTRY: {
notificationsQueue = addressBookNewEntry notificationsQueue = addressBookNewEntry
break break
} }
case TX_NOTIFICATION_TYPES.ADDRESSBOOK_EDIT_ENTRY: { case TX_NOTIFICATION_TYPES.ADDRESS_BOOK_EDIT_ENTRY: {
notificationsQueue = addressBookEditEntry notificationsQueue = addressBookEditEntry
break break
} }
case TX_NOTIFICATION_TYPES.ADDRESSBOOK_DELETE_ENTRY: { case TX_NOTIFICATION_TYPES.ADDRESS_BOOK_IMPORT_ENTRIES: {
notificationsQueue = addressBookImportEntries
break
}
case TX_NOTIFICATION_TYPES.ADDRESS_BOOK_DELETE_ENTRY: {
notificationsQueue = addressBookDeleteEntry notificationsQueue = addressBookDeleteEntry
break break
} }
case TX_NOTIFICATION_TYPES.ADDRESS_BOOK_EXPORT_ENTRIES: {
notificationsQueue = addressBookExportEntries
break
}
default: { default: {
notificationsQueue = defaultNotificationsQueue notificationsQueue = defaultNotificationsQueue
break break

View File

@ -52,7 +52,10 @@ const NOTIFICATION_IDS = {
WRONG_NETWORK_MSG: 'WRONG_NETWORK_MSG', WRONG_NETWORK_MSG: 'WRONG_NETWORK_MSG',
ADDRESS_BOOK_NEW_ENTRY_SUCCESS: 'ADDRESS_BOOK_NEW_ENTRY_SUCCESS', ADDRESS_BOOK_NEW_ENTRY_SUCCESS: 'ADDRESS_BOOK_NEW_ENTRY_SUCCESS',
ADDRESS_BOOK_EDIT_ENTRY_SUCCESS: 'ADDRESS_BOOK_EDIT_ENTRY_SUCCESS', ADDRESS_BOOK_EDIT_ENTRY_SUCCESS: 'ADDRESS_BOOK_EDIT_ENTRY_SUCCESS',
ADDRESS_BOOK_IMPORT_ENTRIES_SUCCESS: 'ADDRESS_BOOK_IMPORT_ENTRIES_SUCCESS',
ADDRESS_BOOK_DELETE_ENTRY_SUCCESS: 'ADDRESS_BOOK_DELETE_ENTRY_SUCCESS', ADDRESS_BOOK_DELETE_ENTRY_SUCCESS: 'ADDRESS_BOOK_DELETE_ENTRY_SUCCESS',
ADDRESS_BOOK_EXPORT_ENTRIES_SUCCESS: 'ADDRESS_BOOK_EXPORT_ENTRIES_SUCCESS',
ADDRESS_BOOK_EXPORT_ENTRIES_ERROR: 'ADDRESS_BOOK_EXPORT_ENTRIES_ERROR',
SAFE_NEW_VERSION_AVAILABLE: 'SAFE_NEW_VERSION_AVAILABLE', SAFE_NEW_VERSION_AVAILABLE: 'SAFE_NEW_VERSION_AVAILABLE',
} }
@ -207,10 +210,22 @@ export const NOTIFICATIONS: Record<NotificationId, Notification> = {
message: 'Entry saved successfully', message: 'Entry saved successfully',
options: { variant: SUCCESS, persist: false, preventDuplicate: false }, options: { variant: SUCCESS, persist: false, preventDuplicate: false },
}, },
ADDRESS_BOOK_IMPORT_ENTRIES_SUCCESS: {
message: 'Entries imported successfully',
options: { variant: SUCCESS, persist: false, preventDuplicate: false },
},
ADDRESS_BOOK_DELETE_ENTRY_SUCCESS: { ADDRESS_BOOK_DELETE_ENTRY_SUCCESS: {
message: 'Entry deleted successfully', message: 'Entry deleted successfully',
options: { variant: SUCCESS, persist: false, preventDuplicate: false }, options: { variant: SUCCESS, persist: false, preventDuplicate: false },
}, },
ADDRESS_BOOK_EXPORT_ENTRIES_SUCCESS: {
message: 'Address book exported',
options: { variant: SUCCESS, persist: false, preventDuplicate: false },
},
ADDRESS_BOOK_EXPORT_ENTRIES_ERROR: {
message: 'An error occurred while generating the address book CSV.',
options: { variant: ERROR, persist: false, preventDuplicate: false },
},
// Safe Version // Safe Version
SAFE_NEW_VERSION_AVAILABLE: { SAFE_NEW_VERSION_AVAILABLE: {

View File

@ -1,7 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useDispatch } from 'react-redux' import { useDispatch } from 'react-redux'
import loadAddressBookFromStorage from 'src/logic/addressBook/store/actions/loadAddressBookFromStorage'
import addViewedSafe from 'src/logic/currentSession/store/actions/addViewedSafe' import addViewedSafe from 'src/logic/currentSession/store/actions/addViewedSafe'
import { fetchSafeTokens } from 'src/logic/tokens/store/actions/fetchSafeTokens' import { fetchSafeTokens } from 'src/logic/tokens/store/actions/fetchSafeTokens'
import fetchLatestMasterContractVersion from 'src/logic/safe/store/actions/fetchLatestMasterContractVersion' import fetchLatestMasterContractVersion from 'src/logic/safe/store/actions/fetchLatestMasterContractVersion'
@ -10,7 +9,7 @@ import fetchTransactions from 'src/logic/safe/store/actions/transactions/fetchTr
import { Dispatch } from 'src/logic/safe/store/actions/types.d' import { Dispatch } from 'src/logic/safe/store/actions/types.d'
import { updateAvailableCurrencies } from 'src/logic/currencyValues/store/actions/updateAvailableCurrencies' import { updateAvailableCurrencies } from 'src/logic/currencyValues/store/actions/updateAvailableCurrencies'
export const useLoadSafe = (safeAddress?: string, loadedViaUrl = true): boolean => { export const useLoadSafe = (safeAddress?: string): boolean => {
const dispatch = useDispatch<Dispatch>() const dispatch = useDispatch<Dispatch>()
const [isSafeLoaded, setIsSafeLoaded] = useState(false) const [isSafeLoaded, setIsSafeLoaded] = useState(false)
@ -23,15 +22,11 @@ export const useLoadSafe = (safeAddress?: string, loadedViaUrl = true): boolean
await dispatch(fetchSafeTokens(safeAddress)) await dispatch(fetchSafeTokens(safeAddress))
await dispatch(updateAvailableCurrencies()) await dispatch(updateAvailableCurrencies())
await dispatch(fetchTransactions(safeAddress)) await dispatch(fetchTransactions(safeAddress))
if (!loadedViaUrl) {
dispatch(addViewedSafe(safeAddress)) dispatch(addViewedSafe(safeAddress))
} }
} }
}
dispatch(loadAddressBookFromStorage())
fetchData() fetchData()
}, [dispatch, safeAddress, loadedViaUrl]) }, [dispatch, safeAddress])
return isSafeLoaded return isSafeLoaded
} }

View File

@ -1,14 +1,12 @@
// --no-ignore // --no-ignore
import axios from 'axios' import axios from 'axios'
import { List, Map } from 'immutable' import { Map } from 'immutable'
import configureMockStore from 'redux-mock-store' import configureMockStore from 'redux-mock-store'
import thunk from 'redux-thunk' import thunk from 'redux-thunk'
import { buildSafe, fetchSafe } from 'src/logic/safe/store/actions/fetchSafe' import { buildSafe, fetchSafe } from 'src/logic/safe/store/actions/fetchSafe'
import * as storageUtils from 'src/utils/storage' import * as storageUtils from 'src/utils/storage'
import { SafeRecordProps } from 'src/logic/safe/store/models/safe' import { SafeRecordProps } from 'src/logic/safe/store/models/safe'
import { makeOwner } from 'src/logic/safe/store/models/owner'
import { Overwrite } from 'src/types/helpers'
import { UPDATE_SAFE } from 'src/logic/safe/store/actions/updateSafe' import { UPDATE_SAFE } from 'src/logic/safe/store/actions/updateSafe'
import { DEFAULT_SAFE_INITIAL_STATE } from 'src/logic/safe/store/reducer/safe' import { DEFAULT_SAFE_INITIAL_STATE } from 'src/logic/safe/store/reducer/safe'
import { inMemoryPartialSafeInformation, localSafesInfo, remoteSafeInfoWithoutModules } from '../mocks/safeInformation' import { inMemoryPartialSafeInformation, localSafesInfo, remoteSafeInfoWithoutModules } from '../mocks/safeInformation'
@ -25,51 +23,46 @@ describe('buildSafe', () => {
jest.unmock('src/utils/storage/index') jest.unmock('src/utils/storage/index')
}) })
it('should return a Partial SafeRecord with a mix of remote and local safe info', async () => { // ToDo: use a property other than `name`
it.skip('should return a Partial SafeRecord with a mix of remote and local safe info', async () => {
mockedAxios.get.mockImplementationOnce(async () => ({ data: remoteSafeInfoWithoutModules })) mockedAxios.get.mockImplementationOnce(async () => ({ data: remoteSafeInfoWithoutModules }))
storageUtil.loadFromStorage.mockImplementationOnce(async () => localSafesInfo) storageUtil.loadFromStorage.mockImplementationOnce(async () => localSafesInfo)
const finalValues: Overwrite<Partial<SafeRecordProps>, { name: string }> = { const finalValues: Partial<SafeRecordProps> = {
name: 'My Safe Name that will last',
modules: undefined, modules: undefined,
spendingLimits: undefined, spendingLimits: undefined,
} }
const builtSafe = await buildSafe(SAFE_ADDRESS, finalValues.name) const builtSafe = await buildSafe(SAFE_ADDRESS)
expect(builtSafe).toStrictEqual({ ...inMemoryPartialSafeInformation, ...finalValues }) expect(builtSafe).toStrictEqual({ ...inMemoryPartialSafeInformation, ...finalValues })
}) })
it('should return a Partial SafeRecord when `remoteSafeInfo` is not present', async () => { it.skip('should return a Partial SafeRecord when `remoteSafeInfo` is not present', async () => {
jest.spyOn(global.console, 'error').mockImplementationOnce(() => {}) jest.spyOn(global.console, 'error').mockImplementationOnce(() => {})
mockedAxios.get.mockImplementationOnce(async () => { mockedAxios.get.mockImplementationOnce(async () => {
throw new Error('-- test -- no resource available') throw new Error('-- test -- no resource available')
}) })
const name = 'My Safe Name that will last'
storageUtil.loadFromStorage.mockImplementationOnce(async () => localSafesInfo) storageUtil.loadFromStorage.mockImplementationOnce(async () => localSafesInfo)
const builtSafe = await buildSafe(SAFE_ADDRESS, name) const builtSafe = await buildSafe(SAFE_ADDRESS)
expect(builtSafe).toStrictEqual({ ...inMemoryPartialSafeInformation, name }) expect(builtSafe).toStrictEqual({ ...inMemoryPartialSafeInformation })
}) })
it('should return a Partial SafeRecord when `localSafeInfo` is not present', async () => { it.skip('should return a Partial SafeRecord when `localSafeInfo` is not present', async () => {
mockedAxios.get.mockImplementationOnce(async () => ({ data: remoteSafeInfoWithoutModules })) mockedAxios.get.mockImplementationOnce(async () => ({ data: remoteSafeInfoWithoutModules }))
storageUtil.loadFromStorage.mockImplementationOnce(async () => undefined) storageUtil.loadFromStorage.mockImplementationOnce(async () => undefined)
const name = 'My Safe Name that WILL last'
const builtSafe = await buildSafe(SAFE_ADDRESS, name) const builtSafe = await buildSafe(SAFE_ADDRESS)
expect(builtSafe).toStrictEqual({ expect(builtSafe).toStrictEqual({
name,
address: SAFE_ADDRESS, address: SAFE_ADDRESS,
threshold: 2, threshold: 2,
owners: List( owners: [
[ '0xcCdd7e3af1c24c08D8B65A328351e7e23923d875',
{ address: '0xcCdd7e3af1c24c08D8B65A328351e7e23923d875' }, '0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F',
{ address: '0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F' }, '0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47',
{ address: '0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47' }, '0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2',
{ address: '0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2' }, '0x5e47249883F6a1d639b84e8228547fB289e222b6',
{ address: '0x5e47249883F6a1d639b84e8228547fB289e222b6' }, ],
].map(makeOwner),
),
modules: undefined, modules: undefined,
spendingLimits: undefined, spendingLimits: undefined,
nonce: 492, nonce: 492,
@ -78,19 +71,18 @@ describe('buildSafe', () => {
featuresEnabled: ['ERC721', 'ERC1155', 'SAFE_APPS', 'CONTRACT_INTERACTION'], featuresEnabled: ['ERC721', 'ERC1155', 'SAFE_APPS', 'CONTRACT_INTERACTION'],
}) })
}) })
it('should return a Partial SafeRecord with only `address` and `name` keys if it fails to recover info', async () => { it.skip('should return a Partial SafeRecord with only `address` and `name` keys if it fails to recover info', async () => {
jest.spyOn(global.console, 'error').mockImplementationOnce(() => {}) jest.spyOn(global.console, 'error').mockImplementationOnce(() => {})
mockedAxios.get.mockImplementationOnce(async () => { mockedAxios.get.mockImplementationOnce(async () => {
throw new Error('-- test -- no resource available') throw new Error('-- test -- no resource available')
}) })
const finalValues: Overwrite<Partial<SafeRecordProps>, { name: string }> = { const finalValues: Partial<SafeRecordProps> = {
name: 'My Safe Name that will last',
address: SAFE_ADDRESS, address: SAFE_ADDRESS,
owners: undefined, owners: undefined,
} }
storageUtil.loadFromStorage.mockImplementationOnce(async () => undefined) storageUtil.loadFromStorage.mockImplementationOnce(async () => undefined)
const builtSafe = await buildSafe(SAFE_ADDRESS, finalValues.name) const builtSafe = await buildSafe(SAFE_ADDRESS)
expect(builtSafe).toStrictEqual(finalValues) expect(builtSafe).toStrictEqual(finalValues)
}) })
@ -99,7 +91,6 @@ describe('buildSafe', () => {
describe('fetchSafe', () => { describe('fetchSafe', () => {
const SAFE_ADDRESS = '0xe414604Ad49602C0b9c0b08D0781ECF96740786a' const SAFE_ADDRESS = '0xe414604Ad49602C0b9c0b08D0781ECF96740786a'
const mockedAxios = axios as jest.Mocked<typeof axios> const mockedAxios = axios as jest.Mocked<typeof axios>
const storageUtil = require('src/utils/storage/index') as jest.Mocked<typeof storageUtils>
const middlewares = [thunk] const middlewares = [thunk]
const mockStore = configureMockStore(middlewares) const mockStore = configureMockStore(middlewares)
@ -115,15 +106,13 @@ describe('fetchSafe', () => {
payload: { payload: {
address: SAFE_ADDRESS, address: SAFE_ADDRESS,
threshold: 2, threshold: 2,
owners: List( owners: [
[ '0xcCdd7e3af1c24c08D8B65A328351e7e23923d875',
{ address: '0xcCdd7e3af1c24c08D8B65A328351e7e23923d875' }, '0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F',
{ address: '0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F' }, '0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47',
{ address: '0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47' }, '0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2',
{ address: '0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2' }, '0x5e47249883F6a1d639b84e8228547fB289e222b6',
{ address: '0x5e47249883F6a1d639b84e8228547fB289e222b6' }, ],
].map(makeOwner),
),
modules: undefined, modules: undefined,
spendingLimits: undefined, spendingLimits: undefined,
nonce: 492, nonce: 492,

View File

@ -1,5 +1,4 @@
import axios from 'axios' import axios from 'axios'
import { List } from 'immutable'
import { FEATURES } from 'src/config/networks/network.d' import { FEATURES } from 'src/config/networks/network.d'
import { import {
@ -9,8 +8,7 @@ import {
getNewTxNonce, getNewTxNonce,
shouldExecuteTransaction, shouldExecuteTransaction,
} from 'src/logic/safe/store/actions/utils' } from 'src/logic/safe/store/actions/utils'
import { makeOwner } from 'src/logic/safe/store/models/owner' import { SafeRecordProps } from 'src/logic/safe/store/models/safe'
import { SafeOwner, SafeRecordProps } from 'src/logic/safe/store/models/safe'
import { buildTxServiceUrl } from 'src/logic/safe/transactions' import { buildTxServiceUrl } from 'src/logic/safe/transactions'
import { getMockedSafeInstance, getMockedTxServiceModel } from 'src/test/utils/safeHelper' import { getMockedSafeInstance, getMockedTxServiceModel } from 'src/test/utils/safeHelper'
import { import {
@ -223,96 +221,49 @@ describe('buildSafeOwners', () => {
expect(buildSafeOwners()).toBeUndefined() expect(buildSafeOwners()).toBeUndefined()
}) })
it('should return `localSafeOwners` if no `remoteSafeOwners` were provided', () => { it('should return `localSafeOwners` if no `remoteSafeOwners` were provided', () => {
const expectedOwners = List( const expectedOwners = [
[ '0xcCdd7e3af1c24c08D8B65A328351e7e23923d875',
{ address: '0xcCdd7e3af1c24c08D8B65A328351e7e23923d875' }, '0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F',
{ address: '0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F' }, '0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47',
{ address: '0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47' }, '0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2',
{ address: '0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2' }, '0x5e47249883F6a1d639b84e8228547fB289e222b6',
{ address: '0x5e47249883F6a1d639b84e8228547fB289e222b6' }, ]
].map(makeOwner),
)
expect(buildSafeOwners(remoteSafeInfoWithModules.owners)).toStrictEqual(expectedOwners) expect(buildSafeOwners(remoteSafeInfoWithModules.owners)).toStrictEqual(expectedOwners)
}) })
it('should discard those owners that are not present in `remoteSafeOwners`', () => { it('should discard those owners that are not present in `remoteSafeOwners`', () => {
const localOwners: List<SafeOwner> = List(localSafesInfo[SAFE_ADDRESS].owners.map(makeOwner)) const localOwners: SafeRecordProps['owners'] = localSafesInfo[SAFE_ADDRESS].owners
const [, ...remoteOwners] = remoteSafeInfoWithModules.owners const [, ...remoteOwners] = remoteSafeInfoWithModules.owners
const expectedOwners = List( const expectedOwners = [
[ '0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F',
{ '0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47',
name: 'UNKNOWN', '0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2',
address: '0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F', '0x5e47249883F6a1d639b84e8228547fB289e222b6',
}, ]
{
name: 'UNKNOWN',
address: '0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47',
},
{
name: 'Owner B',
address: '0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2',
},
{
name: 'Owner A',
address: '0x5e47249883F6a1d639b84e8228547fB289e222b6',
},
].map(makeOwner),
)
expect(buildSafeOwners(remoteOwners, localOwners)).toStrictEqual(expectedOwners) expect(buildSafeOwners(remoteOwners, localOwners)).toStrictEqual(expectedOwners)
}) })
it('should add those owners that are not present in `localSafeOwners`', () => { it('should add those owners that are not present in `localSafeOwners`', () => {
const localOwners: List<SafeOwner> = List(localSafesInfo[SAFE_ADDRESS].owners.slice(0, 4).map(makeOwner)) const localOwners: SafeRecordProps['owners'] = localSafesInfo[SAFE_ADDRESS].owners.slice(0, 4)
const remoteOwners = remoteSafeInfoWithModules.owners const remoteOwners = remoteSafeInfoWithModules.owners
const expectedOwners = List( const expectedOwners = [
[ '0xcCdd7e3af1c24c08D8B65A328351e7e23923d875',
{ '0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F',
name: 'UNKNOWN', '0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47',
address: '0xcCdd7e3af1c24c08D8B65A328351e7e23923d875', '0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2',
}, '0x5e47249883F6a1d639b84e8228547fB289e222b6',
{ ]
name: 'UNKNOWN',
address: '0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F',
},
{
name: 'UNKNOWN',
address: '0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47',
},
{
name: 'Owner B',
address: '0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2',
},
{
name: 'UNKNOWN',
address: '0x5e47249883F6a1d639b84e8228547fB289e222b6',
},
].map(makeOwner),
)
expect(buildSafeOwners(remoteOwners, localOwners)).toStrictEqual(expectedOwners) expect(buildSafeOwners(remoteOwners, localOwners)).toStrictEqual(expectedOwners)
}) })
it('should preserve those owners that are present in `remoteSafeOwners` with data present in `localSafeOwners`', () => { it('should preserve those owners that are present in `remoteSafeOwners` with data present in `localSafeOwners`', () => {
const localOwners: List<SafeOwner> = List(localSafesInfo[SAFE_ADDRESS].owners.slice(0, 4).map(makeOwner)) const localOwners: SafeRecordProps['owners'] = localSafesInfo[SAFE_ADDRESS].owners.slice(0, 4)
const [, ...remoteOwners] = remoteSafeInfoWithModules.owners const [, ...remoteOwners] = remoteSafeInfoWithModules.owners
const expectedOwners = List( const expectedOwners = [
[ '0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F',
{ '0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47',
name: 'UNKNOWN', '0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2',
address: '0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F', '0x5e47249883F6a1d639b84e8228547fB289e222b6',
}, ]
{
name: 'UNKNOWN',
address: '0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47',
},
{
name: 'Owner B',
address: '0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2',
},
{
name: 'UNKNOWN',
address: '0x5e47249883F6a1d639b84e8228547fB289e222b6',
},
].map(makeOwner),
)
expect(buildSafeOwners(remoteOwners, localOwners)).toStrictEqual(expectedOwners) expect(buildSafeOwners(remoteOwners, localOwners)).toStrictEqual(expectedOwners)
}) })

View File

@ -1,17 +1,9 @@
import { createAction } from 'redux-actions' import { createAction } from 'redux-actions'
import { SafeOwner, SafeRecordProps } from '../models/safe' import { SafeRecordProps } from 'src/logic/safe/store/models/safe'
import { List } from 'immutable'
import { makeOwner } from '../models/owner'
export const ADD_OR_UPDATE_SAFE = 'ADD_OR_UPDATE_SAFE' export const ADD_OR_UPDATE_SAFE = 'ADD_OR_UPDATE_SAFE'
export const buildOwnersFrom = (names: string[], addresses: string[]): List<SafeOwner> => {
const owners = names.map((name, index) => makeOwner({ name, address: addresses[index] }))
return List(owners)
}
export const addOrUpdateSafe = createAction(ADD_OR_UPDATE_SAFE, (safe: SafeRecordProps) => ({ export const addOrUpdateSafe = createAction(ADD_OR_UPDATE_SAFE, (safe: SafeRecordProps) => ({
safe, safe,
})) }))

View File

@ -1,5 +0,0 @@
import { createAction } from 'redux-actions'
export const ADD_SAFE_OWNER = 'ADD_SAFE_OWNER'
export const addSafeOwner = createAction(ADD_SAFE_OWNER)

View File

@ -1,7 +0,0 @@
import { createAction } from 'redux-actions'
export const EDIT_SAFE_OWNER = 'EDIT_SAFE_OWNER'
const editSafeOwner = createAction(EDIT_SAFE_OWNER)
export default editSafeOwner

View File

@ -1,16 +1,13 @@
import { List } from 'immutable'
import { Dispatch } from 'redux' import { Dispatch } from 'redux'
import { Action } from 'redux-actions'
import { addressBookSelector } from 'src/logic/addressBook/store/selectors'
import { updateSafe } from 'src/logic/safe/store/actions/updateSafe' import { updateSafe } from 'src/logic/safe/store/actions/updateSafe'
import { SafeRecordProps } from 'src/logic/safe/store/models/safe' import { SafeRecordProps } from 'src/logic/safe/store/models/safe'
import { getLocalSafe } from 'src/logic/safe/utils' import { getLocalSafe } from 'src/logic/safe/utils'
import { allSettled } from 'src/logic/safe/utils/allSettled' import { allSettled } from 'src/logic/safe/utils/allSettled'
import { getSafeInfo, SafeInfo } from 'src/logic/safe/utils/safeInformation' import { getSafeInfo, SafeInfo } from 'src/logic/safe/utils/safeInformation'
import { AppReduxState } from 'src/store'
import { checksumAddress } from 'src/utils/checksumAddress' import { checksumAddress } from 'src/utils/checksumAddress'
import { buildSafeOwners, extractRemoteSafeInfo } from './utils' import { buildSafeOwners, extractRemoteSafeInfo } from './utils'
import { Action } from 'redux-actions'
/** /**
* Builds a Safe Record that will be added to the app's store * Builds a Safe Record that will be added to the app's store
@ -19,15 +16,12 @@ import { Action } from 'redux-actions'
* @note It's being used by "Load Existing Safe" and "Create New Safe" flows * @note It's being used by "Load Existing Safe" and "Create New Safe" flows
* *
* @param {string} safeAddress * @param {string} safeAddress
* @param {string} safeName
* @returns Promise<SafeRecordProps> * @returns Promise<SafeRecordProps>
*/ */
export const buildSafe = async (safeAddress: string, safeName: string): Promise<SafeRecordProps> => { export const buildSafe = async (safeAddress: string): Promise<SafeRecordProps> => {
const address = checksumAddress(safeAddress) const address = checksumAddress(safeAddress)
const safeInfo: Partial<SafeRecordProps> = { // setting `loadedViaUrl` to false, as `buildSafe` is called on safe Load or Open flows
address, const safeInfo: Partial<SafeRecordProps> = { address, loadedViaUrl: false }
name: safeName,
}
const [remote, localSafeInfo] = await allSettled<[SafeInfo | null, SafeRecordProps | undefined | null]>( const [remote, localSafeInfo] = await allSettled<[SafeInfo | null, SafeRecordProps | undefined | null]>(
getSafeInfo(safeAddress), getSafeInfo(safeAddress),
@ -52,7 +46,6 @@ export const buildSafe = async (safeAddress: string, safeName: string): Promise<
*/ */
export const fetchSafe = (safeAddress: string) => async ( export const fetchSafe = (safeAddress: string) => async (
dispatch: Dispatch, dispatch: Dispatch,
getState: () => AppReduxState,
): Promise<Action<Partial<SafeRecordProps>>> => { ): Promise<Action<Partial<SafeRecordProps>>> => {
const address = checksumAddress(safeAddress) const address = checksumAddress(safeAddress)
@ -61,13 +54,10 @@ export const fetchSafe = (safeAddress: string) => async (
// remote (client-gateway) // remote (client-gateway)
const safeInfo = remoteSafeInfo ? await extractRemoteSafeInfo(remoteSafeInfo) : {} const safeInfo = remoteSafeInfo ? await extractRemoteSafeInfo(remoteSafeInfo) : {}
// TODO: REVIEW: having the owner's names duplicated with what's in the address book seems a bit odd
const state = getState()
const addressBook = addressBookSelector(state)
// update owner's information // update owner's information
const owners = remoteSafeInfo const owners = remoteSafeInfo
? // if we have remote info, we can enrich it with local address book information ? // if we have remote info, we use it
buildSafeOwners(remoteSafeInfo.owners, List(addressBook)) buildSafeOwners(remoteSafeInfo.owners)
: // if there's no remote info, we keep what's in memory : // if there's no remote info, we keep what's in memory
undefined undefined

View File

@ -1,25 +1,16 @@
import { Dispatch } from 'redux' import { Dispatch } from 'redux'
import { getLocalSafes } from 'src/logic/safe/utils'
import { SAFES_KEY } from 'src/logic/safe/utils'
import { SafeRecordProps } from 'src/logic/safe/store/models/safe'
import { buildSafe } from 'src/logic/safe/store/reducer/safe' import { buildSafe } from 'src/logic/safe/store/reducer/safe'
import { loadFromStorage } from 'src/utils/storage'
import { addOrUpdateSafe } from './addOrUpdateSafe' import { addOrUpdateSafe } from './addOrUpdateSafe'
const loadSafesFromStorage = () => async (dispatch: Dispatch): Promise<void> => { const loadSafesFromStorage = () => async (dispatch: Dispatch): Promise<void> => {
try { const safes = await getLocalSafes()
const safes = await loadFromStorage<Record<string, SafeRecordProps>>(SAFES_KEY)
if (safes) { if (safes) {
Object.values(safes).forEach((safeProps) => { safes.forEach((safeProps) => {
dispatch(addOrUpdateSafe(buildSafe(safeProps))) dispatch(addOrUpdateSafe(buildSafe(safeProps)))
}) })
} }
} catch (err) {
// eslint-disable-next-line
console.error('Error while getting Safes from storage:', err)
}
return Promise.resolve() return Promise.resolve()
} }

View File

@ -1,6 +1,3 @@
import { List } from 'immutable'
import { makeOwner } from '../../models/owner'
export const remoteSafeInfoWithModules = { export const remoteSafeInfoWithModules = {
address: { address: {
value: '0xe414604Ad49602C0b9c0b08D0781ECF96740786a', value: '0xe414604Ad49602C0b9c0b08D0781ECF96740786a',
@ -86,30 +83,13 @@ export const localSafesInfo = {
name: 'Safe A', name: 'Safe A',
address: '0xe414604Ad49602C0b9c0b08D0781ECF96740786a', address: '0xe414604Ad49602C0b9c0b08D0781ECF96740786a',
threshold: 2, threshold: 2,
owners: List( owners: [
[ '0xcCdd7e3af1c24c08D8B65A328351e7e23923d875',
{ '0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F',
name: 'UNKNOWN', '0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47',
address: '0xcCdd7e3af1c24c08D8B65A328351e7e23923d875', '0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2',
}, '0x5e47249883F6a1d639b84e8228547fB289e222b6',
{ ],
name: 'UNKNOWN',
address: '0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F',
},
{
name: 'UNKNOWN',
address: '0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47',
},
{
name: 'Owner B',
address: '0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2',
},
{
name: 'Owner A',
address: '0x5e47249883F6a1d639b84e8228547fB289e222b6',
},
].map(makeOwner),
),
modules: [['0x0000000000000000000000000000000000000001', '0xCFbFaC74C26F8647cBDb8c5caf80BB5b32E43134']], modules: [['0x0000000000000000000000000000000000000001', '0xCFbFaC74C26F8647cBDb8c5caf80BB5b32E43134']],
spendingLimits: [ spendingLimits: [
{ {
@ -177,30 +157,13 @@ export const inMemoryPartialSafeInformation = {
name: 'Safe A', name: 'Safe A',
address: '0xe414604Ad49602C0b9c0b08D0781ECF96740786a', address: '0xe414604Ad49602C0b9c0b08D0781ECF96740786a',
threshold: 2, threshold: 2,
owners: List( owners: [
[ '0xcCdd7e3af1c24c08D8B65A328351e7e23923d875',
{ '0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F',
name: 'UNKNOWN', '0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47',
address: '0xcCdd7e3af1c24c08D8B65A328351e7e23923d875', '0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2',
}, '0x5e47249883F6a1d639b84e8228547fB289e222b6',
{ ],
name: 'UNKNOWN',
address: '0x04Aa5eC2065224aDB15aCE6fb1aAb988Ae55631F',
},
{
name: 'UNKNOWN',
address: '0x52Da808E9a83FEB147a2d0ca7d2f5bBBd3035C47',
},
{
name: 'Owner B',
address: '0x4dcD12D11dE7382F9c26D59Db1aCE1A4737e58A2',
},
{
name: 'Owner A',
address: '0x5e47249883F6a1d639b84e8228547fB289e222b6',
},
].map(makeOwner),
),
modules: [['0x0000000000000000000000000000000000000001', '0xCFbFaC74C26F8647cBDb8c5caf80BB5b32E43134']], modules: [['0x0000000000000000000000000000000000000001', '0xCFbFaC74C26F8647cBDb8c5caf80BB5b32E43134']],
spendingLimits: [ spendingLimits: [
{ {

View File

@ -1,5 +0,0 @@
import { createAction } from 'redux-actions'
export const REMOVE_SAFE_OWNER = 'REMOVE_SAFE_OWNER'
export const removeSafeOwner = createAction(REMOVE_SAFE_OWNER)

View File

@ -1,5 +0,0 @@
import { createAction } from 'redux-actions'
export const REPLACE_SAFE_OWNER = 'REPLACE_SAFE_OWNER'
export const replaceSafeOwner = createAction(REPLACE_SAFE_OWNER)

View File

@ -8,10 +8,7 @@ import { SafeRecordProps } from 'src/logic/safe/store/models/safe'
import { getSpendingLimits } from 'src/logic/safe/utils/spendingLimits' import { getSpendingLimits } from 'src/logic/safe/utils/spendingLimits'
import { buildModulesLinkedList } from 'src/logic/safe/utils/modules' import { buildModulesLinkedList } from 'src/logic/safe/utils/modules'
import { enabledFeatures, safeNeedsUpdate } from 'src/logic/safe/utils/safeVersion' import { enabledFeatures, safeNeedsUpdate } from 'src/logic/safe/utils/safeVersion'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
import { makeOwner } from 'src/logic/safe/store/models/owner'
import { checksumAddress } from 'src/utils/checksumAddress' import { checksumAddress } from 'src/utils/checksumAddress'
import { List } from 'immutable'
export const getLastTx = async (safeAddress: string): Promise<TxServiceModel | null> => { export const getLastTx = async (safeAddress: string): Promise<TxServiceModel | null> => {
try { try {
@ -105,13 +102,9 @@ export const buildSafeOwners = (
localSafeOwners?: SafeRecordProps['owners'], localSafeOwners?: SafeRecordProps['owners'],
): SafeRecordProps['owners'] | undefined => { ): SafeRecordProps['owners'] | undefined => {
if (remoteSafeOwners) { if (remoteSafeOwners) {
const remoteOwners = remoteSafeOwners.map(({ value }) => { // ToDo: review if checksums addresses is necessary,
const localOwner = localSafeOwners?.find(({ address }) => sameAddress(address, value)) // as they must be provided already in the checksum form from the services
const name = localOwner?.name return remoteSafeOwners.map(({ value }) => checksumAddress(value))
return makeOwner({ name, address: checksumAddress(value) })
})
return List(remoteOwners)
} }
// nothing to do without remote owners, so we return the stored list // nothing to do without remote owners, so we return the stored list

View File

@ -1,30 +1,13 @@
import { Store } from 'redux' import { Store } from 'redux'
import { saveDefaultSafe, saveSafes } from 'src/logic/safe/utils' import { saveDefaultSafe, saveSafes } from 'src/logic/safe/utils'
import { ADD_SAFE_OWNER } from 'src/logic/safe/store/actions/addSafeOwner'
import { EDIT_SAFE_OWNER } from 'src/logic/safe/store/actions/editSafeOwner'
import { REMOVE_SAFE } from 'src/logic/safe/store/actions/removeSafe' import { REMOVE_SAFE } from 'src/logic/safe/store/actions/removeSafe'
import { REMOVE_SAFE_OWNER } from 'src/logic/safe/store/actions/removeSafeOwner'
import { REPLACE_SAFE_OWNER } from 'src/logic/safe/store/actions/replaceSafeOwner'
import { SET_DEFAULT_SAFE } from 'src/logic/safe/store/actions/setDefaultSafe' import { SET_DEFAULT_SAFE } from 'src/logic/safe/store/actions/setDefaultSafe'
import { UPDATE_SAFE } from 'src/logic/safe/store/actions/updateSafe' import { UPDATE_SAFE } from 'src/logic/safe/store/actions/updateSafe'
import { safesMapSelector } from 'src/logic/safe/store/selectors' import { safesMapSelector } from 'src/logic/safe/store/selectors'
import { ADD_OR_UPDATE_SAFE } from 'src/logic/safe/store/actions/addOrUpdateSafe'
import { makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { checksumAddress } from 'src/utils/checksumAddress'
import { isValidAddressBookName } from 'src/logic/addressBook/utils'
import { addOrUpdateAddressBookEntry } from 'src/logic/addressBook/store/actions/addOrUpdateAddressBookEntry'
import { SafeRecord } from '../models/safe' import { SafeRecord } from '../models/safe'
const watchedActions = [ const watchedActions = [REMOVE_SAFE, SET_DEFAULT_SAFE, UPDATE_SAFE]
UPDATE_SAFE,
REMOVE_SAFE,
ADD_OR_UPDATE_SAFE,
ADD_SAFE_OWNER,
REMOVE_SAFE_OWNER,
REPLACE_SAFE_OWNER,
EDIT_SAFE_OWNER,
SET_DEFAULT_SAFE,
]
type SafeProps = { type SafeProps = {
safe: SafeRecord safe: SafeRecord
@ -40,34 +23,10 @@ export const safeStorageMiddleware = (store: Store) => (
if (watchedActions.includes(action.type)) { if (watchedActions.includes(action.type)) {
const state = store.getState() const state = store.getState()
const { dispatch } = store
const safes = safesMapSelector(state) const safes = safesMapSelector(state)
await saveSafes(safes.filter((safe) => !safe.loadedViaUrl).toJSON()) await saveSafes(safes.filter((safe) => !safe.loadedViaUrl).toJSON())
switch (action.type) { switch (action.type) {
case ADD_OR_UPDATE_SAFE: {
const { safe } = action.payload as SafeProps
safe.owners.forEach((owner: { address: string; name: any }) => {
const checksumEntry = makeAddressBookEntry({ address: checksumAddress(owner.address), name: owner.name })
if (isValidAddressBookName(checksumEntry.name)) {
dispatch(addOrUpdateAddressBookEntry(checksumEntry))
}
})
// add the recently loaded safe as an entry in the address book
// if it exists already, it will be replaced with the recently added name
if (safe.name) {
dispatch(addOrUpdateAddressBookEntry(makeAddressBookEntry({ name: safe.name, address: safe.address })))
}
break
}
case UPDATE_SAFE: {
const { name, address } = action.payload as { name: string; address: string }
if (name) {
dispatch(addOrUpdateAddressBookEntry(makeAddressBookEntry({ name, address })))
}
break
}
case SET_DEFAULT_SAFE: { case SET_DEFAULT_SAFE: {
if (action.payload) { if (action.payload) {
saveDefaultSafe(action.payload as string) saveDefaultSafe(action.payload as string)

View File

@ -1,8 +0,0 @@
import { Record } from 'immutable'
export const makeOwner = Record({
name: 'UNKNOWN',
address: '',
})
// Usage const someRecord: Owner = makeOwner({ name: ... })

View File

@ -1,11 +1,9 @@
import { List, Record, RecordOf } from 'immutable' import { Record, RecordOf } from 'immutable'
import { FEATURES } from 'src/config/networks/network.d' import { FEATURES } from 'src/config/networks/network.d'
import { BalanceRecord } from 'src/logic/tokens/store/actions/fetchSafeTokens' import { BalanceRecord } from 'src/logic/tokens/store/actions/fetchSafeTokens'
export type SafeOwner = { export type SafeOwner = string
name: string
address: string
}
export type ModulePair = [ export type ModulePair = [
// previous module // previous module
@ -25,39 +23,37 @@ export type SpendingLimit = {
} }
export type SafeRecordProps = { export type SafeRecordProps = {
name: string
address: string address: string
threshold: number threshold: number
ethBalance: string ethBalance: string
totalFiatBalance: string totalFiatBalance: string
owners: List<SafeOwner> owners: SafeOwner[]
modules?: ModulePair[] | null modules?: ModulePair[] | null
spendingLimits?: SpendingLimit[] | null spendingLimits?: SpendingLimit[] | null
balances: BalanceRecord[] balances: BalanceRecord[]
nonce: number nonce: number
recurringUser?: boolean recurringUser?: boolean
loadedViaUrl?: boolean
currentVersion: string currentVersion: string
needsUpdate: boolean needsUpdate: boolean
featuresEnabled: Array<FEATURES> featuresEnabled: Array<FEATURES>
loadedViaUrl: boolean
} }
const makeSafe = Record<SafeRecordProps>({ const makeSafe = Record<SafeRecordProps>({
name: '',
address: '', address: '',
threshold: 0, threshold: 0,
ethBalance: '0', ethBalance: '0',
totalFiatBalance: '0', totalFiatBalance: '0',
owners: List([]), owners: [],
modules: [], modules: [],
spendingLimits: [], spendingLimits: [],
balances: [], balances: [],
nonce: 0, nonce: 0,
loadedViaUrl: false,
recurringUser: undefined, recurringUser: undefined,
currentVersion: '', currentVersion: '',
needsUpdate: false, needsUpdate: false,
featuresEnabled: [], featuresEnabled: [],
loadedViaUrl: true,
}) })
export type SafeRecord = RecordOf<SafeRecordProps> export type SafeRecord = RecordOf<SafeRecordProps>

View File

@ -1,30 +1,22 @@
import { Map, List } from 'immutable' import { Map, List } from 'immutable'
import { Action, handleActions } from 'redux-actions' import { Action, handleActions } from 'redux-actions'
import { ADD_SAFE_OWNER } from 'src/logic/safe/store/actions/addSafeOwner'
import { EDIT_SAFE_OWNER } from 'src/logic/safe/store/actions/editSafeOwner'
import { REMOVE_SAFE } from 'src/logic/safe/store/actions/removeSafe' import { REMOVE_SAFE } from 'src/logic/safe/store/actions/removeSafe'
import { REMOVE_SAFE_OWNER } from 'src/logic/safe/store/actions/removeSafeOwner'
import { REPLACE_SAFE_OWNER } from 'src/logic/safe/store/actions/replaceSafeOwner'
import { SET_DEFAULT_SAFE } from 'src/logic/safe/store/actions/setDefaultSafe' import { SET_DEFAULT_SAFE } from 'src/logic/safe/store/actions/setDefaultSafe'
import { SET_LATEST_MASTER_CONTRACT_VERSION } from 'src/logic/safe/store/actions/setLatestMasterContractVersion' import { SET_LATEST_MASTER_CONTRACT_VERSION } from 'src/logic/safe/store/actions/setLatestMasterContractVersion'
import { UPDATE_SAFE } from 'src/logic/safe/store/actions/updateSafe' import { UPDATE_SAFE } from 'src/logic/safe/store/actions/updateSafe'
import { makeOwner } from 'src/logic/safe/store/models/owner'
import makeSafe, { SafeRecord, SafeRecordProps } from 'src/logic/safe/store/models/safe' import makeSafe, { SafeRecord, SafeRecordProps } from 'src/logic/safe/store/models/safe'
import { AppReduxState } from 'src/store' import { AppReduxState } from 'src/store'
import { checksumAddress } from 'src/utils/checksumAddress' import { checksumAddress } from 'src/utils/checksumAddress'
import { ADD_OR_UPDATE_SAFE, buildOwnersFrom } from 'src/logic/safe/store/actions/addOrUpdateSafe' import { ADD_OR_UPDATE_SAFE } from 'src/logic/safe/store/actions/addOrUpdateSafe'
import { sameAddress } from 'src/logic/wallets/ethAddresses' import { sameAddress } from 'src/logic/wallets/ethAddresses'
import { shouldSafeStoreBeUpdated } from 'src/logic/safe/utils/shouldSafeStoreBeUpdated' import { shouldSafeStoreBeUpdated } from 'src/logic/safe/utils/shouldSafeStoreBeUpdated'
import { LOADED_SAFE_KEY } from 'src/utils/constants'
export const SAFE_REDUCER_ID = 'safes' export const SAFE_REDUCER_ID = 'safes'
export const DEFAULT_SAFE_INITIAL_STATE = 'NOT_ASKED' export const DEFAULT_SAFE_INITIAL_STATE = 'NOT_ASKED'
export const buildSafe = (storedSafe: SafeRecordProps): SafeRecordProps => { export const buildSafe = (storedSafe: SafeRecordProps): SafeRecordProps => {
const names = storedSafe.owners.map((owner) => owner.name) const owners = storedSafe.owners.map(checksumAddress)
const addresses = storedSafe.owners.map((owner) => checksumAddress(owner.address))
const owners = buildOwnersFrom(Array.from(names), Array.from(addresses))
return { return {
...storedSafe, ...storedSafe,
@ -82,15 +74,8 @@ export default handleActions<AppReduxState['safes'], Payloads>(
const shouldUpdate = shouldSafeStoreBeUpdated(safe, state.getIn(['safes', safeAddress])) const shouldUpdate = shouldSafeStoreBeUpdated(safe, state.getIn(['safes', safeAddress]))
return shouldUpdate return shouldUpdate
? state.updateIn( ? state.updateIn(['safes', safeAddress], makeSafe({ address: safeAddress }), (prevSafe) =>
['safes', safeAddress], updateSafeProps(prevSafe, safe),
// This intermediate value is used as prevSafe if no previous state. Else is not used
makeSafe({
name: safe?.name || LOADED_SAFE_KEY,
address: safeAddress,
loadedViaUrl: !safe?.name || safe?.name === LOADED_SAFE_KEY,
}),
(prevSafe) => updateSafeProps(prevSafe, safe),
) )
: state : state
}, },
@ -104,15 +89,8 @@ export default handleActions<AppReduxState['safes'], Payloads>(
const shouldUpdate = shouldSafeStoreBeUpdated(safe, state.getIn(['safes', safeAddress])) const shouldUpdate = shouldSafeStoreBeUpdated(safe, state.getIn(['safes', safeAddress]))
return shouldUpdate return shouldUpdate
? state.updateIn( ? state.updateIn(['safes', safeAddress], makeSafe({ address: safeAddress }), (prevSafe) =>
['safes', safeAddress], updateSafeProps(prevSafe, safe),
// This intermediate value is used as prevSafe if no previous state. Else is not used
makeSafe({
name: safe?.name || LOADED_SAFE_KEY,
address: safeAddress,
loadedViaUrl: !safe?.name || safe?.name === LOADED_SAFE_KEY,
}),
(prevSafe) => updateSafeProps(prevSafe, safe),
) )
: state : state
}, },
@ -128,54 +106,6 @@ export default handleActions<AppReduxState['safes'], Payloads>(
return newState return newState
}, },
[ADD_SAFE_OWNER]: (state, action: Action<FullOwnerPayload>) => {
const { ownerAddress, ownerName, safeAddress } = action.payload
const addressFound = state
.getIn(['safes', safeAddress])
.owners.find((owner) => sameAddress(owner.address, ownerAddress))
if (addressFound) {
return state
}
return state.updateIn(['safes', safeAddress], (prevSafe) =>
prevSafe.merge({
owners: prevSafe.owners.push(makeOwner({ address: ownerAddress, name: ownerName })),
}),
)
},
[REMOVE_SAFE_OWNER]: (state, action: Action<BaseOwnerPayload>) => {
const { ownerAddress, safeAddress } = action.payload
return state.updateIn(['safes', safeAddress], (prevSafe) =>
prevSafe.merge({
owners: prevSafe.owners.filter((o) => o.address.toLowerCase() !== ownerAddress.toLowerCase()),
}),
)
},
[REPLACE_SAFE_OWNER]: (state, action: Action<ReplaceOwnerPayload>) => {
const { oldOwnerAddress, ownerAddress, ownerName, safeAddress } = action.payload
return state.updateIn(['safes', safeAddress], (prevSafe) =>
prevSafe.merge({
owners: prevSafe.owners
.filter((o) => o.address.toLowerCase() !== oldOwnerAddress.toLowerCase())
.push(makeOwner({ address: ownerAddress, name: ownerName })),
}),
)
},
[EDIT_SAFE_OWNER]: (state, action: Action<FullOwnerPayload>) => {
const { ownerAddress, ownerName, safeAddress } = action.payload
return state.updateIn(['safes', safeAddress], (prevSafe) => {
const ownerToUpdateIndex = prevSafe.owners.findIndex(
(o) => o.address.toLowerCase() === ownerAddress.toLowerCase(),
)
const updatedOwners = prevSafe.owners.update(ownerToUpdateIndex, (owner) => owner.set('name', ownerName))
return prevSafe.merge({ owners: updatedOwners })
})
},
[SET_DEFAULT_SAFE]: (state, action: Action<SafeRecord>) => state.set('defaultSafe', action.payload), [SET_DEFAULT_SAFE]: (state, action: Action<SafeRecord>) => state.set('defaultSafe', action.payload),
[SET_LATEST_MASTER_CONTRACT_VERSION]: (state, action: Action<SafeRecord>) => [SET_LATEST_MASTER_CONTRACT_VERSION]: (state, action: Action<SafeRecord>) =>
state.set('latestMasterContractVersion', action.payload), state.set('latestMasterContractVersion', action.payload),

View File

@ -1,15 +1,20 @@
import { List } from 'immutable' import { List } from 'immutable'
import { matchPath, RouteComponentProps } from 'react-router-dom' import { matchPath, RouteComponentProps } from 'react-router-dom'
import { createSelector } from 'reselect' import { createSelector } from 'reselect'
import { SAFELIST_ADDRESS, SAFE_PARAM_ADDRESS } from 'src/routes/routes'
import { getNetworkId } from 'src/config'
import { SAFE_REDUCER_ID } from 'src/logic/safe/store/reducer/safe' import { SAFE_REDUCER_ID } from 'src/logic/safe/store/reducer/safe'
import { AppReduxState } from 'src/store' import { AppReduxState } from 'src/store'
import { checksumAddress } from 'src/utils/checksumAddress' import { checksumAddress } from 'src/utils/checksumAddress'
import makeSafe, { SafeRecord, SafeRecordProps } from '../models/safe' import { ETHEREUM_NETWORK } from 'src/config/networks/network'
import { makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { addressBookMapSelector, addressBookSelector } from 'src/logic/addressBook/store/selectors'
import makeSafe, { SafeRecord, SafeRecordProps } from 'src/logic/safe/store/models/safe'
import { SAFELIST_ADDRESS, SAFE_PARAM_ADDRESS } from 'src/routes/routes'
import { SafesMap } from 'src/routes/safe/store/reducer/types/safe' import { SafesMap } from 'src/routes/safe/store/reducer/types/safe'
import { BalanceRecord } from 'src/logic/tokens/store/actions/fetchSafeTokens' import { BalanceRecord } from 'src/logic/tokens/store/actions/fetchSafeTokens'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
const safesStateSelector = (state: AppReduxState) => state[SAFE_REDUCER_ID] const safesStateSelector = (state: AppReduxState) => state[SAFE_REDUCER_ID]
@ -17,6 +22,32 @@ export const safesMapSelector = (state: AppReduxState): SafesMap => safesStateSe
export const safesListSelector = createSelector(safesMapSelector, (safes): List<SafeRecord> => safes.toList()) export const safesListSelector = createSelector(safesMapSelector, (safes): List<SafeRecord> => safes.toList())
const chainId = getNetworkId()
type SafeRecordWithName = SafeRecordProps & { name: string }
export const safesListWithAddressBookNameSelector = createSelector(
[safesListSelector, addressBookMapSelector],
(safesList, addressBookMap): List<SafeRecordWithName> => {
const addressBook = addressBookMap?.[chainId]
return safesList
.filter((safeRecord) => !safeRecord.loadedViaUrl)
.map((safeRecord) => {
const safe = safeRecord.toObject()
const name = addressBook?.[safe.address]?.name ?? ''
return { ...safe, name }
})
},
)
export const safeNameSelector = createSelector(
[safesListWithAddressBookNameSelector, (_, safeName: string) => safeName],
(safes, safeAddress): string => {
return safes.find((safe) => sameAddress(safe.address, safeAddress))?.name ?? ''
},
)
export const safesCountSelector = createSelector(safesMapSelector, (safes) => safes.size) export const safesCountSelector = createSelector(safesMapSelector, (safes) => safes.size)
export const defaultSafeSelector = createSelector(safesStateSelector, (safeState) => safeState.get('defaultSafe')) export const defaultSafeSelector = createSelector(safesStateSelector, (safeState) => safeState.get('defaultSafe'))
@ -83,8 +114,6 @@ export const safeFieldSelector = <K extends keyof SafeRecordProps>(field: K) =>
safe: SafeRecord, safe: SafeRecord,
): SafeRecordProps[K] | undefined => (safe ? safe.get(field, baseSafe.get(field)) : undefined) ): SafeRecordProps[K] | undefined => (safe ? safe.get(field, baseSafe.get(field)) : undefined)
export const safeNameSelector = createSelector(safeSelector, safeFieldSelector('name'))
export const safeEthBalanceSelector = createSelector(safeSelector, safeFieldSelector('ethBalance')) export const safeEthBalanceSelector = createSelector(safeSelector, safeFieldSelector('ethBalance'))
export const safeNeedsUpdateSelector = createSelector(safeSelector, safeFieldSelector('needsUpdate')) export const safeNeedsUpdateSelector = createSelector(safeSelector, safeFieldSelector('needsUpdate'))
@ -103,19 +132,24 @@ export const safeFeaturesEnabledSelector = createSelector(safeSelector, safeFiel
export const safeSpendingLimitsSelector = createSelector(safeSelector, safeFieldSelector('spendingLimits')) export const safeSpendingLimitsSelector = createSelector(safeSelector, safeFieldSelector('spendingLimits'))
export const safeLoadedViaUrlSelector = createSelector(safeSelector, safeFieldSelector('loadedViaUrl'))
export const safeOwnersAddressesListSelector = createSelector(
safeOwnersSelector,
(owners): List<string> => {
if (!owners) {
return List([])
}
return owners?.map(({ address }) => address)
},
)
export const safeTotalFiatBalanceSelector = createSelector(safeSelector, (currentSafe) => { export const safeTotalFiatBalanceSelector = createSelector(safeSelector, (currentSafe) => {
return currentSafe?.totalFiatBalance return currentSafe?.totalFiatBalance
}) })
export const safeOwnersWithAddressBookDataSelector = createSelector(
[safeOwnersSelector, addressBookSelector, (_, chainId: ETHEREUM_NETWORK) => chainId],
(owners, addressBook, chainId): AppReduxState['addressBook'] | undefined =>
owners?.map((ownerAddress) => {
const ownerInAddressBook = addressBook.find(
(addressBookEntry) =>
sameAddress(ownerAddress, addressBookEntry.address) && chainId === addressBookEntry.chainId,
)
if (ownerInAddressBook) {
return ownerInAddressBook
}
// if there's no owner's data in the AB, we create an in-memory AB-like structure
return makeAddressBookEntry({ address: ownerAddress, name: '' })
}),
)

View File

@ -8,7 +8,9 @@ export const TX_NOTIFICATION_TYPES = {
REMOVE_SPENDING_LIMIT_TX: 'REMOVE_SPENDING_LIMIT_TX', REMOVE_SPENDING_LIMIT_TX: 'REMOVE_SPENDING_LIMIT_TX',
SAFE_NAME_CHANGE_TX: 'SAFE_NAME_CHANGE_TX', SAFE_NAME_CHANGE_TX: 'SAFE_NAME_CHANGE_TX',
OWNER_NAME_CHANGE_TX: 'OWNER_NAME_CHANGE_TX', OWNER_NAME_CHANGE_TX: 'OWNER_NAME_CHANGE_TX',
ADDRESSBOOK_NEW_ENTRY: 'ADDRESSBOOK_NEW_ENTRY', ADDRESS_BOOK_NEW_ENTRY: 'ADDRESS_BOOK_NEW_ENTRY',
ADDRESSBOOK_EDIT_ENTRY: 'ADDRESSBOOK_EDIT_ENTRY', ADDRESS_BOOK_EDIT_ENTRY: 'ADDRESS_BOOK_EDIT_ENTRY',
ADDRESSBOOK_DELETE_ENTRY: 'ADDRESSBOOK_DELETE_ENTRY', ADDRESS_BOOK_DELETE_ENTRY: 'ADDRESS_BOOK_DELETE_ENTRY',
ADDRESS_BOOK_EXPORT_ENTRIES: 'ADDRESS_BOOK_EXPORT_ENTRIES',
ADDRESS_BOOK_IMPORT_ENTRIES: 'ADDRESS_BOOK_IMPORT_ENTRIES',
} }

View File

@ -12,28 +12,20 @@ const getMockedOldSafe = ({
currentVersion, currentVersion,
ethBalance, ethBalance,
threshold, threshold,
name,
nonce, nonce,
modules, modules,
spendingLimits, spendingLimits,
}: Partial<SafeRecordProps>): SafeRecordProps => { }: Partial<SafeRecordProps>): SafeRecordProps => {
const owner1 = { const owner1 = '0x3bE3c2dE077FBC409ae50AFFA66a94a9aE669A8d'
name: 'MockedOwner1', const owner2 = '0xA2366b0c2607de70777d87aCdD1D22F0708fA6a3'
address: '0x3bE3c2dE077FBC409ae50AFFA66a94a9aE669A8d',
}
const owner2 = {
name: 'MockedOwner2',
address: '0xA2366b0c2607de70777d87aCdD1D22F0708fA6a3',
}
const mockedActiveTokenAddress1 = '0x36591cd3DA96b21Ac9ca54cFaf80fe45107294F1' const mockedActiveTokenAddress1 = '0x36591cd3DA96b21Ac9ca54cFaf80fe45107294F1'
const mockedActiveTokenAddress2 = '0x92aF97cbF10742dD2527ffaBA70e34C03CFFC2c1' const mockedActiveTokenAddress2 = '0x92aF97cbF10742dD2527ffaBA70e34C03CFFC2c1'
return { return {
name: name || 'MockedSafe',
address: address || '0xAE173F30ec9A293d37c44BA68d3fCD35F989Ce9F', address: address || '0xAE173F30ec9A293d37c44BA68d3fCD35F989Ce9F',
threshold: threshold || 2, threshold: threshold || 2,
ethBalance: ethBalance || '10', ethBalance: ethBalance || '10',
owners: owners || List([owner1, owner2]), owners: owners || [owner1, owner2],
modules: modules || [], modules: modules || [],
spendingLimits: spendingLimits || [], spendingLimits: spendingLimits || [],
balances: balances || [ balances: balances || [
@ -46,6 +38,7 @@ const getMockedOldSafe = ({
needsUpdate: needsUpdate || false, needsUpdate: needsUpdate || false,
featuresEnabled: featuresEnabled || [], featuresEnabled: featuresEnabled || [],
totalFiatBalance: '110', totalFiatBalance: '110',
loadedViaUrl: false,
} }
} }
@ -75,21 +68,6 @@ describe('shouldSafeStoreBeUpdated', () => {
// Then // Then
expect(expectedResult).toEqual(true) expect(expectedResult).toEqual(true)
}) })
it(`Given an old safe and a new name for the safe, should return true`, () => {
// given
const oldName = 'oldName'
const newName = 'newName'
const oldSafe = getMockedOldSafe({ name: oldName })
const newSafeProps: Partial<SafeRecordProps> = {
name: newName,
}
// When
const expectedResult = shouldSafeStoreBeUpdated(newSafeProps, oldSafe)
// Then
expect(expectedResult).toEqual(true)
})
it(`Given an old safe and a new threshold for the safe, should return true`, () => { it(`Given an old safe and a new threshold for the safe, should return true`, () => {
// given // given
const oldThreshold = 1 const oldThreshold = 1
@ -122,18 +100,10 @@ describe('shouldSafeStoreBeUpdated', () => {
}) })
it(`Given an old owners list and a new owners list for the safe, should return true`, () => { it(`Given an old owners list and a new owners list for the safe, should return true`, () => {
// given // given
const owner1 = { const owner1 = '0x3bE3c2dE077FBC409ae50AFFA66a94a9aE669A8d'
name: 'MockedOwner1', const owner2 = '0xA2366b0c2607de70777d87aCdD1D22F0708fA6a3'
address: '0x3bE3c2dE077FBC409ae50AFFA66a94a9aE669A8d', const oldSafe = getMockedOldSafe({ owners: [owner1, owner2] })
} const newSafeProps: Partial<SafeRecordProps> = { owners: [owner1] }
const owner2 = {
name: 'MockedOwner2',
address: '0xA2366b0c2607de70777d87aCdD1D22F0708fA6a3',
}
const oldSafe = getMockedOldSafe({ owners: List([owner1, owner2]) })
const newSafeProps: Partial<SafeRecordProps> = {
owners: List([owner1]),
}
// When // When
const expectedResult = shouldSafeStoreBeUpdated(newSafeProps, oldSafe) const expectedResult = shouldSafeStoreBeUpdated(newSafeProps, oldSafe)
@ -146,9 +116,7 @@ describe('shouldSafeStoreBeUpdated', () => {
const oldModulesList = [] const oldModulesList = []
const newModulesList = null const newModulesList = null
const oldSafe = getMockedOldSafe({ modules: oldModulesList }) const oldSafe = getMockedOldSafe({ modules: oldModulesList })
const newSafeProps: Partial<SafeRecordProps> = { const newSafeProps: Partial<SafeRecordProps> = { modules: newModulesList }
modules: newModulesList,
}
// When // When
const expectedResult = shouldSafeStoreBeUpdated(newSafeProps, oldSafe) const expectedResult = shouldSafeStoreBeUpdated(newSafeProps, oldSafe)
@ -161,9 +129,7 @@ describe('shouldSafeStoreBeUpdated', () => {
const oldSpendingLimitsList = [] const oldSpendingLimitsList = []
const newSpendingLimitsList = null const newSpendingLimitsList = null
const oldSafe = getMockedOldSafe({ spendingLimits: oldSpendingLimitsList }) const oldSafe = getMockedOldSafe({ spendingLimits: oldSpendingLimitsList })
const newSafeProps: Partial<SafeRecordProps> = { const newSafeProps: Partial<SafeRecordProps> = { modules: newSpendingLimitsList }
modules: newSpendingLimitsList,
}
// When // When
const expectedResult = shouldSafeStoreBeUpdated(newSafeProps, oldSafe) const expectedResult = shouldSafeStoreBeUpdated(newSafeProps, oldSafe)

View File

@ -4,7 +4,7 @@ import { SafeRecordProps } from 'src/logic/safe/store/models/safe'
export const SAFES_KEY = 'SAFES' export const SAFES_KEY = 'SAFES'
export const DEFAULT_SAFE_KEY = 'DEFAULT_SAFE' export const DEFAULT_SAFE_KEY = 'DEFAULT_SAFE'
type StoredSafes = Record<string, SafeRecordProps> export type StoredSafes = Record<string, SafeRecordProps>
export const loadStoredSafes = (): Promise<StoredSafes | undefined> => { export const loadStoredSafes = (): Promise<StoredSafes | undefined> => {
return loadFromStorage<StoredSafes>(SAFES_KEY) return loadFromStorage<StoredSafes>(SAFES_KEY)
@ -18,6 +18,11 @@ export const saveSafes = async (safes: StoredSafes): Promise<void> => {
} }
} }
export const getLocalSafes = async (): Promise<SafeRecordProps[] | undefined> => {
const storedSafes = await loadStoredSafes()
return storedSafes ? Object.values(storedSafes) : undefined
}
export const getLocalSafe = async (safeAddress: string): Promise<SafeRecordProps | undefined> => { export const getLocalSafe = async (safeAddress: string): Promise<SafeRecordProps | undefined> => {
const storedSafes = await loadStoredSafes() const storedSafes = await loadStoredSafes()
return storedSafes?.[safeAddress] return storedSafes?.[safeAddress]

View File

@ -6,7 +6,7 @@ import { SafeRecordProps } from 'src/logic/safe/store/models/safe'
const isStateSubset = (superObj, subObj) => { const isStateSubset = (superObj, subObj) => {
return Object.keys(subObj).every((key) => { return Object.keys(subObj).every((key) => {
if (subObj[key] && typeof subObj[key] == 'object') { if (subObj[key] && typeof subObj[key] == 'object') {
if (typeof subObj[key] === 'object' || subObj[key].length >= 0) { if (typeof subObj[key] === 'object' || subObj[key].size >= 0) {
// If type is Immutable Map, List or Object we use Immutable equals // If type is Immutable Map, List or Object we use Immutable equals
return isEqual(superObj[key], subObj[key]) return isEqual(superObj[key], subObj[key])
} }

View File

@ -9,7 +9,6 @@ import {
shortVersionOf, shortVersionOf,
} from 'src/logic/wallets/ethAddresses' } from 'src/logic/wallets/ethAddresses'
import makeSafe from 'src/logic/safe/store/models/safe' import makeSafe from 'src/logic/safe/store/models/safe'
import { makeOwner } from 'src/logic/safe/store/models/owner'
describe('src/logic/wallets/ethAddresses', () => { describe('src/logic/wallets/ethAddresses', () => {
describe('Utility function: sameAddress', () => { describe('Utility function: sameAddress', () => {
@ -113,7 +112,7 @@ describe('src/logic/wallets/ethAddresses', () => {
it("Should return false if there's no `userAccount`", () => { it("Should return false if there's no `userAccount`", () => {
// given // given
const userAddress = null const userAddress = null
const owners = List([makeOwner({ address: userAddress })]) const owners = [userAddress]
const safeInstance = makeSafe({ owners }) const safeInstance = makeSafe({ owners })
const expectedResult = false const expectedResult = false
@ -139,7 +138,7 @@ describe('src/logic/wallets/ethAddresses', () => {
it("Should return true if `userAccount` is not in the list of Safe's owners", () => { it("Should return true if `userAccount` is not in the list of Safe's owners", () => {
// given // given
const userAddress = 'address1' const userAddress = 'address1'
const owners = List([makeOwner({ address: userAddress })]) const owners = [userAddress]
const safeInstance = makeSafe({ owners }) const safeInstance = makeSafe({ owners })
const expectedResult = true const expectedResult = true
@ -153,7 +152,7 @@ describe('src/logic/wallets/ethAddresses', () => {
// given // given
const userAddress = 'address1' const userAddress = 'address1'
const userAddress2 = 'address2' const userAddress2 = 'address2'
const owners = List([makeOwner({ address: userAddress })]) const owners = [userAddress]
const safeInstance = makeSafe({ owners }) const safeInstance = makeSafe({ owners })
const expectedResult = false const expectedResult = false
@ -170,8 +169,8 @@ describe('src/logic/wallets/ethAddresses', () => {
// given // given
const userAddress = 'address1' const userAddress = 'address1'
const userAddress2 = 'address2' const userAddress2 = 'address2'
const owners1 = List([makeOwner({ address: userAddress })]) const owners1 = [userAddress]
const owners2 = List([makeOwner({ address: userAddress2 })]) const owners2 = [userAddress2]
const safeInstance = makeSafe({ owners: owners1 }) const safeInstance = makeSafe({ owners: owners1 })
const safeInstance2 = makeSafe({ owners: owners2 }) const safeInstance2 = makeSafe({ owners: owners2 })
const safesList = List([safeInstance, safeInstance2]) const safesList = List([safeInstance, safeInstance2])
@ -188,8 +187,8 @@ describe('src/logic/wallets/ethAddresses', () => {
const userAddress = 'address1' const userAddress = 'address1'
const userAddress2 = 'address2' const userAddress2 = 'address2'
const userAddress3 = 'address3' const userAddress3 = 'address3'
const owners1 = List([makeOwner({ address: userAddress3 })]) const owners1 = [userAddress3]
const owners2 = List([makeOwner({ address: userAddress2 })]) const owners2 = [userAddress2]
const safeInstance = makeSafe({ owners: owners1 }) const safeInstance = makeSafe({ owners: owners1 })
const safeInstance2 = makeSafe({ owners: owners2 }) const safeInstance2 = makeSafe({ owners: owners2 })
const safesList = List([safeInstance, safeInstance2]) const safesList = List([safeInstance, safeInstance2])

View File

@ -40,7 +40,7 @@ export const isUserAnOwner = (safe: SafeRecord, userAccount: string): boolean =>
return false return false
} }
return owners.find((owner) => sameAddress(owner.address, userAccount)) !== undefined return owners.find((address) => sameAddress(address, userAccount)) !== undefined
} }
export const isUserAnOwnerOfAnySafe = (safes: List<SafeRecord> | SafeRecord[], userAccount: string): boolean => export const isUserAnOwnerOfAnySafe = (safes: List<SafeRecord> | SafeRecord[], userAccount: string): boolean =>

View File

@ -1,7 +1,7 @@
import InputAdornment from '@material-ui/core/InputAdornment' import InputAdornment from '@material-ui/core/InputAdornment'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import CheckCircle from '@material-ui/icons/CheckCircle' import CheckCircle from '@material-ui/icons/CheckCircle'
import * as React from 'react' import React, { ReactElement, ReactNode } from 'react'
import { FormApi } from 'final-form' import { FormApi } from 'final-form'
import { ScanQRWrapper } from 'src/components/ScanQRModal/ScanQRWrapper' import { ScanQRWrapper } from 'src/components/ScanQRModal/ScanQRWrapper'
@ -15,7 +15,7 @@ import {
noErrorsOn, noErrorsOn,
required, required,
composeValidators, composeValidators,
minMaxLength, validAddressBookName,
} from 'src/components/forms/validator' } from 'src/components/forms/validator'
import Block from 'src/components/layout/Block' import Block from 'src/components/layout/Block'
import Col from 'src/components/layout/Col' import Col from 'src/components/layout/Col'
@ -68,7 +68,7 @@ interface DetailsFormProps {
form: FormApi form: FormApi
} }
const DetailsForm = ({ errors, form }: DetailsFormProps): React.ReactElement => { const DetailsForm = ({ errors, form }: DetailsFormProps): ReactElement => {
const classes = useStyles() const classes = useStyles()
const handleScan = (value: string, closeQrModal: () => void): void => { const handleScan = (value: string, closeQrModal: () => void): void => {
@ -92,10 +92,10 @@ const DetailsForm = ({ errors, form }: DetailsFormProps): React.ReactElement =>
<Field <Field
component={TextField} component={TextField}
name={FIELD_LOAD_NAME} name={FIELD_LOAD_NAME}
placeholder="Name of the Safe" placeholder="Name of the Safe*"
text="Safe name" text="Safe name"
type="text" type="text"
validate={composeValidators(required, minMaxLength(1, 50))} validate={composeValidators(required, validAddressBookName)}
testId="load-safe-name-field" testId="load-safe-name-field"
/> />
</Col> </Col>
@ -145,13 +145,11 @@ const DetailsForm = ({ errors, form }: DetailsFormProps): React.ReactElement =>
} }
const DetailsPage = () => const DetailsPage = () =>
function LoadSafeDetails(controls: React.ReactNode, { errors, form }: StepperPageFormProps): React.ReactElement { function LoadSafeDetails(controls: ReactNode, { errors, form }: StepperPageFormProps): ReactElement {
return ( return (
<>
<OpenPaper controls={controls}> <OpenPaper controls={controls}>
<DetailsForm errors={errors} form={form} /> <DetailsForm errors={errors} form={form} />
</OpenPaper> </OpenPaper>
</>
) )
} }

View File

@ -1,6 +1,6 @@
import IconButton from '@material-ui/core/IconButton' import IconButton from '@material-ui/core/IconButton'
import ChevronLeft from '@material-ui/icons/ChevronLeft' import ChevronLeft from '@material-ui/icons/ChevronLeft'
import * as React from 'react' import React, { ReactElement } from 'react'
import Stepper, { StepperPage } from 'src/components/Stepper' import Stepper, { StepperPage } from 'src/components/Stepper'
import Block from 'src/components/layout/Block' import Block from 'src/components/layout/Block'
@ -34,13 +34,12 @@ const formMutators = {
} }
interface LayoutProps { interface LayoutProps {
network: string
provider?: string provider?: string
userAddress: string userAddress: string
onLoadSafeSubmit: (values: LoadFormValues) => void onLoadSafeSubmit: (values: LoadFormValues) => void
} }
const Layout = ({ network, onLoadSafeSubmit, provider, userAddress }: LayoutProps): React.ReactElement => ( const Layout = ({ onLoadSafeSubmit, provider, userAddress }: LayoutProps): ReactElement => (
<> <>
{provider ? ( {provider ? (
<Block> <Block>
@ -58,8 +57,8 @@ const Layout = ({ network, onLoadSafeSubmit, provider, userAddress }: LayoutProp
testId="load-safe-form" testId="load-safe-form"
> >
<StepperPage validate={safeFieldsValidation} component={DetailsForm} /> <StepperPage validate={safeFieldsValidation} component={DetailsForm} />
<StepperPage network={network} component={OwnerList} /> <StepperPage component={OwnerList} />
<StepperPage network={network} userAddress={userAddress} component={ReviewInformation} /> <StepperPage userAddress={userAddress} component={ReviewInformation} />
</Stepper> </Stepper>
</Block> </Block>
) : ( ) : (

View File

@ -1,12 +1,13 @@
import { EthHashInfo } from '@gnosis.pm/safe-react-components'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import TableContainer from '@material-ui/core/TableContainer' import TableContainer from '@material-ui/core/TableContainer'
import React, { useEffect, useState } from 'react' import React, { ReactElement, ReactNode, useEffect, useState } from 'react'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { getExplorerInfo } from 'src/config'
import Field from 'src/components/forms/Field' import Field from 'src/components/forms/Field'
import TextField from 'src/components/forms/TextField' import TextField from 'src/components/forms/TextField'
import { composeValidators, minMaxLength, required } from 'src/components/forms/validator' import { minMaxLength } from 'src/components/forms/validator'
import Block from 'src/components/layout/Block' import Block from 'src/components/layout/Block'
import Col from 'src/components/layout/Col' import Col from 'src/components/layout/Col'
import Hairline from 'src/components/layout/Hairline' import Hairline from 'src/components/layout/Hairline'
@ -21,8 +22,7 @@ import { getGnosisSafeInstanceAt } from 'src/logic/contracts/safeContracts'
import { FIELD_LOAD_ADDRESS, THRESHOLD } from 'src/routes/load/components/fields' import { FIELD_LOAD_ADDRESS, THRESHOLD } from 'src/routes/load/components/fields'
import { getOwnerAddressBy, getOwnerNameBy } from 'src/routes/open/components/fields' import { getOwnerAddressBy, getOwnerNameBy } from 'src/routes/open/components/fields'
import { styles } from './styles' import { styles } from './styles'
import { getExplorerInfo } from 'src/config' import { LoadFormValues } from 'src/routes/load/container/Load'
import { EthHashInfo } from '@gnosis.pm/safe-react-components'
const calculateSafeValues = (owners, threshold, values) => { const calculateSafeValues = (owners, threshold, values) => {
const initialValues = { ...values } const initialValues = { ...values }
@ -41,10 +41,14 @@ const useAddressBookForOwnersNames = (ownersList: string[]): AddressBookEntry[]
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
const OwnerListComponent = (props) => { interface OwnerListComponentProps {
values: LoadFormValues
updateInitialProps: (initialValues) => void
}
const OwnerListComponent = ({ values, updateInitialProps }: OwnerListComponentProps): ReactElement => {
const [owners, setOwners] = useState<string[]>([]) const [owners, setOwners] = useState<string[]>([])
const classes = useStyles() const classes = useStyles()
const { updateInitialProps, values } = props
const ownersWithNames = useAddressBookForOwnersNames(owners) const ownersWithNames = useAddressBookForOwnersNames(owners)
@ -88,19 +92,18 @@ const OwnerListComponent = (props) => {
<Hairline /> <Hairline />
<Block margin="md" padding="md"> <Block margin="md" padding="md">
{ownersWithNames.map(({ address, name }, index) => { {ownersWithNames.map(({ address, name }, index) => {
const ownerName = name || `Owner #${index + 1}`
return ( return (
<Row className={classes.owner} key={address} data-testid="owner-row"> <Row className={classes.owner} key={address} data-testid="owner-row">
<Col className={classes.ownerName} xs={4}> <Col className={classes.ownerName} xs={4}>
<Field <Field
className={classes.name} className={classes.name}
component={TextField} component={TextField}
initialValue={ownerName} initialValue={name}
name={getOwnerNameBy(index)} name={getOwnerNameBy(index)}
placeholder="Owner Name*" placeholder="Owner Name"
text="Owner Name" text="Owner Name"
type="text" type="text"
validate={composeValidators(required, minMaxLength(1, 50))} validate={minMaxLength(0, 50)}
testId={`load-safe-owner-name-${index}`} testId={`load-safe-owner-name-${index}`}
/> />
</Col> </Col>
@ -118,14 +121,12 @@ const OwnerListComponent = (props) => {
) )
} }
const OwnerList = ({ updateInitialProps }, network) => const OwnerList = ({ updateInitialProps }) =>
function LoadSafeOwnerList(controls, { values }): React.ReactElement { function LoadSafeOwnerList(controls: ReactNode, { values }: { values: LoadFormValues }): ReactElement {
return ( return (
<>
<OpenPaper controls={controls} padding={false}> <OpenPaper controls={controls} padding={false}>
<OwnerListComponent network={network} updateInitialProps={updateInitialProps} values={values} /> <OwnerListComponent updateInitialProps={updateInitialProps} values={values} />
</OpenPaper> </OpenPaper>
</>
) )
} }

View File

@ -1,6 +1,8 @@
import { EthHashInfo } from '@gnosis.pm/safe-react-components'
import TableContainer from '@material-ui/core/TableContainer' import TableContainer from '@material-ui/core/TableContainer'
import React from 'react' import React, { ReactElement, ReactNode } from 'react'
import { getExplorerInfo } from 'src/config'
import Block from 'src/components/layout/Block' import Block from 'src/components/layout/Block'
import Col from 'src/components/layout/Col' import Col from 'src/components/layout/Col'
import Hairline from 'src/components/layout/Hairline' import Hairline from 'src/components/layout/Hairline'
@ -11,8 +13,6 @@ import { FIELD_LOAD_ADDRESS, FIELD_LOAD_NAME, THRESHOLD } from 'src/routes/load/
import { getNumOwnersFrom, getOwnerAddressBy, getOwnerNameBy } from 'src/routes/open/components/fields' import { getNumOwnersFrom, getOwnerAddressBy, getOwnerNameBy } from 'src/routes/open/components/fields'
import { getAccountsFrom } from 'src/routes/open/utils/safeDataExtractor' import { getAccountsFrom } from 'src/routes/open/utils/safeDataExtractor'
import { useStyles } from './styles' import { useStyles } from './styles'
import { getExplorerInfo } from 'src/config'
import { EthHashInfo } from '@gnosis.pm/safe-react-components'
import { LoadFormValues } from 'src/routes/load/container/Load' import { LoadFormValues } from 'src/routes/load/container/Load'
const checkIfUserAddressIsAnOwner = (values: LoadFormValues, userAddress: string): boolean => { const checkIfUserAddressIsAnOwner = (values: LoadFormValues, userAddress: string): boolean => {
@ -33,14 +33,13 @@ interface Props {
values: LoadFormValues values: LoadFormValues
} }
const ReviewComponent = ({ userAddress, values }: Props): React.ReactElement => { const ReviewComponent = ({ userAddress, values }: Props): ReactElement => {
const classes = useStyles() const classes = useStyles()
const isOwner = checkIfUserAddressIsAnOwner(values, userAddress) const isOwner = checkIfUserAddressIsAnOwner(values, userAddress)
const owners = getAccountsFrom(values) const owners = getAccountsFrom(values)
const safeAddress = values[FIELD_LOAD_ADDRESS] const safeAddress = values[FIELD_LOAD_ADDRESS]
return ( return (
<>
<Row className={classes.root}> <Row className={classes.root}>
<Col className={classes.detailsColumn} layout="column" xs={4}> <Col className={classes.detailsColumn} layout="column" xs={4}>
<Block className={classes.details}> <Block className={classes.details}>
@ -123,18 +122,15 @@ const ReviewComponent = ({ userAddress, values }: Props): React.ReactElement =>
</TableContainer> </TableContainer>
</Col> </Col>
</Row> </Row>
</>
) )
} }
const Review = ({ userAddress }: { userAddress: string }) => const Review = ({ userAddress }: { userAddress: string }) =>
function ReviewPage(controls: React.ReactNode, { values }: { values: LoadFormValues }): React.ReactElement { function ReviewPage(controls: ReactNode, { values }: { values: LoadFormValues }): ReactElement {
return ( return (
<>
<OpenPaper controls={controls} padding={false}> <OpenPaper controls={controls} padding={false}>
<ReviewComponent userAddress={userAddress} values={values} /> <ReviewComponent userAddress={userAddress} values={values} />
</OpenPaper> </OpenPaper>
</>
) )
} }

View File

@ -1,34 +1,25 @@
import { List } from 'immutable' import React, { ReactElement } from 'react'
import * as React from 'react'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
import { ETHEREUM_NETWORK } from 'src/config/networks/network.d'
import Layout from 'src/routes/load/components/Layout' import Layout from 'src/routes/load/components/Layout'
import { FIELD_LOAD_ADDRESS, FIELD_LOAD_NAME } from '../components/fields' import { AddressBookEntry, makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { addressBookSafeLoad } from 'src/logic/addressBook/store/actions'
import { FIELD_LOAD_ADDRESS } from 'src/routes/load/components/fields'
import Page from 'src/components/layout/Page' import Page from 'src/components/layout/Page'
import { getGnosisSafeInstanceAt } from 'src/logic/contracts/safeContracts'
import { saveSafes, loadStoredSafes } from 'src/logic/safe/utils' import { saveSafes, loadStoredSafes } from 'src/logic/safe/utils'
import { getNamesFrom, getOwnersFrom } from 'src/routes/open/utils/safeDataExtractor' import { getAccountsFrom, getNamesFrom } from 'src/routes/open/utils/safeDataExtractor'
import { SAFELIST_ADDRESS } from 'src/routes/routes' import { SAFELIST_ADDRESS } from 'src/routes/routes'
import { buildSafe } from 'src/logic/safe/store/actions/fetchSafe' import { buildSafe } from 'src/logic/safe/store/actions/fetchSafe'
import { history } from 'src/store' import { history } from 'src/store'
import { SafeOwner, SafeRecordProps } from 'src/logic/safe/store/models/safe' import { SafeRecordProps } from 'src/logic/safe/store/models/safe'
import { checksumAddress } from 'src/utils/checksumAddress' import { checksumAddress } from 'src/utils/checksumAddress'
import { networkSelector, providerNameSelector, userAccountSelector } from 'src/logic/wallets/store/selectors' import { isValidAddress } from 'src/utils/isValidAddress'
import { providerNameSelector, userAccountSelector } from 'src/logic/wallets/store/selectors'
import { addOrUpdateSafe } from 'src/logic/safe/store/actions/addOrUpdateSafe' import { addOrUpdateSafe } from 'src/logic/safe/store/actions/addOrUpdateSafe'
export const loadSafe = async ( export const loadSafe = async (safeAddress: string, addSafe: (safe: SafeRecordProps) => void): Promise<void> => {
safeName: string, const safeProps = await buildSafe(safeAddress)
safeAddress: string,
owners: List<SafeOwner>,
addSafe: (safe: SafeRecordProps) => void,
): Promise<void> => {
const safeProps = await buildSafe(safeAddress, safeName)
safeProps.owners = owners
// We are manually adding the safe. We enforce this state in case the safe was previously
// accessed by URL
safeProps.loadedViaUrl = false
const storedSafes = (await loadStoredSafes()) || {} const storedSafes = (await loadStoredSafes()) || {}
@ -56,10 +47,9 @@ interface LoadForm {
export type LoadFormValues = ReviewSafeCreationValues | LoadForm export type LoadFormValues = ReviewSafeCreationValues | LoadForm
const Load = (): React.ReactElement => { const Load = (): ReactElement => {
const dispatch = useDispatch() const dispatch = useDispatch()
const provider = useSelector(providerNameSelector) const provider = useSelector(providerNameSelector)
const network = useSelector(networkSelector)
const userAddress = useSelector(userAccountSelector) const userAddress = useSelector(userAccountSelector)
const addSafeHandler = async (safe: SafeRecordProps) => { const addSafeHandler = async (safe: SafeRecordProps) => {
@ -67,22 +57,32 @@ const Load = (): React.ReactElement => {
} }
const onLoadSafeSubmit = async (values: LoadFormValues) => { const onLoadSafeSubmit = async (values: LoadFormValues) => {
let safeAddress = values[FIELD_LOAD_ADDRESS] let safeAddress = values[FIELD_LOAD_ADDRESS]
// TODO: review this check. It doesn't seems to be necessary at this point
if (!safeAddress) { if (!isValidAddress(safeAddress)) {
console.error('failed to add Safe address', JSON.stringify(values)) console.error('failed to add Safe address', JSON.stringify(values))
return return
} }
const ownersNames = getNamesFrom(values)
const ownersAddresses = getAccountsFrom(values)
const owners = ownersAddresses.reduce((acc, address, index) => {
if (ownersNames[index]) {
// Do not add owners to addressbook if names are empty
const newAddressBookEntry = makeAddressBookEntry({
address,
name: ownersNames[index],
})
acc.push(newAddressBookEntry)
}
return acc
}, [] as AddressBookEntry[])
const safe = makeAddressBookEntry({ address: safeAddress, name: values.name })
await dispatch(addressBookSafeLoad([...owners, safe]))
try { try {
const safeName = values[FIELD_LOAD_NAME]
safeAddress = checksumAddress(safeAddress) safeAddress = checksumAddress(safeAddress)
const ownerNames = getNamesFrom(values) await loadSafe(safeAddress, addSafeHandler)
const gnosisSafe = getGnosisSafeInstanceAt(safeAddress)
const ownerAddresses = await gnosisSafe.methods.getOwners().call()
const owners = getOwnersFrom(ownerNames, ownerAddresses.slice().sort())
await loadSafe(safeName, safeAddress, owners, addSafeHandler)
const url = `${SAFELIST_ADDRESS}/${safeAddress}/balances` const url = `${SAFELIST_ADDRESS}/${safeAddress}/balances`
history.push(url) history.push(url)
@ -93,12 +93,7 @@ const Load = (): React.ReactElement => {
return ( return (
<Page> <Page>
<Layout <Layout onLoadSafeSubmit={onLoadSafeSubmit} userAddress={userAddress} provider={provider} />
onLoadSafeSubmit={onLoadSafeSubmit}
network={ETHEREUM_NETWORK[network]}
userAddress={userAddress}
provider={provider}
/>
</Page> </Page>
) )
} }

View File

@ -5,7 +5,7 @@ import styled from 'styled-components'
import OpenPaper from 'src/components/Stepper/OpenPaper' import OpenPaper from 'src/components/Stepper/OpenPaper'
import Field from 'src/components/forms/Field' import Field from 'src/components/forms/Field'
import TextField from 'src/components/forms/TextField' import TextField from 'src/components/forms/TextField'
import { composeValidators, minMaxLength, required } from 'src/components/forms/validator' import { composeValidators, required, validAddressBookName } from 'src/components/forms/validator'
import Block from 'src/components/layout/Block' import Block from 'src/components/layout/Block'
import Paragraph from 'src/components/layout/Paragraph' import Paragraph from 'src/components/layout/Paragraph'
import { FIELD_NAME } from 'src/routes/open/components/fields' import { FIELD_NAME } from 'src/routes/open/components/fields'
@ -56,7 +56,7 @@ const SafeNameForm = ({ safeName }: { safeName: string }): React.ReactElement =>
placeholder="Name of the new Safe" placeholder="Name of the new Safe"
text="Safe name" text="Safe name"
type="text" type="text"
validate={composeValidators(required, minMaxLength(1, 50))} validate={composeValidators(required, validAddressBookName)}
testId="create-safe-name-field" testId="create-safe-name-field"
/> />
</Block> </Block>

View File

@ -1,7 +1,7 @@
import { Loader } from '@gnosis.pm/safe-react-components' import { Loader } from '@gnosis.pm/safe-react-components'
import { backOff } from 'exponential-backoff' import { backOff } from 'exponential-backoff'
import queryString from 'query-string' import queryString from 'query-string'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState, ReactElement } from 'react'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { PromiEvent, TransactionReceipt } from 'web3-core' import { PromiEvent, TransactionReceipt } from 'web3-core'
@ -16,7 +16,6 @@ import {
CreateSafeValues, CreateSafeValues,
getAccountsFrom, getAccountsFrom,
getNamesFrom, getNamesFrom,
getOwnersFrom,
getSafeCreationSaltFrom, getSafeCreationSaltFrom,
getSafeNameFrom, getSafeNameFrom,
getThresholdFrom, getThresholdFrom,
@ -25,8 +24,9 @@ import { SAFELIST_ADDRESS, WELCOME_ADDRESS } from 'src/routes/routes'
import { buildSafe } from 'src/logic/safe/store/actions/fetchSafe' import { buildSafe } from 'src/logic/safe/store/actions/fetchSafe'
import { history } from 'src/store' import { history } from 'src/store'
import { loadFromStorage, removeFromStorage, saveToStorage } from 'src/utils/storage' import { loadFromStorage, removeFromStorage, saveToStorage } from 'src/utils/storage'
import { makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { addressBookSafeLoad } from 'src/logic/addressBook/store/actions'
import { userAccountSelector } from 'src/logic/wallets/store/selectors' import { userAccountSelector } from 'src/logic/wallets/store/selectors'
import { SafeRecordProps } from 'src/logic/safe/store/models/safe'
import { addOrUpdateSafe } from 'src/logic/safe/store/actions/addOrUpdateSafe' import { addOrUpdateSafe } from 'src/logic/safe/store/actions/addOrUpdateSafe'
import { useAnalytics } from 'src/utils/googleAnalytics' import { useAnalytics } from 'src/utils/googleAnalytics'
import { sleep } from 'src/utils/timer' import { sleep } from 'src/utils/timer'
@ -78,18 +78,6 @@ const getSafePropsValuesFromQueryParams = (queryParams: SafeCreationQueryParams)
} }
} }
export const getSafeProps = async (
safeAddress: string,
safeName: string,
ownersNames: string[],
ownerAddresses: string[],
): Promise<SafeRecordProps> => {
const safeProps = await buildSafe(safeAddress, safeName)
safeProps.owners = getOwnersFrom(ownersNames, ownerAddresses)
return safeProps
}
export const createSafe = (values: CreateSafeValues, userAccount: string): PromiEvent<TransactionReceipt> => { export const createSafe = (values: CreateSafeValues, userAccount: string): PromiEvent<TransactionReceipt> => {
const confirmations = getThresholdFrom(values) const confirmations = getThresholdFrom(values)
const ownerAddresses = getAccountsFrom(values) const ownerAddresses = getAccountsFrom(values)
@ -118,7 +106,7 @@ export const createSafe = (values: CreateSafeValues, userAccount: string): Promi
return promiEvent return promiEvent
} }
const Open = (): React.ReactElement => { const Open = (): ReactElement => {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [showProgress, setShowProgress] = useState(false) const [showProgress, setShowProgress] = useState(false)
const [creationTxPromise, setCreationTxPromise] = useState<PromiEvent<TransactionReceipt>>() const [creationTxPromise, setCreationTxPromise] = useState<PromiEvent<TransactionReceipt>>()
@ -176,14 +164,24 @@ const Open = (): React.ReactElement => {
setShowProgress(true) setShowProgress(true)
} }
const onSafeCreated = async (safeAddress): Promise<void> => { const onSafeCreated = async (safeAddress: string): Promise<void> => {
const pendingCreation = await loadFromStorage<LoadedSafeType>(SAFE_PENDING_CREATION_STORAGE_KEY) const pendingCreation = await loadFromStorage<LoadedSafeType>(SAFE_PENDING_CREATION_STORAGE_KEY)
const name = pendingCreation ? getSafeNameFrom(pendingCreation) : '' let name = ''
const ownersNames = getNamesFrom(pendingCreation as CreateSafeValues) let ownersNames: string[] = []
const ownerAddresses = pendingCreation ? getAccountsFrom(pendingCreation) : [] let ownersAddresses: string[] = []
const safeProps = await getSafeProps(safeAddress, name, ownersNames, ownerAddresses)
if (pendingCreation) {
name = getSafeNameFrom(pendingCreation)
ownersNames = getNamesFrom(pendingCreation as CreateSafeValues)
ownersAddresses = getAccountsFrom(pendingCreation)
}
const owners = ownersAddresses.map((address, index) => makeAddressBookEntry({ address, name: ownersNames[index] }))
const safe = makeAddressBookEntry({ address: safeAddress, name })
await dispatch(addressBookSafeLoad([...owners, safe]))
const safeProps = await buildSafe(safeAddress)
await dispatch(addOrUpdateSafe(safeProps)) await dispatch(addOrUpdateSafe(safeProps))
trackEvent({ trackEvent({

View File

@ -1,7 +1,3 @@
import { List } from 'immutable'
import { makeOwner } from 'src/logic/safe/store/models/owner'
import { SafeOwner } from 'src/logic/safe/store/models/safe'
import { LoadFormValues } from 'src/routes/load/container/Load' import { LoadFormValues } from 'src/routes/load/container/Load'
import { getNumOwnersFrom } from 'src/routes/open/components/fields' import { getNumOwnersFrom } from 'src/routes/open/components/fields'
@ -15,29 +11,18 @@ export type CreateSafeValues = {
owners?: number | string owners?: number | string
} }
export const getAccountsFrom = (values: CreateSafeValues | LoadFormValues): string[] => { const getByRegexFrom = (regex: RegExp) => (values: CreateSafeValues | LoadFormValues): string[] => {
const accounts = Object.keys(values) const accounts = Object.keys(values)
.sort() .sort()
.filter((key) => /^owner\d+Address$/.test(key)) .filter((key) => regex.test(key))
const numOwners = getNumOwnersFrom(values) const numOwners = getNumOwnersFrom(values)
return accounts.map((account) => values[account]).slice(0, numOwners) return accounts.map((account) => values[account]).slice(0, numOwners)
} }
export const getNamesFrom = (values: CreateSafeValues | LoadFormValues): string[] => { export const getAccountsFrom = getByRegexFrom(/^owner\d+Address$/)
const accounts = Object.keys(values)
.sort()
.filter((key) => /^owner\d+Name$/.test(key))
const numOwners = getNumOwnersFrom(values) export const getNamesFrom = getByRegexFrom(/^owner\d+Name$/)
return accounts.map((account) => values[account]).slice(0, numOwners)
}
export const getOwnersFrom = (names: string[], addresses: string[]): List<SafeOwner> => {
const owners = names.map((name, index) => makeOwner({ name, address: addresses[index] }))
return List(owners)
}
export const getThresholdFrom = (values: CreateSafeValues): number => Number(values.confirmations) export const getThresholdFrom = (values: CreateSafeValues): number => Number(values.confirmations)

View File

@ -1,21 +1,17 @@
import IconButton from '@material-ui/core/IconButton'
import Close from '@material-ui/icons/Close'
import React, { ReactElement } from 'react' import React, { ReactElement } from 'react'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { useStyles } from './style' import { useStyles } from './style'
import Modal, { Modal as GenericModal } from 'src/components/Modal' import { Modal } from 'src/components/Modal'
import { ScanQRWrapper } from 'src/components/ScanQRModal/ScanQRWrapper' import { ScanQRWrapper } from 'src/components/ScanQRModal/ScanQRWrapper'
import AddressInput from 'src/components/forms/AddressInput' import AddressInput from 'src/components/forms/AddressInput'
import Field from 'src/components/forms/Field' import Field from 'src/components/forms/Field'
import GnoForm from 'src/components/forms/GnoForm' import GnoForm from 'src/components/forms/GnoForm'
import TextField from 'src/components/forms/TextField' import TextField from 'src/components/forms/TextField'
import { composeValidators, minMaxLength, required, uniqueAddress } from 'src/components/forms/validator' import { composeValidators, required, uniqueAddress, validAddressBookName } from 'src/components/forms/validator'
import Block from 'src/components/layout/Block' import Block from 'src/components/layout/Block'
import Col from 'src/components/layout/Col' import Col from 'src/components/layout/Col'
import Hairline from 'src/components/layout/Hairline'
import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row' import Row from 'src/components/layout/Row'
import { addressBookAddressesListSelector } from 'src/logic/addressBook/store/selectors' import { addressBookAddressesListSelector } from 'src/logic/addressBook/store/selectors'
import { AddressBookEntry } from 'src/logic/addressBook/model/addressBook' import { AddressBookEntry } from 'src/logic/addressBook/model/addressBook'
@ -66,18 +62,12 @@ export const CreateEditEntryModal = ({
description={isNew ? 'Create new addressBook entry' : 'Edit addressBook entry'} description={isNew ? 'Create new addressBook entry' : 'Edit addressBook entry'}
handleClose={onClose} handleClose={onClose}
open={isOpen} open={isOpen}
paperClassName="smaller-modal-window"
title={isNew ? 'Create new entry' : 'Edit entry'} title={isNew ? 'Create new entry' : 'Edit entry'}
> >
<Row align="center" className={classes.heading} grow> <Modal.Header onClose={onClose}>
<Paragraph className={classes.manage} noMargin weight="bolder"> <Modal.Header.Title>{isNew ? 'Create entry' : 'Edit entry'}</Modal.Header.Title>
{isNew ? 'Create entry' : 'Edit entry'} </Modal.Header>
</Paragraph> <Modal.Body withoutPadding>
<IconButton disableRipple onClick={onClose}>
<Close className={classes.close} />
</IconButton>
</Row>
<Hairline />
<GnoForm formMutators={formMutators} onSubmit={onFormSubmitted} initialValues={initialValues}> <GnoForm formMutators={formMutators} onSubmit={onFormSubmitted} initialValues={initialValues}>
{(...args) => { {(...args) => {
const formState = args[2] const formState = args[2]
@ -100,11 +90,11 @@ export const CreateEditEntryModal = ({
<Field <Field
component={TextField} component={TextField}
name="name" name="name"
placeholder="Name" placeholder="Name*"
testId={CREATE_ENTRY_INPUT_NAME_ID} testId={CREATE_ENTRY_INPUT_NAME_ID}
text="Name" text="Name*"
type="text" type="text"
validate={composeValidators(required, minMaxLength(1, 50))} validate={composeValidators(required, validAddressBookName)}
/> />
</Col> </Col>
</Row> </Row>
@ -127,8 +117,8 @@ export const CreateEditEntryModal = ({
) : null} ) : null}
</Row> </Row>
</Block> </Block>
<GenericModal.Footer> <Modal.Footer>
<GenericModal.Footer.Buttons <Modal.Footer.Buttons
cancelButtonProps={{ onClick: onClose }} cancelButtonProps={{ onClick: onClose }}
confirmButtonProps={{ confirmButtonProps={{
disabled: !formState.valid, disabled: !formState.valid,
@ -136,11 +126,12 @@ export const CreateEditEntryModal = ({
text: isNew ? 'Create' : 'Save', text: isNew ? 'Create' : 'Save',
}} }}
/> />
</GenericModal.Footer> </Modal.Footer>
</> </>
) )
}} }}
</GnoForm> </GnoForm>
</Modal.Body>
</Modal> </Modal>
) )
} }

View File

@ -7,6 +7,9 @@ import { push } from 'connected-react-router'
import React from 'react' import React from 'react'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
import { sameString } from 'src/utils/strings'
import { ADDRESS_BOOK_DEFAULT_NAME } from 'src/logic/addressBook/model/addressBook'
import { getNameFromAddressBookSelector } from 'src/logic/addressBook/store/selectors'
import { SAFELIST_ADDRESS } from 'src/routes/routes' import { SAFELIST_ADDRESS } from 'src/routes/routes'
import { safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors' import { safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import { xs } from 'src/theme/variables' import { xs } from 'src/theme/variables'
@ -35,13 +38,11 @@ const useStyles = makeStyles(
type EllipsisTransactionDetailsProps = { type EllipsisTransactionDetailsProps = {
address: string address: string
knownAddress?: boolean
sendModalOpenHandler?: () => void sendModalOpenHandler?: () => void
} }
export const EllipsisTransactionDetails = ({ export const EllipsisTransactionDetails = ({
address, address,
knownAddress,
sendModalOpenHandler, sendModalOpenHandler,
}: EllipsisTransactionDetailsProps): React.ReactElement => { }: EllipsisTransactionDetailsProps): React.ReactElement => {
const classes = useStyles() const classes = useStyles()
@ -51,6 +52,10 @@ export const EllipsisTransactionDetails = ({
const currentSafeAddress = useSelector(safeParamAddressFromStateSelector) const currentSafeAddress = useSelector(safeParamAddressFromStateSelector)
const isOwnerConnected = useSelector(grantedSelector) const isOwnerConnected = useSelector(grantedSelector)
const recipientName = useSelector((state) => getNameFromAddressBookSelector(state, { address }))
// We have to check that the name returned is not UNKNOWN
const isStoredInAddressBook = !sameString(recipientName, ADDRESS_BOOK_DEFAULT_NAME)
const handleClick = (event) => setAnchorEl(event.currentTarget) const handleClick = (event) => setAnchorEl(event.currentTarget)
const closeMenuHandler = () => setAnchorEl(null) const closeMenuHandler = () => setAnchorEl(null)
@ -73,7 +78,7 @@ export const EllipsisTransactionDetails = ({
<Divider key="divider" />, <Divider key="divider" />,
] ]
: null} : null}
{knownAddress ? ( {isStoredInAddressBook ? (
<MenuItem onClick={addOrEditEntryHandler}>Edit Address book Entry</MenuItem> <MenuItem onClick={addOrEditEntryHandler}>Edit Address book Entry</MenuItem>
) : ( ) : (
<MenuItem onClick={addOrEditEntryHandler}>Add to address book</MenuItem> <MenuItem onClick={addOrEditEntryHandler}>Add to address book</MenuItem>

View File

@ -0,0 +1,9 @@
<svg width="108" height="96" viewBox="0 0 108 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M54 4C79.405 4 100 24.595 100 50C100 75.405 79.405 96 54 96C28.595 96 8 75.405 8 50C8 24.595 28.595 4 54 4Z" fill="#F7F5F5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M37.3327 60.8333L37.3327 66.6663H70.6663V60.8333C70.6663 60.6037 70.6663 58.3333 72.7496 58.3333C74.833 58.3333 74.833 60.6037 74.833 60.8333V68.7503C74.8325 68.7503 74.8321 68.7503 74.8317 68.7503C74.8313 69.8959 73.8961 70.833 72.7484 70.833H35.2484C34.1025 70.833 33.165 69.8955 33.165 68.7496C33.165 68.7284 33.1654 68.7073 33.166 68.6862L33.166 60.8333C33.166 60.6037 33.166 58.3333 35.2493 58.3333C37.3327 58.3333 37.3327 60.6037 37.3327 60.8333Z" fill="#B2B5B2"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M51.9156 36.2792V60.4105C51.9156 61.5626 52.849 62.4938 53.999 62.4938C55.151 62.4938 56.0823 61.5626 56.0823 60.4105V36.2792L64.6698 44.8647C65.4844 45.6793 66.8031 45.6793 67.6156 44.8647C68.4302 44.0501 68.4302 42.7334 67.6156 41.9188L55.8302 30.1334C55.7698 30.073 55.7052 30.0167 55.6406 29.9667C55.2573 29.4772 54.6656 29.1667 53.999 29.1667C53.3344 29.1667 52.7406 29.4772 52.3594 29.9667C52.2948 30.0167 52.2281 30.073 52.1698 30.1334L40.3844 41.9188C39.5698 42.7334 39.5698 44.0501 40.3844 44.8647C41.1969 45.6793 42.5156 45.6793 43.3302 44.8647L51.9156 36.2792Z" fill="#B2B5B2"/>
<circle cx="80" cy="74" r="17" fill="#F7F5F5"/>
<rect x="78.25" y="63.5" width="3.5" height="14" rx="1" fill="#F02525"/>
<path d="M80 80.5625C81.2081 80.5625 82.1875 81.5419 82.1875 82.75C82.1875 83.9581 81.2081 84.9375 80 84.9375C78.7919 84.9375 77.8125 83.9581 77.8125 82.75C77.8125 81.5419 78.7919 80.5625 80 80.5625Z" fill="#F02525"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M97.5 74C97.5 83.665 89.665 91.5 80 91.5C70.335 91.5 62.5 83.665 62.5 74C62.5 64.335 70.335 56.5 80 56.5C89.665 56.5 97.5 64.335 97.5 74ZM66 74C66 81.732 72.268 88 80 88C87.732 88 94 81.732 94 74C94 66.268 87.732 60 80 60C72.268 60 66 66.268 66 74Z" fill="#F02525"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,8 @@
<svg width="108" height="96" viewBox="0 0 108 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M54 4C79.405 4 100 24.595 100 50C100 75.405 79.405 96 54 96C28.595 96 8 75.405 8 50C8 24.595 28.595 4 54 4Z" fill="#F7F5F5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M37.3329 60.8333L37.3329 66.6663H70.6665V60.8333C70.6665 60.6037 70.6665 58.3333 72.7499 58.3333C74.8332 58.3333 74.8332 60.6037 74.8332 60.8333V68.7503C74.8328 68.7503 74.8324 68.7503 74.8319 68.7503C74.8316 69.8959 73.8963 70.833 72.7486 70.833H35.2486C34.1028 70.833 33.1653 69.8955 33.1653 68.7496C33.1653 68.7284 33.1656 68.7073 33.1662 68.6862L33.1662 60.8333C33.1662 60.6037 33.1662 58.3333 35.2496 58.3333C37.3329 58.3333 37.3329 60.6037 37.3329 60.8333Z" fill="#B2B5B2"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M51.9161 36.2791V60.4104C51.9161 61.5625 52.8494 62.4937 53.9994 62.4937C55.1515 62.4937 56.0828 61.5625 56.0828 60.4104V36.2791L64.6703 44.8645C65.4849 45.6791 66.8036 45.6791 67.6161 44.8645C68.4307 44.05 68.4307 42.7333 67.6161 41.9187L55.8307 30.1333C55.7703 30.0729 55.7057 30.0166 55.6411 29.9666C55.2578 29.477 54.6661 29.1666 53.9994 29.1666C53.3349 29.1666 52.7411 29.477 52.3599 29.9666C52.2953 30.0166 52.2286 30.0729 52.1703 30.1333L40.3849 41.9187C39.5703 42.7333 39.5703 44.05 40.3849 44.8645C41.1974 45.6791 42.5161 45.6791 43.3307 44.8645L51.9161 36.2791Z" fill="#B2B5B2"/>
<circle cx="80" cy="74" r="17" fill="#F7F5F5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M97.5 74C97.5 83.665 89.665 91.5 80 91.5C70.335 91.5 62.5 83.665 62.5 74C62.5 64.335 70.335 56.5 80 56.5C89.665 56.5 97.5 64.335 97.5 74ZM66 74C66 81.732 72.268 88 80 88C87.732 88 94 81.732 94 74C94 66.268 87.732 60 80 60C72.268 60 66 66.268 66 74Z" fill="#008C73"/>
<path d="M72.443 70.9826C71.7373 70.3222 70.6299 70.3588 69.9694 71.0644C69.309 71.77 69.3456 72.8775 70.0512 73.5379L77.2979 80.3209C77.9934 80.9719 79.0818 80.947 79.7468 80.265L89.1041 70.668C89.7788 69.976 89.7648 68.868 89.0728 68.1933C88.3808 67.5186 87.2728 67.5326 86.5981 68.2246L78.4379 76.5939L72.443 70.9826Z" fill="#008C73"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1,8 @@
<svg width="108" height="96" viewBox="0 0 108 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M54 4C79.405 4 100 24.595 100 50C100 75.405 79.405 96 54 96C28.595 96 8 75.405 8 50C8 24.595 28.595 4 54 4Z" fill="#F7F5F5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M37.3327 60.8333L37.3327 66.6663H70.6663V60.8333C70.6663 60.6037 70.6663 58.3333 72.7496 58.3333C74.833 58.3333 74.833 60.6037 74.833 60.8333V68.7503C74.8325 68.7503 74.8321 68.7503 74.8317 68.7503C74.8313 69.8959 73.8961 70.833 72.7484 70.833H35.2484C34.1025 70.833 33.165 69.8955 33.165 68.7496C33.165 68.7284 33.1654 68.7073 33.166 68.6862L33.166 60.8333C33.166 60.6037 33.166 58.3333 35.2493 58.3333C37.3327 58.3333 37.3327 60.6037 37.3327 60.8333Z" fill="#B2B5B2"/>
<circle cx="80" cy="74" r="17" fill="#F7F5F5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M73.871 80C73.243 80 72.63 79.7078 72.2767 79.1771C71.7366 78.3629 72.0114 77.2989 72.8898 76.7984L78.2621 73.7429V66.7287C78.2621 65.7736 79.0994 65 80.131 65C81.1636 65 82 65.7736 82 66.7287V74.7093C82 75.3091 81.6636 75.8675 81.1104 76.1821L74.8484 79.7442C74.5429 79.917 74.2046 80 73.871 80Z" fill="#5D6D74"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M97.5 74C97.5 83.665 89.665 91.5 80 91.5C70.335 91.5 62.5 83.665 62.5 74C62.5 64.335 70.335 56.5 80 56.5C89.665 56.5 97.5 64.335 97.5 74ZM66 74C66 81.732 72.268 88 80 88C87.732 88 94 81.732 94 74C94 66.268 87.732 60 80 60C72.268 60 66 66.268 66 74Z" fill="#5D6D74"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M51.9156 36.2791V60.4104C51.9156 61.5625 52.849 62.4937 53.999 62.4937C55.151 62.4937 56.0823 61.5625 56.0823 60.4104V36.2791L64.6698 44.8645C65.4844 45.6791 66.8031 45.6791 67.6156 44.8645C68.4302 44.05 68.4302 42.7333 67.6156 41.9187L55.8302 30.1333C55.7698 30.0729 55.7052 30.0166 55.6406 29.9666C55.2573 29.477 54.6656 29.1666 53.999 29.1666C53.3344 29.1666 52.7406 29.477 52.3594 29.9666C52.2948 30.0166 52.2281 30.0729 52.1698 30.1333L40.3844 41.9187C39.5698 42.7333 39.5698 44.05 40.3844 44.8645C41.1969 45.6791 42.5156 45.6791 43.3302 44.8645L51.9156 36.2791Z" fill="#B2B5B2"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1,164 @@
import React, { ReactElement, useEffect, useState } from 'react'
import { format } from 'date-fns'
import { useSelector, useDispatch } from 'react-redux'
import { CSVDownloader, jsonToCSV } from 'react-papaparse'
import { Button, Loader, Text } from '@gnosis.pm/safe-react-components'
import styled from 'styled-components'
import { enhanceSnackbarForAction, getNotificationsFromTxType } from 'src/logic/notifications'
import enqueueSnackbar from 'src/logic/notifications/store/actions/enqueueSnackbar'
import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
import { addressBookSelector } from 'src/logic/addressBook/store/selectors'
import { AddressBookState } from 'src/logic/addressBook/model/addressBook'
import { lg, md, background } from 'src/theme/variables'
import { Modal } from 'src/components/Modal'
import Img from 'src/components/layout/Img'
import Row from 'src/components/layout/Row'
import HelpInfo from 'src/routes/safe/components/AddressBook/HelpInfo'
import SuccessSvg from './assets/success.svg'
import ErrorSvg from './assets/error.svg'
import LoadingSvg from './assets/wait.svg'
type ExportEntriesModalProps = {
isOpen: boolean
onClose: () => void
}
const ImageContainer = styled(Row)`
padding: ${md} ${lg};
justify-content: center;
`
const StyledButton = styled(Button)`
&.MuiButtonBase-root.MuiButton-root {
padding: 0;
.MuiButton-label {
height: 100%;
}
}
`
const InfoContainer = styled(Row)`
background-color: ${background};
flex-direction: column;
justify-content: center;
padding: ${lg};
text-align: center;
`
const BodyImage = styled.div`
grid-row: 1;
`
const StyledLoader = styled(Loader)`
margin-right: 5px;
`
const StyledCSVLink = styled(CSVDownloader)`
height: 100%;
display: flex;
flex: 1;
justify-content: center;
align-items: center;
`
export const ExportEntriesModal = ({ isOpen, onClose }: ExportEntriesModalProps): ReactElement => {
const dispatch = useDispatch()
const addressBook: AddressBookState = useSelector(addressBookSelector)
const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<string | undefined>('')
const [csvData, setCsvData] = useState<string>('')
const [doRetry, setDoRetry] = useState<boolean>(false)
const date = format(new Date(), 'yyyy-MM-dd')
const handleClose = () => {
//This timeout prevents modal to be closed abruptly
setLoading(true)
setTimeout(() => {
if (!loading) {
const notification = getNotificationsFromTxType(TX_NOTIFICATION_TYPES.ADDRESS_BOOK_EXPORT_ENTRIES)
const action = error
? notification.afterExecution.afterExecutionError
: notification.afterExecution.noMoreConfirmationsNeeded
dispatch(enqueueSnackbar(enhanceSnackbarForAction(action)))
}
onClose()
}, 600)
}
useEffect(() => {
const handleCsvData = () => {
if (!isOpen && !doRetry) return
setLoading(true)
setError('')
try {
setCsvData(jsonToCSV(addressBook))
} catch (e) {
setLoading(false)
setError(e.message)
return
}
setLoading(false)
setDoRetry(false)
}
handleCsvData()
}, [addressBook, isOpen, doRetry, csvData])
return (
<Modal description="Export address book" handleClose={onClose} open={isOpen} title="Export address book">
<Modal.Header onClose={onClose}>
<Modal.Header.Title withoutMargin>Export address book</Modal.Header.Title>
</Modal.Header>
<Modal.Body withoutPadding>
<ImageContainer>
<BodyImage>
<Img alt="Export" height={92} src={error ? ErrorSvg : loading ? LoadingSvg : SuccessSvg} />
</BodyImage>
</ImageContainer>
<InfoContainer>
<Text color="primary" as="p" size="xl">
{!error ? (
<Text size="xl" as="span">
You&apos;re about to export a CSV file with{' '}
<Text size="xl" strong as="span">
{addressBook.length} address book entries. <br />
<HelpInfo />
</Text>
.
</Text>
) : (
<Text size="xl" as="span">
An error occurred while generating the address book CSV.
</Text>
)}
</Text>
</InfoContainer>
</Modal.Body>
<Modal.Footer withoutBorder>
<Row>
<Button size="md" variant="outlined" onClick={onClose}>
Cancel
</Button>
<StyledButton
color="primary"
size="md"
disabled={loading}
onClick={error ? () => setDoRetry(true) : handleClose}
>
{!error ? (
<StyledCSVLink data={csvData} bom={true} filename={`gnosis-safe-address-book-${date}`} type="link">
{loading && <StyledLoader color="secondaryLight" size="xs" />}
Download
</StyledCSVLink>
) : (
'Retry'
)}
</StyledButton>
</Row>
</Modal.Footer>
</Modal>
)
}

View File

@ -0,0 +1,27 @@
import React, { ReactElement } from 'react'
import styled from 'styled-components'
import { Text, Link, Icon } from '@gnosis.pm/safe-react-components'
const StyledIcon = styled(Icon)`
svg {
position: relative;
top: 4px;
left: 4px;
}
`
const HelpInfo = (): ReactElement => (
<Link
href="https://help.gnosis-safe.io/en/articles/5299068-address-book-export-and-import"
target="_blank"
rel="noreferrer"
title="Export & import info"
>
<Text size="xl" as="span" color="primary">
Learn about the address book import and export
</Text>
<StyledIcon size="sm" type="externalLink" color="primary" />
</Link>
)
export default HelpInfo

View File

@ -0,0 +1,219 @@
import React, { ReactElement, useState } from 'react'
import styled from 'styled-components'
import { Text } from '@gnosis.pm/safe-react-components'
import { Modal } from 'src/components/Modal'
import { CSVReader } from 'react-papaparse'
import { AddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { isValidAddress } from 'src/utils/isValidAddress'
import { checksumAddress } from 'src/utils/checksumAddress'
import HelpInfo from 'src/routes/safe/components/AddressBook/HelpInfo'
const ImportContainer = styled.div`
flex-direction: column;
justify-content: center;
margin: 24px;
align-items: center;
/* width: 200px;*/
min-height: 100px;
display: flex;
`
const InfoContainer = styled.div`
background-color: ${({ theme }) => theme.colors.background};
flex-direction: column;
justify-content: center;
padding: 24px;
text-align: center;
margin-top: 16px;
`
const WRONG_FILE_EXTENSION_ERROR = 'Only CSV files are allowed'
const FILE_SIZE_TOO_BIG = 'The size of the file is over 1 MB'
const FILE_BYTES_LIMIT = 1000000
const IMPORT_SUPPORTED_FORMATS = [
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/csv',
]
type ImportEntriesModalProps = {
importEntryModalHandler: (addressList: AddressBookEntry[]) => void
isOpen: boolean
onClose: () => void
}
const ImportEntriesModal = ({ importEntryModalHandler, isOpen, onClose }: ImportEntriesModalProps): ReactElement => {
const [csvLoaded, setCsvLoaded] = useState(false)
const [importError, setImportError] = useState('')
const [entryList, setEntryList] = useState<AddressBookEntry[]>([])
const handleImportEntrySubmit = () => {
setCsvLoaded(false)
importEntryModalHandler(entryList)
}
const handleOnDrop = (data, file) => {
const slicedData = data.slice(1)
const fileError = validateFile(file)
if (fileError) {
setImportError(fileError)
return
}
const dataError = validateCsvData(slicedData)
if (dataError) {
setImportError(dataError)
return
}
const formatedList = slicedData.map((entry) => {
return { address: checksumAddress(entry.data[0]), name: entry.data[1], chainId: parseInt(entry.data[2]) }
})
setEntryList(formatedList)
setImportError('')
setCsvLoaded(true)
}
const validateFile = (file) => {
if (!IMPORT_SUPPORTED_FORMATS.includes(file.type)) {
return WRONG_FILE_EXTENSION_ERROR
}
if (file.size >= FILE_BYTES_LIMIT) {
return FILE_SIZE_TOO_BIG
}
return
}
const validateCsvData = (data) => {
for (let index = 0; index < data.length; index++) {
const entry = data[index]
if (!entry.data[0] || !entry.data[1] || !entry.data[2]) {
return `Invalid amount of columns on row ${index + 1}`
}
// Verify address properties
const address = entry.data[0].toLowerCase()
if (!isValidAddress(address)) {
return `Invalid address on row ${index + 1}`
}
if (isNaN(entry.data[2])) {
return `Invalid chain id on row ${index + 1}`
}
}
return
}
const handleOnError = (error) => {
setImportError(error.message)
}
const handleOnRemoveFile = () => {
setCsvLoaded(false)
setImportError('')
}
const handleClose = () => {
setCsvLoaded(false)
setEntryList([])
setImportError('')
onClose()
}
return (
<Modal description="Import address book" handleClose={handleClose} open={isOpen} title="Import address book">
<Modal.Header onClose={handleClose}>
<Modal.Header.Title>Import address book</Modal.Header.Title>
</Modal.Header>
<Modal.Body withoutPadding>
<ImportContainer>
<CSVReader
onDrop={handleOnDrop}
onError={handleOnError}
addRemoveButton
onRemoveFile={handleOnRemoveFile}
style={{
dropArea: {
borderColor: '#B2B5B2',
borderRadius: 8,
},
dropAreaActive: {
borderColor: '#008C73',
/* borderColor: '${({ theme }) => theme.colors.primary}', */
},
dropFile: {
width: 200,
height: 100,
background: '#fff',
boxShadow: 'rgb(40 54 61 / 18%) 1px 2px 10px 0px',
borderRadius: 8,
},
fileSizeInfo: {
color: '#001428',
lineHeight: 1,
position: 'absolute',
left: '10px',
top: '12px',
},
fileNameInfo: {
color: importError === '' ? '#008C73' : '#DB3A3D',
backgroundColor: '#fff',
fontSize: 14,
lineHeight: 1.4,
padding: '0 0.4em',
margin: '1.2em 0 0.5em 0',
maxHeight: '59px',
overflow: 'hidden',
},
progressBar: {
backgroundColor: '#008C73',
},
removeButton: {
color: '#DB3A3D',
},
}}
>
<Text size="xl">
Drop your CSV file here <br />
or click to upload.
</Text>
</CSVReader>
</ImportContainer>
<InfoContainer>
{importError !== '' && (
<Text size="xl" color="error">
{importError}
</Text>
)}
{!csvLoaded && importError === '' && (
<Text color="text" as="p" size="xl">
Only CSV files exported from Gnosis Safe are allowed. <br />
<HelpInfo />
</Text>
)}
{csvLoaded && importError === '' && (
<>
<Text size="xl" as="span">{`You're about to import`}</Text>
<Text size="xl" strong as="span">{` ${entryList.length} entries to your address book`}</Text>
</>
)}
</InfoContainer>
</Modal.Body>
<Modal.Footer>
<Modal.Footer.Buttons
cancelButtonProps={{ onClick: () => handleClose() }}
confirmButtonProps={{
color: 'primary',
disabled: !csvLoaded || importError !== '',
onClick: handleImportEntrySubmit,
text: 'Import',
}}
/>
</Modal.Footer>
</Modal>
)
}
export default ImportEntriesModal

View File

@ -1,4 +1,4 @@
import { Button, EthHashInfo, FixedIcon, Text } from '@gnosis.pm/safe-react-components' import { Button, EthHashInfo, FixedIcon, Text, ButtonLink, Icon } from '@gnosis.pm/safe-react-components'
import TableCell from '@material-ui/core/TableCell' import TableCell from '@material-ui/core/TableCell'
import TableContainer from '@material-ui/core/TableContainer' import TableContainer from '@material-ui/core/TableContainer'
import TableRow from '@material-ui/core/TableRow' import TableRow from '@material-ui/core/TableRow'
@ -10,37 +10,32 @@ import { useDispatch, useSelector } from 'react-redux'
import { styles } from './style' import { styles } from './style'
import { getExplorerInfo } from 'src/config' import { getExplorerInfo, getNetworkId } from 'src/config'
import Table from 'src/components/Table' import Table from 'src/components/Table'
import { cellWidth } from 'src/components/Table/TableHead' import { cellWidth } from 'src/components/Table/TableHead'
import Block from 'src/components/layout/Block' import Block from 'src/components/layout/Block'
import ButtonLink from 'src/components/layout/ButtonLink'
import Col from 'src/components/layout/Col' import Col from 'src/components/layout/Col'
import Img from 'src/components/layout/Img'
import Row from 'src/components/layout/Row' import Row from 'src/components/layout/Row'
import { AddressBookEntry, makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook' import { AddressBookEntry, makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { addAddressBookEntry } from 'src/logic/addressBook/store/actions/addAddressBookEntry' import { addressBookAddOrUpdate, addressBookImport, addressBookRemove } from 'src/logic/addressBook/store/actions'
import { removeAddressBookEntry } from 'src/logic/addressBook/store/actions/removeAddressBookEntry'
import { updateAddressBookEntry } from 'src/logic/addressBook/store/actions/updateAddressBookEntry'
import { addressBookSelector } from 'src/logic/addressBook/store/selectors' import { addressBookSelector } from 'src/logic/addressBook/store/selectors'
import { isUserAnOwnerOfAnySafe, sameAddress } from 'src/logic/wallets/ethAddresses' import { isUserAnOwnerOfAnySafe, sameAddress } from 'src/logic/wallets/ethAddresses'
import { CreateEditEntryModal } from 'src/routes/safe/components/AddressBook/CreateEditEntryModal' import { CreateEditEntryModal } from 'src/routes/safe/components/AddressBook/CreateEditEntryModal'
import { ExportEntriesModal } from 'src/routes/safe/components/AddressBook/ExportEntriesModal'
import { DeleteEntryModal } from 'src/routes/safe/components/AddressBook/DeleteEntryModal' import { DeleteEntryModal } from 'src/routes/safe/components/AddressBook/DeleteEntryModal'
import { import {
AB_NAME_ID,
AB_ADDRESS_ID, AB_ADDRESS_ID,
ADDRESS_BOOK_ROW_ID, ADDRESS_BOOK_ROW_ID,
EDIT_ENTRY_BUTTON,
REMOVE_ENTRY_BUTTON,
SEND_ENTRY_BUTTON, SEND_ENTRY_BUTTON,
generateColumns, generateColumns,
} from 'src/routes/safe/components/AddressBook/columns' } from 'src/routes/safe/components/AddressBook/columns'
import SendModal from 'src/routes/safe/components/Balances/SendModal' import SendModal from 'src/routes/safe/components/Balances/SendModal'
import RenameOwnerIcon from 'src/routes/safe/components/Settings/ManageOwners/assets/icons/rename-owner.svg'
import RemoveOwnerIcon from 'src/routes/safe/components/Settings/assets/icons/bin.svg'
import { addressBookQueryParamsSelector, safesListSelector } from 'src/logic/safe/store/selectors' import { addressBookQueryParamsSelector, safesListSelector } from 'src/logic/safe/store/selectors'
import { checksumAddress } from 'src/utils/checksumAddress' import { checksumAddress } from 'src/utils/checksumAddress'
import { grantedSelector } from 'src/routes/safe/container/selector' import { grantedSelector } from 'src/routes/safe/container/selector'
import { useAnalytics, SAFE_NAVIGATION_EVENT } from 'src/utils/googleAnalytics' import { useAnalytics, SAFE_NAVIGATION_EVENT } from 'src/utils/googleAnalytics'
import ImportEntriesModal from './ImportEntriesModal'
const StyledButton = styled(Button)` const StyledButton = styled(Button)`
&&.MuiButton-root { &&.MuiButton-root {
@ -48,10 +43,22 @@ const StyledButton = styled(Button)`
padding: 0 12px; padding: 0 12px;
min-width: auto; min-width: auto;
} }
svg { svg {
margin: 0 6px 0 0; margin: 0 6px 0 0;
} }
` `
const UnStyledButton = styled.button`
background: none;
color: inherit;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
outline-color: ${({ theme }) => theme.colors.icon};
display: flex;
align-items: center;
`
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
interface AddressBookSelectedEntry extends AddressBookEntry { interface AddressBookSelectedEntry extends AddressBookEntry {
@ -64,7 +71,8 @@ export type Entry = {
isOwnerAddress?: boolean isOwnerAddress?: boolean
} }
const initialEntryState: Entry = { entry: { address: '', name: '', isNew: true } } const chainId = getNetworkId()
const initialEntryState: Entry = { entry: { address: '', name: '', chainId, isNew: true } }
const AddressBookTable = (): ReactElement => { const AddressBookTable = (): ReactElement => {
const classes = useStyles() const classes = useStyles()
@ -77,7 +85,9 @@ const AddressBookTable = (): ReactElement => {
const granted = useSelector(grantedSelector) const granted = useSelector(grantedSelector)
const [selectedEntry, setSelectedEntry] = useState<Entry>(initialEntryState) const [selectedEntry, setSelectedEntry] = useState<Entry>(initialEntryState)
const [editCreateEntryModalOpen, setEditCreateEntryModalOpen] = useState(false) const [editCreateEntryModalOpen, setEditCreateEntryModalOpen] = useState(false)
const [importEntryModalOpen, setImportEntryModalOpen] = useState(false)
const [deleteEntryModalOpen, setDeleteEntryModalOpen] = useState(false) const [deleteEntryModalOpen, setDeleteEntryModalOpen] = useState(false)
const [exportEntriesModalOpen, setExportEntriesModalOpen] = useState(false)
const [sendFundsModalOpen, setSendFundsModalOpen] = useState(false) const [sendFundsModalOpen, setSendFundsModalOpen] = useState(false)
const { trackEvent } = useAnalytics() const { trackEvent } = useAnalytics()
@ -105,6 +115,7 @@ const AddressBookTable = (): ReactElement => {
entry: { entry: {
name: '', name: '',
address, address,
chainId,
isNew: true, isNew: true,
}, },
}) })
@ -113,44 +124,73 @@ const AddressBookTable = (): ReactElement => {
}, [addressBook, entryAddressToEditOrCreateNew]) }, [addressBook, entryAddressToEditOrCreateNew])
const newEntryModalHandler = (entry: AddressBookEntry) => { const newEntryModalHandler = (entry: AddressBookEntry) => {
// close the modal
setEditCreateEntryModalOpen(false) setEditCreateEntryModalOpen(false)
const checksumEntries = { // update the store
...entry, dispatch(addressBookAddOrUpdate(makeAddressBookEntry({ ...entry, address: checksumAddress(entry.address) })))
address: checksumAddress(entry.address),
}
dispatch(addAddressBookEntry(makeAddressBookEntry(checksumEntries)))
} }
const editEntryModalHandler = (entry: AddressBookEntry) => { const editEntryModalHandler = (entry: AddressBookEntry) => {
// reset the form
setSelectedEntry(initialEntryState) setSelectedEntry(initialEntryState)
// close the modal
setEditCreateEntryModalOpen(false) setEditCreateEntryModalOpen(false)
const checksumEntries = { // update the store
...entry, dispatch(addressBookAddOrUpdate(makeAddressBookEntry({ ...entry, address: checksumAddress(entry.address) })))
address: checksumAddress(entry.address),
}
dispatch(updateAddressBookEntry(makeAddressBookEntry(checksumEntries)))
} }
const deleteEntryModalHandler = () => { const deleteEntryModalHandler = () => {
const entryAddress = selectedEntry?.entry ? checksumAddress(selectedEntry.entry.address) : '' // reset the form
setSelectedEntry(initialEntryState) setSelectedEntry(initialEntryState)
// close the modal
setDeleteEntryModalOpen(false) setDeleteEntryModalOpen(false)
dispatch(removeAddressBookEntry(entryAddress)) // update the store
selectedEntry?.entry && dispatch(addressBookRemove(selectedEntry.entry))
}
const importEntryModalHandler = (addressList: AddressBookEntry[]) => {
dispatch(addressBookImport(addressList))
setImportEntryModalOpen(false)
} }
return ( return (
<> <>
<Row align="center" className={classes.message}> <Row align="center" className={classes.message}>
<Col end="sm" xs={12}> <Col end="sm" xs={12}>
<ButtonLink
onClick={() => {
setSelectedEntry(initialEntryState)
setExportEntriesModalOpen(true)
}}
color="primary"
iconType="exportImg"
iconSize="sm"
textSize="xl"
>
Export
</ButtonLink>
<ButtonLink
onClick={() => {
setImportEntryModalOpen(true)
}}
color="primary"
iconType="importImg"
iconSize="sm"
textSize="xl"
>
Import
</ButtonLink>
<ButtonLink <ButtonLink
onClick={() => { onClick={() => {
setSelectedEntry(initialEntryState) setSelectedEntry(initialEntryState)
setEditCreateEntryModalOpen(true) setEditCreateEntryModalOpen(true)
}} }}
size="lg" color="primary"
testId="manage-tokens-btn" iconType="add"
iconSize="sm"
textSize="xl"
> >
+ Create entry Create entry
</ButtonLink> </ButtonLink>
</Col> </Col>
</Row> </Row>
@ -160,6 +200,7 @@ const AddressBookTable = (): ReactElement => {
columns={columns} columns={columns}
data={addressBook} data={addressBook}
defaultFixed defaultFixed
defaultOrderBy={AB_NAME_ID}
defaultRowsPerPage={25} defaultRowsPerPage={25}
disableLoadingOnEmptyTable disableLoadingOnEmptyTable
label="Owners" label="Owners"
@ -196,9 +237,7 @@ const AddressBookTable = (): ReactElement => {
})} })}
<TableCell component="td"> <TableCell component="td">
<Row align="end" className={classes.actions}> <Row align="end" className={classes.actions}>
<Img <UnStyledButton
alt="Edit entry"
className={granted ? classes.editEntryButton : classes.editEntryButtonNonOwner}
onClick={() => { onClick={() => {
setSelectedEntry({ setSelectedEntry({
entry: row, entry: row,
@ -206,19 +245,28 @@ const AddressBookTable = (): ReactElement => {
}) })
setEditCreateEntryModalOpen(true) setEditCreateEntryModalOpen(true)
}} }}
src={RenameOwnerIcon} >
testId={EDIT_ENTRY_BUTTON} <Icon
size="sm"
type="edit"
tooltip="Edit entry"
className={granted ? classes.editEntryButton : classes.editEntryButtonNonOwner}
/> />
<Img </UnStyledButton>
alt="Remove entry" <UnStyledButton
className={granted ? classes.removeEntryButton : classes.removeEntryButtonNonOwner}
onClick={() => { onClick={() => {
setSelectedEntry({ entry: row }) setSelectedEntry({ entry: row })
setDeleteEntryModalOpen(true) setDeleteEntryModalOpen(true)
}} }}
src={RemoveOwnerIcon} >
testId={REMOVE_ENTRY_BUTTON} <Icon
size="sm"
type="delete"
color="error"
tooltip="Delete entry"
className={granted ? classes.removeEntryButton : classes.removeEntryButtonNonOwner}
/> />
</UnStyledButton>
{granted ? ( {granted ? (
<StyledButton <StyledButton
color="primary" color="primary"
@ -258,6 +306,12 @@ const AddressBookTable = (): ReactElement => {
isOpen={deleteEntryModalOpen} isOpen={deleteEntryModalOpen}
onClose={() => setDeleteEntryModalOpen(false)} onClose={() => setDeleteEntryModalOpen(false)}
/> />
<ExportEntriesModal isOpen={exportEntriesModalOpen} onClose={() => setExportEntriesModalOpen(false)} />
<ImportEntriesModal
importEntryModalHandler={importEntryModalHandler}
isOpen={importEntryModalOpen}
onClose={() => setImportEntryModalOpen(false)}
/>
<SendModal <SendModal
activeScreenType="chooseTxType" activeScreenType="chooseTxType"
isOpen={sendFundsModalOpen} isOpen={sendFundsModalOpen}

View File

@ -6,11 +6,8 @@ import { useHistory } from 'react-router-dom'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { INTERFACE_MESSAGES, Transaction, RequestId, LowercaseNetworks } from '@gnosis.pm/safe-apps-sdk-v1' import { INTERFACE_MESSAGES, Transaction, RequestId, LowercaseNetworks } from '@gnosis.pm/safe-apps-sdk-v1'
import { import { safeEthBalanceSelector, safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
safeEthBalanceSelector, import { useSafeName } from 'src/logic/addressBook/hooks/useSafeName'
safeParamAddressFromStateSelector,
safeNameSelector,
} from 'src/logic/safe/store/selectors'
import { grantedSelector } from 'src/routes/safe/container/selector' import { grantedSelector } from 'src/routes/safe/container/selector'
import { getNetworkId, getNetworkName, getTxServiceUrl } from 'src/config' import { getNetworkId, getNetworkName, getTxServiceUrl } from 'src/config'
import { SAFELIST_ADDRESS } from 'src/routes/routes' import { SAFELIST_ADDRESS } from 'src/routes/routes'
@ -89,7 +86,7 @@ const AppFrame = ({ appUrl }: Props): ReactElement => {
const granted = useSelector(grantedSelector) const granted = useSelector(grantedSelector)
const safeAddress = useSelector(safeParamAddressFromStateSelector) const safeAddress = useSelector(safeParamAddressFromStateSelector)
const ethBalance = useSelector(safeEthBalanceSelector) const ethBalance = useSelector(safeEthBalanceSelector)
const safeName = useSelector(safeNameSelector) const safeName = useSafeName(safeAddress)
const { trackEvent } = useAnalytics() const { trackEvent } = useAnalytics()
const history = useHistory() const history = useHistory()
const { consentReceived, onConsentReceipt } = useLegalConsent() const { consentReceived, onConsentReceipt } = useLegalConsent()

View File

@ -12,12 +12,10 @@ import {
} from '@gnosis.pm/safe-apps-sdk-v1' } from '@gnosis.pm/safe-apps-sdk-v1'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
import { useEffect, useCallback, MutableRefObject } from 'react' import { useEffect, useCallback, MutableRefObject } from 'react'
import { getNetworkName, getTxServiceUrl } from 'src/config/' import { getNetworkName, getTxServiceUrl } from 'src/config/'
import { import { useSafeName } from 'src/logic/addressBook/hooks/useSafeName'
safeEthBalanceSelector, import { safeEthBalanceSelector, safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
safeNameSelector,
safeParamAddressFromStateSelector,
} from 'src/logic/safe/store/selectors'
import { TransactionParams } from '../components/AppFrame' import { TransactionParams } from '../components/AppFrame'
import { SafeApp } from 'src/routes/safe/components/Apps/types' import { SafeApp } from 'src/routes/safe/components/Apps/types'
@ -39,8 +37,8 @@ const useIframeMessageHandler = (
iframeRef: MutableRefObject<HTMLIFrameElement | null>, iframeRef: MutableRefObject<HTMLIFrameElement | null>,
): ReturnType => { ): ReturnType => {
const { enqueueSnackbar, closeSnackbar } = useSnackbar() const { enqueueSnackbar, closeSnackbar } = useSnackbar()
const safeName = useSelector(safeNameSelector)
const safeAddress = useSelector(safeParamAddressFromStateSelector) const safeAddress = useSelector(safeParamAddressFromStateSelector)
const safeName = useSafeName(safeAddress)
const ethBalance = useSelector(safeEthBalanceSelector) const ethBalance = useSelector(safeEthBalanceSelector)
const dispatch = useDispatch() const dispatch = useDispatch()

View File

@ -1,14 +1,20 @@
import React from 'react' import React from 'react'
import { useSafeAppUrl } from 'src/logic/hooks/useSafeAppUrl'
import { useLocation } from 'react-router-dom'
import AppFrame from './components/AppFrame' import AppFrame from './components/AppFrame'
import AppsList from './components/AppsList' import AppsList from './components/AppsList'
const Apps = (): React.ReactElement => { const useQuery = () => {
const { url } = useSafeAppUrl() return new URLSearchParams(useLocation().search)
}
if (url) { const Apps = (): React.ReactElement => {
return <AppFrame appUrl={url} /> const query = useQuery()
const appUrl = query.get('appUrl')
if (appUrl) {
return <AppFrame appUrl={appUrl} />
} else { } else {
return <AppsList /> return <AppsList />
} }

View File

@ -4,11 +4,12 @@ import { EthHashInfo } from '@gnosis.pm/safe-react-components'
import styled from 'styled-components' import styled from 'styled-components'
import { getExplorerInfo, getNetworkInfo } from 'src/config' import { getExplorerInfo, getNetworkInfo } from 'src/config'
import { safeSelector } from 'src/logic/safe/store/selectors' import { safeNameSelector, safeSelector } from 'src/logic/safe/store/selectors'
import Paragraph from 'src/components/layout/Paragraph' import Paragraph from 'src/components/layout/Paragraph'
import Bold from 'src/components/layout/Bold' import Bold from 'src/components/layout/Bold'
import { border, xs } from 'src/theme/variables' import { border, xs } from 'src/theme/variables'
import Block from 'src/components/layout/Block' import Block from 'src/components/layout/Block'
const { nativeCoin } = getNetworkInfo() const { nativeCoin } = getNetworkInfo()
const StyledBlock = styled(Block)` const StyledBlock = styled(Block)`
@ -24,7 +25,8 @@ const StyledBlock = styled(Block)`
` `
const SafeInfo = (): React.ReactElement => { const SafeInfo = (): React.ReactElement => {
const { address: safeAddress = '', ethBalance, name: safeName } = useSelector(safeSelector) || {} const { address: safeAddress = '', ethBalance } = useSelector(safeSelector) || {}
const safeName = useSelector((state) => safeNameSelector(state, safeAddress))
return ( return (
<> <>

View File

@ -5,7 +5,7 @@ import React, { Dispatch, ReactElement, SetStateAction, useEffect, useState } fr
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { mustBeEthereumAddress, mustBeEthereumContractAddress } from 'src/components/forms/validator' import { mustBeEthereumAddress, mustBeEthereumContractAddress } from 'src/components/forms/validator'
import { isFeatureEnabled } from 'src/config' import { getNetworkId, isFeatureEnabled } from 'src/config'
import { FEATURES } from 'src/config/networks/network.d' import { FEATURES } from 'src/config/networks/network.d'
import { AddressBookEntry } from 'src/logic/addressBook/model/addressBook' import { AddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { addressBookSelector } from 'src/logic/addressBook/store/selectors' import { addressBookSelector } from 'src/logic/addressBook/store/selectors'
@ -18,6 +18,8 @@ import {
} from 'src/routes/safe/components/Balances/SendModal/screens/AddressBookInput/style' } from 'src/routes/safe/components/Balances/SendModal/screens/AddressBookInput/style'
import { trimSpaces } from 'src/utils/strings' import { trimSpaces } from 'src/utils/strings'
const chainId = getNetworkId()
export interface AddressBookProps { export interface AddressBookProps {
fieldMutator: (address: string) => void fieldMutator: (address: string) => void
label?: string label?: string
@ -65,8 +67,8 @@ const BaseAddressBookInput = ({
const onChange: AutocompleteProps<AddressBookEntry, false, false, true>['onChange'] = (_, value, reason) => { const onChange: AutocompleteProps<AddressBookEntry, false, false, true>['onChange'] = (_, value, reason) => {
switch (reason) { switch (reason) {
case 'select-option': { case 'select-option': {
const { address, name } = value as AddressBookEntry const { address, name, chainId } = value as AddressBookEntry
updateAddressInfo({ address, name }) updateAddressInfo({ address, name, chainId })
break break
} }
} }
@ -99,7 +101,14 @@ const BaseAddressBookInput = ({
break break
} }
const newEntry = typeof validatedAddress === 'string' ? { address, name: normalizedValue } : validatedAddress const newEntry =
typeof validatedAddress === 'string'
? {
address,
name: normalizedValue,
chainId,
}
: validatedAddress
updateAddressInfo(newEntry) updateAddressInfo(newEntry)
break break
@ -114,7 +123,13 @@ const BaseAddressBookInput = ({
} }
const newEntry = const newEntry =
typeof validatedAddress === 'string' ? { address: validatedAddress, name: '' } : validatedAddress typeof validatedAddress === 'string'
? {
address: validatedAddress,
name: '',
chainId,
}
: validatedAddress
updateAddressInfo(newEntry) updateAddressInfo(newEntry)

View File

@ -27,6 +27,7 @@ import {
getValueFromTxInputs, getValueFromTxInputs,
} from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/utils' } from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/utils'
import { useEstimateTransactionGas, EstimationStatus } from 'src/logic/hooks/useEstimateTransactionGas' import { useEstimateTransactionGas, EstimationStatus } from 'src/logic/hooks/useEstimateTransactionGas'
import { getNameFromAddressBookSelector } from 'src/logic/addressBook/store/selectors'
import { useEstimationStatus } from 'src/logic/hooks/useEstimationStatus' import { useEstimationStatus } from 'src/logic/hooks/useEstimationStatus'
import { ButtonStatus, Modal } from 'src/components/Modal' import { ButtonStatus, Modal } from 'src/components/Modal'
import { TransactionFees } from 'src/components/TransactionsFees' import { TransactionFees } from 'src/components/TransactionsFees'
@ -60,6 +61,9 @@ const ContractInteractionReview = ({ onClose, onPrev, tx }: Props): React.ReactE
const [manualSafeTxGas, setManualSafeTxGas] = useState(0) const [manualSafeTxGas, setManualSafeTxGas] = useState(0)
const [manualGasPrice, setManualGasPrice] = useState<string | undefined>() const [manualGasPrice, setManualGasPrice] = useState<string | undefined>()
const [manualGasLimit, setManualGasLimit] = useState<string | undefined>() const [manualGasLimit, setManualGasLimit] = useState<string | undefined>()
const addressName = useSelector((state) =>
getNameFromAddressBookSelector(state, { address: tx.contractAddress as string }),
)
const [txInfo, setTxInfo] = useState<{ const [txInfo, setTxInfo] = useState<{
txRecipient: string txRecipient: string
@ -154,7 +158,13 @@ const ContractInteractionReview = ({ onClose, onPrev, tx }: Props): React.ReactE
</Paragraph> </Paragraph>
</Row> </Row>
<Row align="center" margin="md"> <Row align="center" margin="md">
<EthHashInfo hash={tx.contractAddress as string} showAvatar showCopyBtn explorerUrl={explorerUrl} /> <EthHashInfo
hash={tx.contractAddress as string}
name={addressName}
showAvatar
showCopyBtn
explorerUrl={explorerUrl}
/>
</Row> </Row>
<Row margin="xs"> <Row margin="xs">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}> <Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>

View File

@ -35,6 +35,7 @@ const useStyles = makeStyles(styles)
export type CollectibleTx = { export type CollectibleTx = {
recipientAddress: string recipientAddress: string
recipientName?: string
assetAddress: string assetAddress: string
assetName: string assetName: string
nftTokenId: string nftTokenId: string
@ -177,6 +178,7 @@ const ReviewCollectible = ({ onClose, onPrev, tx }: Props): React.ReactElement =
<Col xs={12}> <Col xs={12}>
<EthHashInfo <EthHashInfo
hash={tx.recipientAddress} hash={tx.recipientAddress}
name={tx.recipientName}
showAvatar showAvatar
showCopyBtn showCopyBtn
explorerUrl={getExplorerInfo(tx.recipientAddress)} explorerUrl={getExplorerInfo(tx.recipientAddress)}

View File

@ -57,6 +57,7 @@ export type SendCollectibleTxInfo = {
assetName: string assetName: string
nftTokenId: string nftTokenId: string
recipientAddress?: string recipientAddress?: string
recipientName?: string
} }
const SendCollectible = ({ const SendCollectible = ({
@ -106,7 +107,7 @@ const SendCollectible = ({
if (!values.recipientAddress) { if (!values.recipientAddress) {
values.recipientAddress = selectedEntry?.address values.recipientAddress = selectedEntry?.address
} }
values.recipientName = selectedEntry?.name
values.assetName = nftAssets[values.assetAddress].name values.assetName = nftAssets[values.assetAddress].name
onNext(values) onNext(values)

View File

@ -1,5 +1,5 @@
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import React, { useEffect, useState } from 'react' import React, { ReactElement, useEffect, useState } from 'react'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import ReceiveModal from 'src/components/App/ReceiveModal' import ReceiveModal from 'src/components/App/ReceiveModal'
@ -13,11 +13,8 @@ import Row from 'src/components/layout/Row'
import { SAFELIST_ADDRESS } from 'src/routes/routes' import { SAFELIST_ADDRESS } from 'src/routes/routes'
import SendModal from 'src/routes/safe/components/Balances/SendModal' import SendModal from 'src/routes/safe/components/Balances/SendModal'
import { CurrencyDropdown } from 'src/routes/safe/components/CurrencyDropdown' import { CurrencyDropdown } from 'src/routes/safe/components/CurrencyDropdown'
import { import { useSafeName } from 'src/logic/addressBook/hooks/useSafeName'
safeFeaturesEnabledSelector, import { safeFeaturesEnabledSelector, safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
safeNameSelector,
safeParamAddressFromStateSelector,
} from 'src/logic/safe/store/selectors'
import { wrapInSuspense } from 'src/utils/wrapInSuspense' import { wrapInSuspense } from 'src/utils/wrapInSuspense'
import { useFetchTokens } from 'src/logic/safe/hooks/useFetchTokens' import { useFetchTokens } from 'src/logic/safe/hooks/useFetchTokens'
@ -45,13 +42,13 @@ export const COLLECTIBLES_LOCATION_REGEX = /\/balances\/collectibles$/
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
const Balances = (): React.ReactElement => { const Balances = (): ReactElement => {
const classes = useStyles() const classes = useStyles()
const [state, setState] = useState(INITIAL_STATE) const [state, setState] = useState(INITIAL_STATE)
const address = useSelector(safeParamAddressFromStateSelector) const address = useSelector(safeParamAddressFromStateSelector)
const featuresEnabled = useSelector(safeFeaturesEnabledSelector) const featuresEnabled = useSelector(safeFeaturesEnabledSelector)
const safeName = useSelector(safeNameSelector) ?? '' const safeName = useSafeName(address)
useFetchTokens(address) useFetchTokens(address)

View File

@ -2,10 +2,9 @@ import React, { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
import Modal from 'src/components/Modal' import Modal from 'src/components/Modal'
import { addOrUpdateAddressBookEntry } from 'src/logic/addressBook/store/actions/addOrUpdateAddressBookEntry' import { addressBookAddOrUpdate } from 'src/logic/addressBook/store/actions'
import { getGnosisSafeInstanceAt } from 'src/logic/contracts/safeContracts' import { getGnosisSafeInstanceAt } from 'src/logic/contracts/safeContracts'
import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions' import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
import { addSafeOwner } from 'src/logic/safe/store/actions/addSafeOwner'
import { createTransaction } from 'src/logic/safe/store/actions/createTransaction' import { createTransaction } from 'src/logic/safe/store/actions/createTransaction'
import { safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors' import { safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import { checksumAddress } from 'src/utils/checksumAddress' import { checksumAddress } from 'src/utils/checksumAddress'
@ -46,7 +45,7 @@ export const sendAddOwner = async (
) )
if (txHash) { if (txHash) {
dispatch(addSafeOwner({ safeAddress, ownerName: values.ownerName, ownerAddress: values.ownerAddress })) dispatch(addressBookAddOrUpdate(makeAddressBookEntry({ address: values.ownerAddress, name: values.ownerName })))
} }
} }
@ -99,9 +98,7 @@ export const AddOwnerModal = ({ isOpen, onClose }: Props): React.ReactElement =>
try { try {
await sendAddOwner(values, safeAddress, txParameters, dispatch) await sendAddOwner(values, safeAddress, txParameters, dispatch)
dispatch( dispatch(addressBookAddOrUpdate(makeAddressBookEntry({ name: values.ownerName, address: values.ownerAddress })))
addOrUpdateAddressBookEntry(makeAddressBookEntry({ name: values.ownerName, address: values.ownerAddress })),
)
} catch (error) { } catch (error) {
console.error('Error while removing an owner', error) console.error('Error while removing an owner', error)
} }

View File

@ -1,11 +1,14 @@
import IconButton from '@material-ui/core/IconButton' import IconButton from '@material-ui/core/IconButton'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close' import Close from '@material-ui/icons/Close'
import { Mutator } from 'final-form'
import React from 'react' import React from 'react'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { OnChange } from 'react-final-form-listeners'
import { styles } from './style' import { styles } from './style'
import { getNetworkId } from 'src/config'
import { ScanQRWrapper } from 'src/components/ScanQRModal/ScanQRWrapper' import { ScanQRWrapper } from 'src/components/ScanQRModal/ScanQRWrapper'
import AddressInput from 'src/components/forms/AddressInput' import AddressInput from 'src/components/forms/AddressInput'
import Field from 'src/components/forms/Field' import Field from 'src/components/forms/Field'
@ -14,16 +17,18 @@ import TextField from 'src/components/forms/TextField'
import { import {
addressIsNotCurrentSafe, addressIsNotCurrentSafe,
composeValidators, composeValidators,
minMaxLength,
required, required,
uniqueAddress, uniqueAddress,
validAddressBookName,
} from 'src/components/forms/validator' } from 'src/components/forms/validator'
import Block from 'src/components/layout/Block' import Block from 'src/components/layout/Block'
import Col from 'src/components/layout/Col' import Col from 'src/components/layout/Col'
import Hairline from 'src/components/layout/Hairline' import Hairline from 'src/components/layout/Hairline'
import Paragraph from 'src/components/layout/Paragraph' import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row' import Row from 'src/components/layout/Row'
import { safeOwnersAddressesListSelector, safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors' import { addressBookMapSelector } from 'src/logic/addressBook/store/selectors'
import { safeOwnersSelector, safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import { web3ReadOnly } from 'src/logic/wallets/getWeb3'
import { OwnerValues } from '../..' import { OwnerValues } from '../..'
import { Modal } from 'src/components/Modal' import { Modal } from 'src/components/Modal'
@ -32,14 +37,22 @@ export const ADD_OWNER_NAME_INPUT_TEST_ID = 'add-owner-name-input'
export const ADD_OWNER_ADDRESS_INPUT_TEST_ID = 'add-owner-address-testid' export const ADD_OWNER_ADDRESS_INPUT_TEST_ID = 'add-owner-address-testid'
export const ADD_OWNER_NEXT_BTN_TEST_ID = 'add-owner-next-btn' export const ADD_OWNER_NEXT_BTN_TEST_ID = 'add-owner-next-btn'
const formMutators = { const formMutators: Record<
string,
Mutator<{ setOwnerAddress: { address: string }; setOwnerName: { name: string } }>
> = {
setOwnerAddress: (args, state, utils) => { setOwnerAddress: (args, state, utils) => {
utils.changeValue(state, 'ownerAddress', () => args[0]) utils.changeValue(state, 'ownerAddress', () => args[0])
}, },
setOwnerName: (args, state, utils) => {
utils.changeValue(state, 'ownerName', () => args[0])
},
} }
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
const chainId = getNetworkId()
type OwnerFormProps = { type OwnerFormProps = {
onClose: () => void onClose: () => void
onSubmit: (values) => void onSubmit: (values) => void
@ -51,7 +64,8 @@ export const OwnerForm = ({ onClose, onSubmit, initialValues }: OwnerFormProps):
const handleSubmit = (values) => { const handleSubmit = (values) => {
onSubmit(values) onSubmit(values)
} }
const owners = useSelector(safeOwnersAddressesListSelector) const addressBookMap = useSelector(addressBookMapSelector)
const owners = useSelector(safeOwnersSelector)
const safeAddress = useSelector(safeParamAddressFromStateSelector) const safeAddress = useSelector(safeParamAddressFromStateSelector)
const ownerDoesntExist = uniqueAddress(owners) const ownerDoesntExist = uniqueAddress(owners)
const ownerAddressIsNotSafeAddress = addressIsNotCurrentSafe(safeAddress) const ownerAddressIsNotSafeAddress = addressIsNotCurrentSafe(safeAddress)
@ -104,8 +118,18 @@ export const OwnerForm = ({ onClose, onSubmit, initialValues }: OwnerFormProps):
testId={ADD_OWNER_NAME_INPUT_TEST_ID} testId={ADD_OWNER_NAME_INPUT_TEST_ID}
text="Owner name*" text="Owner name*"
type="text" type="text"
validate={composeValidators(required, minMaxLength(1, 50))} validate={composeValidators(required, validAddressBookName)}
/> />
<OnChange name="ownerAddress">
{async (address: string) => {
if (web3ReadOnly.utils.isAddress(address)) {
const { name: ownerName } = addressBookMap[chainId][address]
if (ownerName) {
mutators.setOwnerName(ownerName)
}
}
}}
</OnChange>
</Col> </Col>
</Row> </Row>
<Row margin="md"> <Row margin="md">

View File

@ -1,11 +1,11 @@
import IconButton from '@material-ui/core/IconButton' import IconButton from '@material-ui/core/IconButton'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close' import Close from '@material-ui/icons/Close'
import React, { useEffect, useState } from 'react' import React, { ReactElement, useEffect, useState } from 'react'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { EthHashInfo } from '@gnosis.pm/safe-react-components' import { EthHashInfo } from '@gnosis.pm/safe-react-components'
import { getExplorerInfo } from 'src/config' import { getExplorerInfo, getNetworkId } from 'src/config'
import Block from 'src/components/layout/Block' import Block from 'src/components/layout/Block'
import Col from 'src/components/layout/Col' import Col from 'src/components/layout/Col'
import Hairline from 'src/components/layout/Hairline' import Hairline from 'src/components/layout/Hairline'
@ -13,7 +13,11 @@ import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row' import Row from 'src/components/layout/Row'
import { getGnosisSafeInstanceAt } from 'src/logic/contracts/safeContracts' import { getGnosisSafeInstanceAt } from 'src/logic/contracts/safeContracts'
import { useEstimationStatus } from 'src/logic/hooks/useEstimationStatus' import { useEstimationStatus } from 'src/logic/hooks/useEstimationStatus'
import { safeNameSelector, safeOwnersSelector, safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors' import { useSafeName } from 'src/logic/addressBook/hooks/useSafeName'
import {
safeOwnersWithAddressBookDataSelector,
safeParamAddressFromStateSelector,
} from 'src/logic/safe/store/selectors'
import { TxParametersDetail } from 'src/routes/safe/components/Transactions/helpers/TxParametersDetail' import { TxParametersDetail } from 'src/routes/safe/components/Transactions/helpers/TxParametersDetail'
import { TxParameters } from 'src/routes/safe/container/hooks/useTransactionParameters' import { TxParameters } from 'src/routes/safe/container/hooks/useTransactionParameters'
import { EstimationStatus, useEstimateTransactionGas } from 'src/logic/hooks/useEstimateTransactionGas' import { EstimationStatus, useEstimateTransactionGas } from 'src/logic/hooks/useEstimateTransactionGas'
@ -28,6 +32,8 @@ export const ADD_OWNER_SUBMIT_BTN_TEST_ID = 'add-owner-submit-btn'
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
const chainId = getNetworkId()
type ReviewAddOwnerProps = { type ReviewAddOwnerProps = {
onClickBack: () => void onClickBack: () => void
onClose: () => void onClose: () => void
@ -35,12 +41,12 @@ type ReviewAddOwnerProps = {
values: OwnerValues values: OwnerValues
} }
export const ReviewAddOwner = ({ onClickBack, onClose, onSubmit, values }: ReviewAddOwnerProps): React.ReactElement => { export const ReviewAddOwner = ({ onClickBack, onClose, onSubmit, values }: ReviewAddOwnerProps): ReactElement => {
const classes = useStyles() const classes = useStyles()
const [data, setData] = useState('') const [data, setData] = useState('')
const safeAddress = useSelector(safeParamAddressFromStateSelector) as string const safeAddress = useSelector(safeParamAddressFromStateSelector) as string
const safeName = useSelector(safeNameSelector) const safeName = useSafeName(safeAddress)
const owners = useSelector(safeOwnersSelector) const owners = useSelector((state) => safeOwnersWithAddressBookDataSelector(state, chainId))
const [manualSafeTxGas, setManualSafeTxGas] = useState(0) const [manualSafeTxGas, setManualSafeTxGas] = useState(0)
const [manualGasPrice, setManualGasPrice] = useState<string | undefined>() const [manualGasPrice, setManualGasPrice] = useState<string | undefined>()
const [manualGasLimit, setManualGasLimit] = useState<string | undefined>() const [manualGasLimit, setManualGasLimit] = useState<string | undefined>()
@ -148,7 +154,7 @@ export const ReviewAddOwner = ({ onClickBack, onClose, onSubmit, values }: Revie
Any transaction requires the confirmation of: Any transaction requires the confirmation of:
</Paragraph> </Paragraph>
<Paragraph className={classes.name} color="primary" noMargin size="lg" weight="bolder"> <Paragraph className={classes.name} color="primary" noMargin size="lg" weight="bolder">
{`${values.threshold} out of ${(owners?.size || 0) + 1} owner(s)`} {`${values.threshold} out of ${(owners?.length || 0) + 1} owner(s)`}
</Paragraph> </Paragraph>
</Block> </Block>
</Block> </Block>
@ -156,7 +162,7 @@ export const ReviewAddOwner = ({ onClickBack, onClose, onSubmit, values }: Revie
<Col className={classes.owners} layout="column" xs={8}> <Col className={classes.owners} layout="column" xs={8}>
<Row className={classes.ownersTitle}> <Row className={classes.ownersTitle}>
<Paragraph color="primary" noMargin size="lg"> <Paragraph color="primary" noMargin size="lg">
{`${(owners?.size || 0) + 1} Safe owner(s)`} {`${(owners?.length || 0) + 1} Safe owner(s)`}
</Paragraph> </Paragraph>
</Row> </Row>
<Hairline /> <Hairline />

View File

@ -42,6 +42,7 @@ export const ThresholdForm = ({ onClickBack, onClose, onSubmit, initialValues }:
const classes = useStyles() const classes = useStyles()
const threshold = useSelector(safeThresholdSelector) as number const threshold = useSelector(safeThresholdSelector) as number
const owners = useSelector(safeOwnersSelector) const owners = useSelector(safeOwnersSelector)
const numOptions = owners ? owners.length + 1 : 0
const handleSubmit = (values: SubmitProps) => { const handleSubmit = (values: SubmitProps) => {
onSubmit(values) onSubmit(values)
@ -79,7 +80,7 @@ export const ThresholdForm = ({ onClickBack, onClose, onSubmit, initialValues }:
render={(props) => ( render={(props) => (
<> <>
<SelectField {...props} disableError> <SelectField {...props} disableError>
{[...Array(Number(owners ? owners.size + 1 : 0))].map((x, index) => ( {[...Array(Number(numOptions))].map((x, index) => (
<MenuItem key={index} value={`${index + 1}`}> <MenuItem key={index} value={`${index + 1}`}>
{index + 1} {index + 1}
</MenuItem> </MenuItem>
@ -92,17 +93,12 @@ export const ThresholdForm = ({ onClickBack, onClose, onSubmit, initialValues }:
)} )}
</> </>
)} )}
validate={composeValidators( validate={composeValidators(required, mustBeInteger, minValue(1), maxValue(numOptions))}
required,
mustBeInteger,
minValue(1),
maxValue(owners ? owners.size + 1 : 0),
)}
/> />
</Col> </Col>
<Col xs={10}> <Col xs={10}>
<Paragraph className={classes.ownersText} color="primary" noMargin size="lg"> <Paragraph className={classes.ownersText} color="primary" noMargin size="lg">
out of {owners ? owners.size + 1 : 0} owner(s) out of {numOptions} owner(s)
</Paragraph> </Paragraph>
</Col> </Col>
</Row> </Row>

View File

@ -1,23 +1,22 @@
import IconButton from '@material-ui/core/IconButton' import IconButton from '@material-ui/core/IconButton'
import Close from '@material-ui/icons/Close' import Close from '@material-ui/icons/Close'
import React from 'react' import React from 'react'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch } from 'react-redux'
import Field from 'src/components/forms/Field' import Field from 'src/components/forms/Field'
import GnoForm from 'src/components/forms/GnoForm' import GnoForm from 'src/components/forms/GnoForm'
import TextField from 'src/components/forms/TextField' import TextField from 'src/components/forms/TextField'
import { composeValidators, minMaxLength, required } from 'src/components/forms/validator' import { composeValidators, required, validAddressBookName } from 'src/components/forms/validator'
import Block from 'src/components/layout/Block' import Block from 'src/components/layout/Block'
import Hairline from 'src/components/layout/Hairline' import Hairline from 'src/components/layout/Hairline'
import Paragraph from 'src/components/layout/Paragraph' import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row' import Row from 'src/components/layout/Row'
import Modal, { Modal as GenericModal } from 'src/components/Modal' import Modal, { Modal as GenericModal } from 'src/components/Modal'
import { makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook' import { makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { addOrUpdateAddressBookEntry } from 'src/logic/addressBook/store/actions/addOrUpdateAddressBookEntry' import { addressBookAddOrUpdate } from 'src/logic/addressBook/store/actions'
import { NOTIFICATIONS } from 'src/logic/notifications' import { NOTIFICATIONS } from 'src/logic/notifications'
import enqueueSnackbar from 'src/logic/notifications/store/actions/enqueueSnackbar' import enqueueSnackbar from 'src/logic/notifications/store/actions/enqueueSnackbar'
import editSafeOwner from 'src/logic/safe/store/actions/editSafeOwner' import { OwnerData } from 'src/routes/safe/components/Settings/ManageOwners/dataFetcher'
import { safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import { useStyles } from './style' import { useStyles } from './style'
import { getExplorerInfo } from 'src/config' import { getExplorerInfo } from 'src/config'
@ -29,20 +28,17 @@ export const SAVE_OWNER_CHANGES_BTN_TEST_ID = 'save-owner-changes-btn'
type OwnProps = { type OwnProps = {
isOpen: boolean isOpen: boolean
onClose: () => void onClose: () => void
ownerAddress: string owner: OwnerData
selectedOwnerName: string
} }
export const EditOwnerModal = ({ isOpen, onClose, ownerAddress, selectedOwnerName }: OwnProps): React.ReactElement => { export const EditOwnerModal = ({ isOpen, onClose, owner }: OwnProps): React.ReactElement => {
const classes = useStyles() const classes = useStyles()
const dispatch = useDispatch() const dispatch = useDispatch()
const safeAddress = useSelector(safeParamAddressFromStateSelector)
const handleSubmit = ({ ownerName }: { ownerName: string }): void => { const handleSubmit = ({ ownerName }: { ownerName: string }): void => {
// Update the value only if the ownerName really changed // Update the value only if the ownerName really changed
if (ownerName !== selectedOwnerName) { if (ownerName !== owner.name) {
dispatch(editSafeOwner({ safeAddress, ownerAddress, ownerName })) dispatch(addressBookAddOrUpdate(makeAddressBookEntry({ address: owner.address, name: ownerName })))
dispatch(addOrUpdateAddressBookEntry(makeAddressBookEntry({ address: ownerAddress, name: ownerName })))
dispatch(enqueueSnackbar(NOTIFICATIONS.OWNER_NAME_CHANGE_EXECUTED_MSG)) dispatch(enqueueSnackbar(NOTIFICATIONS.OWNER_NAME_CHANGE_EXECUTED_MSG))
} }
onClose() onClose()
@ -74,22 +70,22 @@ export const EditOwnerModal = ({ isOpen, onClose, ownerAddress, selectedOwnerNam
<Row margin="md"> <Row margin="md">
<Field <Field
component={TextField} component={TextField}
initialValue={selectedOwnerName} initialValue={owner.name}
name="ownerName" name="ownerName"
placeholder="Owner name*" placeholder="Owner name*"
testId={RENAME_OWNER_INPUT_TEST_ID} testId={RENAME_OWNER_INPUT_TEST_ID}
text="Owner name*" text="Owner name*"
type="text" type="text"
validate={composeValidators(required, minMaxLength(1, 50))} validate={composeValidators(required, validAddressBookName)}
/> />
</Row> </Row>
<Row> <Row>
<Block justify="center"> <Block justify="center">
<EthHashInfo <EthHashInfo
hash={ownerAddress} hash={owner.address}
showCopyBtn showCopyBtn
showAvatar showAvatar
explorerUrl={getExplorerInfo(ownerAddress)} explorerUrl={getExplorerInfo(owner.address)}
/> />
</Block> </Block>
</Row> </Row>

View File

@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
import { OwnerData } from 'src/routes/safe/components/Settings/ManageOwners/dataFetcher'
import { CheckOwner } from './screens/CheckOwner' import { CheckOwner } from './screens/CheckOwner'
import { ReviewRemoveOwnerModal } from './screens/Review' import { ReviewRemoveOwnerModal } from './screens/Review'
@ -9,14 +10,11 @@ import Modal from 'src/components/Modal'
import { SENTINEL_ADDRESS, getGnosisSafeInstanceAt } from 'src/logic/contracts/safeContracts' import { SENTINEL_ADDRESS, getGnosisSafeInstanceAt } from 'src/logic/contracts/safeContracts'
import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions' import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
import { createTransaction } from 'src/logic/safe/store/actions/createTransaction' import { createTransaction } from 'src/logic/safe/store/actions/createTransaction'
import { removeSafeOwner } from 'src/logic/safe/store/actions/removeSafeOwner' import { safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import { safeParamAddressFromStateSelector, safeThresholdSelector } from 'src/logic/safe/store/selectors'
import { Dispatch } from 'src/logic/safe/store/actions/types.d' import { Dispatch } from 'src/logic/safe/store/actions/types.d'
import { TxParameters } from 'src/routes/safe/container/hooks/useTransactionParameters' import { TxParameters } from 'src/routes/safe/container/hooks/useTransactionParameters'
type OwnerValues = { type OwnerValues = OwnerData & {
ownerAddress: string
ownerName: string
threshold: string threshold: string
} }
@ -27,7 +25,6 @@ export const sendRemoveOwner = async (
ownerNameToRemove: string, ownerNameToRemove: string,
dispatch: Dispatch, dispatch: Dispatch,
txParameters: TxParameters, txParameters: TxParameters,
threshold?: number,
): Promise<void> => { ): Promise<void> => {
const gnosisSafe = getGnosisSafeInstanceAt(safeAddress) const gnosisSafe = getGnosisSafeInstanceAt(safeAddress)
const safeOwners = await gnosisSafe.methods.getOwners().call() const safeOwners = await gnosisSafe.methods.getOwners().call()
@ -37,7 +34,7 @@ export const sendRemoveOwner = async (
const prevAddress = index === 0 ? SENTINEL_ADDRESS : safeOwners[index - 1] const prevAddress = index === 0 ? SENTINEL_ADDRESS : safeOwners[index - 1]
const txData = gnosisSafe.methods.removeOwner(prevAddress, ownerAddressToRemove, values.threshold).encodeABI() const txData = gnosisSafe.methods.removeOwner(prevAddress, ownerAddressToRemove, values.threshold).encodeABI()
const txHash = await dispatch( dispatch(
createTransaction({ createTransaction({
safeAddress, safeAddress,
to: safeAddress, to: safeAddress,
@ -49,30 +46,19 @@ export const sendRemoveOwner = async (
notifiedTransaction: TX_NOTIFICATION_TYPES.SETTINGS_CHANGE_TX, notifiedTransaction: TX_NOTIFICATION_TYPES.SETTINGS_CHANGE_TX,
}), }),
) )
if (txHash && threshold === 1) {
dispatch(removeSafeOwner({ safeAddress, ownerAddress: ownerAddressToRemove }))
}
} }
type RemoveOwnerProps = { type RemoveOwnerProps = {
isOpen: boolean isOpen: boolean
onClose: () => void onClose: () => void
ownerAddress: string owner: OwnerData
ownerName: string
} }
export const RemoveOwnerModal = ({ export const RemoveOwnerModal = ({ isOpen, onClose, owner }: RemoveOwnerProps): React.ReactElement => {
isOpen,
onClose,
ownerAddress,
ownerName,
}: RemoveOwnerProps): React.ReactElement => {
const [activeScreen, setActiveScreen] = useState('checkOwner') const [activeScreen, setActiveScreen] = useState('checkOwner')
const [values, setValues] = useState<OwnerValues>({ ownerAddress, ownerName, threshold: '' }) const [values, setValues] = useState<OwnerValues>({ ...owner, threshold: '' })
const dispatch = useDispatch() const dispatch = useDispatch()
const safeAddress = useSelector(safeParamAddressFromStateSelector) const safeAddress = useSelector(safeParamAddressFromStateSelector)
const threshold = useSelector(safeThresholdSelector) || 1
useEffect( useEffect(
() => () => { () => () => {
@ -101,7 +87,7 @@ export const RemoveOwnerModal = ({
const onRemoveOwner = (txParameters: TxParameters) => { const onRemoveOwner = (txParameters: TxParameters) => {
onClose() onClose()
sendRemoveOwner(values, safeAddress, ownerAddress, ownerName, dispatch, txParameters, threshold) sendRemoveOwner(values, safeAddress, owner.address, owner.name, dispatch, txParameters)
} }
return ( return (
@ -113,9 +99,7 @@ export const RemoveOwnerModal = ({
title="Remove owner from Safe" title="Remove owner from Safe"
> >
<> <>
{activeScreen === 'checkOwner' && ( {activeScreen === 'checkOwner' && <CheckOwner onClose={onClose} onSubmit={ownerSubmitted} owner={owner} />}
<CheckOwner onClose={onClose} onSubmit={ownerSubmitted} ownerAddress={ownerAddress} ownerName={ownerName} />
)}
{activeScreen === 'selectThreshold' && ( {activeScreen === 'selectThreshold' && (
<ThresholdForm <ThresholdForm
onClickBack={onClickBack} onClickBack={onClickBack}
@ -129,8 +113,7 @@ export const RemoveOwnerModal = ({
onClickBack={onClickBack} onClickBack={onClickBack}
onClose={onClose} onClose={onClose}
onSubmit={onRemoveOwner} onSubmit={onRemoveOwner}
ownerAddress={ownerAddress} owner={owner}
ownerName={ownerName}
threshold={Number(values.threshold)} threshold={Number(values.threshold)}
/> />
)} )}

View File

@ -7,6 +7,7 @@ import Col from 'src/components/layout/Col'
import Hairline from 'src/components/layout/Hairline' import Hairline from 'src/components/layout/Hairline'
import Paragraph from 'src/components/layout/Paragraph' import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row' import Row from 'src/components/layout/Row'
import { OwnerData } from 'src/routes/safe/components/Settings/ManageOwners/dataFetcher'
import { useStyles } from './style' import { useStyles } from './style'
import { EthHashInfo } from '@gnosis.pm/safe-react-components' import { EthHashInfo } from '@gnosis.pm/safe-react-components'
@ -18,11 +19,10 @@ export const REMOVE_OWNER_MODAL_NEXT_BTN_TEST_ID = 'remove-owner-next-btn'
interface CheckOwnerProps { interface CheckOwnerProps {
onClose: () => void onClose: () => void
onSubmit: () => void onSubmit: () => void
ownerAddress: string owner: OwnerData
ownerName: string
} }
export const CheckOwner = ({ onClose, onSubmit, ownerAddress, ownerName }: CheckOwnerProps): ReactElement => { export const CheckOwner = ({ onClose, onSubmit, owner }: CheckOwnerProps): ReactElement => {
const classes = useStyles() const classes = useStyles()
return ( return (
@ -44,11 +44,11 @@ export const CheckOwner = ({ onClose, onSubmit, ownerAddress, ownerName }: Check
<Row> <Row>
<Col align="center" xs={12}> <Col align="center" xs={12}>
<EthHashInfo <EthHashInfo
hash={ownerAddress} hash={owner.address}
name={ownerName} name={owner.name}
showCopyBtn showCopyBtn
showAvatar showAvatar
explorerUrl={getExplorerInfo(ownerAddress)} explorerUrl={getExplorerInfo(owner.address)}
/> />
</Col> </Col>
</Row> </Row>

View File

@ -3,21 +3,23 @@ import Close from '@material-ui/icons/Close'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { EthHashInfo } from '@gnosis.pm/safe-react-components' import { EthHashInfo } from '@gnosis.pm/safe-react-components'
import { List } from 'immutable'
import { getExplorerInfo } from 'src/config' import { getExplorerInfo, getNetworkId } from 'src/config'
import Block from 'src/components/layout/Block' import Block from 'src/components/layout/Block'
import Col from 'src/components/layout/Col' import Col from 'src/components/layout/Col'
import Hairline from 'src/components/layout/Hairline' import Hairline from 'src/components/layout/Hairline'
import Paragraph from 'src/components/layout/Paragraph' import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row' import Row from 'src/components/layout/Row'
import { getGnosisSafeInstanceAt, SENTINEL_ADDRESS } from 'src/logic/contracts/safeContracts' import { getGnosisSafeInstanceAt, SENTINEL_ADDRESS } from 'src/logic/contracts/safeContracts'
import { safeNameSelector, safeOwnersSelector, safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors' import {
import { getOwnersWithNameFromAddressBook } from 'src/logic/addressBook/utils' safeOwnersWithAddressBookDataSelector,
import { addressBookSelector } from 'src/logic/addressBook/store/selectors' safeParamAddressFromStateSelector,
} from 'src/logic/safe/store/selectors'
import { useSafeName } from 'src/logic/addressBook/hooks/useSafeName'
import { TxParametersDetail } from 'src/routes/safe/components/Transactions/helpers/TxParametersDetail' import { TxParametersDetail } from 'src/routes/safe/components/Transactions/helpers/TxParametersDetail'
import { EstimationStatus, useEstimateTransactionGas } from 'src/logic/hooks/useEstimateTransactionGas' import { EstimationStatus, useEstimateTransactionGas } from 'src/logic/hooks/useEstimateTransactionGas'
import { TxParameters } from 'src/routes/safe/container/hooks/useTransactionParameters' import { TxParameters } from 'src/routes/safe/container/hooks/useTransactionParameters'
import { OwnerData } from 'src/routes/safe/components/Settings/ManageOwners/dataFetcher'
import { useStyles } from './style' import { useStyles } from './style'
import { Modal } from 'src/components/Modal' import { Modal } from 'src/components/Modal'
@ -28,12 +30,13 @@ import { sameAddress } from 'src/logic/wallets/ethAddresses'
export const REMOVE_OWNER_REVIEW_BTN_TEST_ID = 'remove-owner-review-btn' export const REMOVE_OWNER_REVIEW_BTN_TEST_ID = 'remove-owner-review-btn'
const chainId = getNetworkId()
type ReviewRemoveOwnerProps = { type ReviewRemoveOwnerProps = {
onClickBack: () => void onClickBack: () => void
onClose: () => void onClose: () => void
onSubmit: (txParameters: TxParameters) => void onSubmit: (txParameters: TxParameters) => void
ownerAddress: string owner: OwnerData
ownerName: string
threshold?: number threshold?: number
} }
@ -41,17 +44,15 @@ export const ReviewRemoveOwnerModal = ({
onClickBack, onClickBack,
onClose, onClose,
onSubmit, onSubmit,
ownerAddress, owner,
ownerName,
threshold = 1, threshold = 1,
}: ReviewRemoveOwnerProps): React.ReactElement => { }: ReviewRemoveOwnerProps): React.ReactElement => {
const classes = useStyles() const classes = useStyles()
const [data, setData] = useState('') const [data, setData] = useState('')
const safeAddress = useSelector(safeParamAddressFromStateSelector) const safeAddress = useSelector(safeParamAddressFromStateSelector)
const safeName = useSelector(safeNameSelector) const safeName = useSafeName(safeAddress)
const owners = useSelector(safeOwnersSelector) const owners = useSelector((state) => safeOwnersWithAddressBookDataSelector(state, chainId))
const addressBook = useSelector(addressBookSelector) const numOptions = owners ? owners.length - 1 : 0
const ownersWithAddressBookName = owners ? getOwnersWithNameFromAddressBook(addressBook, owners) : List([])
const [manualSafeTxGas, setManualSafeTxGas] = useState(0) const [manualSafeTxGas, setManualSafeTxGas] = useState(0)
const [manualGasPrice, setManualGasPrice] = useState<string | undefined>() const [manualGasPrice, setManualGasPrice] = useState<string | undefined>()
const [manualGasLimit, setManualGasLimit] = useState<string | undefined>() const [manualGasLimit, setManualGasLimit] = useState<string | undefined>()
@ -85,11 +86,13 @@ export const ReviewRemoveOwnerModal = ({
const calculateRemoveOwnerData = async () => { const calculateRemoveOwnerData = async () => {
try { try {
// FixMe: if the order returned by the service is the same as in the contracts
// the data lookup can be removed from here
const gnosisSafe = getGnosisSafeInstanceAt(safeAddress) const gnosisSafe = getGnosisSafeInstanceAt(safeAddress)
const safeOwners = await gnosisSafe.methods.getOwners().call() const safeOwners = await gnosisSafe.methods.getOwners().call()
const index = safeOwners.findIndex((owner) => sameAddress(owner, ownerAddress)) const index = safeOwners.findIndex((ownerAddress) => sameAddress(ownerAddress, owner.address))
const prevAddress = index === 0 ? SENTINEL_ADDRESS : safeOwners[index - 1] const prevAddress = index === 0 ? SENTINEL_ADDRESS : safeOwners[index - 1]
const txData = gnosisSafe.methods.removeOwner(prevAddress, ownerAddress, threshold).encodeABI() const txData = gnosisSafe.methods.removeOwner(prevAddress, owner.address, threshold).encodeABI()
if (isCurrent) { if (isCurrent) {
setData(txData) setData(txData)
@ -103,7 +106,7 @@ export const ReviewRemoveOwnerModal = ({
return () => { return () => {
isCurrent = false isCurrent = false
} }
}, [safeAddress, ownerAddress, threshold]) }, [safeAddress, owner.address, threshold])
const closeEditModalCallback = (txParameters: TxParameters) => { const closeEditModalCallback = (txParameters: TxParameters) => {
const oldGasPrice = Number(gasPriceFormatted) const oldGasPrice = Number(gasPriceFormatted)
@ -168,7 +171,7 @@ export const ReviewRemoveOwnerModal = ({
Any transaction requires the confirmation of: Any transaction requires the confirmation of:
</Paragraph> </Paragraph>
<Paragraph className={classes.name} color="primary" noMargin size="lg" weight="bolder"> <Paragraph className={classes.name} color="primary" noMargin size="lg" weight="bolder">
{`${threshold} out of ${owners ? owners.size - 1 : 0} owner(s)`} {`${threshold} out of ${numOptions} owner(s)`}
</Paragraph> </Paragraph>
</Block> </Block>
</Block> </Block>
@ -177,22 +180,22 @@ export const ReviewRemoveOwnerModal = ({
<Col className={classes.owners} layout="column" xs={8}> <Col className={classes.owners} layout="column" xs={8}>
<Row className={classes.ownersTitle}> <Row className={classes.ownersTitle}>
<Paragraph color="primary" noMargin size="lg"> <Paragraph color="primary" noMargin size="lg">
{`${owners ? owners.size - 1 : 0} Safe owner(s)`} {`${numOptions} Safe owner(s)`}
</Paragraph> </Paragraph>
</Row> </Row>
<Hairline /> <Hairline />
{ownersWithAddressBookName?.map( {owners?.map(
(owner) => (safeOwner) =>
owner.address !== ownerAddress && ( !sameAddress(safeOwner.address, owner.address) && (
<React.Fragment key={owner.address}> <React.Fragment key={safeOwner.address}>
<Row className={classes.owner}> <Row className={classes.owner}>
<Col align="center" xs={12}> <Col align="center" xs={12}>
<EthHashInfo <EthHashInfo
hash={owner.address} hash={safeOwner.address}
name={owner.name} name={safeOwner.name}
showCopyBtn showCopyBtn
showAvatar showAvatar
explorerUrl={getExplorerInfo(owner.address)} explorerUrl={getExplorerInfo(safeOwner.address)}
/> />
</Col> </Col>
</Row> </Row>
@ -209,11 +212,11 @@ export const ReviewRemoveOwnerModal = ({
<Row className={classes.selectedOwner}> <Row className={classes.selectedOwner}>
<Col align="center" xs={12}> <Col align="center" xs={12}>
<EthHashInfo <EthHashInfo
hash={ownerAddress} hash={owner.address}
name={ownerName} name={owner.name}
showCopyBtn showCopyBtn
showAvatar showAvatar
explorerUrl={getExplorerInfo(ownerAddress)} explorerUrl={getExplorerInfo(owner.address)}
/> />
</Col> </Col>
</Row> </Row>

View File

@ -35,6 +35,7 @@ type Props = {
export const ThresholdForm = ({ onClickBack, onClose, onSubmit, initialValues }: Props): ReactElement => { export const ThresholdForm = ({ onClickBack, onClose, onSubmit, initialValues }: Props): ReactElement => {
const classes = useStyles() const classes = useStyles()
const owners = useSelector(safeOwnersSelector) const owners = useSelector(safeOwnersSelector)
const ownersCount = owners?.length ?? 0
const threshold = useSelector(safeThresholdSelector) as number const threshold = useSelector(safeThresholdSelector) as number
const handleSubmit = (values) => { const handleSubmit = (values) => {
onSubmit(values) onSubmit(values)
@ -58,7 +59,7 @@ export const ThresholdForm = ({ onClickBack, onClose, onSubmit, initialValues }:
onSubmit={handleSubmit} onSubmit={handleSubmit}
> >
{() => { {() => {
const numOptions = owners && owners.size > 1 ? owners.size - 1 : 1 const numOptions = ownersCount > 1 ? ownersCount - 1 : 1
return ( return (
<> <>
@ -97,7 +98,7 @@ export const ThresholdForm = ({ onClickBack, onClose, onSubmit, initialValues }:
</Col> </Col>
<Col xs={10}> <Col xs={10}>
<Paragraph className={classes.ownersText} color="primary" noMargin size="lg"> <Paragraph className={classes.ownersText} color="primary" noMargin size="lg">
out of {owners ? owners.size - 1 : 0} owner(s) out of {ownersCount ? ownersCount - 1 : 0} owner(s)
</Paragraph> </Paragraph>
</Col> </Col>
</Row> </Row>

View File

@ -2,12 +2,11 @@ import React, { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
import Modal from 'src/components/Modal' import Modal from 'src/components/Modal'
import { addOrUpdateAddressBookEntry } from 'src/logic/addressBook/store/actions/addOrUpdateAddressBookEntry' import { addressBookAddOrUpdate } from 'src/logic/addressBook/store/actions'
import { SENTINEL_ADDRESS, getGnosisSafeInstanceAt } from 'src/logic/contracts/safeContracts' import { SENTINEL_ADDRESS, getGnosisSafeInstanceAt } from 'src/logic/contracts/safeContracts'
import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions' import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
import { createTransaction } from 'src/logic/safe/store/actions/createTransaction' import { createTransaction } from 'src/logic/safe/store/actions/createTransaction'
import { replaceSafeOwner } from 'src/logic/safe/store/actions/replaceSafeOwner' import { safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import { safeParamAddressFromStateSelector, safeThresholdSelector } from 'src/logic/safe/store/selectors'
import { checksumAddress } from 'src/utils/checksumAddress' import { checksumAddress } from 'src/utils/checksumAddress'
import { makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook' import { makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { sameAddress } from 'src/logic/wallets/ethAddresses' import { sameAddress } from 'src/logic/wallets/ethAddresses'
@ -16,25 +15,26 @@ import { Dispatch } from 'src/logic/safe/store/actions/types.d'
import { OwnerForm } from 'src/routes/safe/components/Settings/ManageOwners/ReplaceOwnerModal/screens/OwnerForm' import { OwnerForm } from 'src/routes/safe/components/Settings/ManageOwners/ReplaceOwnerModal/screens/OwnerForm'
import { ReviewReplaceOwnerModal } from 'src/routes/safe/components/Settings/ManageOwners/ReplaceOwnerModal/screens/Review' import { ReviewReplaceOwnerModal } from 'src/routes/safe/components/Settings/ManageOwners/ReplaceOwnerModal/screens/Review'
import { TxParameters } from 'src/routes/safe/container/hooks/useTransactionParameters' import { TxParameters } from 'src/routes/safe/container/hooks/useTransactionParameters'
import { isValidAddress } from 'src/utils/isValidAddress'
import { OwnerData } from 'src/routes/safe/components/Settings/ManageOwners/dataFetcher'
export type OwnerValues = { export type OwnerValues = {
newOwnerAddress: string address: string
newOwnerName: string name: string
} }
export const sendReplaceOwner = async ( export const sendReplaceOwner = async (
values: OwnerValues, newOwner: OwnerValues,
safeAddress: string, safeAddress: string,
ownerAddressToRemove: string, ownerAddressToRemove: string,
dispatch: Dispatch, dispatch: Dispatch,
txParameters: TxParameters, txParameters: TxParameters,
threshold?: number,
): Promise<void> => { ): Promise<void> => {
const gnosisSafe = getGnosisSafeInstanceAt(safeAddress) const gnosisSafe = getGnosisSafeInstanceAt(safeAddress)
const safeOwners = await gnosisSafe.methods.getOwners().call() const safeOwners = await gnosisSafe.methods.getOwners().call()
const index = safeOwners.findIndex((ownerAddress) => sameAddress(ownerAddress, ownerAddressToRemove)) const index = safeOwners.findIndex((ownerAddress) => sameAddress(ownerAddress, ownerAddressToRemove))
const prevAddress = index === 0 ? SENTINEL_ADDRESS : safeOwners[index - 1] const prevAddress = index === 0 ? SENTINEL_ADDRESS : safeOwners[index - 1]
const txData = gnosisSafe.methods.swapOwner(prevAddress, ownerAddressToRemove, values.newOwnerAddress).encodeABI() const txData = gnosisSafe.methods.swapOwner(prevAddress, ownerAddressToRemove, newOwner.address).encodeABI()
const txHash = await dispatch( const txHash = await dispatch(
createTransaction({ createTransaction({
@ -49,47 +49,28 @@ export const sendReplaceOwner = async (
}), }),
) )
if (txHash && threshold === 1) { if (txHash) {
dispatch( // update the AB
replaceSafeOwner({ dispatch(addressBookAddOrUpdate(makeAddressBookEntry(newOwner)))
safeAddress,
oldOwnerAddress: ownerAddressToRemove,
ownerAddress: values.newOwnerAddress,
ownerName: values.newOwnerName,
}),
)
} }
} }
type ReplaceOwnerProps = { type ReplaceOwnerProps = {
isOpen: boolean isOpen: boolean
onClose: () => void onClose: () => void
ownerAddress: string owner: OwnerData
ownerName: string
} }
export const ReplaceOwnerModal = ({ export const ReplaceOwnerModal = ({ isOpen, onClose, owner }: ReplaceOwnerProps): React.ReactElement => {
isOpen,
onClose,
ownerAddress,
ownerName,
}: ReplaceOwnerProps): React.ReactElement => {
const [activeScreen, setActiveScreen] = useState('checkOwner') const [activeScreen, setActiveScreen] = useState('checkOwner')
const [values, setValues] = useState({ const [newOwner, setNewOwner] = useState({ address: '', name: '' })
newOwnerAddress: '',
newOwnerName: '',
})
const dispatch = useDispatch() const dispatch = useDispatch()
const safeAddress = useSelector(safeParamAddressFromStateSelector) const safeAddress = useSelector(safeParamAddressFromStateSelector)
const threshold = useSelector(safeThresholdSelector) || 1
useEffect( useEffect(
() => () => { () => () => {
setActiveScreen('checkOwner') setActiveScreen('checkOwner')
setValues({ setNewOwner({ address: '', name: '' })
newOwnerAddress: '',
newOwnerName: '',
})
}, },
[isOpen], [isOpen],
) )
@ -98,24 +79,19 @@ export const ReplaceOwnerModal = ({
const ownerSubmitted = (newValues) => { const ownerSubmitted = (newValues) => {
const { ownerAddress, ownerName } = newValues const { ownerAddress, ownerName } = newValues
if (isValidAddress(ownerAddress)) {
const checksumAddr = checksumAddress(ownerAddress) const checksumAddr = checksumAddress(ownerAddress)
setValues({ setNewOwner({ address: checksumAddr, name: ownerName })
newOwnerAddress: checksumAddr,
newOwnerName: ownerName,
})
setActiveScreen('reviewReplaceOwner') setActiveScreen('reviewReplaceOwner')
} }
}
const onReplaceOwner = async (txParameters: TxParameters) => { const onReplaceOwner = async (txParameters: TxParameters) => {
onClose() onClose()
try { try {
await sendReplaceOwner(values, safeAddress, ownerAddress, dispatch, txParameters, threshold) await sendReplaceOwner(newOwner, safeAddress, owner.address, dispatch, txParameters)
dispatch(addressBookAddOrUpdate(makeAddressBookEntry(newOwner)))
dispatch(
addOrUpdateAddressBookEntry(
makeAddressBookEntry({ address: values.newOwnerAddress, name: values.newOwnerName }),
),
)
} catch (error) { } catch (error) {
console.error('Error while removing an owner', error) console.error('Error while removing an owner', error)
} }
@ -131,22 +107,15 @@ export const ReplaceOwnerModal = ({
> >
<> <>
{activeScreen === 'checkOwner' && ( {activeScreen === 'checkOwner' && (
<OwnerForm <OwnerForm onClose={onClose} onSubmit={ownerSubmitted} initialValues={newOwner} owner={owner} />
onClose={onClose}
onSubmit={ownerSubmitted}
initialValues={values}
ownerAddress={ownerAddress}
ownerName={ownerName}
/>
)} )}
{activeScreen === 'reviewReplaceOwner' && ( {activeScreen === 'reviewReplaceOwner' && (
<ReviewReplaceOwnerModal <ReviewReplaceOwnerModal
onClickBack={onClickBack} onClickBack={onClickBack}
onClose={onClose} onClose={onClose}
onSubmit={onReplaceOwner} onSubmit={onReplaceOwner}
ownerAddress={ownerAddress} owner={owner}
ownerName={ownerName} newOwner={newOwner}
values={values}
/> />
)} )}
</> </>

View File

@ -1,7 +1,9 @@
import IconButton from '@material-ui/core/IconButton' import IconButton from '@material-ui/core/IconButton'
import Close from '@material-ui/icons/Close' import Close from '@material-ui/icons/Close'
import { Mutator } from 'final-form'
import React, { ReactElement } from 'react' import React, { ReactElement } from 'react'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { OnChange } from 'react-final-form-listeners'
import AddressInput from 'src/components/forms/AddressInput' import AddressInput from 'src/components/forms/AddressInput'
import Field from 'src/components/forms/Field' import Field from 'src/components/forms/Field'
@ -10,9 +12,9 @@ import TextField from 'src/components/forms/TextField'
import { import {
addressIsNotCurrentSafe, addressIsNotCurrentSafe,
composeValidators, composeValidators,
minMaxLength,
required, required,
uniqueAddress, uniqueAddress,
validAddressBookName,
} from 'src/components/forms/validator' } from 'src/components/forms/validator'
import Block from 'src/components/layout/Block' import Block from 'src/components/layout/Block'
import Col from 'src/components/layout/Col' import Col from 'src/components/layout/Col'
@ -21,10 +23,13 @@ import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row' import Row from 'src/components/layout/Row'
import { ScanQRWrapper } from 'src/components/ScanQRModal/ScanQRWrapper' import { ScanQRWrapper } from 'src/components/ScanQRModal/ScanQRWrapper'
import { Modal } from 'src/components/Modal' import { Modal } from 'src/components/Modal'
import { safeOwnersAddressesListSelector, safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors' import { safeOwnersSelector, safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import { addressBookMapSelector } from 'src/logic/addressBook/store/selectors'
import { web3ReadOnly } from 'src/logic/wallets/getWeb3'
import { OwnerData } from 'src/routes/safe/components/Settings/ManageOwners/dataFetcher'
import { useStyles } from './style' import { useStyles } from './style'
import { getExplorerInfo } from 'src/config' import { getExplorerInfo, getNetworkId } from 'src/config'
import { EthHashInfo } from '@gnosis.pm/safe-react-components' import { EthHashInfo } from '@gnosis.pm/safe-react-components'
export const REPLACE_OWNER_NAME_INPUT_TEST_ID = 'replace-owner-name-input' export const REPLACE_OWNER_NAME_INPUT_TEST_ID = 'replace-owner-name-input'
@ -33,12 +38,20 @@ export const REPLACE_OWNER_NEXT_BTN_TEST_ID = 'replace-owner-next-btn'
import { OwnerValues } from '../..' import { OwnerValues } from '../..'
const formMutators = { const formMutators: Record<
string,
Mutator<{ setOwnerAddress: { address: string }; setOwnerName: { name: string } }>
> = {
setOwnerAddress: (args, state, utils) => { setOwnerAddress: (args, state, utils) => {
utils.changeValue(state, 'ownerAddress', () => args[0]) utils.changeValue(state, 'ownerAddress', () => args[0])
}, },
setOwnerName: (args, state, utils) => {
utils.changeValue(state, 'ownerName', () => args[0])
},
} }
const chainId = getNetworkId()
type NewOwnerProps = { type NewOwnerProps = {
ownerAddress: string ownerAddress: string
ownerName: string ownerName: string
@ -47,26 +60,21 @@ type NewOwnerProps = {
type OwnerFormProps = { type OwnerFormProps = {
onClose: () => void onClose: () => void
onSubmit: (values: NewOwnerProps) => void onSubmit: (values: NewOwnerProps) => void
ownerAddress: string owner: OwnerData
ownerName: string
initialValues?: OwnerValues initialValues?: OwnerValues
} }
export const OwnerForm = ({ export const OwnerForm = ({ onClose, onSubmit, owner, initialValues }: OwnerFormProps): ReactElement => {
onClose,
onSubmit,
ownerAddress,
ownerName,
initialValues,
}: OwnerFormProps): ReactElement => {
const classes = useStyles() const classes = useStyles()
const handleSubmit = (values: NewOwnerProps) => { const handleSubmit = (values: NewOwnerProps) => {
onSubmit(values) onSubmit(values)
} }
const owners = useSelector(safeOwnersAddressesListSelector) const addressBookMap = useSelector(addressBookMapSelector)
const safeAddress = useSelector(safeParamAddressFromStateSelector) const owners = useSelector(safeOwnersSelector)
const ownerDoesntExist = uniqueAddress(owners) const ownerDoesntExist = uniqueAddress(owners)
const safeAddress = useSelector(safeParamAddressFromStateSelector)
const ownerAddressIsNotSafeAddress = addressIsNotCurrentSafe(safeAddress) const ownerAddressIsNotSafeAddress = addressIsNotCurrentSafe(safeAddress)
return ( return (
@ -85,8 +93,8 @@ export const OwnerForm = ({
formMutators={formMutators} formMutators={formMutators}
onSubmit={handleSubmit} onSubmit={handleSubmit}
initialValues={{ initialValues={{
ownerName: initialValues?.newOwnerName, ownerName: initialValues?.name,
ownerAddress: initialValues?.newOwnerAddress, ownerAddress: initialValues?.address,
}} }}
> >
{(...args) => { {(...args) => {
@ -118,11 +126,11 @@ export const OwnerForm = ({
<Row className={classes.owner}> <Row className={classes.owner}>
<Col align="center" xs={12}> <Col align="center" xs={12}>
<EthHashInfo <EthHashInfo
hash={ownerAddress} hash={owner.address}
name={ownerName} name={owner.name}
showCopyBtn showCopyBtn
showAvatar showAvatar
explorerUrl={getExplorerInfo(ownerAddress)} explorerUrl={getExplorerInfo(owner.address)}
/> />
</Col> </Col>
</Row> </Row>
@ -138,8 +146,18 @@ export const OwnerForm = ({
testId={REPLACE_OWNER_NAME_INPUT_TEST_ID} testId={REPLACE_OWNER_NAME_INPUT_TEST_ID}
text="Owner name*" text="Owner name*"
type="text" type="text"
validate={composeValidators(required, minMaxLength(1, 50))} validate={composeValidators(required, validAddressBookName)}
/> />
<OnChange name="ownerAddress">
{async (address: string) => {
if (web3ReadOnly.utils.isAddress(address)) {
const ownerName = addressBookMap?.[chainId]?.[address]?.name
if (ownerName) {
mutators.setOwnerName(ownerName)
}
}
}}
</OnChange>
</Col> </Col>
</Row> </Row>
<Row margin="md"> <Row margin="md">

View File

@ -2,10 +2,9 @@ import IconButton from '@material-ui/core/IconButton'
import Close from '@material-ui/icons/Close' import Close from '@material-ui/icons/Close'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { List } from 'immutable'
import { EthHashInfo } from '@gnosis.pm/safe-react-components' import { EthHashInfo } from '@gnosis.pm/safe-react-components'
import { getExplorerInfo } from 'src/config' import { getExplorerInfo, getNetworkId } from 'src/config'
import Block from 'src/components/layout/Block' import Block from 'src/components/layout/Block'
import Col from 'src/components/layout/Col' import Col from 'src/components/layout/Col'
import Hairline from 'src/components/layout/Hairline' import Hairline from 'src/components/layout/Hairline'
@ -13,34 +12,35 @@ import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row' import Row from 'src/components/layout/Row'
import { getGnosisSafeInstanceAt, SENTINEL_ADDRESS } from 'src/logic/contracts/safeContracts' import { getGnosisSafeInstanceAt, SENTINEL_ADDRESS } from 'src/logic/contracts/safeContracts'
import { import {
safeNameSelector, safeOwnersWithAddressBookDataSelector,
safeOwnersSelector,
safeParamAddressFromStateSelector, safeParamAddressFromStateSelector,
safeThresholdSelector, safeThresholdSelector,
} from 'src/logic/safe/store/selectors' } from 'src/logic/safe/store/selectors'
import { getOwnersWithNameFromAddressBook } from 'src/logic/addressBook/utils'
import { addressBookSelector } from 'src/logic/addressBook/store/selectors'
import { useEstimationStatus } from 'src/logic/hooks/useEstimationStatus' import { useEstimationStatus } from 'src/logic/hooks/useEstimationStatus'
import { useSafeName } from 'src/logic/addressBook/hooks/useSafeName'
import { TxParametersDetail } from 'src/routes/safe/components/Transactions/helpers/TxParametersDetail' import { TxParametersDetail } from 'src/routes/safe/components/Transactions/helpers/TxParametersDetail'
import { EstimationStatus, useEstimateTransactionGas } from 'src/logic/hooks/useEstimateTransactionGas' import { EstimationStatus, useEstimateTransactionGas } from 'src/logic/hooks/useEstimateTransactionGas'
import { TxParameters } from 'src/routes/safe/container/hooks/useTransactionParameters' import { TxParameters } from 'src/routes/safe/container/hooks/useTransactionParameters'
import { Modal } from 'src/components/Modal' import { Modal } from 'src/components/Modal'
import { TransactionFees } from 'src/components/TransactionsFees' import { TransactionFees } from 'src/components/TransactionsFees'
import { EditableTxParameters } from 'src/routes/safe/components/Transactions/helpers/EditableTxParameters' import { EditableTxParameters } from 'src/routes/safe/components/Transactions/helpers/EditableTxParameters'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
import { OwnerData } from 'src/routes/safe/components/Settings/ManageOwners/dataFetcher'
import { useStyles } from './style' import { useStyles } from './style'
export const REPLACE_OWNER_SUBMIT_BTN_TEST_ID = 'replace-owner-submit-btn' export const REPLACE_OWNER_SUBMIT_BTN_TEST_ID = 'replace-owner-submit-btn'
const chainId = getNetworkId()
type ReplaceOwnerProps = { type ReplaceOwnerProps = {
onClose: () => void onClose: () => void
onClickBack: () => void onClickBack: () => void
onSubmit: (txParameters: TxParameters) => void onSubmit: (txParameters: TxParameters) => void
ownerAddress: string owner: OwnerData
ownerName: string newOwner: {
values: { address: string
newOwnerAddress: string name: string
newOwnerName: string
} }
} }
@ -48,18 +48,15 @@ export const ReviewReplaceOwnerModal = ({
onClickBack, onClickBack,
onClose, onClose,
onSubmit, onSubmit,
ownerAddress, owner,
ownerName, newOwner,
values,
}: ReplaceOwnerProps): React.ReactElement => { }: ReplaceOwnerProps): React.ReactElement => {
const classes = useStyles() const classes = useStyles()
const [data, setData] = useState('') const [data, setData] = useState('')
const safeAddress = useSelector(safeParamAddressFromStateSelector) const safeAddress = useSelector(safeParamAddressFromStateSelector)
const safeName = useSelector(safeNameSelector) const safeName = useSafeName(safeAddress)
const owners = useSelector(safeOwnersSelector) const owners = useSelector((state) => safeOwnersWithAddressBookDataSelector(state, chainId))
const threshold = useSelector(safeThresholdSelector) || 1 const threshold = useSelector(safeThresholdSelector) || 1
const addressBook = useSelector(addressBookSelector)
const ownersWithAddressBookName = owners ? getOwnersWithNameFromAddressBook(addressBook, owners) : List([])
const [manualSafeTxGas, setManualSafeTxGas] = useState(0) const [manualSafeTxGas, setManualSafeTxGas] = useState(0)
const [manualGasPrice, setManualGasPrice] = useState<string | undefined>() const [manualGasPrice, setManualGasPrice] = useState<string | undefined>()
const [manualGasLimit, setManualGasLimit] = useState<string | undefined>() const [manualGasLimit, setManualGasLimit] = useState<string | undefined>()
@ -88,9 +85,9 @@ export const ReviewReplaceOwnerModal = ({
const calculateReplaceOwnerData = async () => { const calculateReplaceOwnerData = async () => {
const gnosisSafe = getGnosisSafeInstanceAt(safeAddress) const gnosisSafe = getGnosisSafeInstanceAt(safeAddress)
const safeOwners = await gnosisSafe.methods.getOwners().call() const safeOwners = await gnosisSafe.methods.getOwners().call()
const index = safeOwners.findIndex((owner) => owner.toLowerCase() === ownerAddress.toLowerCase()) const index = safeOwners.findIndex((ownerAddress) => sameAddress(ownerAddress, owner.address))
const prevAddress = index === 0 ? SENTINEL_ADDRESS : safeOwners[index - 1] const prevAddress = index === 0 ? SENTINEL_ADDRESS : safeOwners[index - 1]
const txData = gnosisSafe.methods.swapOwner(prevAddress, ownerAddress, values.newOwnerAddress).encodeABI() const txData = gnosisSafe.methods.swapOwner(prevAddress, owner.address, newOwner.address).encodeABI()
if (isCurrent) { if (isCurrent) {
setData(txData) setData(txData)
} }
@ -100,7 +97,7 @@ export const ReviewReplaceOwnerModal = ({
return () => { return () => {
isCurrent = false isCurrent = false
} }
}, [ownerAddress, safeAddress, values.newOwnerAddress]) }, [owner.address, safeAddress, newOwner.address])
const closeEditModalCallback = (txParameters: TxParameters) => { const closeEditModalCallback = (txParameters: TxParameters) => {
const oldGasPrice = Number(gasPriceFormatted) const oldGasPrice = Number(gasPriceFormatted)
@ -164,7 +161,7 @@ export const ReviewReplaceOwnerModal = ({
Any transaction requires the confirmation of: Any transaction requires the confirmation of:
</Paragraph> </Paragraph>
<Paragraph className={classes.name} color="primary" noMargin size="lg" weight="bolder"> <Paragraph className={classes.name} color="primary" noMargin size="lg" weight="bolder">
{`${threshold} out of ${owners?.size || 0} owner(s)`} {`${threshold} out of ${owners?.length || 0} owner(s)`}
</Paragraph> </Paragraph>
</Block> </Block>
</Block> </Block>
@ -172,22 +169,22 @@ export const ReviewReplaceOwnerModal = ({
<Col className={classes.owners} layout="column" xs={8}> <Col className={classes.owners} layout="column" xs={8}>
<Row className={classes.ownersTitle}> <Row className={classes.ownersTitle}>
<Paragraph color="primary" noMargin size="lg"> <Paragraph color="primary" noMargin size="lg">
{`${owners?.size || 0} Safe owner(s)`} {`${owners?.length || 0} Safe owner(s)`}
</Paragraph> </Paragraph>
</Row> </Row>
<Hairline /> <Hairline />
{ownersWithAddressBookName?.map( {owners?.map(
(owner) => (safeOwner) =>
owner.address !== ownerAddress && ( !sameAddress(safeOwner.address, owner.address) && (
<React.Fragment key={owner.address}> <React.Fragment key={safeOwner.address}>
<Row className={classes.owner}> <Row className={classes.owner}>
<Col align="center" xs={12}> <Col align="center" xs={12}>
<EthHashInfo <EthHashInfo
hash={owner.address} hash={safeOwner.address}
name={owner.name} name={safeOwner.name}
showCopyBtn showCopyBtn
showAvatar showAvatar
explorerUrl={getExplorerInfo(owner.address)} explorerUrl={getExplorerInfo(safeOwner.address)}
/> />
</Col> </Col>
</Row> </Row>
@ -204,11 +201,11 @@ export const ReviewReplaceOwnerModal = ({
<Row className={classes.selectedOwnerRemoved}> <Row className={classes.selectedOwnerRemoved}>
<Col align="center" xs={12}> <Col align="center" xs={12}>
<EthHashInfo <EthHashInfo
hash={ownerAddress} hash={owner.address}
name={ownerName} name={owner.name}
showCopyBtn showCopyBtn
showAvatar showAvatar
explorerUrl={getExplorerInfo(ownerAddress)} explorerUrl={getExplorerInfo(owner.address)}
/> />
</Col> </Col>
</Row> </Row>
@ -221,11 +218,11 @@ export const ReviewReplaceOwnerModal = ({
<Row className={classes.selectedOwnerAdded}> <Row className={classes.selectedOwnerAdded}>
<Col align="center" xs={12}> <Col align="center" xs={12}>
<EthHashInfo <EthHashInfo
hash={values.newOwnerAddress} hash={newOwner.address}
name={values.newOwnerName} name={newOwner.name}
showCopyBtn showCopyBtn
showAvatar showAvatar
explorerUrl={getExplorerInfo(values.newOwnerAddress)} explorerUrl={getExplorerInfo(newOwner.address)}
/> />
</Col> </Col>
</Row> </Row>

View File

@ -1,12 +1,15 @@
import { List } from 'immutable' import { List } from 'immutable'
import { TableColumn } from 'src/components/Table/types.d' import { TableColumn } from 'src/components/Table/types.d'
import { SafeOwner } from 'src/logic/safe/store/models/safe' import { AddressBookState } from 'src/logic/addressBook/model/addressBook'
export const OWNERS_TABLE_NAME_ID = 'name' export const OWNERS_TABLE_NAME_ID = 'name'
export const OWNERS_TABLE_ADDRESS_ID = 'address' export const OWNERS_TABLE_ADDRESS_ID = 'address'
export const OWNERS_TABLE_ACTIONS_ID = 'actions' export const OWNERS_TABLE_ACTIONS_ID = 'actions'
export const getOwnerData = (owners: List<SafeOwner>): List<{ address: string; name: string }> => { export type OwnerData = { address: string; name: string }
export const getOwnerData = (owners: AddressBookState): OwnerData[] => {
return owners.map((owner) => ({ return owners.map((owner) => ({
[OWNERS_TABLE_NAME_ID]: owner.name, [OWNERS_TABLE_NAME_ID]: owner.name,
[OWNERS_TABLE_ADDRESS_ID]: owner.address, [OWNERS_TABLE_ADDRESS_ID]: owner.address,

View File

@ -1,10 +1,9 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect, ReactElement } from 'react'
import { EthHashInfo } from '@gnosis.pm/safe-react-components' import { EthHashInfo } from '@gnosis.pm/safe-react-components'
import TableCell from '@material-ui/core/TableCell' import TableCell from '@material-ui/core/TableCell'
import TableContainer from '@material-ui/core/TableContainer' import TableContainer from '@material-ui/core/TableContainer'
import TableRow from '@material-ui/core/TableRow' import TableRow from '@material-ui/core/TableRow'
import cn from 'classnames' import cn from 'classnames'
import { List } from 'immutable'
import RemoveOwnerIcon from '../assets/icons/bin.svg' import RemoveOwnerIcon from '../assets/icons/bin.svg'
@ -14,7 +13,7 @@ import { RemoveOwnerModal } from './RemoveOwnerModal'
import { ReplaceOwnerModal } from './ReplaceOwnerModal' import { ReplaceOwnerModal } from './ReplaceOwnerModal'
import RenameOwnerIcon from './assets/icons/rename-owner.svg' import RenameOwnerIcon from './assets/icons/rename-owner.svg'
import ReplaceOwnerIcon from './assets/icons/replace-owner.svg' import ReplaceOwnerIcon from './assets/icons/replace-owner.svg'
import { OWNERS_TABLE_ADDRESS_ID, OWNERS_TABLE_NAME_ID, generateColumns, getOwnerData } from './dataFetcher' import { OWNERS_TABLE_ADDRESS_ID, generateColumns, getOwnerData, OwnerData } from './dataFetcher'
import { useStyles } from './style' import { useStyles } from './style'
import { getExplorerInfo } from 'src/config' import { getExplorerInfo } from 'src/config'
@ -28,10 +27,8 @@ import Heading from 'src/components/layout/Heading'
import Img from 'src/components/layout/Img' import Img from 'src/components/layout/Img'
import Paragraph from 'src/components/layout/Paragraph/index' import Paragraph from 'src/components/layout/Paragraph/index'
import Row from 'src/components/layout/Row' import Row from 'src/components/layout/Row'
import { getOwnersWithNameFromAddressBook } from 'src/logic/addressBook/utils'
import { useAnalytics, SAFE_NAVIGATION_EVENT } from 'src/utils/googleAnalytics' import { useAnalytics, SAFE_NAVIGATION_EVENT } from 'src/utils/googleAnalytics'
import { AddressBookState } from 'src/logic/addressBook/model/addressBook' import { AddressBookState } from 'src/logic/addressBook/model/addressBook'
import { SafeOwner } from 'src/logic/safe/store/models/safe'
export const RENAME_OWNER_BTN_TEST_ID = 'rename-owner-btn' export const RENAME_OWNER_BTN_TEST_ID = 'rename-owner-btn'
export const REMOVE_OWNER_BTN_TEST_ID = 'remove-owner-btn' export const REMOVE_OWNER_BTN_TEST_ID = 'remove-owner-btn'
@ -40,17 +37,15 @@ export const REPLACE_OWNER_BTN_TEST_ID = 'replace-owner-btn'
export const OWNERS_ROW_TEST_ID = 'owners-row' export const OWNERS_ROW_TEST_ID = 'owners-row'
type Props = { type Props = {
addressBook: AddressBookState
granted: boolean granted: boolean
owners: List<SafeOwner> owners: AddressBookState
} }
const ManageOwners = ({ addressBook, granted, owners }: Props): React.ReactElement => { const ManageOwners = ({ granted, owners }: Props): ReactElement => {
const { trackEvent } = useAnalytics() const { trackEvent } = useAnalytics()
const classes = useStyles() const classes = useStyles()
const [selectedOwnerAddress, setSelectedOwnerAddress] = useState('') const [selectedOwner, setSelectedOwner] = useState<OwnerData | undefined>()
const [selectedOwnerName, setSelectedOwnerName] = useState('')
const [modalsStatus, setModalStatus] = useState({ const [modalsStatus, setModalStatus] = useState({
showAddOwner: false, showAddOwner: false,
showRemoveOwner: false, showRemoveOwner: false,
@ -58,13 +53,14 @@ const ManageOwners = ({ addressBook, granted, owners }: Props): React.ReactEleme
showEditOwner: false, showEditOwner: false,
}) })
const onShow = (action, row?: any) => () => { const onShow = (action, row?: OwnerData) => () => {
setModalStatus((prevState) => ({ setModalStatus((prevState) => ({
...prevState, ...prevState,
[`show${action}`]: !prevState[`show${action}`], [`show${action}`]: !prevState[`show${action}`],
})) }))
setSelectedOwnerAddress(row && row.address) if (row) {
setSelectedOwnerName(row && row.name) setSelectedOwner(row)
}
} }
const onHide = (action) => () => { const onHide = (action) => () => {
@ -72,8 +68,7 @@ const ManageOwners = ({ addressBook, granted, owners }: Props): React.ReactEleme
...prevState, ...prevState,
[`show${action}`]: !Boolean(prevState[`show${action}`]), [`show${action}`]: !Boolean(prevState[`show${action}`]),
})) }))
setSelectedOwnerAddress('') setSelectedOwner(undefined)
setSelectedOwnerName('')
} }
useEffect(() => { useEffect(() => {
@ -82,8 +77,7 @@ const ManageOwners = ({ addressBook, granted, owners }: Props): React.ReactEleme
const columns = generateColumns() const columns = generateColumns()
const autoColumns = columns.filter((c) => !c.custom) const autoColumns = columns.filter((c) => !c.custom)
const ownersWithAddressBookName = getOwnersWithNameFromAddressBook(addressBook, owners) const ownerData = getOwnerData(owners)
const ownerData = getOwnerData(ownersWithAddressBookName)
return ( return (
<> <>
@ -100,11 +94,11 @@ const ManageOwners = ({ addressBook, granted, owners }: Props): React.ReactEleme
columns={columns} columns={columns}
data={ownerData} data={ownerData}
defaultFixed defaultFixed
defaultOrderBy={OWNERS_TABLE_NAME_ID} defaultOrderBy={OWNERS_TABLE_ADDRESS_ID}
disablePagination disablePagination
label="Owners" label="Owners"
noBorder noBorder
size={ownerData.size} size={ownerData.length}
> >
{(sortedData) => {(sortedData) =>
sortedData.map((row, index) => ( sortedData.map((row, index) => (
@ -147,7 +141,7 @@ const ManageOwners = ({ addressBook, granted, owners }: Props): React.ReactEleme
src={ReplaceOwnerIcon} src={ReplaceOwnerIcon}
testId={REPLACE_OWNER_BTN_TEST_ID} testId={REPLACE_OWNER_BTN_TEST_ID}
/> />
{ownerData.size > 1 && ( {ownerData.length > 1 && (
<Img <Img
alt="Remove owner" alt="Remove owner"
className={classes.removeOwnerIcon} className={classes.removeOwnerIcon}
@ -185,24 +179,21 @@ const ManageOwners = ({ addressBook, granted, owners }: Props): React.ReactEleme
</> </>
)} )}
<AddOwnerModal isOpen={modalsStatus.showAddOwner} onClose={onHide('AddOwner')} /> <AddOwnerModal isOpen={modalsStatus.showAddOwner} onClose={onHide('AddOwner')} />
{selectedOwner && (
<>
<RemoveOwnerModal <RemoveOwnerModal
isOpen={modalsStatus.showRemoveOwner} isOpen={modalsStatus.showRemoveOwner}
onClose={onHide('RemoveOwner')} onClose={onHide('RemoveOwner')}
ownerAddress={selectedOwnerAddress} owner={selectedOwner}
ownerName={selectedOwnerName}
/> />
<ReplaceOwnerModal <ReplaceOwnerModal
isOpen={modalsStatus.showReplaceOwner} isOpen={modalsStatus.showReplaceOwner}
onClose={onHide('ReplaceOwner')} onClose={onHide('ReplaceOwner')}
ownerAddress={selectedOwnerAddress} owner={selectedOwner}
ownerName={selectedOwnerName}
/>
<EditOwnerModal
isOpen={modalsStatus.showEditOwner}
onClose={onHide('EditOwner')}
ownerAddress={selectedOwnerAddress}
selectedOwnerName={selectedOwnerName}
/> />
<EditOwnerModal isOpen={modalsStatus.showEditOwner} onClose={onHide('EditOwner')} owner={selectedOwner} />
</>
)}
</> </>
) )
} }

View File

@ -1,3 +1,4 @@
import { EthHashInfo } from '@gnosis.pm/safe-react-components'
import IconButton from '@material-ui/core/IconButton' import IconButton from '@material-ui/core/IconButton'
import Close from '@material-ui/icons/Close' import Close from '@material-ui/icons/Close'
import React from 'react' import React from 'react'
@ -5,25 +6,23 @@ import { useDispatch, useSelector } from 'react-redux'
import { useStyles } from './style' import { useStyles } from './style'
import { EthHashInfo } from '@gnosis.pm/safe-react-components'
import Modal, { Modal as GenericModal } from 'src/components/Modal' import Modal, { Modal as GenericModal } from 'src/components/Modal'
import Block from 'src/components/layout/Block' import Block from 'src/components/layout/Block'
import Hairline from 'src/components/layout/Hairline' import Hairline from 'src/components/layout/Hairline'
import Paragraph from 'src/components/layout/Paragraph' import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row' import Row from 'src/components/layout/Row'
import { import { addressBookMapSelector } from 'src/logic/addressBook/store/selectors'
defaultSafeSelector, import { defaultSafeSelector, safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
safeNameSelector,
safeParamAddressFromStateSelector,
} from 'src/logic/safe/store/selectors'
import { WELCOME_ADDRESS } from 'src/routes/routes' import { WELCOME_ADDRESS } from 'src/routes/routes'
import { removeLocalSafe } from 'src/logic/safe/store/actions/removeLocalSafe' import { removeLocalSafe } from 'src/logic/safe/store/actions/removeLocalSafe'
import { sameAddress } from 'src/logic/wallets/ethAddresses' import { sameAddress } from 'src/logic/wallets/ethAddresses'
import { saveDefaultSafe } from 'src/logic/safe/utils' import { saveDefaultSafe } from 'src/logic/safe/utils'
import { getExplorerInfo } from 'src/config' import { getExplorerInfo, getNetworkId } from 'src/config'
import Col from 'src/components/layout/Col' import Col from 'src/components/layout/Col'
const chainId = getNetworkId()
type RemoveSafeModalProps = { type RemoveSafeModalProps = {
isOpen: boolean isOpen: boolean
onClose: () => void onClose: () => void
@ -32,12 +31,16 @@ type RemoveSafeModalProps = {
export const RemoveSafeModal = ({ isOpen, onClose }: RemoveSafeModalProps): React.ReactElement => { export const RemoveSafeModal = ({ isOpen, onClose }: RemoveSafeModalProps): React.ReactElement => {
const classes = useStyles() const classes = useStyles()
const safeAddress = useSelector(safeParamAddressFromStateSelector) const safeAddress = useSelector(safeParamAddressFromStateSelector)
const safeName = useSelector(safeNameSelector) const addressBookMap = useSelector(addressBookMapSelector)
const safeAddressBookEntry = addressBookMap[chainId]?.[safeAddress]
const safeName = safeAddressBookEntry?.name
const defaultSafe = useSelector(defaultSafeSelector) const defaultSafe = useSelector(defaultSafeSelector)
const dispatch = useDispatch() const dispatch = useDispatch()
const onRemoveSafeHandler = async () => { const onRemoveSafeHandler = async () => {
// ToDo: review if this is necessary or we should directly use the `removeSafe` action.
await dispatch(removeLocalSafe(safeAddress)) await dispatch(removeLocalSafe(safeAddress))
if (sameAddress(safeAddress, defaultSafe)) { if (sameAddress(safeAddress, defaultSafe)) {
await saveDefaultSafe('') await saveDefaultSafe('')
} }

View File

@ -1,6 +1,6 @@
import { Icon, Link, Text } from '@gnosis.pm/safe-react-components' import { Icon, Link, Text } from '@gnosis.pm/safe-react-components'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import React, { useEffect, useState } from 'react' import React, { ReactElement, useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
import styled from 'styled-components' import styled from 'styled-components'
@ -10,13 +10,15 @@ import Modal from 'src/components/Modal'
import Field from 'src/components/forms/Field' import Field from 'src/components/forms/Field'
import GnoForm from 'src/components/forms/GnoForm' import GnoForm from 'src/components/forms/GnoForm'
import TextField from 'src/components/forms/TextField' import TextField from 'src/components/forms/TextField'
import { composeValidators, minMaxLength, required } from 'src/components/forms/validator' import { composeValidators, required, validAddressBookName } from 'src/components/forms/validator'
import Block from 'src/components/layout/Block' import Block from 'src/components/layout/Block'
import Button from 'src/components/layout/Button' import Button from 'src/components/layout/Button'
import Col from 'src/components/layout/Col' import Col from 'src/components/layout/Col'
import Heading from 'src/components/layout/Heading' import Heading from 'src/components/layout/Heading'
import Paragraph from 'src/components/layout/Paragraph' import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row' import Row from 'src/components/layout/Row'
import { makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { addressBookAddOrUpdate } from 'src/logic/addressBook/store/actions'
import enqueueSnackbar from 'src/logic/notifications/store/actions/enqueueSnackbar' import enqueueSnackbar from 'src/logic/notifications/store/actions/enqueueSnackbar'
import { getNotificationsFromTxType, enhanceSnackbarForAction } from 'src/logic/notifications' import { getNotificationsFromTxType, enhanceSnackbarForAction } from 'src/logic/notifications'
import { sameAddress } from 'src/logic/wallets/ethAddresses' import { sameAddress } from 'src/logic/wallets/ethAddresses'
@ -25,17 +27,16 @@ import { UpdateSafeModal } from 'src/routes/safe/components/Settings/UpdateSafeM
import { grantedSelector } from 'src/routes/safe/container/selector' import { grantedSelector } from 'src/routes/safe/container/selector'
import { updateSafe } from 'src/logic/safe/store/actions/updateSafe' import { updateSafe } from 'src/logic/safe/store/actions/updateSafe'
import { useSafeName } from 'src/logic/addressBook/hooks/useSafeName'
import { import {
latestMasterContractVersionSelector, latestMasterContractVersionSelector,
safeCurrentVersionSelector, safeCurrentVersionSelector,
safeNameSelector,
safeNeedsUpdateSelector, safeNeedsUpdateSelector,
safeParamAddressFromStateSelector, safeParamAddressFromStateSelector,
} from 'src/logic/safe/store/selectors' } from 'src/logic/safe/store/selectors'
import { useAnalytics, SAFE_NAVIGATION_EVENT } from 'src/utils/googleAnalytics' import { useAnalytics, SAFE_NAVIGATION_EVENT } from 'src/utils/googleAnalytics'
import { fetchMasterCopies, MasterCopy, MasterCopyDeployer } from 'src/logic/contracts/api/masterCopies' import { fetchMasterCopies, MasterCopy, MasterCopyDeployer } from 'src/logic/contracts/api/masterCopies'
import { getMasterCopyAddressFromProxyAddress } from 'src/logic/contracts/safeContracts' import { getMasterCopyAddressFromProxyAddress } from 'src/logic/contracts/safeContracts'
import { LOADED_SAFE_KEY } from 'src/utils/constants'
export const SAFE_NAME_INPUT_TEST_ID = 'safe-name-input' export const SAFE_NAME_INPUT_TEST_ID = 'safe-name-input'
export const SAFE_NAME_SUBMIT_BTN_TEST_ID = 'change-safe-name-btn' export const SAFE_NAME_SUBMIT_BTN_TEST_ID = 'change-safe-name-btn'
@ -52,18 +53,18 @@ const StyledIcon = styled(Icon)`
left: 6px; left: 6px;
` `
const SafeDetails = (): React.ReactElement => { const SafeDetails = (): ReactElement => {
const classes = useStyles() const classes = useStyles()
const isUserOwner = useSelector(grantedSelector) const isUserOwner = useSelector(grantedSelector)
const latestMasterContractVersion = useSelector(latestMasterContractVersionSelector) const latestMasterContractVersion = useSelector(latestMasterContractVersionSelector)
const dispatch = useDispatch() const dispatch = useDispatch()
const safeName = useSelector(safeNameSelector) const safeAddress = useSelector(safeParamAddressFromStateSelector)
const safeName = useSafeName(safeAddress)
const safeNeedsUpdate = useSelector(safeNeedsUpdateSelector) const safeNeedsUpdate = useSelector(safeNeedsUpdateSelector)
const safeCurrentVersion = useSelector(safeCurrentVersionSelector) const safeCurrentVersion = useSelector(safeCurrentVersionSelector)
const { trackEvent } = useAnalytics() const { trackEvent } = useAnalytics()
const [isModalOpen, setModalOpen] = React.useState(false) const [isModalOpen, setModalOpen] = useState(false)
const safeAddress = useSelector(safeParamAddressFromStateSelector)
const [safeInfo, setSafeInfo] = useState<MasterCopy | undefined>() const [safeInfo, setSafeInfo] = useState<MasterCopy | undefined>()
const toggleModal = () => { const toggleModal = () => {
@ -71,10 +72,9 @@ const SafeDetails = (): React.ReactElement => {
} }
const handleSubmit = (values) => { const handleSubmit = (values) => {
// In case they set a name we assume the safe want to be stored even if it was opened via URL dispatch(addressBookAddOrUpdate(makeAddressBookEntry({ address: safeAddress, name: values.safeName })))
dispatch( // setting `loadedViaUrl` to `false` as setting a safe's name is considered to intentionally add the safe
updateSafe({ address: safeAddress, name: values.safeName, loadedViaUrl: values.safeName === LOADED_SAFE_KEY }), dispatch(updateSafe({ address: safeAddress, loadedViaUrl: false }))
)
const notification = getNotificationsFromTxType(TX_NOTIFICATION_TYPES.SAFE_NAME_CHANGE_TX) const notification = getNotificationsFromTxType(TX_NOTIFICATION_TYPES.SAFE_NAME_CHANGE_TX)
dispatch(enqueueSnackbar(enhanceSnackbarForAction(notification.afterExecution.noMoreConfirmationsNeeded))) dispatch(enqueueSnackbar(enhanceSnackbarForAction(notification.afterExecution.noMoreConfirmationsNeeded)))
@ -166,7 +166,7 @@ const SafeDetails = (): React.ReactElement => {
testId={SAFE_NAME_INPUT_TEST_ID} testId={SAFE_NAME_INPUT_TEST_ID}
text="Safe name*" text="Safe name*"
type="text" type="text"
validate={composeValidators(required, minMaxLength(1, 50))} validate={composeValidators(required, validAddressBookName)}
/> />
</Block> </Block>
</Block> </Block>

View File

@ -4,6 +4,7 @@ import { useSelector } from 'react-redux'
import { getExplorerInfo } from 'src/config' import { getExplorerInfo } from 'src/config'
import { getNameFromAddressBookSelector } from 'src/logic/addressBook/store/selectors' import { getNameFromAddressBookSelector } from 'src/logic/addressBook/store/selectors'
import { ADDRESS_BOOK_DEFAULT_NAME } from 'src/logic/addressBook/model/addressBook'
import { sameString } from 'src/utils/strings' import { sameString } from 'src/utils/strings'
import DataDisplay from './DataDisplay' import DataDisplay from './DataDisplay'
@ -14,14 +15,14 @@ interface AddressInfoProps {
} }
const AddressInfo = ({ address, title }: AddressInfoProps): ReactElement => { const AddressInfo = ({ address, title }: AddressInfoProps): ReactElement => {
const name = useSelector((state) => getNameFromAddressBookSelector(state, address)) const name = useSelector((state) => getNameFromAddressBookSelector(state, { address }))
const explorerUrl = getExplorerInfo(address) const explorerUrl = getExplorerInfo(address)
return ( return (
<DataDisplay title={title}> <DataDisplay title={title}>
<EthHashInfo <EthHashInfo
hash={address} hash={address}
name={sameString(name, 'UNKNOWN') ? undefined : name} name={sameString(name, ADDRESS_BOOK_DEFAULT_NAME) ? undefined : name}
showCopyBtn showCopyBtn
showAvatar showAvatar
textSize="lg" textSize="lg"

View File

@ -3,7 +3,6 @@ import MenuItem from '@material-ui/core/MenuItem'
import Close from '@material-ui/icons/Close' import Close from '@material-ui/icons/Close'
import React, { ReactElement, useEffect, useState } from 'react' import React, { ReactElement, useEffect, useState } from 'react'
import { useDispatch } from 'react-redux' import { useDispatch } from 'react-redux'
import { List } from 'immutable'
import Field from 'src/components/forms/Field' import Field from 'src/components/forms/Field'
import GnoForm from 'src/components/forms/GnoForm' import GnoForm from 'src/components/forms/GnoForm'
@ -16,7 +15,6 @@ import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row' import Row from 'src/components/layout/Row'
import { getGnosisSafeInstanceAt } from 'src/logic/contracts/safeContracts' import { getGnosisSafeInstanceAt } from 'src/logic/contracts/safeContracts'
import { useEstimationStatus } from 'src/logic/hooks/useEstimationStatus' import { useEstimationStatus } from 'src/logic/hooks/useEstimationStatus'
import { SafeOwner } from 'src/logic/safe/store/models/safe'
import { EstimationStatus, useEstimateTransactionGas } from 'src/logic/hooks/useEstimateTransactionGas' import { EstimationStatus, useEstimateTransactionGas } from 'src/logic/hooks/useEstimateTransactionGas'
import { ButtonStatus, Modal } from 'src/components/Modal' import { ButtonStatus, Modal } from 'src/components/Modal'
import { TransactionFees } from 'src/components/TransactionsFees' import { TransactionFees } from 'src/components/TransactionsFees'
@ -32,14 +30,14 @@ const THRESHOLD_FIELD_NAME = 'threshold'
type ChangeThresholdModalProps = { type ChangeThresholdModalProps = {
onClose: () => void onClose: () => void
owners?: List<SafeOwner> ownersCount?: number
safeAddress: string safeAddress: string
threshold?: number threshold?: number
} }
export const ChangeThresholdModal = ({ export const ChangeThresholdModal = ({
onClose, onClose,
owners, ownersCount = 0,
safeAddress, safeAddress,
threshold = 1, threshold = 1,
}: ChangeThresholdModalProps): ReactElement => { }: ChangeThresholdModalProps): ReactElement => {
@ -164,7 +162,7 @@ export const ChangeThresholdModal = ({
render={(props) => ( render={(props) => (
<> <>
<SelectField {...props} disableError> <SelectField {...props} disableError>
{[...Array(Number(owners?.size))].map((x, index) => ( {[...Array(Number(ownersCount))].map((x, index) => (
<MenuItem key={index} value={`${index + 1}`}> <MenuItem key={index} value={`${index + 1}`}>
{index + 1} {index + 1}
</MenuItem> </MenuItem>
@ -177,7 +175,7 @@ export const ChangeThresholdModal = ({
</Col> </Col>
<Col xs={10}> <Col xs={10}>
<Paragraph className={classes.ownersText} color="primary" noMargin size="lg"> <Paragraph className={classes.ownersText} color="primary" noMargin size="lg">
{`out of ${owners?.size} owner(s)`} {`out of ${ownersCount} owner(s)`}
</Paragraph> </Paragraph>
</Col> </Col>
</Row> </Row>

View File

@ -46,9 +46,9 @@ const ThresholdSettings = (): React.ReactElement => {
<Heading tag="h2">Required confirmations</Heading> <Heading tag="h2">Required confirmations</Heading>
<Paragraph>Any transaction requires the confirmation of:</Paragraph> <Paragraph>Any transaction requires the confirmation of:</Paragraph>
<Paragraph className={classes.ownersText} size="lg"> <Paragraph className={classes.ownersText} size="lg">
<Bold>{threshold}</Bold> out of <Bold>{owners?.size || 0}</Bold> owners <Bold>{threshold}</Bold> out of <Bold>{owners?.length || 0}</Bold> owners
</Paragraph> </Paragraph>
{owners && owners.size > 1 && granted && ( {owners && owners.length > 1 && granted && (
<Row className={classes.buttonRow}> <Row className={classes.buttonRow}>
<Button <Button
className={classes.modifyBtn} className={classes.modifyBtn}
@ -68,7 +68,12 @@ const ThresholdSettings = (): React.ReactElement => {
open={isModalOpen} open={isModalOpen}
title="Change Required Confirmations" title="Change Required Confirmations"
> >
<ChangeThresholdModal onClose={toggleModal} owners={owners} safeAddress={safeAddress} threshold={threshold} /> <ChangeThresholdModal
onClose={toggleModal}
ownersCount={owners?.length}
safeAddress={safeAddress}
threshold={threshold}
/>
</Modal> </Modal>
</> </>
) )

View File

@ -16,6 +16,7 @@ import ThresholdSettings from './ThresholdSettings'
import RemoveSafeIcon from './assets/icons/bin.svg' import RemoveSafeIcon from './assets/icons/bin.svg'
import { styles } from './style' import { styles } from './style'
import { getNetworkId } from 'src/config'
import Block from 'src/components/layout/Block' import Block from 'src/components/layout/Block'
import ButtonLink from 'src/components/layout/ButtonLink' import ButtonLink from 'src/components/layout/ButtonLink'
import Col from 'src/components/layout/Col' import Col from 'src/components/layout/Col'
@ -24,12 +25,17 @@ import Img from 'src/components/layout/Img'
import Paragraph from 'src/components/layout/Paragraph' import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row' import Row from 'src/components/layout/Row'
import Span from 'src/components/layout/Span' import Span from 'src/components/layout/Span'
import { addressBookSelector } from 'src/logic/addressBook/store/selectors' import {
safeNeedsUpdateSelector,
safeOwnersWithAddressBookDataSelector,
safeSelector,
} from 'src/logic/safe/store/selectors'
import { grantedSelector } from 'src/routes/safe/container/selector' import { grantedSelector } from 'src/routes/safe/container/selector'
import { safeLoadedViaUrlSelector, safeNeedsUpdateSelector, safeOwnersSelector } from 'src/logic/safe/store/selectors'
export const OWNERS_SETTINGS_TAB_TEST_ID = 'owner-settings-tab' export const OWNERS_SETTINGS_TAB_TEST_ID = 'owner-settings-tab'
const chainId = getNetworkId()
const INITIAL_STATE = { const INITIAL_STATE = {
showRemoveSafe: false, showRemoveSafe: false,
menuOptionIndex: 1, menuOptionIndex: 1,
@ -40,11 +46,10 @@ const useStyles = makeStyles(styles)
const Settings: React.FC = () => { const Settings: React.FC = () => {
const classes = useStyles() const classes = useStyles()
const [state, setState] = useState(INITIAL_STATE) const [state, setState] = useState(INITIAL_STATE)
const owners = useSelector(safeOwnersSelector) const owners = useSelector((state) => safeOwnersWithAddressBookDataSelector(state, chainId))
const isSafeLoadedViaUrl = useSelector(safeLoadedViaUrlSelector)
const needsUpdate = useSelector(safeNeedsUpdateSelector) const needsUpdate = useSelector(safeNeedsUpdateSelector)
const granted = useSelector(grantedSelector) const granted = useSelector(grantedSelector)
const addressBook = useSelector(addressBookSelector) const safe = useSelector(safeSelector)
const handleChange = (menuOptionIndex) => () => { const handleChange = (menuOptionIndex) => () => {
setState((prevState) => ({ ...prevState, menuOptionIndex })) setState((prevState) => ({ ...prevState, menuOptionIndex }))
@ -67,13 +72,15 @@ const Settings: React.FC = () => {
) : ( ) : (
<> <>
<Row className={classes.message}> <Row className={classes.message}>
{!isSafeLoadedViaUrl && ( {!safe?.loadedViaUrl && (
<>
<ButtonLink className={classes.removeSafeBtn} color="error" onClick={onShow('RemoveSafe')} size="lg"> <ButtonLink className={classes.removeSafeBtn} color="error" onClick={onShow('RemoveSafe')} size="lg">
<Span className={classes.links}>Remove Safe</Span> <Span className={classes.links}>Remove Safe</Span>
<Img alt="Trash Icon" className={classes.removeSafeIcon} src={RemoveSafeIcon} /> <Img alt="Trash Icon" className={classes.removeSafeIcon} src={RemoveSafeIcon} />
</ButtonLink> </ButtonLink>
)}
<RemoveSafeModal isOpen={showRemoveSafe} onClose={onHide('RemoveSafe')} /> <RemoveSafeModal isOpen={showRemoveSafe} onClose={onHide('RemoveSafe')} />
</>
)}
</Row> </Row>
<Block className={classes.root}> <Block className={classes.root}>
<Col className={classes.menuWrapper} layout="column"> <Col className={classes.menuWrapper} layout="column">
@ -109,7 +116,7 @@ const Settings: React.FC = () => {
color={menuOptionIndex === 2 ? 'primary' : 'secondary'} color={menuOptionIndex === 2 ? 'primary' : 'secondary'}
/> />
<Paragraph className={classes.counter} size="xs"> <Paragraph className={classes.counter} size="xs">
{owners.size} {owners.length}
</Paragraph> </Paragraph>
</Row> </Row>
<Hairline className={classes.hairline} /> <Hairline className={classes.hairline} />
@ -148,7 +155,7 @@ const Settings: React.FC = () => {
<Col className={classes.contents} layout="column"> <Col className={classes.contents} layout="column">
<Block className={classes.container}> <Block className={classes.container}>
{menuOptionIndex === 1 && <SafeDetails />} {menuOptionIndex === 1 && <SafeDetails />}
{menuOptionIndex === 2 && <ManageOwners addressBook={addressBook} granted={granted} owners={owners} />} {menuOptionIndex === 2 && <ManageOwners granted={granted} owners={owners} />}
{menuOptionIndex === 3 && <ThresholdSettings />} {menuOptionIndex === 3 && <ThresholdSettings />}
{menuOptionIndex === 4 && <SpendingLimitSettings />} {menuOptionIndex === 4 && <SpendingLimitSettings />}
{menuOptionIndex === 5 && <Advanced />} {menuOptionIndex === 5 && <Advanced />}

Some files were not shown because too many files have changed in this diff Show More