(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', () => { 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'] const addresses = ['0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe']
expect(uniqueAddress(addresses)()).toBeUndefined() expect(uniqueAddress(addresses)('0x2D6F2B448b0F711Eb81f2929566504117d67E44F')).toBeUndefined()
}) })
it('Returns an error message for an array with duplicated values', async () => { it('Returns an error message if address is in the `addresses` list already', async () => {
const addresses = ['0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe', '0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe'] const addresses = ['0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe', '0x2D6F2B448b0F711Eb81f2929566504117d67E44F']
expect(uniqueAddress(addresses)()).toEqual(ADDRESS_REPEATED_ERROR) expect(uniqueAddress(addresses)('0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe')).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)
}) })
}) })

View File

@ -1,8 +1,10 @@
import { getWeb3 } from 'src/logic/wallets/getWeb3' import { List } from 'immutable'
import memoize from 'lodash.memoize' 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 { isFeatureEnabled } from 'src/config'
import { FEATURES } from 'src/config/networks/network.d' import { FEATURES } from 'src/config/networks/network.d'
import { List } from 'immutable'
type ValidatorReturnType = string | undefined type ValidatorReturnType = string | undefined
export type GenericValidatorType = (...args: unknown[]) => ValidatorReturnType 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 ADDRESS_REPEATED_ERROR = 'Address already introduced'
export const uniqueAddress = (addresses: string[] | List<string>): GenericValidatorType => (): ValidatorReturnType => { export const uniqueAddress = (addresses: string[] | List<string> = []) => (address?: string): string | undefined => {
// @ts-expect-error both list and array have signatures for map but TS thinks they're not compatible const addressExists = addresses.some((addressFromList) => sameAddress(addressFromList, address))
const lowercaseAddresses = addresses.map((address) => address.toLowerCase()) return addressExists ? ADDRESS_REPEATED_ERROR : undefined
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 composeValidators = (...validators: Validator[]) => (value: unknown): ValidatorReturnType => 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 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) return addressBook.map((entry) => entry.address)
}) }
export const getNameFromAddressBookSelector = createSelector( export const getNameFromAddressBookSelector = createSelector(
addressBookSelector, 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 const { location } = state.router
let entryAddressToEditOrCreateNew = null
if (location && location.query) { if (location?.query) {
const { entryAddress } = location.query const { entryAddress } = location.query
entryAddressToEditOrCreateNew = entryAddress return entryAddress
} }
return entryAddressToEditOrCreateNew
} }
export const safeCancellationTransactionsSelector = createSelector( export const safeCancellationTransactionsSelector = createSelector(

View File

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

View File

@ -1,6 +1,9 @@
import { createStyles, makeStyles } from '@material-ui/core/styles'
import { lg, md } from 'src/theme/variables' import { lg, md } from 'src/theme/variables'
export const styles = () => ({ export const useStyles = makeStyles(
createStyles({
heading: { heading: {
padding: lg, padding: lg,
justifyContent: 'space-between', justifyContent: 'space-between',
@ -23,4 +26,5 @@ export const styles = () => ({
smallerModalWindow: { smallerModalWindow: {
height: 'auto', height: 'auto',
}, },
}) }),
)

View File

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