Trim spaces from AddressInput (#1142)

* Remove spaces

* Change naming convention to make clear that only edge whitespaces are removed

Fix function documentation in string util

* Add trim spaces from address input in AddToken and AddAsset

* Use validator type

* Trim spaces on Safe App links

Co-authored-by: Mati Dastugue <mdastugu@amazon.com>
Co-authored-by: Mati Dastugue <matias.dastugue@altoros.com>
Co-authored-by: Mikhail Mikheev <mmvsha73@gmail.com>
This commit is contained in:
Daniel Sanchez 2020-07-29 15:28:43 +02:00 committed by GitHub
parent bbfa7d8166
commit 89c17180de
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 288 additions and 1026 deletions

View File

@ -220,17 +220,17 @@
"web3": "1.2.11" "web3": "1.2.11"
}, },
"devDependencies": { "devDependencies": {
"@types/history": "4.6.2",
"@types/lodash.memoize": "^4.1.6",
"@types/react-router-dom": "^5.1.5",
"@types/react-redux": "^7.1.9",
"@testing-library/jest-dom": "5.11.1", "@testing-library/jest-dom": "5.11.1",
"@testing-library/react": "10.4.7", "@testing-library/react": "10.4.7",
"@testing-library/user-event": "12.0.13", "@testing-library/user-event": "12.0.13",
"@types/history": "4.6.2",
"@types/jest": "^26.0.7", "@types/jest": "^26.0.7",
"@types/lodash.memoize": "^4.1.6",
"@types/node": "14.0.25", "@types/node": "14.0.25",
"@types/react": "^16.9.43", "@types/react": "^16.9.43",
"@types/react-dom": "^16.9.8", "@types/react-dom": "^16.9.8",
"@types/react-redux": "^7.1.9",
"@types/react-router-dom": "^5.1.5",
"@types/styled-components": "^5.1.1", "@types/styled-components": "^5.1.1",
"@typescript-eslint/eslint-plugin": "3.7.0", "@typescript-eslint/eslint-plugin": "3.7.0",
"@typescript-eslint/parser": "3.7.0", "@typescript-eslint/parser": "3.7.0",
@ -254,6 +254,7 @@
"node-sass": "^4.14.1", "node-sass": "^4.14.1",
"prettier": "2.0.5", "prettier": "2.0.5",
"react-app-rewired": "^2.1.6", "react-app-rewired": "^2.1.6",
"source-map-explorer": "^2.4.2",
"truffle": "5.1.35", "truffle": "5.1.35",
"typescript": "^3.9.7", "typescript": "^3.9.7",
"wait-on": "5.1.0", "wait-on": "5.1.0",

View File

@ -3,13 +3,27 @@ import { Field } from 'react-final-form'
import { OnChange } from 'react-final-form-listeners' import { OnChange } from 'react-final-form-listeners'
import TextField from 'src/components/forms/TextField' import TextField from 'src/components/forms/TextField'
import { composeValidators, mustBeEthereumAddress, required } from 'src/components/forms/validator' import { Validator, composeValidators, mustBeEthereumAddress, required } from 'src/components/forms/validator'
import { trimSpaces } from 'src/utils/strings'
import { getAddressFromENS } from 'src/logic/wallets/getWeb3' import { getAddressFromENS } from 'src/logic/wallets/getWeb3'
import { isValidEnsName } from 'src/logic/wallets/ethAddresses' import { isValidEnsName } from 'src/logic/wallets/ethAddresses'
// an idea for second field was taken from here // an idea for second field was taken from here
// https://github.com/final-form/react-final-form-listeners/blob/master/src/OnBlur.js // https://github.com/final-form/react-final-form-listeners/blob/master/src/OnBlur.js
export interface AddressInputProps {
fieldMutator: (address: string) => void
name?: string
text?: string
placeholder?: string
inputAdornment?: { endAdornment: React.ReactElement } | undefined
testId: string
validators?: Validator[]
defaultValue?: string
disabled?: boolean
className?: string
}
const AddressInput = ({ const AddressInput = ({
className = '', className = '',
name = 'recipientAddress', name = 'recipientAddress',
@ -21,7 +35,7 @@ const AddressInput = ({
validators = [], validators = [],
defaultValue, defaultValue,
disabled, disabled,
}: any) => ( }: AddressInputProps): React.ReactElement => (
<> <>
<Field <Field
className={className} className={className}
@ -38,14 +52,15 @@ const AddressInput = ({
/> />
<OnChange name={name}> <OnChange name={name}>
{async (value) => { {async (value) => {
if (isValidEnsName(value)) { const address = trimSpaces(value)
if (isValidEnsName(address)) {
try { try {
const resolverAddr = await getAddressFromENS(value) const resolverAddr = await getAddressFromENS(address)
fieldMutator(resolverAddr) fieldMutator(resolverAddr)
} catch (err) { } catch (err) {
console.error('Failed to resolve address for ENS name: ', err) console.error('Failed to resolve address for ENS name: ', err)
} }
} } else fieldMutator(address)
}} }}
</OnChange> </OnChange>
</> </>

View File

@ -3,15 +3,19 @@
import React from 'react' import React from 'react'
import { Field } from 'react-final-form' import { Field } from 'react-final-form'
import { trimSpaces } from 'src/utils/strings'
const DebounceValidationField = ({ debounce = 1000, validate, ...rest }: any) => { const DebounceValidationField = ({ debounce = 1000, validate, ...rest }: any) => {
let clearTimeout let clearTimeout
const localValidation = (value, values, fieldState) => { const localValidation = (value, values, fieldState) => {
const url = trimSpaces(value)
if (fieldState.active) { if (fieldState.active) {
return new Promise((resolve) => { return new Promise((resolve) => {
if (clearTimeout) clearTimeout() if (clearTimeout) clearTimeout()
const timerId = setTimeout(() => { const timerId = setTimeout(() => {
resolve(validate(value, values, fieldState)) resolve(validate(url, values, fieldState))
}, debounce) }, debounce)
clearTimeout = () => { clearTimeout = () => {
clearTimeout(timerId) clearTimeout(timerId)
@ -19,11 +23,11 @@ const DebounceValidationField = ({ debounce = 1000, validate, ...rest }: any) =>
} }
}) })
} else { } else {
return validate(value, values, fieldState) return validate(url, values, fieldState)
} }
} }
return <Field {...rest} validate={localValidation} /> return <Field {...rest} format={trimSpaces} validate={localValidation} />
} }
export default DebounceValidationField export default DebounceValidationField

View File

@ -7,7 +7,7 @@ import memoize from 'lodash.memoize'
type ValidatorReturnType = string | undefined type ValidatorReturnType = string | undefined
type GenericValidatorType = (...args: unknown[]) => ValidatorReturnType type GenericValidatorType = (...args: unknown[]) => ValidatorReturnType
type AsyncValidator = (...args: unknown[]) => Promise<ValidatorReturnType> type AsyncValidator = (...args: unknown[]) => Promise<ValidatorReturnType>
type Validator = GenericValidatorType | AsyncValidator export type Validator = GenericValidatorType | AsyncValidator
export const required = (value?: string): ValidatorReturnType => { export const required = (value?: string): ValidatorReturnType => {
const required = 'Required' const required = 'Required'

View File

@ -107,7 +107,6 @@ const Details = ({ classes, errors, form }) => {
<Block className={classes.root} margin="lg"> <Block className={classes.root} margin="lg">
<Col xs={11}> <Col xs={11}>
<AddressInput <AddressInput
component={TextField}
fieldMutator={(val) => { fieldMutator={(val) => {
form.mutators.setValue(FIELD_LOAD_ADDRESS, val) form.mutators.setValue(FIELD_LOAD_ADDRESS, val)
}} }}
@ -123,7 +122,6 @@ const Details = ({ classes, errors, form }) => {
name={FIELD_LOAD_ADDRESS} name={FIELD_LOAD_ADDRESS}
placeholder="Safe Address*" placeholder="Safe Address*"
text="Safe Address" text="Safe Address"
type="text"
testId="load-safe-address-field" testId="load-safe-address-field"
/> />
</Col> </Col>

View File

@ -135,7 +135,6 @@ const SafeOwners = (props) => {
</Col> </Col>
<Col className={classes.ownerAddress} xs={6}> <Col className={classes.ownerAddress} xs={6}>
<AddressInput <AddressInput
component={TextField}
fieldMutator={(val) => { fieldMutator={(val) => {
form.mutators.setValue(addressName, val) form.mutators.setValue(addressName, val)
}} }}
@ -151,7 +150,6 @@ const SafeOwners = (props) => {
name={addressName} name={addressName}
placeholder="Owner Address*" placeholder="Owner Address*"
text="Owner Address" text="Owner Address"
type="text"
validators={[getAddressValidator(otherAccounts, index)]} validators={[getAddressValidator(otherAccounts, index)]}
testId={`create-safe-address-field-${index}`} testId={`create-safe-address-field-${index}`}
/> />

View File

@ -5,6 +5,7 @@ import Autocomplete from '@material-ui/lab/Autocomplete'
import { List } from 'immutable' import { List } from 'immutable'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { trimSpaces } from 'src/utils/strings'
import { styles } from './style' import { styles } from './style'
@ -75,19 +76,20 @@ const AddressBookInput = ({
const [inputAddValue, setInputAddValue] = useState(recipientAddress) const [inputAddValue, setInputAddValue] = useState(recipientAddress)
const onAddressInputChanged = async (addressValue: string): Promise<void> => { const onAddressInputChanged = async (value: string): Promise<void> => {
setInputAddValue(addressValue) const normalizedAddress = trimSpaces(value)
let resolvedAddress = addressValue setInputAddValue(normalizedAddress)
let resolvedAddress = normalizedAddress
let isValidText let isValidText
if (inputTouched && !addressValue) { if (inputTouched && !normalizedAddress) {
setIsValidForm(false) setIsValidForm(false)
setValidationText('Required') setValidationText('Required')
setIsValidAddress(false) setIsValidAddress(false)
return return
} }
if (addressValue) { if (normalizedAddress) {
if (isValidEnsName(addressValue)) { if (isValidEnsName(normalizedAddress)) {
resolvedAddress = await getAddressFromENS(addressValue) resolvedAddress = await getAddressFromENS(normalizedAddress)
setInputAddValue(resolvedAddress) setInputAddValue(resolvedAddress)
} }
isValidText = mustBeEthereumAddress(resolvedAddress) isValidText = mustBeEthereumAddress(resolvedAddress)
@ -101,13 +103,13 @@ const AddressBookInput = ({
const filteredADBK = adbkToFilter.filter((adbkEntry) => { const filteredADBK = adbkToFilter.filter((adbkEntry) => {
const { address, name } = adbkEntry const { address, name } = adbkEntry
return ( return (
name.toLowerCase().includes(addressValue.toLowerCase()) || name.toLowerCase().includes(normalizedAddress.toLowerCase()) ||
address.toLowerCase().includes(addressValue.toLowerCase()) address.toLowerCase().includes(normalizedAddress.toLowerCase())
) )
}) })
setADBKList(filteredADBK) setADBKList(filteredADBK)
if (!isValidText) { if (!isValidText) {
setSelectedEntry({ address: addressValue }) setSelectedEntry({ address: normalizedAddress })
} }
} }
setIsValidForm(isValidText === undefined) setIsValidForm(isValidText === undefined)

View File

@ -48,7 +48,7 @@ const formMutators = {
const useStyles = makeStyles(styles as any) const useStyles = makeStyles(styles as any)
const SendFunds = ({ initialValues, onClose, onNext, recipientAddress, selectedToken = '' }) => { const SendFunds = ({ initialValues, onClose, onNext, recipientAddress, selectedToken = '' }): React.ReactElement => {
const classes = useStyles() const classes = useStyles()
const tokens = useSelector(extendedSafeTokensSelector) const tokens = useSelector(extendedSafeTokensSelector)
const addressBook = useSelector(getAddressBook) const addressBook = useSelector(getAddressBook)

View File

@ -9,7 +9,8 @@ import { getSymbolAndDecimalsFromContract } from './utils'
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, mustBeEthereumAddress, required } from 'src/components/forms/validator' import AddressInput from 'src/components/forms/AddressInput'
import { composeValidators, minMaxLength, required } 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'
@ -80,93 +81,102 @@ const AddCustomAsset = (props) => {
} }
} }
const formMutators = {
setAssetAddress: (args, state, utils) => {
utils.changeValue(state, 'address', () => args[0])
},
}
const goBack = () => { const goBack = () => {
setActiveScreen(parentList) setActiveScreen(parentList)
} }
return ( return (
<> <>
<GnoForm initialValues={formValues} onSubmit={handleSubmit} testId={ADD_CUSTOM_ASSET_FORM}> <GnoForm
{() => ( initialValues={formValues}
<> onSubmit={handleSubmit}
<Block className={classes.formContainer}> formMutators={formMutators}
<Paragraph className={classes.title} noMargin size="lg" weight="bolder"> testId={ADD_CUSTOM_ASSET_FORM}
Add custom asset >
</Paragraph> {(...args) => {
<Field const mutators = args[3]
className={classes.addressInput}
component={TextField} return (
name="address" <>
placeholder="Asset contract address*" <Block className={classes.formContainer}>
testId={ADD_CUSTOM_ASSET_ADDRESS_INPUT_TEST_ID} <Paragraph className={classes.title} noMargin size="lg" weight="bolder">
text="Token contract address*" Add custom asset
type="text" </Paragraph>
validate={composeValidators( <AddressInput
required, fieldMutator={mutators.setAssetAddress}
mustBeEthereumAddress, className={classes.addressInput}
doesntExistInAssetsList(nftAssetsList), name="address"
addressIsAssetContract, placeholder="Asset contract address*"
)} testId={ADD_CUSTOM_ASSET_ADDRESS_INPUT_TEST_ID}
/> text="Asset contract address*"
<FormSpy validators={[doesntExistInAssetsList(nftAssetsList), addressIsAssetContract]}
onChange={formSpyOnChangeHandler} />
subscription={{ <FormSpy
values: true, onChange={formSpyOnChangeHandler}
errors: true, subscription={{
validating: true, values: true,
dirty: true, errors: true,
submitSucceeded: true, validating: true,
}} dirty: true,
/> submitSucceeded: true,
<Row> }}
<Col layout="column" xs={6}> />
<Field <Row>
className={classes.addressInput} <Col layout="column" xs={6}>
component={TextField}
name="symbol"
placeholder="Token symbol*"
testId={ADD_CUSTOM_ASSET_SYMBOLS_INPUT_TEST_ID}
text="Token symbol"
type="text"
validate={composeValidators(required, minMaxLength(2, 12))}
/>
<Field
className={classes.addressInput}
component={TextField}
disabled
name="decimals"
placeholder="Token decimals*"
testId={ADD_CUSTOM_ASSET_DECIMALS_INPUT_TEST_ID}
text="Token decimals*"
type="text"
/>
<Block justify="center">
<Field <Field
className={classes.checkbox} className={classes.addressInput}
component={Checkbox} component={TextField}
name="showForAllSafes" name="symbol"
type="checkbox" placeholder="Token symbol*"
label="Activate assets for all Safes" testId={ADD_CUSTOM_ASSET_SYMBOLS_INPUT_TEST_ID}
text="Token symbol"
type="text"
validate={composeValidators(required, minMaxLength(2, 12))}
/> />
</Block> <Field
</Col> className={classes.addressInput}
<Col align="center" layout="column" xs={6}> component={TextField}
<Paragraph className={classes.tokenImageHeading}>Token Image</Paragraph> disabled
<Img alt="Token image" height={100} src={TokenPlaceholder} /> name="decimals"
</Col> placeholder="Token decimals*"
testId={ADD_CUSTOM_ASSET_DECIMALS_INPUT_TEST_ID}
text="Token decimals*"
type="text"
/>
<Block justify="center">
<Field
className={classes.checkbox}
component={Checkbox}
name="showForAllSafes"
type="checkbox"
label="Activate assets for all Safes"
/>
</Block>
</Col>
<Col align="center" layout="column" xs={6}>
<Paragraph className={classes.tokenImageHeading}>Token Image</Paragraph>
<Img alt="Token image" height={100} src={TokenPlaceholder} />
</Col>
</Row>
</Block>
<Hairline />
<Row align="center" className={classes.buttonRow}>
<Button minHeight={42} minWidth={140} onClick={goBack}>
Cancel
</Button>
<Button color="primary" minHeight={42} minWidth={140} type="submit" variant="contained">
Save
</Button>
</Row> </Row>
</Block> </>
<Hairline /> )
<Row align="center" className={classes.buttonRow}> }}
<Button minHeight={42} minWidth={140} onClick={goBack}>
Cancel
</Button>
<Button color="primary" minHeight={42} minWidth={140} type="submit" variant="contained">
Save
</Button>
</Row>
</>
)}
</GnoForm> </GnoForm>
</> </>
) )

View File

@ -9,7 +9,8 @@ import { addressIsTokenContract, doesntExistInTokenList } from './validators'
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, mustBeEthereumAddress, required } from 'src/components/forms/validator' import AddressInput from 'src/components/forms/AddressInput'
import { composeValidators, minMaxLength, required } 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'
@ -101,93 +102,102 @@ const AddCustomToken = (props) => {
} }
} }
const formMutators = {
setTokenAddress: (args, state, utils) => {
utils.changeValue(state, 'address', () => args[0])
},
}
const goBack = () => { const goBack = () => {
setActiveScreen(parentList) setActiveScreen(parentList)
} }
return ( return (
<> <>
<GnoForm initialValues={formValues} onSubmit={handleSubmit} testId={ADD_CUSTOM_TOKEN_FORM}> <GnoForm
{() => ( initialValues={formValues}
<> onSubmit={handleSubmit}
<Block className={classes.formContainer}> formMutators={formMutators}
<Paragraph className={classes.title} noMargin size="lg" weight="bolder"> testId={ADD_CUSTOM_TOKEN_FORM}
Add custom token >
</Paragraph> {(...args) => {
<Field const mutators = args[3]
className={classes.addressInput}
component={TextField} return (
name="address" <>
placeholder="Token contract address*" <Block className={classes.formContainer}>
testId={ADD_CUSTOM_TOKEN_ADDRESS_INPUT_TEST_ID} <Paragraph className={classes.title} noMargin size="lg" weight="bolder">
text="Token contract address*" Add custom token
type="text" </Paragraph>
validate={composeValidators( <AddressInput
required, fieldMutator={mutators.setTokenAddress}
mustBeEthereumAddress, className={classes.addressInput}
doesntExistInTokenList(tokens), name="address"
addressIsTokenContract, placeholder="Token contract address*"
)} testId={ADD_CUSTOM_TOKEN_ADDRESS_INPUT_TEST_ID}
/> text="Token contract address*"
<FormSpy validators={[doesntExistInTokenList(tokens), addressIsTokenContract]}
onChange={formSpyOnChangeHandler} />
subscription={{ <FormSpy
values: true, onChange={formSpyOnChangeHandler}
errors: true, subscription={{
validating: true, values: true,
dirty: true, errors: true,
submitSucceeded: true, validating: true,
}} dirty: true,
/> submitSucceeded: true,
<Row> }}
<Col layout="column" xs={6}> />
<Field <Row>
className={classes.addressInput} <Col layout="column" xs={6}>
component={TextField}
name="symbol"
placeholder="Token symbol*"
testId={ADD_CUSTOM_TOKEN_SYMBOLS_INPUT_TEST_ID}
text="Token symbol"
type="text"
validate={composeValidators(required, minMaxLength(2, 12))}
/>
<Field
className={classes.addressInput}
component={TextField}
disabled
name="decimals"
placeholder="Token decimals*"
testId={ADD_CUSTOM_TOKEN_DECIMALS_INPUT_TEST_ID}
text="Token decimals*"
type="text"
/>
<Block justify="center">
<Field <Field
className={classes.checkbox} className={classes.addressInput}
component={Checkbox} component={TextField}
name="showForAllSafes" name="symbol"
type="checkbox" placeholder="Token symbol*"
label="Activate token for all Safes" testId={ADD_CUSTOM_TOKEN_SYMBOLS_INPUT_TEST_ID}
text="Token symbol"
type="text"
validate={composeValidators(required, minMaxLength(2, 12))}
/> />
</Block> <Field
</Col> className={classes.addressInput}
<Col align="center" layout="column" xs={6}> component={TextField}
<Paragraph className={classes.tokenImageHeading}>Token Image</Paragraph> disabled
<Img alt="Token image" height={100} src={TokenPlaceholder} /> name="decimals"
</Col> placeholder="Token decimals*"
testId={ADD_CUSTOM_TOKEN_DECIMALS_INPUT_TEST_ID}
text="Token decimals*"
type="text"
/>
<Block justify="center">
<Field
className={classes.checkbox}
component={Checkbox}
name="showForAllSafes"
type="checkbox"
label="Activate token for all Safes"
/>
</Block>
</Col>
<Col align="center" layout="column" xs={6}>
<Paragraph className={classes.tokenImageHeading}>Token Image</Paragraph>
<Img alt="Token image" height={100} src={TokenPlaceholder} />
</Col>
</Row>
</Block>
<Hairline />
<Row align="center" className={classes.buttonRow}>
<Button minHeight={42} minWidth={140} onClick={goBack}>
Cancel
</Button>
<Button color="primary" minHeight={42} minWidth={140} type="submit" variant="contained">
Save
</Button>
</Row> </Row>
</Block> </>
<Hairline /> )
<Row align="center" className={classes.buttonRow}> }}
<Button minHeight={42} minWidth={140} onClick={goBack}>
Cancel
</Button>
<Button color="primary" minHeight={42} minWidth={140} type="submit" variant="contained">
Save
</Button>
</Row>
</>
)}
</GnoForm> </GnoForm>
</> </>
) )

View File

@ -120,7 +120,6 @@ const OwnerForm = ({ classes, onClose, onSubmit, ownerAddress, ownerName }) => {
<Col xs={8}> <Col xs={8}>
<AddressInput <AddressInput
className={classes.addressInput} className={classes.addressInput}
component={TextField}
fieldMutator={mutators.setOwnerAddress} fieldMutator={mutators.setOwnerAddress}
name="ownerAddress" name="ownerAddress"
placeholder="Owner address*" placeholder="Owner address*"

View File

@ -45,3 +45,10 @@ export const textShortener = ({ charsEnd = 10, charsStart = 10, ellipsis = '...'
return `${textStart}${ellipsis}${textEnd}` return `${textStart}${ellipsis}${textEnd}`
} }
/**
* Util to remove whitespace from both sides of a string.
* @param {string} value
* @returns {string} string without side whitespaces
*/
export const trimSpaces = (value: string): string => (value === undefined ? '' : value.trim())

896
yarn.lock

File diff suppressed because it is too large Load Diff