(Fix) Duplicated address validation (#1699)

* use createStyles/makeStyles

* simplify `addressBookQueryParamsSelector`

* avoid using `createSelector`

as memoization in this scenario is not working as expected and list is not refreshed

* refactor `uniqueAddress` curried function and strategy to validate

- `selectedEntry` being `null` made the code harder to follow

* fix `uniqueAddress` validator tests

* use arrow function
This commit is contained in:
Fernando 2020-12-10 12:07:38 -03:00 committed by GitHub
parent 4079ff9abe
commit 71f1ea7ad1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 116 additions and 111 deletions

View File

@ -166,22 +166,16 @@ describe('Forms > Validators', () => {
})
describe('uniqueAddress validator', () => {
it('Returns undefined for an address not contained in the passed array', async () => {
it('Returns undefined if `addresses` does not contains the provided address', async () => {
const addresses = ['0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe']
expect(uniqueAddress(addresses)()).toBeUndefined()
expect(uniqueAddress(addresses)('0x2D6F2B448b0F711Eb81f2929566504117d67E44F')).toBeUndefined()
})
it('Returns an error message for an array with duplicated values', async () => {
const addresses = ['0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe', '0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe']
it('Returns an error message if address is in the `addresses` list already', async () => {
const addresses = ['0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe', '0x2D6F2B448b0F711Eb81f2929566504117d67E44F']
expect(uniqueAddress(addresses)()).toEqual(ADDRESS_REPEATED_ERROR)
})
it('Returns an error message for an array with duplicated checksum and not checksum values', async () => {
const addresses = ['0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe', '0xde0b295669a9fd93d5f28d9ec85e40f4cb697bae']
expect(uniqueAddress(addresses)()).toEqual(ADDRESS_REPEATED_ERROR)
expect(uniqueAddress(addresses)('0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe')).toEqual(ADDRESS_REPEATED_ERROR)
})
})

View File

@ -1,8 +1,10 @@
import { getWeb3 } from 'src/logic/wallets/getWeb3'
import { List } from 'immutable'
import memoize from 'lodash.memoize'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
import { getWeb3 } from 'src/logic/wallets/getWeb3'
import { isFeatureEnabled } from 'src/config'
import { FEATURES } from 'src/config/networks/network.d'
import { List } from 'immutable'
type ValidatorReturnType = string | undefined
export type GenericValidatorType = (...args: unknown[]) => ValidatorReturnType
@ -87,17 +89,9 @@ export const minMaxLength = (minLen: number, maxLen: number) => (value: string):
export const ADDRESS_REPEATED_ERROR = 'Address already introduced'
export const uniqueAddress = (addresses: string[] | List<string>): GenericValidatorType => (): ValidatorReturnType => {
// @ts-expect-error both list and array have signatures for map but TS thinks they're not compatible
const lowercaseAddresses = addresses.map((address) => address.toLowerCase())
const uniqueAddresses = new Set(lowercaseAddresses)
const lengthPropName = 'size' in addresses ? 'size' : 'length'
if (uniqueAddresses.size !== addresses?.[lengthPropName]) {
return ADDRESS_REPEATED_ERROR
}
return undefined
export const uniqueAddress = (addresses: string[] | List<string> = []) => (address?: string): string | undefined => {
const addressExists = addresses.some((addressFromList) => sameAddress(addressFromList, address))
return addressExists ? ADDRESS_REPEATED_ERROR : undefined
}
export const composeValidators = (...validators: Validator[]) => (value: unknown): ValidatorReturnType =>

View File

@ -8,9 +8,10 @@ import { AddressBookState } from 'src/logic/addressBook/model/addressBook'
export const addressBookSelector = (state: AppReduxState): AddressBookState => state[ADDRESS_BOOK_REDUCER_ID]
export const addressBookAddressesListSelector = createSelector(addressBookSelector, (addressBook): string[] => {
export const addressBookAddressesListSelector = (state: AppReduxState): string[] => {
const addressBook = addressBookSelector(state)
return addressBook.map((entry) => entry.address)
})
}
export const getNameFromAddressBookSelector = createSelector(
addressBookSelector,

View File

@ -72,14 +72,13 @@ export const safeTransactionsSelector = createSelector(
},
)
export const addressBookQueryParamsSelector = (state: AppReduxState): string | null => {
export const addressBookQueryParamsSelector = (state: AppReduxState): string | undefined => {
const { location } = state.router
let entryAddressToEditOrCreateNew = null
if (location && location.query) {
if (location?.query) {
const { entryAddress } = location.query
entryAddressToEditOrCreateNew = entryAddress
return entryAddress
}
return entryAddressToEditOrCreateNew
}
export const safeCancellationTransactionsSelector = createSelector(

View File

@ -1,10 +1,9 @@
import IconButton from '@material-ui/core/IconButton'
import { withStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close'
import React from 'react'
import React, { ReactElement } from 'react'
import { useSelector } from 'react-redux'
import { styles } from './style'
import { useStyles } from './style'
import Modal from 'src/components/Modal'
import { ScanQRWrapper } from 'src/components/ScanQRModal/ScanQRWrapper'
@ -20,55 +19,69 @@ import Hairline from 'src/components/layout/Hairline'
import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row'
import { addressBookAddressesListSelector } from 'src/logic/addressBook/store/selectors'
import { AddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { Entry } from 'src/routes/safe/components/AddressBook/index'
export const CREATE_ENTRY_INPUT_NAME_ID = 'create-entry-input-name'
export const CREATE_ENTRY_INPUT_ADDRESS_ID = 'create-entry-input-address'
export const SAVE_NEW_ENTRY_BTN_ID = 'save-new-entry-btn-id'
const CreateEditEntryModalComponent = ({
classes,
const formMutators = {
setOwnerAddress: (args, state, utils) => {
utils.changeValue(state, 'address', () => args[0])
},
}
type CreateEditEntryModalProps = {
editEntryModalHandler: (entry: AddressBookEntry) => void
entryToEdit: Entry
isOpen: boolean
newEntryModalHandler: (entry: AddressBookEntry) => void
onClose: () => void
}
export const CreateEditEntryModal = ({
editEntryModalHandler,
entryToEdit,
isOpen,
newEntryModalHandler,
onClose,
}) => {
const onFormSubmitted = (values) => {
if (entryToEdit && !entryToEdit.entry.isNew) {
editEntryModalHandler(values)
} else {
}: CreateEditEntryModalProps): ReactElement => {
const classes = useStyles()
const { isNew, ...initialValues } = entryToEdit.entry
const onFormSubmitted = (values: AddressBookEntry) => {
if (isNew) {
newEntryModalHandler(values)
} else {
editEntryModalHandler(values)
}
}
const addressBookAddressesList = useSelector(addressBookAddressesListSelector)
const entryDoesntExist = uniqueAddress(addressBookAddressesList)
const formMutators = {
setOwnerAddress: (args, state, utils) => {
utils.changeValue(state, 'address', () => args[0])
},
}
const storedAddresses = useSelector(addressBookAddressesListSelector)
const isUniqueAddress = uniqueAddress(storedAddresses)
return (
<Modal
description={entryToEdit ? 'Edit addressBook entry' : 'Create new addressBook entry'}
description={isNew ? 'Create new addressBook entry' : 'Edit addressBook entry'}
handleClose={onClose}
open={isOpen}
paperClassName={classes.smallerModalWindow}
title={entryToEdit ? 'Edit entry' : 'Create new entry'}
title={isNew ? 'Create new entry' : 'Edit entry'}
>
<Row align="center" className={classes.heading} grow>
<Paragraph className={classes.manage} noMargin weight="bolder">
{entryToEdit ? 'Edit entry' : 'Create entry'}
{isNew ? 'Create entry' : 'Edit entry'}
</Paragraph>
<IconButton disableRipple onClick={onClose}>
<Close className={classes.close} />
</IconButton>
</Row>
<Hairline />
<GnoForm formMutators={formMutators} onSubmit={onFormSubmitted}>
<GnoForm formMutators={formMutators} onSubmit={onFormSubmitted} initialValues={initialValues}>
{(...args) => {
const formState = args[2]
const mutators = args[3]
const handleScan = (value, closeQrModal) => {
let scannedAddress = value
@ -86,13 +99,11 @@ const CreateEditEntryModalComponent = ({
<Row margin="md">
<Col xs={11}>
<Field
className={classes.addressInput}
component={TextField}
defaultValue={entryToEdit ? entryToEdit.entry.name : undefined}
name="name"
placeholder={entryToEdit ? 'Entry name' : 'New entry'}
placeholder="Name"
testId={CREATE_ENTRY_INPUT_NAME_ID}
text={entryToEdit ? 'Entry*' : 'New entry*'}
text="Name"
type="text"
validate={composeValidators(required, minMaxLength(1, 50))}
/>
@ -101,18 +112,16 @@ const CreateEditEntryModalComponent = ({
<Row margin="md">
<Col xs={11}>
<AddressInput
className={classes.addressInput}
defaultValue={entryToEdit ? entryToEdit.entry.address : undefined}
disabled={!!entryToEdit}
disabled={!isNew}
fieldMutator={mutators.setOwnerAddress}
name="address"
placeholder="Owner address*"
placeholder="Address*"
testId={CREATE_ENTRY_INPUT_ADDRESS_ID}
text="Owner address*"
validators={entryToEdit ? undefined : [entryDoesntExist]}
text="Address*"
validators={[(value?: string) => (isNew ? isUniqueAddress(value) : undefined)]}
/>
</Col>
{!entryToEdit ? (
{isNew ? (
<Col center="xs" className={classes} middle="xs" xs={1}>
<ScanQRWrapper handleScan={handleScan} />
</Col>
@ -131,8 +140,9 @@ const CreateEditEntryModalComponent = ({
testId={SAVE_NEW_ENTRY_BTN_ID}
type="submit"
variant="contained"
disabled={!formState.valid}
>
{entryToEdit ? 'Save' : 'Create'}
{isNew ? 'Create' : 'Save'}
</Button>
</Row>
</>
@ -142,5 +152,3 @@ const CreateEditEntryModalComponent = ({
</Modal>
)
}
export default withStyles(styles as any)(CreateEditEntryModalComponent)

View File

@ -1,26 +1,30 @@
import { createStyles, makeStyles } from '@material-ui/core/styles'
import { lg, md } from 'src/theme/variables'
export const styles = () => ({
heading: {
padding: lg,
justifyContent: 'space-between',
boxSizing: 'border-box',
},
manage: {
fontSize: lg,
},
container: {
padding: `${md} ${lg}`,
},
close: {
height: '35px',
width: '35px',
},
buttonRow: {
height: '84px',
justifyContent: 'center',
},
smallerModalWindow: {
height: 'auto',
},
})
export const useStyles = makeStyles(
createStyles({
heading: {
padding: lg,
justifyContent: 'space-between',
boxSizing: 'border-box',
},
manage: {
fontSize: lg,
},
container: {
padding: `${md} ${lg}`,
},
close: {
height: '35px',
width: '35px',
},
buttonRow: {
height: '84px',
justifyContent: 'center',
},
smallerModalWindow: {
height: 'auto',
},
}),
)

View File

@ -3,7 +3,7 @@ import TableContainer from '@material-ui/core/TableContainer'
import TableRow from '@material-ui/core/TableRow'
import { makeStyles } from '@material-ui/core/styles'
import cn from 'classnames'
import React, { useEffect, useState } from 'react'
import React, { ReactElement, useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { styles } from './style'
@ -21,8 +21,8 @@ import { addAddressBookEntry } from 'src/logic/addressBook/store/actions/addAddr
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 { isUserAnOwnerOfAnySafe } from 'src/logic/wallets/ethAddresses'
import CreateEditEntryModal from 'src/routes/safe/components/AddressBook/CreateEditEntryModal'
import { isUserAnOwnerOfAnySafe, sameAddress } from 'src/logic/wallets/ethAddresses'
import { CreateEditEntryModal } from 'src/routes/safe/components/AddressBook/CreateEditEntryModal'
import DeleteEntryModal from 'src/routes/safe/components/AddressBook/DeleteEntryModal'
import {
AB_ADDRESS_ID,
@ -47,20 +47,24 @@ interface AddressBookSelectedEntry extends AddressBookEntry {
isNew?: boolean
}
const AddressBookTable = (): React.ReactElement => {
export type Entry = {
entry: AddressBookSelectedEntry
index?: number
isOwnerAddress?: boolean
}
const initialEntryState: Entry = { entry: { address: '', name: '', isNew: true } }
const AddressBookTable = (): ReactElement => {
const classes = useStyles()
const columns = generateColumns()
const autoColumns = columns.filter((c) => !c.custom)
const autoColumns = columns.filter(({ custom }) => !custom)
const dispatch = useDispatch()
const safesList = useSelector(safesListSelector)
const entryAddressToEditOrCreateNew = useSelector(addressBookQueryParamsSelector)
const addressBook = useSelector(addressBookSelector)
const granted = useSelector(grantedSelector)
const [selectedEntry, setSelectedEntry] = useState<{
entry?: AddressBookSelectedEntry
index?: number
isOwnerAddress?: boolean
} | null>(null)
const [selectedEntry, setSelectedEntry] = useState<Entry>(initialEntryState)
const [editCreateEntryModalOpen, setEditCreateEntryModalOpen] = useState(false)
const [deleteEntryModalOpen, setDeleteEntryModalOpen] = useState(false)
const [sendFundsModalOpen, setSendFundsModalOpen] = useState(false)
@ -78,8 +82,9 @@ const AddressBookTable = (): React.ReactElement => {
useEffect(() => {
if (entryAddressToEditOrCreateNew) {
const checksumEntryAdd = checksumAddress(entryAddressToEditOrCreateNew)
const oldEntryIndex = addressBook.findIndex((entry) => entry.address === checksumEntryAdd)
const address = checksumAddress(entryAddressToEditOrCreateNew)
const oldEntryIndex = addressBook.findIndex((entry) => sameAddress(entry.address, address))
if (oldEntryIndex >= 0) {
// Edit old entry
setSelectedEntry({ entry: addressBook[oldEntryIndex], index: oldEntryIndex })
@ -88,7 +93,7 @@ const AddressBookTable = (): React.ReactElement => {
setSelectedEntry({
entry: {
name: '',
address: checksumEntryAdd,
address,
isNew: true,
},
})
@ -96,7 +101,7 @@ const AddressBookTable = (): React.ReactElement => {
}
}, [addressBook, entryAddressToEditOrCreateNew])
const newEntryModalHandler = (entry) => {
const newEntryModalHandler = (entry: AddressBookEntry) => {
setEditCreateEntryModalOpen(false)
const checksumEntries = {
...entry,
@ -105,8 +110,8 @@ const AddressBookTable = (): React.ReactElement => {
dispatch(addAddressBookEntry(makeAddressBookEntry(checksumEntries)))
}
const editEntryModalHandler = (entry) => {
setSelectedEntry(null)
const editEntryModalHandler = (entry: AddressBookEntry) => {
setSelectedEntry(initialEntryState)
setEditCreateEntryModalOpen(false)
const checksumEntries = {
...entry,
@ -116,8 +121,8 @@ const AddressBookTable = (): React.ReactElement => {
}
const deleteEntryModalHandler = () => {
const entryAddress = selectedEntry && selectedEntry.entry ? checksumAddress(selectedEntry.entry.address) : ''
setSelectedEntry(null)
const entryAddress = selectedEntry?.entry ? checksumAddress(selectedEntry.entry.address) : ''
setSelectedEntry(initialEntryState)
setDeleteEntryModalOpen(false)
dispatch(removeAddressBookEntry(entryAddress))
}
@ -128,8 +133,8 @@ const AddressBookTable = (): React.ReactElement => {
<Col end="sm" xs={12}>
<ButtonLink
onClick={() => {
setSelectedEntry(null)
setEditCreateEntryModalOpen(!editCreateEntryModalOpen)
setSelectedEntry(initialEntryState)
setEditCreateEntryModalOpen(true)
}}
size="lg"
testId="manage-tokens-btn"