Merge branch 'development' of github.com:gnosis/safe-react into 1013-undefined-error

This commit is contained in:
Mikhail Mikheev 2020-06-26 18:09:55 +04:00
commit ca51768b04
37 changed files with 1666 additions and 817 deletions

View File

@ -18,7 +18,6 @@ module.exports = {
'react-hooks/rules-of-hooks': 'error', 'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn', 'react-hooks/exhaustive-deps': 'warn',
'react/prop-types': 'off', 'react/prop-types': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/camelcase': 'off', '@typescript-eslint/camelcase': 'off',
'@typescript-eslint/no-var-requires': 'off', '@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/no-empty-function': 'off', '@typescript-eslint/no-empty-function': 'off',

View File

@ -1,6 +1,6 @@
{ {
"name": "safe-react", "name": "safe-react",
"version": "2.1.1", "version": "2.3.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": {

View File

@ -144,6 +144,7 @@ const CookiesBanner = () => {
onKeyDown={closeCookiesBannerHandler} onKeyDown={closeCookiesBannerHandler}
role="button" role="button"
tabIndex={0} tabIndex={0}
data-testid="accept-preferences"
> >
Accept preferences > Accept preferences >
</span> </span>

View File

@ -61,7 +61,7 @@ const Layout = openHoc(({ classes, clickAway, open, providerDetails, providerInf
<Row className={classes.summary}> <Row className={classes.summary}>
<Col className={classes.logo} middle="xs" start="xs"> <Col className={classes.logo} middle="xs" start="xs">
<Link to="/"> <Link to="/">
<Img alt="Gnosis Team Safe" height={32} src={logo} /> <Img alt="Gnosis Team Safe" height={32} src={logo} testId="heading-gnosis-logo" />
</Link> </Link>
</Col> </Col>
<Divider /> <Divider />

View File

@ -45,7 +45,7 @@ const ConnectDetails = ({ classes }) => (
<CircleDot center circleSize={75} dotRight={25} dotSize={25} dotTop={50} keySize={32} mode="error" /> <CircleDot center circleSize={75} dotRight={25} dotSize={25} dotTop={50} keySize={32} mode="error" />
</Row> </Row>
<Block className={classes.connect}> <Block className={classes.connect}>
<ConnectButton /> <ConnectButton data-testid="heading-connect-btn" />
</Block> </Block>
</> </>
) )

View File

@ -163,7 +163,14 @@ const UserDetails = ({ classes, connected, network, onDisconnect, openDashboard,
</Row> </Row>
)} )}
<Row className={classes.disconnect}> <Row className={classes.disconnect}>
<Button color="primary" fullWidth onClick={onDisconnect} size="medium" variant="contained"> <Button
color="primary"
fullWidth
onClick={onDisconnect}
size="medium"
variant="contained"
data-testid="disconnect-btn"
>
<Paragraph className={classes.disconnectText} color="white" noMargin size="md"> <Paragraph className={classes.disconnectText} color="white" noMargin size="md">
Disconnect Disconnect
</Paragraph> </Paragraph>

View File

@ -62,7 +62,14 @@ const ProviderInfo = ({ classes, connected, network, provider, userAddress }) =>
)} )}
{!connected && <CircleDot circleSize={35} dotRight={11} dotSize={16} dotTop={24} keySize={14} mode="warning" />} {!connected && <CircleDot circleSize={35} dotRight={11} dotSize={16} dotTop={24} keySize={14} mode="warning" />}
<Col className={classes.account} layout="column" start="sm"> <Col className={classes.account} layout="column" start="sm">
<Paragraph className={classes.network} noMargin size="xs" transform="capitalize" weight="bolder"> <Paragraph
className={classes.network}
noMargin
size="xs"
transform="capitalize"
weight="bolder"
data-testid="connected-wallet"
>
{providerText} {providerText}
</Paragraph> </Paragraph>
<Paragraph className={classes.address} color={color} noMargin size="xs"> <Paragraph className={classes.address} color={color} noMargin size="xs">

View File

@ -29,7 +29,14 @@ const ProviderDisconnected = ({ classes }) => (
<> <>
<CircleDot circleSize={35} dotRight={11} dotSize={16} dotTop={24} keySize={17} mode="error" /> <CircleDot circleSize={35} dotRight={11} dotSize={16} dotTop={24} keySize={17} mode="error" />
<Col className={classes.account} end="sm" layout="column" middle="xs"> <Col className={classes.account} end="sm" layout="column" middle="xs">
<Paragraph className={classes.network} noMargin size="sm" transform="capitalize" weight="bold"> <Paragraph
className={classes.network}
noMargin
size="sm"
transform="capitalize"
weight="bold"
data-testid="not-connected-wallet"
>
Not Connected Not Connected
</Paragraph> </Paragraph>
<Paragraph className={classes.connect} color="fancy" noMargin size="sm"> <Paragraph className={classes.connect} color="fancy" noMargin size="sm">

View File

@ -44,7 +44,7 @@ const SafeListHeader = ({ safesCount }) => {
return ( return (
<Col className={classes.container} middle="xs" start="xs"> <Col className={classes.container} middle="xs" start="xs">
Safes Safes
<Paragraph className={classes.counter} size="xs"> <Paragraph className={classes.counter} size="xs" data-testid="safe-counter-heading">
{safesCount} {safesCount}
</Paragraph> </Paragraph>
<IconButton <IconButton

View File

@ -39,6 +39,7 @@ export const ScanQRWrapper = (props) => {
}} }}
role="button" role="button"
src={QRIcon} src={QRIcon}
testId="qr-icon"
/> />
{qrModalOpen && <ScanQRModal isOpen={qrModalOpen} onClose={closeQrModal} onScan={onScanFinished} />} {qrModalOpen && <ScanQRModal isOpen={qrModalOpen} onClose={closeQrModal} onScan={onScanFinished} />}
</> </>

View File

@ -58,14 +58,18 @@ export const minValue = (min: number | string) => (value: string) => {
return `Should be at least ${min}` return `Should be at least ${min}`
} }
export const maxValue = (max: number | string) => (value: string) => { export const maxValueCheck = (max: number | string, value: string): string | undefined => {
if (Number.isNaN(Number(value)) || parseFloat(value) <= parseFloat(max.toString())) { if (!max || Number.isNaN(Number(value)) || parseFloat(value) <= parseFloat(max.toString())) {
return undefined return undefined
} }
return `Maximum value is ${max}` return `Maximum value is ${max}`
} }
export const maxValue = (max: number | string) => (value: string) => {
return maxValueCheck(max, value)
}
export const ok = () => undefined export const ok = () => undefined
export const mustBeEthereumAddress = simpleMemoize((address: string) => { export const mustBeEthereumAddress = simpleMemoize((address: string) => {

View File

@ -100,6 +100,7 @@ const Details = ({ classes, errors, form }) => {
text="Safe name" text="Safe name"
type="text" type="text"
validate={required} validate={required}
testId="load-safe-name-field"
/> />
</Col> </Col>
</Block> </Block>
@ -114,7 +115,7 @@ const Details = ({ classes, errors, form }) => {
noErrorsOn(FIELD_LOAD_ADDRESS, errors) && { noErrorsOn(FIELD_LOAD_ADDRESS, errors) && {
endAdornment: ( endAdornment: (
<InputAdornment position="end"> <InputAdornment position="end">
<CheckCircle className={classes.check} /> <CheckCircle className={classes.check} data-testid="valid-address" />
</InputAdornment> </InputAdornment>
), ),
} }
@ -123,6 +124,7 @@ const Details = ({ classes, errors, form }) => {
placeholder="Safe Address*" placeholder="Safe Address*"
text="Safe Address" text="Safe Address"
type="text" type="text"
testId="load-safe-address-field"
/> />
</Col> </Col>
<Col center="xs" className={classes} middle="xs" xs={1}> <Col center="xs" className={classes} middle="xs" xs={1}>

View File

@ -109,7 +109,7 @@ const OwnerListComponent = (props) => {
return ( return (
<> <>
<Block className={classes.title}> <Block className={classes.title}>
<Paragraph color="primary" noMargin size="md"> <Paragraph color="primary" noMargin size="md" data-testid="load-safe-step-two">
{`This Safe has ${owners.length} owners. Optional: Provide a name for each owner.`} {`This Safe has ${owners.length} owners. Optional: Provide a name for each owner.`}
</Paragraph> </Paragraph>
</Block> </Block>
@ -122,7 +122,7 @@ const OwnerListComponent = (props) => {
<Hairline /> <Hairline />
<Block margin="md" padding="md"> <Block margin="md" padding="md">
{owners.map((address, index) => ( {owners.map((address, index) => (
<Row className={classes.owner} key={address}> <Row className={classes.owner} key={address} data-testid="owner-row">
<Col className={classes.ownerName} xs={4}> <Col className={classes.ownerName} xs={4}>
<Field <Field
className={classes.name} className={classes.name}
@ -133,6 +133,7 @@ const OwnerListComponent = (props) => {
text="Owner Name" text="Owner Name"
type="text" type="text"
validate={required} validate={required}
testId={`load-safe-owner-name-${index}`}
/> />
</Col> </Col>
<Col xs={8}> <Col xs={8}>

View File

@ -109,7 +109,7 @@ class ReviewComponent extends React.PureComponent<any> {
<Col className={classes.detailsColumn} layout="column" xs={4}> <Col className={classes.detailsColumn} layout="column" xs={4}>
<Block className={classes.details}> <Block className={classes.details}>
<Block margin="lg"> <Block margin="lg">
<Paragraph color="primary" noMargin size="lg"> <Paragraph color="primary" noMargin size="lg" data-testid="load-safe-step-three">
Review details Review details
</Paragraph> </Paragraph>
</Block> </Block>
@ -117,7 +117,14 @@ class ReviewComponent extends React.PureComponent<any> {
<Paragraph color="disabled" noMargin size="sm"> <Paragraph color="disabled" noMargin size="sm">
Name of the Safe Name of the Safe
</Paragraph> </Paragraph>
<Paragraph className={classes.name} color="primary" noMargin size="lg" weight="bolder"> <Paragraph
className={classes.name}
color="primary"
noMargin
size="lg"
weight="bolder"
data-testid="load-form-review-safe-name"
>
{values[FIELD_LOAD_NAME]} {values[FIELD_LOAD_NAME]}
</Paragraph> </Paragraph>
</Block> </Block>
@ -168,7 +175,7 @@ class ReviewComponent extends React.PureComponent<any> {
</Col> </Col>
<Col xs={11}> <Col xs={11}>
<Block className={classNames(classes.name, classes.userName)}> <Block className={classNames(classes.name, classes.userName)}>
<Paragraph noMargin size="lg"> <Paragraph noMargin size="lg" data-testid="load-safe-review-owner-name">
{values[getOwnerNameBy(index)]} {values[getOwnerNameBy(index)]}
</Paragraph> </Paragraph>
<Block className={classes.user} justify="center"> <Block className={classes.user} justify="center">

View File

@ -87,7 +87,9 @@ const Layout = (props) => {
<IconButton disableRipple onClick={back} style={iconStyle}> <IconButton disableRipple onClick={back} style={iconStyle}>
<ChevronLeft /> <ChevronLeft />
</IconButton> </IconButton>
<Heading tag="h2">Create New Safe</Heading> <Heading tag="h2" testId="create-safe-form-title">
Create New Safe
</Heading>
</Row> </Row>
<Stepper <Stepper
initialValues={initialValues} initialValues={initialValues}

View File

@ -112,7 +112,7 @@ const ReviewComponent = ({ classes, userAccount, values }: any) => {
<Col className={classes.detailsColumn} layout="column" xs={4}> <Col className={classes.detailsColumn} layout="column" xs={4}>
<Block className={classes.details}> <Block className={classes.details}>
<Block margin="lg"> <Block margin="lg">
<Paragraph color="primary" noMargin size="lg"> <Paragraph color="primary" noMargin size="lg" data-testid="create-safe-step-three">
Details Details
</Paragraph> </Paragraph>
</Block> </Block>
@ -120,7 +120,14 @@ const ReviewComponent = ({ classes, userAccount, values }: any) => {
<Paragraph color="disabled" noMargin size="sm"> <Paragraph color="disabled" noMargin size="sm">
Name of new Safe Name of new Safe
</Paragraph> </Paragraph>
<Paragraph className={classes.name} color="primary" noMargin size="lg" weight="bolder"> <Paragraph
className={classes.name}
color="primary"
noMargin
size="lg"
weight="bolder"
data-testid="create-safe-review-name"
>
{values[FIELD_NAME]} {values[FIELD_NAME]}
</Paragraph> </Paragraph>
</Block> </Block>
@ -128,7 +135,13 @@ const ReviewComponent = ({ classes, userAccount, values }: any) => {
<Paragraph color="disabled" noMargin size="sm"> <Paragraph color="disabled" noMargin size="sm">
Any transaction requires the confirmation of: Any transaction requires the confirmation of:
</Paragraph> </Paragraph>
<Paragraph color="primary" noMargin size="lg" weight="bolder"> <Paragraph
color="primary"
noMargin
size="lg"
weight="bolder"
data-testid={`create-safe-review-req-owners-${values[FIELD_CONFIRMATIONS]}`}
>
{`${values[FIELD_CONFIRMATIONS]} out of ${numOwners} owners`} {`${values[FIELD_CONFIRMATIONS]} out of ${numOwners} owners`}
</Paragraph> </Paragraph>
</Block> </Block>
@ -150,11 +163,16 @@ const ReviewComponent = ({ classes, userAccount, values }: any) => {
</Col> </Col>
<Col xs={11}> <Col xs={11}>
<Block className={classNames(classes.name, classes.userName)}> <Block className={classNames(classes.name, classes.userName)}>
<Paragraph noMargin size="lg"> <Paragraph noMargin size="lg" data-testid={`create-safe-owner-name-${index}`}>
{name} {name}
</Paragraph> </Paragraph>
<Block className={classes.user} justify="center"> <Block className={classes.user} justify="center">
<Paragraph color="disabled" noMargin size="md"> <Paragraph
color="disabled"
noMargin
size="md"
data-testid={`create-safe-owner-address-${index}`}
>
{addresses[index]} {addresses[index]}
</Paragraph> </Paragraph>
<CopyBtn content={addresses[index]} /> <CopyBtn content={addresses[index]} />

View File

@ -45,6 +45,7 @@ const SafeName = ({ classes, safeName }) => (
text="Safe name" text="Safe name"
type="text" type="text"
validate={required} validate={required}
testId="create-safe-name-field"
/> />
</Block> </Block>
<Block margin="lg"> <Block margin="lg">

View File

@ -99,7 +99,7 @@ const SafeOwners = (props) => {
return ( return (
<> <>
<Block className={classes.title}> <Block className={classes.title}>
<Paragraph color="primary" noMargin size="md"> <Paragraph color="primary" noMargin size="md" data-testid="create-safe-step-two">
Your Safe will have one or more owners. We have prefilled the first owner with your connected wallet details, Your Safe will have one or more owners. We have prefilled the first owner with your connected wallet details,
but you are free to change this to a different owner. but you are free to change this to a different owner.
<br /> <br />
@ -120,7 +120,7 @@ const SafeOwners = (props) => {
const addressName = getOwnerAddressBy(index) const addressName = getOwnerAddressBy(index)
return ( return (
<Row className={classes.owner} key={`owner${index}`}> <Row className={classes.owner} key={`owner${index}`} data-testid={`create-safe-owner-row`}>
<Col className={classes.ownerName} xs={4}> <Col className={classes.ownerName} xs={4}>
<Field <Field
className={classes.name} className={classes.name}
@ -130,6 +130,7 @@ const SafeOwners = (props) => {
text="Owner Name" text="Owner Name"
type="text" type="text"
validate={required} validate={required}
testId={`create-safe-owner-name-field-${index}`}
/> />
</Col> </Col>
<Col className={classes.ownerAddress} xs={6}> <Col className={classes.ownerAddress} xs={6}>
@ -142,7 +143,7 @@ const SafeOwners = (props) => {
noErrorsOn(addressName, errors) && { noErrorsOn(addressName, errors) && {
endAdornment: ( endAdornment: (
<InputAdornment position="end"> <InputAdornment position="end">
<CheckCircle className={classes.check} /> <CheckCircle className={classes.check} data-testid={`valid-address-${index}`} />
</InputAdornment> </InputAdornment>
), ),
} }
@ -152,6 +153,7 @@ const SafeOwners = (props) => {
text="Owner Address" text="Owner Address"
type="text" type="text"
validators={[getAddressValidator(otherAccounts, index)]} validators={[getAddressValidator(otherAccounts, index)]}
testId={`create-safe-address-field-${index}`}
/> />
</Col> </Col>
<Col center="xs" className={classes.remove} middle="xs" xs={1}> <Col center="xs" className={classes.remove} middle="xs" xs={1}>
@ -191,14 +193,20 @@ const SafeOwners = (props) => {
validate={composeValidators(required, mustBeInteger, minValue(1))} validate={composeValidators(required, mustBeInteger, minValue(1))}
> >
{[...Array(Number(validOwners))].map((x, index) => ( {[...Array(Number(validOwners))].map((x, index) => (
<MenuItem key={`selectOwner${index}`} value={`${index + 1}`}> <MenuItem key={`selectOwner${index}`} value={`${index + 1}`} data-testid={`input-${index + 1}`}>
{index + 1} {index + 1}
</MenuItem> </MenuItem>
))} ))}
</Field> </Field>
</Col> </Col>
<Col className={classes.ownersAmountItem} xs={10}> <Col className={classes.ownersAmountItem} xs={10}>
<Paragraph className={classes.owners} color="primary" noMargin size="lg"> <Paragraph
className={classes.owners}
color="primary"
noMargin
size="lg"
data-testid={`create-safe-req-conf-${validOwners}`}
>
out of {validOwners} owner(s) out of {validOwners} owner(s)
</Paragraph> </Paragraph>
</Col> </Col>

View File

@ -38,7 +38,13 @@ export const ContinueFooter = ({
continueButtonDisabled: boolean continueButtonDisabled: boolean
onContinue: (event: SyntheticEvent) => void onContinue: (event: SyntheticEvent) => void
}) => ( }) => (
<Button color="primary" disabled={continueButtonDisabled} onClick={onContinue} variant="contained"> <Button
color="primary"
disabled={continueButtonDisabled}
onClick={onContinue}
variant="contained"
data-testid="continue-btn"
>
Continue Continue
</Button> </Button>
) )

View File

@ -302,7 +302,9 @@ const SafeDeployment = ({ creationTxHash, onCancel, onRetry, onSuccess, provider
return ( return (
<Wrapper> <Wrapper>
<Title tag="h2">Safe creation process</Title> <Title tag="h2" testId="safe-creation-process-title">
Safe creation process
</Title>
<Nav> <Nav>
<Stepper activeStepIndex={stepIndex} error={error} orientation="vertical" steps={steps} /> <Stepper activeStepIndex={stepIndex} error={error} orientation="vertical" steps={steps} />
</Nav> </Nav>
@ -336,7 +338,7 @@ const SafeDeployment = ({ creationTxHash, onCancel, onRetry, onSuccess, provider
) : null} ) : null}
</BodyFooter> </BodyFooter>
</Body> </Body>
<BackButton color="primary" minWidth={140} onClick={onCancel}> <BackButton color="primary" minWidth={140} onClick={onCancel} data-testid="safe-creation-back-btn">
Back Back
</BackButton> </BackButton>
</Wrapper> </Wrapper>

View File

@ -3,7 +3,6 @@ import { BigNumber } from 'bignumber.js'
import React, { ReactElement } from 'react' import React, { ReactElement } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import { getWeb3 } from 'src/logic/wallets/getWeb3'
import AddressInfo from 'src/components/AddressInfo' import AddressInfo from 'src/components/AddressInfo'
import DividerLine from 'src/components/DividerLine' import DividerLine from 'src/components/DividerLine'
import Collapse from 'src/components/Collapse' import Collapse from 'src/components/Collapse'
@ -55,17 +54,12 @@ const IconText = styled.div`
margin-right: 4px; margin-right: 4px;
} }
` `
const isTxValid = (t): boolean => { const isTxValid = (t: SafeAppTx): boolean => {
try { if (!['string', 'number'].includes(typeof t.value)) {
if (!['string', 'number'].includes(typeof t.value)) { return false
return false }
}
if (typeof t.value === 'string') { if (typeof t.value === 'string' && !/^\d+$/.test(t.value)) {
const web3 = getWeb3()
web3.eth.abi.decodeParameter('uint256', t.value)
}
} catch (error) {
return false return false
} }

View File

@ -13,10 +13,14 @@ const removeLastTrailingSlash = (url) => {
const gnosisAppsUrl = removeLastTrailingSlash(getGnosisSafeAppsUrl()) const gnosisAppsUrl = removeLastTrailingSlash(getGnosisSafeAppsUrl())
export const staticAppsList: Array<{ url: string; disabled: boolean }> = [ export const staticAppsList: Array<{ url: string; disabled: boolean }> = [
// Sablier
{ url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmabPEk7g4zaytFefp6fE4nz8f85QMJoWmRQQZypvJViNG`, disabled: false },
// request
{ url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmQapdJP6zERqpDKKPECNeMDDgwmGUqbKk1PjHpYj8gfDJ`, disabled: false }, { url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmQapdJP6zERqpDKKPECNeMDDgwmGUqbKk1PjHpYj8gfDJ`, disabled: false },
// Aave
// { url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmUfgEqdJ5kVjWTQofnDmvxdhDLBAaejiHkhQhfw6aYvBg`, disabled: false },
{ url: `${gnosisAppsUrl}/compound`, disabled: false }, { url: `${gnosisAppsUrl}/compound`, disabled: false },
{ url: `${gnosisAppsUrl}/tx-builder`, disabled: false }, { url: `${gnosisAppsUrl}/tx-builder`, disabled: false },
{ url: `${gnosisAppsUrl}/aave`, disabled: false },
{ url: `${gnosisAppsUrl}/pool-together`, disabled: false }, { url: `${gnosisAppsUrl}/pool-together`, disabled: false },
{ url: `${gnosisAppsUrl}/open-zeppelin`, disabled: false }, { url: `${gnosisAppsUrl}/open-zeppelin`, disabled: false },
{ url: `${gnosisAppsUrl}/synthetix`, disabled: false }, { url: `${gnosisAppsUrl}/synthetix`, disabled: false },

View File

@ -19,6 +19,10 @@ const ContractInteraction = React.lazy(() => import('./screens/ContractInteracti
const ContractInteractionReview: any = React.lazy(() => import('./screens/ContractInteraction/Review')) const ContractInteractionReview: any = React.lazy(() => import('./screens/ContractInteraction/Review'))
const SendCustomTx = React.lazy(() => import('./screens/ContractInteraction/SendCustomTx'))
const ReviewCustomTx = React.lazy(() => import('./screens/ContractInteraction/ReviewCustomTx'))
const useStyles = makeStyles({ const useStyles = makeStyles({
scalableModalWindow: { scalableModalWindow: {
height: 'auto', height: 'auto',
@ -40,9 +44,11 @@ const SendModal = ({ activeScreenType, isOpen, onClose, recipientAddress, select
const classes = useStyles() const classes = useStyles()
const [activeScreen, setActiveScreen] = useState(activeScreenType || 'chooseTxType') const [activeScreen, setActiveScreen] = useState(activeScreenType || 'chooseTxType')
const [tx, setTx] = useState({}) const [tx, setTx] = useState({})
const [isABI, setIsABI] = useState(true)
useEffect(() => { useEffect(() => {
setActiveScreen(activeScreenType || 'chooseTxType') setActiveScreen(activeScreenType || 'chooseTxType')
setIsABI(true)
setTx({}) setTx({})
}, [activeScreenType, isOpen]) }, [activeScreenType, isOpen])
@ -53,9 +59,14 @@ const SendModal = ({ activeScreenType, isOpen, onClose, recipientAddress, select
setTx(txInfo) setTx(txInfo)
} }
const handleContractInteractionCreation = (contractInteractionInfo) => { const handleContractInteractionCreation = (contractInteractionInfo: any, submit: boolean): void => {
setTx(contractInteractionInfo) setTx(contractInteractionInfo)
setActiveScreen('contractInteractionReview') if (submit) setActiveScreen('contractInteractionReview')
}
const handleCustomTxCreation = (customTxInfo: any, submit: boolean): void => {
setTx(customTxInfo)
if (submit) setActiveScreen('reviewCustomTx')
} }
const handleSendCollectible = (txInfo) => { const handleSendCollectible = (txInfo) => {
@ -63,6 +74,10 @@ const SendModal = ({ activeScreenType, isOpen, onClose, recipientAddress, select
setTx(txInfo) setTx(txInfo)
} }
const handleSwitchMethod = (): void => {
setIsABI(!isABI)
}
return ( return (
<Modal <Modal
description="Send Tokens Form" description="Send Tokens Form"
@ -93,17 +108,32 @@ const SendModal = ({ activeScreenType, isOpen, onClose, recipientAddress, select
{activeScreen === 'reviewTx' && ( {activeScreen === 'reviewTx' && (
<ReviewTx onClose={onClose} onPrev={() => setActiveScreen('sendFunds')} tx={tx} /> <ReviewTx onClose={onClose} onPrev={() => setActiveScreen('sendFunds')} tx={tx} />
)} )}
{activeScreen === 'contractInteraction' && ( {activeScreen === 'contractInteraction' && isABI && (
<ContractInteraction <ContractInteraction
isABI={isABI}
switchMethod={handleSwitchMethod}
contractAddress={recipientAddress} contractAddress={recipientAddress}
initialValues={tx} initialValues={tx}
onClose={onClose} onClose={onClose}
onNext={handleContractInteractionCreation} onNext={handleContractInteractionCreation}
/> />
)} )}
{activeScreen === 'contractInteractionReview' && tx && ( {activeScreen === 'contractInteractionReview' && isABI && tx && (
<ContractInteractionReview onClose={onClose} onPrev={() => setActiveScreen('contractInteraction')} tx={tx} /> <ContractInteractionReview onClose={onClose} onPrev={() => setActiveScreen('contractInteraction')} tx={tx} />
)} )}
{activeScreen === 'contractInteraction' && !isABI && (
<SendCustomTx
initialValues={tx}
isABI={isABI}
switchMethod={handleSwitchMethod}
onClose={onClose}
onNext={handleCustomTxCreation}
contractAddress={recipientAddress}
/>
)}
{activeScreen === 'reviewCustomTx' && (
<ReviewCustomTx onClose={onClose} onPrev={() => setActiveScreen('contractInteraction')} tx={tx} />
)}
{activeScreen === 'sendCollectible' && ( {activeScreen === 'sendCollectible' && (
<SendCollectible <SendCollectible
initialValues={tx} initialValues={tx}

View File

@ -88,6 +88,9 @@ const AddressBookInput = ({
) )
}) })
setADBKList(filteredADBK) setADBKList(filteredADBK)
if (!isValidText) {
setSelectedEntry({ address: addressValue })
}
} }
setIsValidForm(isValidText === undefined) setIsValidForm(isValidText === undefined)
setValidationText(isValidText) setValidationText(isValidText)

View File

@ -50,7 +50,7 @@ const ContractInteractionReview = ({ onClose, onPrev, tx }: Props) => {
useEffect(() => { useEffect(() => {
let isCurrent = true let isCurrent = true
const estimateGas = async () => { const estimateGas = async (): Promise<void> => {
const { fromWei, toBN } = getWeb3().utils const { fromWei, toBN } = getWeb3().utils
const txData = tx.data ? tx.data.trim() : '' const txData = tx.data ? tx.data.trim() : ''

View File

@ -0,0 +1,184 @@
import IconButton from '@material-ui/core/IconButton'
import { makeStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close'
import { useSnackbar } from 'notistack'
import React, { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import ArrowDown from '../../assets/arrow-down.svg'
import { styles } from './style'
import CopyBtn from 'src/components/CopyBtn'
import EtherscanBtn from 'src/components/EtherscanBtn'
import Identicon from 'src/components/Identicon'
import Block from 'src/components/layout/Block'
import Button from 'src/components/layout/Button'
import Col from 'src/components/layout/Col'
import Hairline from 'src/components/layout/Hairline'
import Img from 'src/components/layout/Img'
import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row'
import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
import { estimateTxGasCosts } from 'src/logic/safe/transactions/gasNew'
import { formatAmount } from 'src/logic/tokens/utils/formatAmount'
import { getEthAsToken } from 'src/logic/tokens/utils/tokenHelpers'
import { getWeb3 } from 'src/logic/wallets/getWeb3'
import SafeInfo from 'src/routes/safe/components/Balances/SendModal/SafeInfo'
import { setImageToPlaceholder } from 'src/routes/safe/components/Balances/utils'
import createTransaction from 'src/routes/safe/store/actions/createTransaction'
import { safeSelector } from 'src/routes/safe/store/selectors'
import { sm } from 'src/theme/variables'
type Props = {
onClose: () => void
onPrev: () => void
tx: { contractAddress?: string; data?: string; value?: string }
}
const useStyles = makeStyles(styles)
const ReviewCustomTx = ({ onClose, onPrev, tx }: Props) => {
const { enqueueSnackbar, closeSnackbar } = useSnackbar()
const classes = useStyles()
const dispatch = useDispatch()
const { address: safeAddress } = useSelector(safeSelector)
const [gasCosts, setGasCosts] = useState<string>('< 0.001')
useEffect(() => {
let isCurrent = true
const estimateGas = async () => {
const { fromWei, toBN } = getWeb3().utils
const txData = tx.data ? tx.data.trim() : ''
const estimatedGasCosts = await estimateTxGasCosts(safeAddress, tx.contractAddress, txData)
const gasCostsAsEth = fromWei(toBN(estimatedGasCosts), 'ether')
const formattedGasCosts = formatAmount(gasCostsAsEth)
if (isCurrent) {
setGasCosts(formattedGasCosts)
}
}
estimateGas()
return () => {
isCurrent = false
}
}, [safeAddress, tx.data, tx.contractAddress])
const submitTx = async (): Promise<void> => {
const web3 = getWeb3()
const txRecipient = tx.contractAddress
const txData = tx.data ? tx.data.trim() : ''
const txValue = tx.value ? web3.utils.toWei(tx.value, 'ether') : '0'
dispatch(
createTransaction({
safeAddress,
to: txRecipient,
valueInWei: txValue,
txData,
notifiedTransaction: TX_NOTIFICATION_TYPES.STANDARD_TX,
enqueueSnackbar,
closeSnackbar,
} as any),
)
onClose()
}
return (
<>
<Row align="center" className={classes.heading} grow>
<Paragraph className={classes.headingText} noMargin weight="bolder">
Send Custom Tx
</Paragraph>
<Paragraph className={classes.annotation}>2 of 2</Paragraph>
<IconButton disableRipple onClick={onClose}>
<Close className={classes.closeIcon} />
</IconButton>
</Row>
<Hairline />
<Block className={classes.container}>
<SafeInfo />
<Row margin="md">
<Col xs={1}>
<img alt="Arrow Down" src={ArrowDown} style={{ marginLeft: sm }} />
</Col>
<Col center="xs" layout="column" xs={11}>
<Hairline />
</Col>
</Row>
<Row margin="xs">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Recipient
</Paragraph>
</Row>
<Row align="center" margin="md">
<Col xs={1}>
<Identicon address={tx.contractAddress} diameter={32} />
</Col>
<Col layout="column" xs={11}>
<Block justify="left">
<Paragraph noMargin weight="bolder">
{tx.contractAddress}
</Paragraph>
<CopyBtn content={tx.contractAddress} />
<EtherscanBtn type="address" value={tx.contractAddress} />
</Block>
</Col>
</Row>
<Row margin="xs">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Value
</Paragraph>
</Row>
<Row align="center" margin="md">
<Img alt="Ether" height={28} onError={setImageToPlaceholder} src={getEthAsToken('0').logoUri} />
<Paragraph className={classes.value} noMargin size="md">
{tx.value || 0}
{' ETH'}
</Paragraph>
</Row>
<Row margin="xs">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Data (hex encoded)
</Paragraph>
</Row>
<Row align="center" margin="md">
<Col className={classes.outerData}>
<Row className={classes.data} size="md">
{tx.data}
</Row>
</Col>
</Row>
<Row>
<Paragraph>
{`You're about to create a transaction and will have to confirm it with your currently connected wallet. Make sure you have ${gasCosts} (fee price) ETH in this wallet to fund this confirmation.`}
</Paragraph>
</Row>
</Block>
<Hairline />
<Row align="center" className={classes.buttonRow}>
<Button minWidth={140} onClick={onPrev}>
Back
</Button>
<Button
className={classes.submitButton}
color="primary"
data-testid="submit-tx-btn"
minWidth={140}
onClick={submitTx}
type="submit"
variant="contained"
>
Submit
</Button>
</Row>
</>
)
}
export default ReviewCustomTx

View File

@ -0,0 +1,58 @@
import { border, lg, md, secondaryText, sm } from 'src/theme/variables'
import { createStyles } from '@material-ui/core'
export const styles = createStyles({
heading: {
padding: `${md} ${lg}`,
justifyContent: 'flex-start',
boxSizing: 'border-box',
maxHeight: '75px',
},
annotation: {
letterSpacing: '-1px',
color: secondaryText,
marginRight: 'auto',
marginLeft: '20px',
},
headingText: {
fontSize: lg,
},
closeIcon: {
height: '35px',
width: '35px',
},
container: {
padding: `${md} ${lg}`,
},
value: {
marginLeft: sm,
},
outerData: {
borderRadius: '5px',
border: `1px solid ${border}`,
padding: '11px',
minHeight: '21px',
},
data: {
wordBreak: 'break-all',
overflow: 'auto',
fontSize: '14px',
fontFamily: 'Averta',
maxHeight: '100px',
letterSpacing: 'normal',
fontStretch: 'normal',
lineHeight: '1.43',
},
buttonRow: {
height: '84px',
justifyContent: 'center',
'& > button': {
fontFamily: 'Averta',
fontSize: md,
},
},
submitButton: {
boxShadow: '1px 2px 10px 0 rgba(212, 212, 211, 0.59)',
marginLeft: '15px',
},
})

View File

@ -0,0 +1,278 @@
import IconButton from '@material-ui/core/IconButton'
import InputAdornment from '@material-ui/core/InputAdornment'
import Switch from '@material-ui/core/Switch'
import { makeStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close'
import React, { useState } from 'react'
import { useSelector } from 'react-redux'
import ArrowDown from '../../assets/arrow-down.svg'
import { styles } from './style'
import QRIcon from 'src/assets/icons/qrcode.svg'
import CopyBtn from 'src/components/CopyBtn'
import EtherscanBtn from 'src/components/EtherscanBtn'
import Identicon from 'src/components/Identicon'
import ScanQRModal from 'src/components/ScanQRModal'
import Field from 'src/components/forms/Field'
import GnoForm from 'src/components/forms/GnoForm'
import TextField from 'src/components/forms/TextField'
import TextareaField from 'src/components/forms/TextareaField'
import { composeValidators, maxValue, mustBeFloat, greaterThan } from 'src/components/forms/validator'
import Block from 'src/components/layout/Block'
import Button from 'src/components/layout/Button'
import ButtonLink from 'src/components/layout/ButtonLink'
import Col from 'src/components/layout/Col'
import Hairline from 'src/components/layout/Hairline'
import Img from 'src/components/layout/Img'
import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row'
import SafeInfo from 'src/routes/safe/components/Balances/SendModal/SafeInfo'
import AddressBookInput from 'src/routes/safe/components/Balances/SendModal/screens/AddressBookInput'
import { safeSelector } from 'src/routes/safe/store/selectors'
import { sm } from 'src/theme/variables'
export interface CreatedTx {
contractAddress: string
data: string
value: string | number
}
type Props = {
initialValues: { contractAddress?: string }
onClose: () => void
onNext: (tx: CreatedTx, submit: boolean) => void
isABI: boolean
switchMethod: () => void
contractAddress: string
}
const useStyles = makeStyles(styles)
const SendCustomTx: React.FC<Props> = ({ initialValues, onClose, onNext, contractAddress, switchMethod, isABI }) => {
const classes = useStyles()
const { ethBalance } = useSelector(safeSelector)
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false)
const [selectedEntry, setSelectedEntry] = useState<{ address?: string; name?: string } | null>({
address: contractAddress || initialValues.contractAddress,
name: '',
})
const [isValidAddress, setIsValidAddress] = useState<boolean>(true)
const saveForm = async (values) => {
await handleSubmit(values, false)
switchMethod()
}
const handleSubmit = (values: any, submit = true) => {
if (values.data || values.value) {
onNext(values, submit)
}
}
const openQrModal = () => {
setQrModalOpen(true)
}
const closeQrModal = () => {
setQrModalOpen(false)
}
const formMutators = {
setMax: (args, state, utils) => {
utils.changeValue(state, 'value', () => ethBalance)
},
setRecipient: (args, state, utils) => {
utils.changeValue(state, 'contractAddress', () => args[0])
},
}
return (
<>
<Row align="center" className={classes.heading} grow>
<Paragraph className={classes.manage} noMargin weight="bolder">
Send custom transactions
</Paragraph>
<Paragraph className={classes.annotation}>1 of 2</Paragraph>
<IconButton disableRipple onClick={onClose}>
<Close className={classes.closeIcon} />
</IconButton>
</Row>
<Hairline />
<GnoForm
formMutators={formMutators}
initialValues={initialValues}
subscription={{ submitting: true, pristine: true, values: true }}
onSubmit={handleSubmit}
>
{(...args) => {
const mutators = args[3]
const pristine = args[2].pristine
let shouldDisableSubmitButton = !isValidAddress
if (selectedEntry) {
shouldDisableSubmitButton = !selectedEntry.address
}
const handleScan = (value) => {
let scannedAddress = value
if (scannedAddress.startsWith('ethereum:')) {
scannedAddress = scannedAddress.replace('ethereum:', '')
}
mutators.setRecipient(scannedAddress)
closeQrModal()
}
return (
<>
<Block className={classes.formContainer}>
<SafeInfo />
<Row margin="md">
<Col xs={1}>
<img alt="Arrow Down" src={ArrowDown} style={{ marginLeft: sm }} />
</Col>
<Col center="xs" layout="column" xs={11}>
<Hairline />
</Col>
</Row>
{selectedEntry && selectedEntry.address ? (
<div
onKeyDown={(e) => {
if (e.keyCode !== 9) {
setSelectedEntry(null)
}
}}
role="listbox"
tabIndex={0}
>
<Row margin="xs">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Recipient
</Paragraph>
</Row>
<Row align="center" margin="md">
<Col xs={1}>
<Identicon address={selectedEntry.address} diameter={32} />
</Col>
<Col layout="column" xs={11}>
<Block justify="left">
<Block>
<Paragraph
className={classes.selectAddress}
noMargin
onClick={() => setSelectedEntry(null)}
weight="bolder"
>
{selectedEntry.name}
</Paragraph>
<Paragraph
className={classes.selectAddress}
noMargin
onClick={() => setSelectedEntry(null)}
weight="bolder"
>
{selectedEntry.address}
</Paragraph>
</Block>
<CopyBtn content={selectedEntry.address} />
<EtherscanBtn type="address" value={selectedEntry.address} />
</Block>
</Col>
</Row>
</div>
) : (
<>
<Row margin="md">
<Col xs={11}>
<AddressBookInput
fieldMutator={mutators.setRecipient}
isCustomTx
pristine={pristine}
setIsValidAddress={setIsValidAddress}
setSelectedEntry={setSelectedEntry}
/>
</Col>
<Col center="xs" className={classes} middle="xs" xs={1}>
<Img
alt="Scan QR"
className={classes.qrCodeBtn}
height={20}
onClick={() => {
openQrModal()
}}
role="button"
src={QRIcon}
/>
</Col>
</Row>
</>
)}
<Row margin="xs">
<Col between="lg">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Value
</Paragraph>
<ButtonLink onClick={mutators.setMax} weight="bold">
Send max
</ButtonLink>
</Col>
</Row>
<Row margin="md">
<Col>
<Field
component={TextField}
inputAdornment={{
endAdornment: <InputAdornment position="end">ETH</InputAdornment>,
}}
name="value"
placeholder="Value*"
text="Value*"
type="text"
validate={composeValidators(mustBeFloat, maxValue(ethBalance), greaterThan(0))}
/>
</Col>
</Row>
<Row margin="sm">
<Col>
<TextareaField
name="data"
placeholder="Data (hex encoded)*"
text="Data (hex encoded)*"
type="text"
/>
</Col>
</Row>
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Use custom data (hex encoded)
<Switch onChange={() => saveForm(args[2].values)} checked={!isABI} />
</Paragraph>
</Block>
<Hairline />
<Row align="center" className={classes.buttonRow}>
<Button minWidth={140} onClick={onClose}>
Cancel
</Button>
<Button
className={classes.submitButton}
color="primary"
data-testid="review-tx-btn"
disabled={shouldDisableSubmitButton}
minWidth={140}
type="submit"
variant="contained"
>
Review
</Button>
</Row>
{qrModalOpen && <ScanQRModal isOpen={qrModalOpen} onClose={closeQrModal} onScan={handleScan} />}
</>
)
}}
</GnoForm>
</>
)
}
export default SendCustomTx

View File

@ -0,0 +1,51 @@
import { lg, md } from 'src/theme/variables'
import { createStyles } from '@material-ui/core'
export const styles = createStyles({
heading: {
padding: `${md} ${lg}`,
justifyContent: 'flex-start',
boxSizing: 'border-box',
maxHeight: '75px',
},
annotation: {
letterSpacing: '-1px',
color: '#a2a8ba',
marginRight: 'auto',
marginLeft: '20px',
},
manage: {
fontSize: lg,
},
closeIcon: {
height: '35px',
width: '35px',
},
qrCodeBtn: {
cursor: 'pointer',
},
formContainer: {
padding: `${md} ${lg}`,
},
buttonRow: {
height: '84px',
justifyContent: 'center',
'& > button': {
fontFamily: 'Averta',
fontSize: md,
},
},
submitButton: {
boxShadow: '1px 2px 10px 0 rgba(212, 212, 211, 0.59)',
marginLeft: '15px',
},
dataInput: {
'& TextField-root-294': {
lineHeight: 'auto',
border: 'green',
},
},
selectAddress: {
cursor: 'pointer',
},
})

View File

@ -1,13 +1,14 @@
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import React from 'react' import React from 'react'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import Switch from '@material-ui/core/Switch'
import { styles } from './style' import { styles } from './style'
import GnoForm from 'src/components/forms/GnoForm' import GnoForm from 'src/components/forms/GnoForm'
import Block from 'src/components/layout/Block' import Block from 'src/components/layout/Block'
import Hairline from 'src/components/layout/Hairline' import Hairline from 'src/components/layout/Hairline'
import SafeInfo from 'src/routes/safe/components/Balances/SendModal/SafeInfo' import SafeInfo from 'src/routes/safe/components/Balances/SendModal/SafeInfo'
import { safeSelector } from 'src/routes/safe/store/selectors' import { safeSelector } from 'src/routes/safe/store/selectors'
import Paragraph from 'src/components/layout/Paragraph'
import Buttons from './Buttons' import Buttons from './Buttons'
import ContractABI from './ContractABI' import ContractABI from './ContractABI'
import EthAddressInput from './EthAddressInput' import EthAddressInput from './EthAddressInput'
@ -33,11 +34,20 @@ export interface CreatedTx {
export interface ContractInteractionProps { export interface ContractInteractionProps {
contractAddress: string contractAddress: string
initialValues: { contractAddress?: string } initialValues: { contractAddress?: string }
isABI: boolean
onClose: () => void onClose: () => void
onNext: (tx: CreatedTx) => void switchMethod: () => void
onNext: (tx: CreatedTx, submit: boolean) => void
} }
const ContractInteraction = ({ contractAddress, initialValues, onClose, onNext }: ContractInteractionProps) => { const ContractInteraction: React.FC<ContractInteractionProps> = ({
contractAddress,
initialValues,
onClose,
onNext,
switchMethod,
isABI,
}) => {
const classes = useStyles() const classes = useStyles()
const { address: safeAddress = '' } = useSelector(safeSelector) const { address: safeAddress = '' } = useSelector(safeSelector)
let setCallResults let setCallResults
@ -48,13 +58,21 @@ const ContractInteraction = ({ contractAddress, initialValues, onClose, onNext }
} }
}, [contractAddress, initialValues.contractAddress]) }, [contractAddress, initialValues.contractAddress])
const handleSubmit = async ({ contractAddress, selectedMethod, value, ...values }) => { const saveForm = async (values: CreatedTx): Promise<void> => {
await handleSubmit(values, false)
switchMethod()
}
const handleSubmit = async (
{ contractAddress, selectedMethod, value, ...values },
submit = true,
): Promise<void | any> => {
if (value || (contractAddress && selectedMethod)) { if (value || (contractAddress && selectedMethod)) {
try { try {
const txObject = createTxObject(selectedMethod, contractAddress, values) const txObject = createTxObject(selectedMethod, contractAddress, values)
const data = txObject.encodeABI() const data = txObject.encodeABI()
if (isReadMethod(selectedMethod)) { if (isReadMethod(selectedMethod) && submit) {
const result = await txObject.call({ from: safeAddress }) const result = await txObject.call({ from: safeAddress })
setCallResults(result) setCallResults(result)
@ -62,7 +80,7 @@ const ContractInteraction = ({ contractAddress, initialValues, onClose, onNext }
return return
} }
onNext({ ...values, contractAddress, data, selectedMethod, value }) onNext({ ...values, contractAddress, data, selectedMethod, value }, submit)
} catch (error) { } catch (error) {
return handleSubmitError(error, values) return handleSubmitError(error, values)
} }
@ -78,7 +96,7 @@ const ContractInteraction = ({ contractAddress, initialValues, onClose, onNext }
formMutators={formMutators} formMutators={formMutators}
initialValues={initialValues} initialValues={initialValues}
onSubmit={handleSubmit} onSubmit={handleSubmit}
subscription={{ submitting: true, pristine: true }} subscription={{ submitting: true, pristine: true, values: true }}
> >
{(submitting, validating, rest, mutators) => { {(submitting, validating, rest, mutators) => {
setCallResults = mutators.setCallResults setCallResults = mutators.setCallResults
@ -99,6 +117,10 @@ const ContractInteraction = ({ contractAddress, initialValues, onClose, onNext }
<RenderInputParams /> <RenderInputParams />
<RenderOutputParams /> <RenderOutputParams />
<FormErrorMessage /> <FormErrorMessage />
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Use custom data (hex encoded)
<Switch checked={!isABI} onChange={() => saveForm(rest.values)} />
</Paragraph>
</Block> </Block>
<Hairline /> <Hairline />
<Buttons onClose={onClose} /> <Buttons onClose={onClose} />

View File

@ -17,7 +17,14 @@ import { ScanQRWrapper } from 'src/components/ScanQRModal/ScanQRWrapper'
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, greaterThan, maxValue, mustBeFloat, required } from 'src/components/forms/validator' import {
composeValidators,
greaterThan,
maxValue,
maxValueCheck,
mustBeFloat,
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 ButtonLink from 'src/components/layout/ButtonLink' import ButtonLink from 'src/components/layout/ButtonLink'
@ -39,7 +46,7 @@ const formMutators = {
utils.changeValue(state, 'amount', () => args[0]) utils.changeValue(state, 'amount', () => args[0])
}, },
onTokenChange: (args, state, utils) => { onTokenChange: (args, state, utils) => {
utils.changeValue(state, 'amount', () => '') utils.changeValue(state, 'amount', () => state.formState.values.amount)
}, },
setRecipient: (args, state, utils) => { setRecipient: (args, state, utils) => {
utils.changeValue(state, 'recipientAddress', () => args[0]) utils.changeValue(state, 'recipientAddress', () => args[0])
@ -56,6 +63,7 @@ const SendFunds = ({ initialValues, onClose, onNext, recipientAddress, selectedT
address: recipientAddress || initialValues.recipientAddress, address: recipientAddress || initialValues.recipientAddress,
name: '', name: '',
}) })
const [pristine, setPristine] = useState(true) const [pristine, setPristine] = useState(true)
const [isValidAddress, setIsValidAddress] = useState(true) const [isValidAddress, setIsValidAddress] = useState(true)
@ -86,7 +94,18 @@ const SendFunds = ({ initialValues, onClose, onNext, recipientAddress, selectedT
</IconButton> </IconButton>
</Row> </Row>
<Hairline /> <Hairline />
<GnoForm formMutators={formMutators} initialValues={initialValues} onSubmit={handleSubmit}> <GnoForm
formMutators={formMutators}
initialValues={initialValues}
onSubmit={handleSubmit}
validation={(values) => {
const selectedTokenRecord = tokens.find((token) => token.address === values?.token)
return {
amount: maxValueCheck(selectedTokenRecord?.balance, values.amount),
}
}}
>
{(...args) => { {(...args) => {
const formState = args[2] const formState = args[2]
const mutators = args[3] const mutators = args[3]
@ -224,11 +243,15 @@ const SendFunds = ({ initialValues, onClose, onNext, recipientAddress, selectedT
required, required,
mustBeFloat, mustBeFloat,
greaterThan(0), greaterThan(0),
maxValue(selectedTokenRecord && selectedTokenRecord.balance), maxValue(selectedTokenRecord?.balance),
)} )}
/> />
<OnChange name="token"> <OnChange name="token">
{() => { {() => {
setSelectedEntry({
name: selectedEntry?.name,
address: selectedEntry?.address,
})
mutators.onTokenChange() mutators.onTokenChange()
}} }}
</OnChange> </OnChange>

View File

@ -38,7 +38,13 @@ const LayoutHeader = (props) => {
{!granted && <Block className={classes.readonly}>Read Only</Block>} {!granted && <Block className={classes.readonly}>Read Only</Block>}
</Row> </Row>
<Block className={classes.user} justify="center"> <Block className={classes.user} justify="center">
<Paragraph className={classes.address} color="disabled" noMargin size="md"> <Paragraph
className={classes.address}
color="disabled"
noMargin
size="md"
data-testid="safe-address-heading"
>
{address} {address}
</Paragraph> </Paragraph>
<CopyBtn content={address} /> <CopyBtn content={address} />
@ -54,6 +60,7 @@ const LayoutHeader = (props) => {
onClick={() => showSendFunds('')} onClick={() => showSendFunds('')}
size="small" size="small"
variant="contained" variant="contained"
testId="main-send-btn"
> >
<CallMade <CallMade
alt="Send Transaction" alt="Send Transaction"

View File

@ -10,6 +10,7 @@ import {
ADDRESS_BOOK_TAB_BTN_TEST_ID, ADDRESS_BOOK_TAB_BTN_TEST_ID,
BALANCES_TAB_BTN_TEST_ID, BALANCES_TAB_BTN_TEST_ID,
SETTINGS_TAB_BTN_TEST_ID, SETTINGS_TAB_BTN_TEST_ID,
APPS_TAB_BTN_TEST_ID,
TRANSACTIONS_TAB_BTN_TEST_ID, TRANSACTIONS_TAB_BTN_TEST_ID,
} from 'src/routes/safe/components/Layout' } from 'src/routes/safe/components/Layout'
import SettingsTab from 'src/routes/safe/components/Layout/Tabs/SettingsTab' import SettingsTab from 'src/routes/safe/components/Layout/Tabs/SettingsTab'
@ -105,7 +106,7 @@ const TabsComponent = (props: Props) => {
selected: classes.tabWrapperSelected, selected: classes.tabWrapperSelected,
wrapper: classes.tabWrapper, wrapper: classes.tabWrapper,
}} }}
data-testid={TRANSACTIONS_TAB_BTN_TEST_ID} data-testid={APPS_TAB_BTN_TEST_ID}
label={AppsLabel} label={AppsLabel}
value={`${match.url}/apps`} value={`${match.url}/apps`}
/> />

View File

@ -21,6 +21,7 @@ import { wrapInSuspense } from 'src/utils/wrapInSuspense'
export const BALANCES_TAB_BTN_TEST_ID = 'balances-tab-btn' export const BALANCES_TAB_BTN_TEST_ID = 'balances-tab-btn'
export const SETTINGS_TAB_BTN_TEST_ID = 'settings-tab-btn' export const SETTINGS_TAB_BTN_TEST_ID = 'settings-tab-btn'
export const APPS_TAB_BTN_TEST_ID = 'apps-tab-btn'
export const TRANSACTIONS_TAB_BTN_TEST_ID = 'transactions-tab-btn' export const TRANSACTIONS_TAB_BTN_TEST_ID = 'transactions-tab-btn'
export const ADDRESS_BOOK_TAB_BTN_TEST_ID = 'address-book-tab-btn' export const ADDRESS_BOOK_TAB_BTN_TEST_ID = 'address-book-tab-btn'
export const SAFE_VIEW_NAME_HEADING_TEST_ID = 'safe-name-heading' export const SAFE_VIEW_NAME_HEADING_TEST_ID = 'safe-name-heading'

View File

@ -18,15 +18,13 @@ export const getTxData = (tx) => {
const { to } = tx.decodedParams.transfer const { to } = tx.decodedParams.transfer
txData.recipient = to txData.recipient = to
txData.isTokenTransfer = true txData.isTokenTransfer = true
} } else if (tx.isCollectibleTransfer) {
if (tx.isCollectibleTransfer) {
const { safeTransferFrom, transfer, transferFrom } = tx.decodedParams const { safeTransferFrom, transfer, transferFrom } = tx.decodedParams
const { to, value } = safeTransferFrom || transferFrom || transfer const { to, value } = safeTransferFrom || transferFrom || transfer
txData.recipient = to txData.recipient = to
txData.tokenId = value txData.tokenId = value
txData.isCollectibleTransfer = true txData.isCollectibleTransfer = true
} } else if (tx.modifySettingsTx) {
if (tx.modifySettingsTx) {
txData.recipient = tx.recipient txData.recipient = tx.recipient
txData.modifySettingsTx = true txData.modifySettingsTx = true
@ -50,11 +48,12 @@ export const getTxData = (tx) => {
txData.removedOwner = oldOwner txData.removedOwner = oldOwner
txData.addedOwner = newOwner txData.addedOwner = newOwner
} }
} } else if (tx.multiSendTx) {
if (tx.multiSendTx) {
txData.recipient = tx.recipient txData.recipient = tx.recipient
txData.data = tx.data txData.data = tx.data
txData.customTx = true txData.customTx = true
} else {
txData.recipient = tx.recipient
} }
} else if (tx.customTx) { } else if (tx.customTx) {
txData.recipient = tx.recipient txData.recipient = tx.recipient

View File

@ -35,6 +35,7 @@ export const CreateSafe = ({ provider, size }: any) => (
size={size || 'medium'} size={size || 'medium'}
to={OPEN_ADDRESS} to={OPEN_ADDRESS}
variant="contained" variant="contained"
testId="create-new-safe-btn"
> >
<Img alt="Safe" height={14} src={plus} /> <Img alt="Safe" height={14} src={plus} />
<div style={buttonStyle}>Create new Safe</div> <div style={buttonStyle}>Create new Safe</div>
@ -50,6 +51,7 @@ export const LoadSafe = ({ provider, size }) => (
size={size || 'medium'} size={size || 'medium'}
to={LOAD_ADDRESS} to={LOAD_ADDRESS}
variant="outlined" variant="outlined"
testId="load-existing-safe-btn"
> >
<Img alt="Safe" height={14} src={safe} /> <Img alt="Safe" height={14} src={safe} />
<div style={buttonStyle}>Load existing Safe</div> <div style={buttonStyle}>Load existing Safe</div>
@ -108,7 +110,7 @@ const Welcome = ({ isOldMultisigMigration, provider }: any) => {
<Heading align="center" margin="md" tag="h3"> <Heading align="center" margin="md" tag="h3">
Get Started by Connecting a Wallet Get Started by Connecting a Wallet
</Heading> </Heading>
<ConnectButton minHeight={42} minWidth={240} /> <ConnectButton minHeight={42} minWidth={240} data-testid="connect-btn" />
</Block> </Block>
)} )}
</Block> </Block>

1619
yarn.lock

File diff suppressed because it is too large Load Diff