mirror of
https://github.com/status-im/safe-react.git
synced 2025-01-13 03:24:09 +00:00
Merge branch 'development' into issue-1144
This commit is contained in:
commit
b84db710e8
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@ -14,6 +14,7 @@ env:
|
|||||||
REACT_APP_PORTIS_ID: ${{ secrets.REACT_APP_PORTIS_ID }}
|
REACT_APP_PORTIS_ID: ${{ secrets.REACT_APP_PORTIS_ID }}
|
||||||
REACT_APP_GNOSIS_APPS_URL: ${{ secrets.REACT_APP_GNOSIS_APPS_URL }}
|
REACT_APP_GNOSIS_APPS_URL: ${{ secrets.REACT_APP_GNOSIS_APPS_URL }}
|
||||||
REACT_APP_INTERCOM_ID: ${{ secrets.REACT_APP_INTERCOM_ID }}
|
REACT_APP_INTERCOM_ID: ${{ secrets.REACT_APP_INTERCOM_ID }}
|
||||||
|
REACT_APP_IPFS_GATEWAY: ${{ secrets.REACT_APP_IPFS_GATEWAY }}
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
12
package.json
12
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "safe-react",
|
"name": "safe-react",
|
||||||
"version": "2.7.2",
|
"version": "2.8.1",
|
||||||
"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": {
|
||||||
@ -191,6 +191,7 @@
|
|||||||
"immortal-db": "^1.0.3",
|
"immortal-db": "^1.0.3",
|
||||||
"immutable": "^4.0.0-rc.12",
|
"immutable": "^4.0.0-rc.12",
|
||||||
"js-cookie": "^2.2.1",
|
"js-cookie": "^2.2.1",
|
||||||
|
"lodash.debounce": "^4.0.8",
|
||||||
"lodash.memoize": "^4.1.2",
|
"lodash.memoize": "^4.1.2",
|
||||||
"material-ui-search-bar": "^1.0.0-beta.13",
|
"material-ui-search-bar": "^1.0.0-beta.13",
|
||||||
"notistack": "https://github.com/gnosis/notistack.git#v0.9.4",
|
"notistack": "https://github.com/gnosis/notistack.git#v0.9.4",
|
||||||
@ -220,17 +221,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 +255,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",
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
@ -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
|
||||||
|
@ -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'
|
||||||
|
@ -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>
|
||||||
|
@ -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}`}
|
||||||
/>
|
/>
|
||||||
|
@ -1,205 +0,0 @@
|
|||||||
import { Checkbox, Text, TextField } from '@gnosis.pm/safe-react-components'
|
|
||||||
import memoize from 'lodash.memoize'
|
|
||||||
import React, { useState } from 'react'
|
|
||||||
import { FormSpy } from 'react-final-form'
|
|
||||||
import styled from 'styled-components'
|
|
||||||
|
|
||||||
import Field from 'src/components/forms/Field'
|
|
||||||
import DebounceValidationField from 'src/components/forms/Field/DebounceValidationField'
|
|
||||||
import GnoForm from 'src/components/forms/GnoForm'
|
|
||||||
import { required } from 'src/components/forms/validator'
|
|
||||||
import Img from 'src/components/layout/Img'
|
|
||||||
import { getContentFromENS } from 'src/logic/wallets/getWeb3'
|
|
||||||
import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg'
|
|
||||||
import { isValid as isURLValid } from 'src/utils/url'
|
|
||||||
|
|
||||||
import { getAppInfoFromUrl } from './utils'
|
|
||||||
import { SafeApp } from './types'
|
|
||||||
|
|
||||||
const APP_INFO: SafeApp = {
|
|
||||||
id: undefined,
|
|
||||||
url: '',
|
|
||||||
name: '',
|
|
||||||
iconUrl: appsIconSvg,
|
|
||||||
error: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
const StyledText = styled(Text)`
|
|
||||||
margin-bottom: 19px;
|
|
||||||
`
|
|
||||||
|
|
||||||
const StyledTextFileAppName = styled(TextField)`
|
|
||||||
&& {
|
|
||||||
width: 335px;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
const AppInfo = styled.div`
|
|
||||||
margin: 36px 0 24px 0;
|
|
||||||
|
|
||||||
img {
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
const StyledCheckbox = styled(Checkbox)`
|
|
||||||
margin: 0;
|
|
||||||
`
|
|
||||||
|
|
||||||
const uniqueAppValidator = memoize((appList, value) => {
|
|
||||||
const exists = appList.some((a) => {
|
|
||||||
try {
|
|
||||||
const currentUrl = new URL(a.url)
|
|
||||||
const newUrl = new URL(value)
|
|
||||||
return currentUrl.href === newUrl.href
|
|
||||||
} catch (error) {
|
|
||||||
return 'There was a problem trying to validate the URL existence.'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return exists ? 'This app is already registered.' : undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
const getIpfsLinkFromEns = memoize(async (name) => {
|
|
||||||
try {
|
|
||||||
const content = await getContentFromENS(name)
|
|
||||||
if (content && content.protocolType === 'ipfs') {
|
|
||||||
return `${process.env.REACT_APP_IPFS_GATEWAY}/${content.decoded}/`
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const getUrlFromFormValue = memoize(async (value: string) => {
|
|
||||||
const isUrlValid = isURLValid(value)
|
|
||||||
let ensContent
|
|
||||||
if (!isUrlValid) {
|
|
||||||
ensContent = await getIpfsLinkFromEns(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isUrlValid && ensContent === undefined) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
return isUrlValid ? value : ensContent
|
|
||||||
})
|
|
||||||
|
|
||||||
const curriedSafeAppValidator = memoize((appList) => async (value: string) => {
|
|
||||||
const url = await getUrlFromFormValue(value)
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
return 'Provide a valid url or ENS name.'
|
|
||||||
}
|
|
||||||
|
|
||||||
const appExistsRes = uniqueAppValidator(appList, url)
|
|
||||||
if (appExistsRes) {
|
|
||||||
return appExistsRes
|
|
||||||
}
|
|
||||||
|
|
||||||
const appInfo = await getAppInfoFromUrl(url)
|
|
||||||
if (appInfo.error) {
|
|
||||||
return 'This is not a valid Safe app.'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const composeValidatorsApps = (...validators) => (value, values, meta) => {
|
|
||||||
if (!meta.modified) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return validators.reduce((error, validator) => error || validator(value), undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
formId: string
|
|
||||||
appList: Array<SafeApp>
|
|
||||||
closeModal: () => void
|
|
||||||
onAppAdded: (app: SafeApp) => void
|
|
||||||
setIsSubmitDisabled: (status: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const AddAppForm = ({ appList, formId, closeModal, onAppAdded, setIsSubmitDisabled }: Props) => {
|
|
||||||
const [appInfo, setAppInfo] = useState<SafeApp>(APP_INFO)
|
|
||||||
const safeAppValidator = curriedSafeAppValidator(appList)
|
|
||||||
|
|
||||||
const onFormStatusChange = async ({ pristine, valid, validating, values, errors }) => {
|
|
||||||
if (!pristine) {
|
|
||||||
setIsSubmitDisabled(validating || !valid)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (validating) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!values.appUrl || !values.appUrl.length || errors.appUrl) {
|
|
||||||
setAppInfo(APP_INFO)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = await getUrlFromFormValue(values.appUrl)
|
|
||||||
const appInfo = await getAppInfoFromUrl(url)
|
|
||||||
setAppInfo({ ...appInfo })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
closeModal()
|
|
||||||
onAppAdded(appInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onTextFieldChange = () => {}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<GnoForm
|
|
||||||
initialValues={{
|
|
||||||
appUrl: '',
|
|
||||||
agreed: false,
|
|
||||||
}}
|
|
||||||
// submit is triggered from ManageApps Component
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
testId={formId}
|
|
||||||
>
|
|
||||||
{() => (
|
|
||||||
<>
|
|
||||||
<StyledText size="xl">Add custom app</StyledText>
|
|
||||||
<DebounceValidationField
|
|
||||||
component={TextField}
|
|
||||||
label="App URL"
|
|
||||||
name="appUrl"
|
|
||||||
placeholder="App URL"
|
|
||||||
type="text"
|
|
||||||
validate={composeValidatorsApps(required, safeAppValidator)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AppInfo>
|
|
||||||
<Img alt="Token image" height={55} src={appInfo.iconUrl} />
|
|
||||||
<StyledTextFileAppName label="App name" readOnly value={appInfo.name} onChange={onTextFieldChange} />
|
|
||||||
</AppInfo>
|
|
||||||
|
|
||||||
<FormSpy
|
|
||||||
onChange={onFormStatusChange}
|
|
||||||
subscription={{
|
|
||||||
values: true,
|
|
||||||
valid: true,
|
|
||||||
errors: true,
|
|
||||||
pristine: true,
|
|
||||||
validating: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Field
|
|
||||||
component={StyledCheckbox}
|
|
||||||
label={
|
|
||||||
<p>
|
|
||||||
This app is not a Gnosis product and I agree to use this app <br /> at my own risk.
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
name="agreed"
|
|
||||||
type="checkbox"
|
|
||||||
validate={required}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</GnoForm>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AddAppForm
|
|
36
src/routes/safe/components/Apps/AddAppForm/AppAgreement.tsx
Normal file
36
src/routes/safe/components/Apps/AddAppForm/AppAgreement.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { Checkbox, Text } from '@gnosis.pm/safe-react-components'
|
||||||
|
import React from 'react'
|
||||||
|
import { useFormState } from 'react-final-form'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
import { required } from 'src/components/forms/validator'
|
||||||
|
import Field from 'src/components/forms/Field'
|
||||||
|
|
||||||
|
const StyledCheckbox = styled(Checkbox)`
|
||||||
|
margin: 0;
|
||||||
|
`
|
||||||
|
|
||||||
|
const AppAgreement = (): React.ReactElement => {
|
||||||
|
const { visited } = useFormState({ subscription: { visited: true } })
|
||||||
|
|
||||||
|
// trick to prevent having the field validated by default. Not sure why this happens in this form
|
||||||
|
const validate = !visited.agreementAccepted ? undefined : required
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field
|
||||||
|
component={StyledCheckbox}
|
||||||
|
label={
|
||||||
|
<Text size="xl">
|
||||||
|
This app is not a Gnosis product and I agree to use this app
|
||||||
|
<br />
|
||||||
|
at my own risk.
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
name="agreementAccepted"
|
||||||
|
type="checkbox"
|
||||||
|
validate={validate}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppAgreement
|
62
src/routes/safe/components/Apps/AddAppForm/AppUrl.tsx
Normal file
62
src/routes/safe/components/Apps/AddAppForm/AppUrl.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { TextField } from '@gnosis.pm/safe-react-components'
|
||||||
|
import createDecorator from 'final-form-calculate'
|
||||||
|
import React from 'react'
|
||||||
|
import { useField, useFormState } from 'react-final-form'
|
||||||
|
|
||||||
|
import { SafeApp } from 'src/routes/safe/components/Apps/types'
|
||||||
|
import { getAppInfoFromUrl, getIpfsLinkFromEns, uniqueApp } from 'src/routes/safe/components/Apps/utils'
|
||||||
|
import { composeValidators, required } from 'src/components/forms/validator'
|
||||||
|
import Field from 'src/components/forms/Field'
|
||||||
|
import { isValid as isURLValid } from 'src/utils/url'
|
||||||
|
import { isValidEnsName } from 'src/logic/wallets/ethAddresses'
|
||||||
|
import { useDebounce } from 'src/routes/safe/container/hooks/useDebounce'
|
||||||
|
|
||||||
|
const validateUrl = (url: string): string | undefined => (isURLValid(url) ? undefined : 'Invalid URL')
|
||||||
|
|
||||||
|
export const appUrlResolver = createDecorator({
|
||||||
|
field: 'appUrl',
|
||||||
|
updates: {
|
||||||
|
appUrl: async (appUrl: string): Promise<string | undefined> => {
|
||||||
|
const ensContent = !isURLValid(appUrl) && isValidEnsName(appUrl) && (await getIpfsLinkFromEns(appUrl))
|
||||||
|
|
||||||
|
if (ensContent) {
|
||||||
|
return ensContent
|
||||||
|
}
|
||||||
|
|
||||||
|
return appUrl
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const AppInfoUpdater = ({ onAppInfo }: { onAppInfo: (appInfo: SafeApp) => void }): React.ReactElement => {
|
||||||
|
const {
|
||||||
|
input: { value: appUrl },
|
||||||
|
} = useField('appUrl', { subscription: { value: true } })
|
||||||
|
const debouncedValue = useDebounce(appUrl, 500)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const updateAppInfo = async () => {
|
||||||
|
const appInfo = await getAppInfoFromUrl(debouncedValue)
|
||||||
|
onAppInfo({ ...appInfo })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isURLValid(debouncedValue)) {
|
||||||
|
updateAppInfo()
|
||||||
|
}
|
||||||
|
}, [debouncedValue, onAppInfo])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppUrl = ({ appList }: { appList: SafeApp[] }): React.ReactElement => {
|
||||||
|
const { visited } = useFormState({ subscription: { visited: true } })
|
||||||
|
|
||||||
|
// trick to prevent having the field validated by default. Not sure why this happens in this form
|
||||||
|
const validate = !visited.appUrl ? undefined : composeValidators(required, validateUrl, uniqueApp(appList))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field label="App URL" name="appUrl" placeholder="App URL" type="text" component={TextField} validate={validate} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppUrl
|
@ -0,0 +1,27 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useFormState } from 'react-final-form'
|
||||||
|
|
||||||
|
import { SafeApp } from 'src/routes/safe/components/Apps/types'
|
||||||
|
import { isAppManifestValid } from 'src/routes/safe/components/Apps/utils'
|
||||||
|
|
||||||
|
interface SubmitButtonStatusProps {
|
||||||
|
appInfo: SafeApp
|
||||||
|
onSubmitButtonStatusChange: (disabled: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SubmitButtonStatus = ({ appInfo, onSubmitButtonStatusChange }: SubmitButtonStatusProps): React.ReactElement => {
|
||||||
|
const { valid, validating, visited } = useFormState({
|
||||||
|
subscription: { valid: true, validating: true, visited: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
// if non visited, fields were not evaluated yet. Then, the default value is considered invalid
|
||||||
|
const fieldsVisited = visited.agreementAccepted && visited.appUrl
|
||||||
|
|
||||||
|
onSubmitButtonStatusChange(validating || !valid || !fieldsVisited || !isAppManifestValid(appInfo))
|
||||||
|
}, [validating, valid, visited, onSubmitButtonStatusChange, appInfo])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SubmitButtonStatus
|
90
src/routes/safe/components/Apps/AddAppForm/index.tsx
Normal file
90
src/routes/safe/components/Apps/AddAppForm/index.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { Text, TextField } from '@gnosis.pm/safe-react-components'
|
||||||
|
import React from 'react'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
import AppAgreement from './AppAgreement'
|
||||||
|
import AppUrl, { AppInfoUpdater, appUrlResolver } from './AppUrl'
|
||||||
|
import SubmitButtonStatus from './SubmitButtonStatus'
|
||||||
|
|
||||||
|
import { SafeApp } from 'src/routes/safe/components/Apps/types'
|
||||||
|
import GnoForm from 'src/components/forms/GnoForm'
|
||||||
|
import Img from 'src/components/layout/Img'
|
||||||
|
import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg'
|
||||||
|
|
||||||
|
const StyledText = styled(Text)`
|
||||||
|
margin-bottom: 19px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const StyledTextFileAppName = styled(TextField)`
|
||||||
|
&& {
|
||||||
|
width: 335px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const AppInfo = styled.div`
|
||||||
|
margin: 36px 0 24px 0;
|
||||||
|
|
||||||
|
img {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export interface AddAppFormValues {
|
||||||
|
appUrl: string
|
||||||
|
agreementAccepted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_VALUES: AddAppFormValues = {
|
||||||
|
appUrl: '',
|
||||||
|
agreementAccepted: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const APP_INFO: SafeApp = {
|
||||||
|
id: undefined,
|
||||||
|
url: '',
|
||||||
|
name: '',
|
||||||
|
iconUrl: appsIconSvg,
|
||||||
|
error: false,
|
||||||
|
description: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddAppProps {
|
||||||
|
appList: SafeApp[]
|
||||||
|
closeModal: () => void
|
||||||
|
formId: string
|
||||||
|
onAppAdded: (app: SafeApp) => void
|
||||||
|
setIsSubmitDisabled: (disabled: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddApp = ({ appList, closeModal, formId, onAppAdded, setIsSubmitDisabled }: AddAppProps): React.ReactElement => {
|
||||||
|
const [appInfo, setAppInfo] = React.useState<SafeApp>(APP_INFO)
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
closeModal()
|
||||||
|
onAppAdded(appInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GnoForm decorators={[appUrlResolver]} initialValues={INITIAL_VALUES} onSubmit={handleSubmit} testId={formId}>
|
||||||
|
{() => (
|
||||||
|
<>
|
||||||
|
<StyledText size="xl">Add custom app</StyledText>
|
||||||
|
|
||||||
|
<AppUrl appList={appList} />
|
||||||
|
<AppInfoUpdater onAppInfo={setAppInfo} />
|
||||||
|
|
||||||
|
<AppInfo>
|
||||||
|
<Img alt="Token image" height={55} src={appInfo.iconUrl} />
|
||||||
|
<StyledTextFileAppName label="App name" readOnly value={appInfo.name} onChange={() => {}} />
|
||||||
|
</AppInfo>
|
||||||
|
|
||||||
|
<AppAgreement />
|
||||||
|
|
||||||
|
<SubmitButtonStatus onSubmitButtonStatusChange={setIsSubmitDisabled} appInfo={appInfo} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</GnoForm>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AddApp
|
@ -2,18 +2,18 @@ import { ButtonLink, ManageListModal } from '@gnosis.pm/safe-react-components'
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg'
|
import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg'
|
||||||
import AddAppFrom from './AddAppForm'
|
import AddAppForm from './AddAppForm'
|
||||||
import { SafeApp } from './types'
|
import { SafeApp } from './types'
|
||||||
|
|
||||||
const FORM_ID = 'add-apps-form'
|
const FORM_ID = 'add-apps-form'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
appList: Array<SafeApp>
|
appList: Array<SafeApp>
|
||||||
onAppAdded: (app: any) => void
|
onAppAdded: (app: SafeApp) => void
|
||||||
onAppToggle: (appId: string, enabled: boolean) => void
|
onAppToggle: (appId: string, enabled: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ManageApps = ({ appList, onAppAdded, onAppToggle }: Props) => {
|
const ManageApps = ({ appList, onAppAdded, onAppToggle }: Props): React.ReactElement => {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const [isSubmitDisabled, setIsSubmitDisabled] = useState(true)
|
const [isSubmitDisabled, setIsSubmitDisabled] = useState(true)
|
||||||
|
|
||||||
@ -32,30 +32,30 @@ const ManageApps = ({ appList, onAppAdded, onAppToggle }: Props) => {
|
|||||||
return { ...a, checked: !a.disabled }
|
return { ...a, checked: !a.disabled }
|
||||||
})
|
})
|
||||||
|
|
||||||
const onItemToggle = (itemId, checked) => {
|
const onItemToggle = (itemId: string, checked: boolean): void => {
|
||||||
onAppToggle(itemId, checked)
|
onAppToggle(itemId, checked)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ButtonLinkAux: any = ButtonLink
|
const Form = (
|
||||||
|
<AddAppForm
|
||||||
|
formId={FORM_ID}
|
||||||
|
appList={appList}
|
||||||
|
closeModal={closeModal}
|
||||||
|
onAppAdded={onAppAdded}
|
||||||
|
setIsSubmitDisabled={setIsSubmitDisabled}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ButtonLinkAux color="primary" onClick={toggleOpen as any}>
|
<ButtonLink color="primary" onClick={toggleOpen}>
|
||||||
Manage Apps
|
Manage Apps
|
||||||
</ButtonLinkAux>
|
</ButtonLink>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<ManageListModal
|
<ManageListModal
|
||||||
addButtonLabel="Add custom app"
|
addButtonLabel="Add custom app"
|
||||||
defaultIconUrl={appsIconSvg}
|
defaultIconUrl={appsIconSvg}
|
||||||
formBody={
|
formBody={Form}
|
||||||
<AddAppFrom
|
|
||||||
formId={FORM_ID}
|
|
||||||
appList={appList}
|
|
||||||
closeModal={closeModal}
|
|
||||||
onAppAdded={onAppAdded}
|
|
||||||
setIsSubmitDisabled={setIsSubmitDisabled}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
isSubmitFormDisabled={isSubmitDisabled}
|
isSubmitFormDisabled={isSubmitDisabled}
|
||||||
itemList={getItemList()}
|
itemList={getItemList()}
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
|
@ -341,7 +341,7 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
|
|||||||
try {
|
try {
|
||||||
const currentApp = list[index]
|
const currentApp = list[index]
|
||||||
|
|
||||||
const appInfo: any = await getAppInfoFromUrl(currentApp.url)
|
const appInfo: SafeApp = await getAppInfoFromUrl(currentApp.url)
|
||||||
if (appInfo.error) {
|
if (appInfo.error) {
|
||||||
throw Error(`There was a problem trying to load app ${currentApp.url}`)
|
throw Error(`There was a problem trying to load app ${currentApp.url}`)
|
||||||
}
|
}
|
||||||
|
1
src/routes/safe/components/Apps/types.d.ts
vendored
1
src/routes/safe/components/Apps/types.d.ts
vendored
@ -5,6 +5,7 @@ export type SafeApp = {
|
|||||||
iconUrl: string
|
iconUrl: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
error: boolean
|
error: boolean
|
||||||
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StoredSafeApp = {
|
export type StoredSafeApp = {
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
import memoize from 'lodash.memoize'
|
||||||
|
|
||||||
import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg'
|
|
||||||
import { getGnosisSafeAppsUrl } from 'src/config/index'
|
|
||||||
import { SafeApp } from './types'
|
import { SafeApp } from './types'
|
||||||
|
|
||||||
|
import { getGnosisSafeAppsUrl } from 'src/config/index'
|
||||||
|
import { getContentFromENS } from 'src/logic/wallets/getWeb3'
|
||||||
|
import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg'
|
||||||
|
|
||||||
const removeLastTrailingSlash = (url) => {
|
const removeLastTrailingSlash = (url) => {
|
||||||
if (url.substr(-1) === '/') {
|
if (url.substr(-1) === '/') {
|
||||||
return url.substr(0, url.length - 1)
|
return url.substr(0, url.length - 1)
|
||||||
@ -31,7 +34,7 @@ export const staticAppsList: Array<{ url: string; disabled: boolean }> = [
|
|||||||
{ url: `${gnosisAppsUrl}/tx-builder`, disabled: false },
|
{ url: `${gnosisAppsUrl}/tx-builder`, disabled: false },
|
||||||
]
|
]
|
||||||
|
|
||||||
export const getAppInfoFromOrigin = (origin) => {
|
export const getAppInfoFromOrigin = (origin: string): Record<string, string> | null => {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(origin)
|
return JSON.parse(origin)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -40,49 +43,91 @@ export const getAppInfoFromOrigin = (origin) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getAppInfoFromUrl = async (appUrl?: string): Promise<SafeApp> => {
|
export const isAppManifestValid = (appInfo: SafeApp): boolean =>
|
||||||
let res = { id: undefined, url: appUrl, name: 'unknown', iconUrl: appsIconSvg, error: true }
|
// `appInfo` exists and `name` exists
|
||||||
|
!!appInfo?.name &&
|
||||||
|
// if `name` exists is not 'unknown'
|
||||||
|
appInfo.name !== 'unknown' &&
|
||||||
|
// `description` exists
|
||||||
|
!!appInfo.description &&
|
||||||
|
// `url` exists
|
||||||
|
!!appInfo.url &&
|
||||||
|
// no `error` (or `error` undefined)
|
||||||
|
!appInfo.error
|
||||||
|
|
||||||
if (!appUrl?.length) {
|
export const getAppInfoFromUrl = memoize(
|
||||||
return res
|
async (appUrl?: string): Promise<SafeApp> => {
|
||||||
}
|
let res = { id: undefined, url: appUrl, name: 'unknown', iconUrl: appsIconSvg, error: true, description: '' }
|
||||||
|
|
||||||
res.url = appUrl.trim()
|
if (!appUrl?.length) {
|
||||||
const noTrailingSlashUrl = removeLastTrailingSlash(res.url)
|
return res
|
||||||
|
|
||||||
try {
|
|
||||||
const appInfo = await axios.get(`${noTrailingSlashUrl}/manifest.json`, { timeout: 5_000 })
|
|
||||||
|
|
||||||
// verify imported app fulfil safe requirements
|
|
||||||
if (!appInfo || !appInfo.data || !appInfo.data.name || !appInfo.data.description) {
|
|
||||||
throw Error('The app does not fulfil the structure required.')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// the DB origin field has a limit of 100 characters
|
res.url = appUrl.trim()
|
||||||
const originFieldSize = 100
|
const noTrailingSlashUrl = removeLastTrailingSlash(res.url)
|
||||||
const jsonDataLength = 20
|
|
||||||
const remainingSpace = originFieldSize - res.url.length - jsonDataLength
|
|
||||||
|
|
||||||
res = {
|
try {
|
||||||
...res,
|
const appInfo = await axios.get(`${noTrailingSlashUrl}/manifest.json`, { timeout: 5_000 })
|
||||||
...appInfo.data,
|
|
||||||
id: JSON.stringify({ url: res.url, name: appInfo.data.name.substring(0, remainingSpace) }),
|
|
||||||
error: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (appInfo.data.iconPath) {
|
// verify imported app fulfil safe requirements
|
||||||
try {
|
if (!appInfo?.data || isAppManifestValid(appInfo.data)) {
|
||||||
const iconInfo = await axios.get(`${noTrailingSlashUrl}/${appInfo.data.iconPath}`, { timeout: 1000 * 10 })
|
throw Error('The app does not fulfil the structure required.')
|
||||||
if (/image\/\w/gm.test(iconInfo.headers['content-type'])) {
|
|
||||||
res.iconUrl = `${noTrailingSlashUrl}/${appInfo.data.iconPath}`
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`It was not possible to fetch icon from app ${res.url}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the DB origin field has a limit of 100 characters
|
||||||
|
const originFieldSize = 100
|
||||||
|
const jsonDataLength = 20
|
||||||
|
const remainingSpace = originFieldSize - res.url.length - jsonDataLength
|
||||||
|
|
||||||
|
res = {
|
||||||
|
...res,
|
||||||
|
...appInfo.data,
|
||||||
|
id: JSON.stringify({ url: res.url, name: appInfo.data.name.substring(0, remainingSpace) }),
|
||||||
|
error: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appInfo.data.iconPath) {
|
||||||
|
try {
|
||||||
|
const iconInfo = await axios.get(`${noTrailingSlashUrl}/${appInfo.data.iconPath}`, { timeout: 1000 * 10 })
|
||||||
|
if (/image\/\w/gm.test(iconInfo.headers['content-type'])) {
|
||||||
|
res.iconUrl = `${noTrailingSlashUrl}/${appInfo.data.iconPath}`
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`It was not possible to fetch icon from app ${res.url}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`It was not possible to fetch app from ${res.url}: ${error.message}`)
|
||||||
|
return res
|
||||||
}
|
}
|
||||||
return res
|
},
|
||||||
} catch (error) {
|
)
|
||||||
console.error(`It was not possible to fetch app from ${res.url}: ${error.message}`)
|
|
||||||
return res
|
export const getIpfsLinkFromEns = memoize(
|
||||||
}
|
async (name: string): Promise<string | undefined> => {
|
||||||
|
try {
|
||||||
|
const content = await getContentFromENS(name)
|
||||||
|
if (content && content.protocolType === 'ipfs') {
|
||||||
|
return `${process.env.REACT_APP_IPFS_GATEWAY}/${content.decoded}/`
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export const uniqueApp = (appList: SafeApp[]) => (url: string): string | undefined => {
|
||||||
|
const exists = appList.some((a) => {
|
||||||
|
try {
|
||||||
|
const currentUrl = new URL(a.url)
|
||||||
|
const newUrl = new URL(url)
|
||||||
|
return currentUrl.href === newUrl.href
|
||||||
|
} catch (error) {
|
||||||
|
console.error('There was a problem trying to validate the URL existence.', error.message)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return exists ? 'This app is already registered.' : undefined
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
@ -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*"
|
||||||
|
@ -8,6 +8,7 @@ import SettingsTxIcon from './assets/settings.svg'
|
|||||||
|
|
||||||
import CustomIconText from 'src/components/CustomIconText'
|
import CustomIconText from 'src/components/CustomIconText'
|
||||||
import { getAppInfoFromOrigin, getAppInfoFromUrl } from 'src/routes/safe/components/Apps/utils'
|
import { getAppInfoFromOrigin, getAppInfoFromUrl } from 'src/routes/safe/components/Apps/utils'
|
||||||
|
import { SafeApp } from 'src/routes/safe/components/Apps/types'
|
||||||
|
|
||||||
const typeToIcon = {
|
const typeToIcon = {
|
||||||
outgoing: OutgoingTxIcon,
|
outgoing: OutgoingTxIcon,
|
||||||
@ -33,20 +34,28 @@ const typeToLabel = {
|
|||||||
upgrade: 'Contract Upgrade',
|
upgrade: 'Contract Upgrade',
|
||||||
}
|
}
|
||||||
|
|
||||||
const TxType = ({ origin, txType }: any) => {
|
interface TxTypeProps {
|
||||||
|
origin?: string
|
||||||
|
txType: keyof typeof typeToLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
const TxType = ({ origin, txType }: TxTypeProps): React.ReactElement => {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [appInfo, setAppInfo] = useState<any>()
|
const [appInfo, setAppInfo] = useState<SafeApp>()
|
||||||
const [forceCustom, setForceCustom] = useState(false)
|
const [forceCustom, setForceCustom] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const getAppInfo = async () => {
|
const getAppInfo = async () => {
|
||||||
const parsedOrigin = getAppInfoFromOrigin(origin)
|
const parsedOrigin = getAppInfoFromOrigin(origin)
|
||||||
|
|
||||||
if (!parsedOrigin) {
|
if (!parsedOrigin) {
|
||||||
setForceCustom(true)
|
setForceCustom(true)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const appInfo = await getAppInfoFromUrl(parsedOrigin.url)
|
const appInfo = await getAppInfoFromUrl(parsedOrigin.url)
|
||||||
|
|
||||||
setAppInfo(appInfo)
|
setAppInfo(appInfo)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
38
src/routes/safe/container/hooks/useDebounce.tsx
Normal file
38
src/routes/safe/container/hooks/useDebounce.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import debounce from 'lodash.debounce'
|
||||||
|
import { useCallback, useEffect, useState, useRef } from 'react'
|
||||||
|
|
||||||
|
/*
|
||||||
|
This code snippet is copied from https://github.com/gnbaron/use-lodash-debounce
|
||||||
|
with the sole intention to be able to tweak it if is needed and prevent from having
|
||||||
|
a new dependency for something relatively trivial
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface DebounceOptions {
|
||||||
|
leading: boolean
|
||||||
|
maxWait: number
|
||||||
|
trailing: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDebouncedCallback = <T extends (...args: unknown[]) => unknown>(
|
||||||
|
callback: T,
|
||||||
|
delay = 0,
|
||||||
|
options: DebounceOptions,
|
||||||
|
): T & { cancel: () => void } => useCallback(debounce(callback, delay, options), [callback, delay, options])
|
||||||
|
|
||||||
|
export const useDebounce = <T extends unknown>(value: T, delay = 0, options?: DebounceOptions): T => {
|
||||||
|
const previousValue = useRef(value)
|
||||||
|
const [current, setCurrent] = useState(value)
|
||||||
|
const debouncedCallback = useDebouncedCallback((value: T) => setCurrent(value), delay, options)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// does trigger the debounce timer initially
|
||||||
|
if (value !== previousValue.current) {
|
||||||
|
debouncedCallback(value)
|
||||||
|
previousValue.current = value
|
||||||
|
// cancel the debounced callback on clean up
|
||||||
|
return debouncedCallback.cancel
|
||||||
|
}
|
||||||
|
}, [debouncedCallback, value])
|
||||||
|
|
||||||
|
return current
|
||||||
|
}
|
@ -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())
|
||||||
|
40
yarn.lock
40
yarn.lock
@ -6408,7 +6408,7 @@ ee-first@1.1.1:
|
|||||||
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
|
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
|
||||||
integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
|
integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
|
||||||
|
|
||||||
ejs@^3.1.3:
|
ejs@^3.0.2, ejs@^3.1.3:
|
||||||
version "3.1.3"
|
version "3.1.3"
|
||||||
resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.3.tgz#514d967a8894084d18d3d47bd169a1c0560f093d"
|
resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.3.tgz#514d967a8894084d18d3d47bd169a1c0560f093d"
|
||||||
integrity sha512-wmtrUGyfSC23GC/B1SMv2ogAUgbQEtDmTIhfqielrG5ExIM9TP4UoYdi90jLF1aTcsWCJNEO0UrgKzP0y3nTSg==
|
integrity sha512-wmtrUGyfSC23GC/B1SMv2ogAUgbQEtDmTIhfqielrG5ExIM9TP4UoYdi90jLF1aTcsWCJNEO0UrgKzP0y3nTSg==
|
||||||
@ -6730,7 +6730,7 @@ escape-goat@^2.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675"
|
resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675"
|
||||||
integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==
|
integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==
|
||||||
|
|
||||||
escape-html@~1.0.3:
|
escape-html@^1.0.3, escape-html@~1.0.3:
|
||||||
version "1.0.3"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
|
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
|
||||||
integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=
|
integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=
|
||||||
@ -8584,7 +8584,7 @@ growly@^1.3.0:
|
|||||||
resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081"
|
resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081"
|
||||||
integrity sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=
|
integrity sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=
|
||||||
|
|
||||||
gzip-size@5.1.1:
|
gzip-size@5.1.1, gzip-size@^5.1.1:
|
||||||
version "5.1.1"
|
version "5.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-5.1.1.tgz#cb9bee692f87c0612b232840a873904e4c135274"
|
resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-5.1.1.tgz#cb9bee692f87c0612b232840a873904e4c135274"
|
||||||
integrity sha512-FNHi6mmoHvs1mxZAds4PpdCS6QG8B4C1krxJsMutgxl5t3+GlRTzzI3NEkifXx2pVsOvJdOGSmIgDhQ55FwdPA==
|
integrity sha512-FNHi6mmoHvs1mxZAds4PpdCS6QG8B4C1krxJsMutgxl5t3+GlRTzzI3NEkifXx2pVsOvJdOGSmIgDhQ55FwdPA==
|
||||||
@ -10933,6 +10933,11 @@ lodash._reinterpolate@^3.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
|
resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
|
||||||
integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=
|
integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=
|
||||||
|
|
||||||
|
lodash.debounce@^4.0.8:
|
||||||
|
version "4.0.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
|
||||||
|
integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168=
|
||||||
|
|
||||||
lodash.defaults@^4.2.0:
|
lodash.defaults@^4.2.0:
|
||||||
version "4.2.0"
|
version "4.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
|
resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
|
||||||
@ -12229,7 +12234,7 @@ onetime@^5.1.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
mimic-fn "^2.1.0"
|
mimic-fn "^2.1.0"
|
||||||
|
|
||||||
open@^7.0.2, open@^7.1.0:
|
open@^7.0.2, open@^7.0.3, open@^7.1.0:
|
||||||
version "7.1.0"
|
version "7.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/open/-/open-7.1.0.tgz#68865f7d3cb238520fa1225a63cf28bcf8368a1c"
|
resolved "https://registry.yarnpkg.com/open/-/open-7.1.0.tgz#68865f7d3cb238520fa1225a63cf28bcf8368a1c"
|
||||||
integrity sha512-lLPI5KgOwEYCDKXf4np7y1PBEkj7HYIyP2DY8mVDRnx0VIIu6bNrRB0R66TuO7Mack6EnTNLm4uvcl1UoklTpA==
|
integrity sha512-lLPI5KgOwEYCDKXf4np7y1PBEkj7HYIyP2DY8mVDRnx0VIIu6bNrRB0R66TuO7Mack6EnTNLm4uvcl1UoklTpA==
|
||||||
@ -14739,7 +14744,7 @@ rgba-regex@^1.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/rgba-regex/-/rgba-regex-1.0.0.tgz#43374e2e2ca0968b0ef1523460b7d730ff22eeb3"
|
resolved "https://registry.yarnpkg.com/rgba-regex/-/rgba-regex-1.0.0.tgz#43374e2e2ca0968b0ef1523460b7d730ff22eeb3"
|
||||||
integrity sha1-QzdOLiyglosO8VI0YLfXMP8i7rM=
|
integrity sha1-QzdOLiyglosO8VI0YLfXMP8i7rM=
|
||||||
|
|
||||||
rimraf@2.6.3:
|
rimraf@2.6.3, rimraf@~2.6.2:
|
||||||
version "2.6.3"
|
version "2.6.3"
|
||||||
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab"
|
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab"
|
||||||
integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==
|
integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==
|
||||||
@ -15428,6 +15433,24 @@ source-list-map@^2.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34"
|
resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34"
|
||||||
integrity sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==
|
integrity sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==
|
||||||
|
|
||||||
|
source-map-explorer@^2.4.2:
|
||||||
|
version "2.4.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/source-map-explorer/-/source-map-explorer-2.4.2.tgz#fb23f86c3112eacde5683f24efaf4ddc9f677985"
|
||||||
|
integrity sha512-3ECQLffCFV8QgrTqcmddLkWL4/aQs6ljYfgWCLselo5QtizOfOeUCKnS4rFn7MIrdeZLM6TZrseOtsrWZhWKoQ==
|
||||||
|
dependencies:
|
||||||
|
btoa "^1.2.1"
|
||||||
|
chalk "^3.0.0"
|
||||||
|
convert-source-map "^1.7.0"
|
||||||
|
ejs "^3.0.2"
|
||||||
|
escape-html "^1.0.3"
|
||||||
|
glob "^7.1.6"
|
||||||
|
gzip-size "^5.1.1"
|
||||||
|
lodash "^4.17.15"
|
||||||
|
open "^7.0.3"
|
||||||
|
source-map "^0.7.3"
|
||||||
|
temp "^0.9.1"
|
||||||
|
yargs "^15.3.1"
|
||||||
|
|
||||||
source-map-resolve@^0.5.0, source-map-resolve@^0.5.2:
|
source-map-resolve@^0.5.0, source-map-resolve@^0.5.2:
|
||||||
version "0.5.3"
|
version "0.5.3"
|
||||||
resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a"
|
resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a"
|
||||||
@ -16183,6 +16206,13 @@ temp-file@^3.3.7:
|
|||||||
async-exit-hook "^2.0.1"
|
async-exit-hook "^2.0.1"
|
||||||
fs-extra "^8.1.0"
|
fs-extra "^8.1.0"
|
||||||
|
|
||||||
|
temp@^0.9.1:
|
||||||
|
version "0.9.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/temp/-/temp-0.9.1.tgz#2d666114fafa26966cd4065996d7ceedd4dd4697"
|
||||||
|
integrity sha512-WMuOgiua1xb5R56lE0eH6ivpVmg/lq2OHm4+LtT/xtEtPQ+sz6N3bBM6WZ5FvO1lO4IKIOb43qnhoc4qxP5OeA==
|
||||||
|
dependencies:
|
||||||
|
rimraf "~2.6.2"
|
||||||
|
|
||||||
term-size@^2.1.0:
|
term-size@^2.1.0:
|
||||||
version "2.2.0"
|
version "2.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/term-size/-/term-size-2.2.0.tgz#1f16adedfe9bdc18800e1776821734086fcc6753"
|
resolved "https://registry.yarnpkg.com/term-size/-/term-size-2.2.0.tgz#1f16adedfe9bdc18800e1776821734086fcc6753"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user