Network tab refactor (#12502)
parent
e3e6da1a75
commit
524725b24b
@ -1 +1 @@ |
||||
export { default } from './networks-tab.container'; |
||||
export { default } from './networks-tab'; |
||||
|
@ -1 +0,0 @@ |
||||
export { default } from './network-form.component'; |
@ -1,76 +0,0 @@ |
||||
.add-network-form { |
||||
&__body { |
||||
padding-right: 24px; |
||||
} |
||||
|
||||
&__subheader { |
||||
@include H4; |
||||
|
||||
padding: 16px 4px; |
||||
border-bottom: 1px solid $alto; |
||||
height: 72px; |
||||
align-items: center; |
||||
display: flex; |
||||
flex-flow: row nowrap; |
||||
} |
||||
|
||||
&__subheader--break { |
||||
margin-inline-start: 10px; |
||||
} |
||||
|
||||
&__sub-header-text { |
||||
@include H4; |
||||
|
||||
color: $ui-4; |
||||
margin-right: 10px; |
||||
} |
||||
|
||||
&__content { |
||||
justify-content: space-between; |
||||
display: flex; |
||||
flex-direction: column; |
||||
|
||||
&--warning { |
||||
@include H7; |
||||
|
||||
background-color: $Yellow-000; |
||||
border: 1px solid $alert-1; |
||||
border-radius: 5px; |
||||
box-sizing: border-box; |
||||
padding: 12px; |
||||
margin: 12px 0; |
||||
} |
||||
} |
||||
|
||||
&__form-column { |
||||
display: flex; |
||||
flex-direction: column; |
||||
margin-top: 12px; |
||||
} |
||||
|
||||
&__form-row { |
||||
display: flex; |
||||
flex-direction: row; |
||||
justify-content: space-between; |
||||
} |
||||
|
||||
&__network-form-row { |
||||
padding-bottom: 30px; |
||||
width: 48%; |
||||
} |
||||
|
||||
&__footer { |
||||
display: flex; |
||||
flex-flow: row; |
||||
padding: 0 0 0.75rem 0; |
||||
width: 60%; |
||||
|
||||
&-cancel-button { |
||||
margin-right: 1.25rem; |
||||
} |
||||
|
||||
&-submit-button { |
||||
margin-left: 1.25rem; |
||||
} |
||||
} |
||||
} |
@ -1,761 +0,0 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import validUrl from 'valid-url'; |
||||
import log from 'loglevel'; |
||||
import TextField from '../../../../components/ui/text-field'; |
||||
import Button from '../../../../components/ui/button'; |
||||
import Tooltip from '../../../../components/ui/tooltip'; |
||||
import { |
||||
isPrefixedFormattedHexString, |
||||
isSafeChainId, |
||||
} from '../../../../../shared/modules/network.utils'; |
||||
import { jsonRpcRequest } from '../../../../../shared/modules/rpc.utils'; |
||||
import { decimalToHex } from '../../../../helpers/utils/conversions.util'; |
||||
|
||||
const FORM_STATE_KEYS = [ |
||||
'rpcUrl', |
||||
'chainId', |
||||
'ticker', |
||||
'networkName', |
||||
'blockExplorerUrl', |
||||
]; |
||||
|
||||
export default class NetworkForm extends PureComponent { |
||||
static contextTypes = { |
||||
t: PropTypes.func.isRequired, |
||||
metricsEvent: PropTypes.func, |
||||
}; |
||||
|
||||
static propTypes = { |
||||
editRpc: PropTypes.func, |
||||
showConfirmDeleteNetworkModal: PropTypes.func, |
||||
rpcUrl: PropTypes.string, |
||||
chainId: PropTypes.string, |
||||
ticker: PropTypes.string, |
||||
viewOnly: PropTypes.bool, |
||||
networkName: PropTypes.string, |
||||
onClear: PropTypes.func.isRequired, |
||||
setRpcTarget: PropTypes.func.isRequired, |
||||
isCurrentRpcTarget: PropTypes.bool, |
||||
blockExplorerUrl: PropTypes.string, |
||||
rpcPrefs: PropTypes.object, |
||||
networksToRender: PropTypes.array.isRequired, |
||||
onAddNetwork: PropTypes.func, |
||||
setNewNetworkAdded: PropTypes.func, |
||||
addNewNetwork: PropTypes.bool, |
||||
}; |
||||
|
||||
static defaultProps = { |
||||
rpcUrl: '', |
||||
chainId: '', |
||||
ticker: '', |
||||
networkName: '', |
||||
blockExplorerUrl: '', |
||||
}; |
||||
|
||||
state = { |
||||
rpcUrl: this.props.rpcUrl, |
||||
chainId: this.getDisplayChainId(this.props.chainId), |
||||
ticker: this.props.ticker, |
||||
networkName: this.props.networkName, |
||||
blockExplorerUrl: this.props.blockExplorerUrl, |
||||
errors: {}, |
||||
isSubmitting: false, |
||||
}; |
||||
|
||||
componentDidUpdate(prevProps) { |
||||
const { addNewNetwork: prevAddMode } = prevProps; |
||||
const { addNewNetwork } = this.props; |
||||
|
||||
if (!prevAddMode && addNewNetwork) { |
||||
this.setState({ |
||||
rpcUrl: '', |
||||
chainId: '', |
||||
ticker: '', |
||||
networkName: '', |
||||
blockExplorerUrl: '', |
||||
errors: {}, |
||||
isSubmitting: false, |
||||
}); |
||||
} else { |
||||
for (const key of FORM_STATE_KEYS) { |
||||
if (prevProps[key] !== this.props[key]) { |
||||
this.resetForm(); |
||||
break; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
componentWillUnmount() { |
||||
this.setState({ |
||||
rpcUrl: '', |
||||
chainId: '', |
||||
ticker: '', |
||||
networkName: '', |
||||
blockExplorerUrl: '', |
||||
errors: {}, |
||||
}); |
||||
// onClear will push the network settings route unless was pass false.
|
||||
// Since we call onClear to cause this component to be unmounted, the
|
||||
// route will already have been updated, and we avoid setting it twice.
|
||||
this.props.onClear(false); |
||||
} |
||||
|
||||
resetForm() { |
||||
const { |
||||
rpcUrl, |
||||
chainId, |
||||
ticker, |
||||
networkName, |
||||
blockExplorerUrl, |
||||
} = this.props; |
||||
|
||||
this.setState({ |
||||
rpcUrl, |
||||
chainId: this.getDisplayChainId(chainId), |
||||
ticker, |
||||
networkName, |
||||
blockExplorerUrl, |
||||
errors: {}, |
||||
isSubmitting: false, |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* Attempts to convert the given chainId to a decimal string, for display |
||||
* purposes. |
||||
* |
||||
* Should be called with the props chainId whenever it is used to set the |
||||
* component's state. |
||||
* |
||||
* @param {unknown} chainId - The chainId to convert. |
||||
* @returns {string} The props chainId in decimal, or the original value if |
||||
* it can't be converted. |
||||
*/ |
||||
getDisplayChainId(chainId) { |
||||
if (!chainId || typeof chainId !== 'string' || !chainId.startsWith('0x')) { |
||||
return chainId; |
||||
} |
||||
return parseInt(chainId, 16).toString(10); |
||||
} |
||||
|
||||
/** |
||||
* Prefixes a given id with '0x' if the prefix does not exist |
||||
* |
||||
* @param {string} chainId - The chainId to prefix |
||||
* @returns {string} The chainId, prefixed with '0x' |
||||
*/ |
||||
prefixChainId(chainId) { |
||||
let prefixedChainId = chainId; |
||||
if (!chainId.startsWith('0x')) { |
||||
prefixedChainId = `0x${parseInt(chainId, 10).toString(16)}`; |
||||
} |
||||
return prefixedChainId; |
||||
} |
||||
|
||||
onSubmit = async () => { |
||||
this.setState({ |
||||
isSubmitting: true, |
||||
}); |
||||
|
||||
try { |
||||
const { |
||||
setRpcTarget, |
||||
rpcUrl: propsRpcUrl, |
||||
editRpc, |
||||
rpcPrefs = {}, |
||||
onAddNetwork, |
||||
setNewNetworkAdded, |
||||
addNewNetwork, |
||||
} = this.props; |
||||
const { |
||||
networkName, |
||||
rpcUrl, |
||||
chainId: stateChainId, |
||||
ticker, |
||||
blockExplorerUrl, |
||||
} = this.state; |
||||
|
||||
const formChainId = stateChainId.trim().toLowerCase(); |
||||
const chainId = this.prefixChainId(formChainId); |
||||
|
||||
if (!(await this.validateChainIdOnSubmit(formChainId, chainId, rpcUrl))) { |
||||
this.setState({ |
||||
isSubmitting: false, |
||||
}); |
||||
return; |
||||
} |
||||
|
||||
// After this point, isSubmitting will be reset in componentDidUpdate
|
||||
if (propsRpcUrl && rpcUrl !== propsRpcUrl) { |
||||
await editRpc(propsRpcUrl, rpcUrl, chainId, ticker, networkName, { |
||||
...rpcPrefs, |
||||
blockExplorerUrl: blockExplorerUrl || rpcPrefs.blockExplorerUrl, |
||||
}); |
||||
} else { |
||||
await setRpcTarget(rpcUrl, chainId, ticker, networkName, { |
||||
...rpcPrefs, |
||||
blockExplorerUrl: blockExplorerUrl || rpcPrefs.blockExplorerUrl, |
||||
}); |
||||
} |
||||
|
||||
if (addNewNetwork) { |
||||
setNewNetworkAdded(networkName); |
||||
onAddNetwork(); |
||||
} |
||||
} catch (error) { |
||||
this.setState({ |
||||
isSubmitting: false, |
||||
}); |
||||
throw error; |
||||
} |
||||
}; |
||||
|
||||
onCancel = () => { |
||||
const { addNewNetwork, onClear } = this.props; |
||||
|
||||
if (addNewNetwork) { |
||||
onClear(); |
||||
} else { |
||||
this.resetForm(); |
||||
} |
||||
}; |
||||
|
||||
onDelete = () => { |
||||
const { showConfirmDeleteNetworkModal, rpcUrl, onClear } = this.props; |
||||
showConfirmDeleteNetworkModal({ |
||||
target: rpcUrl, |
||||
onConfirm: () => { |
||||
this.resetForm(); |
||||
onClear(); |
||||
}, |
||||
}); |
||||
}; |
||||
|
||||
isSubmitting() { |
||||
return this.state.isSubmitting; |
||||
} |
||||
|
||||
stateIsUnchanged() { |
||||
const { |
||||
rpcUrl, |
||||
chainId: propsChainId, |
||||
ticker, |
||||
networkName, |
||||
blockExplorerUrl, |
||||
} = this.props; |
||||
|
||||
const { |
||||
rpcUrl: stateRpcUrl, |
||||
chainId: stateChainId, |
||||
ticker: stateTicker, |
||||
networkName: stateNetworkName, |
||||
blockExplorerUrl: stateBlockExplorerUrl, |
||||
} = this.state; |
||||
|
||||
// These added conditions are in case the saved chainId is invalid, which
|
||||
// was possible in versions <8.1 of the extension.
|
||||
// Basically, we always want to be able to overwrite an invalid chain ID.
|
||||
const chainIdIsUnchanged = |
||||
typeof propsChainId === 'string' && |
||||
propsChainId.toLowerCase().startsWith('0x') && |
||||
stateChainId === this.getDisplayChainId(propsChainId); |
||||
|
||||
return ( |
||||
stateRpcUrl === rpcUrl && |
||||
chainIdIsUnchanged && |
||||
stateTicker === ticker && |
||||
stateNetworkName === networkName && |
||||
stateBlockExplorerUrl === blockExplorerUrl |
||||
); |
||||
} |
||||
|
||||
renderFormTextField({ |
||||
className, |
||||
fieldKey, |
||||
textFieldId, |
||||
onChange, |
||||
value, |
||||
optionalTextFieldKey, |
||||
tooltipText, |
||||
autoFocus = false, |
||||
}) { |
||||
const { errors } = this.state; |
||||
const { viewOnly } = this.props; |
||||
const errorMessage = errors[fieldKey]?.msg || ''; |
||||
|
||||
return ( |
||||
<div className={className}> |
||||
<div className="networks-tab__network-form-label"> |
||||
<div className="networks-tab__network-form-label-text"> |
||||
{this.context.t(optionalTextFieldKey || fieldKey)} |
||||
</div> |
||||
{!viewOnly && tooltipText ? ( |
||||
<Tooltip |
||||
position="top" |
||||
title={tooltipText} |
||||
wrapperClassName="networks-tab__network-form-label-tooltip" |
||||
> |
||||
<i className="fa fa-info-circle" /> |
||||
</Tooltip> |
||||
) : null} |
||||
</div> |
||||
<TextField |
||||
type="text" |
||||
id={textFieldId} |
||||
onChange={onChange} |
||||
fullWidth |
||||
margin="dense" |
||||
value={value} |
||||
disabled={viewOnly} |
||||
error={errorMessage} |
||||
autoFocus={autoFocus} |
||||
/> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
setStateWithValue = (stateKey, validator) => { |
||||
return (e) => { |
||||
validator?.(e.target.value, stateKey); |
||||
this.setState({ [stateKey]: e.target.value }); |
||||
}; |
||||
}; |
||||
|
||||
setErrorTo = (errorKey, errorVal) => { |
||||
this.setState({ |
||||
errors: { |
||||
...this.state.errors, |
||||
[errorKey]: errorVal, |
||||
}, |
||||
}); |
||||
}; |
||||
|
||||
setErrorEmpty = (errorKey) => { |
||||
this.setState({ |
||||
errors: { |
||||
...this.state.errors, |
||||
[errorKey]: { |
||||
msg: '', |
||||
key: '', |
||||
}, |
||||
}, |
||||
}); |
||||
}; |
||||
|
||||
hasError = (errorKey, errorKeyVal) => { |
||||
return this.state.errors[errorKey]?.key === errorKeyVal; |
||||
}; |
||||
|
||||
hasErrors = () => { |
||||
const { errors } = this.state; |
||||
return Object.keys(errors).some((key) => { |
||||
const error = errors[key]; |
||||
// Do not factor in duplicate chain id error for submission disabling
|
||||
if (key === 'chainId' && error.key === 'chainIdExistsErrorMsg') { |
||||
return false; |
||||
} |
||||
return error.key && error.msg; |
||||
}); |
||||
}; |
||||
|
||||
validateChainIdOnChange = (selfRpcUrl, chainIdArg = '') => { |
||||
const { t } = this.context; |
||||
const { networksToRender } = this.props; |
||||
const chainId = chainIdArg.trim(); |
||||
|
||||
let errorKey = ''; |
||||
let errorMessage = ''; |
||||
let radix = 10; |
||||
let hexChainId = chainId; |
||||
|
||||
if (!hexChainId.startsWith('0x')) { |
||||
try { |
||||
hexChainId = `0x${decimalToHex(hexChainId)}`; |
||||
} catch (err) { |
||||
this.setErrorTo('chainId', { |
||||
key: 'invalidHexNumber', |
||||
msg: t('invalidHexNumber'), |
||||
}); |
||||
return; |
||||
} |
||||
} |
||||
|
||||
const [matchingChainId] = networksToRender.filter( |
||||
(e) => e.chainId === hexChainId && e.rpcUrl !== selfRpcUrl, |
||||
); |
||||
|
||||
if (chainId === '') { |
||||
this.setErrorEmpty('chainId'); |
||||
return; |
||||
} else if (matchingChainId) { |
||||
errorKey = 'chainIdExistsErrorMsg'; |
||||
errorMessage = t('chainIdExistsErrorMsg', [ |
||||
matchingChainId.label ?? matchingChainId.labelKey, |
||||
]); |
||||
} else if (chainId.startsWith('0x')) { |
||||
radix = 16; |
||||
if (!/^0x[0-9a-f]+$/iu.test(chainId)) { |
||||
errorKey = 'invalidHexNumber'; |
||||
errorMessage = t('invalidHexNumber'); |
||||
} else if (!isPrefixedFormattedHexString(chainId)) { |
||||
errorMessage = t('invalidHexNumberLeadingZeros'); |
||||
} |
||||
} else if (!/^[0-9]+$/u.test(chainId)) { |
||||
errorKey = 'invalidNumber'; |
||||
errorMessage = t('invalidNumber'); |
||||
} else if (chainId.startsWith('0')) { |
||||
errorKey = 'invalidNumberLeadingZeros'; |
||||
errorMessage = t('invalidNumberLeadingZeros'); |
||||
} else if (!isSafeChainId(parseInt(chainId, radix))) { |
||||
errorKey = 'invalidChainIdTooBig'; |
||||
errorMessage = t('invalidChainIdTooBig'); |
||||
} |
||||
|
||||
this.setErrorTo('chainId', { |
||||
key: errorKey, |
||||
msg: errorMessage, |
||||
}); |
||||
}; |
||||
|
||||
/** |
||||
* Validates the chain ID by checking it against the `eth_chainId` return |
||||
* value from the given RPC URL. |
||||
* Assumes that all strings are non-empty and correctly formatted. |
||||
* |
||||
* @param {string} formChainId - Non-empty, hex or decimal number string from |
||||
* the form. |
||||
* @param {string} parsedChainId - The parsed, hex string chain ID. |
||||
* @param {string} rpcUrl - The RPC URL from the form. |
||||
*/ |
||||
validateChainIdOnSubmit = async (formChainId, parsedChainId, rpcUrl) => { |
||||
const { t } = this.context; |
||||
let errorKey; |
||||
let errorMessage; |
||||
let endpointChainId; |
||||
let providerError; |
||||
|
||||
try { |
||||
endpointChainId = await jsonRpcRequest(rpcUrl, 'eth_chainId'); |
||||
} catch (err) { |
||||
log.warn('Failed to fetch the chainId from the endpoint.', err); |
||||
providerError = err; |
||||
} |
||||
|
||||
if (providerError || typeof endpointChainId !== 'string') { |
||||
errorKey = 'failedToFetchChainId'; |
||||
errorMessage = t('failedToFetchChainId'); |
||||
} else if (parsedChainId !== endpointChainId) { |
||||
// Here, we are in an error state. The endpoint should always return a
|
||||
// hexadecimal string. If the user entered a decimal string, we attempt
|
||||
// to convert the endpoint's return value to decimal before rendering it
|
||||
// in an error message in the form.
|
||||
if (!formChainId.startsWith('0x')) { |
||||
try { |
||||
endpointChainId = parseInt(endpointChainId, 16).toString(10); |
||||
} catch (err) { |
||||
log.warn( |
||||
'Failed to convert endpoint chain ID to decimal', |
||||
endpointChainId, |
||||
); |
||||
} |
||||
} |
||||
|
||||
errorKey = 'endpointReturnedDifferentChainId'; |
||||
errorMessage = t('endpointReturnedDifferentChainId', [ |
||||
endpointChainId.length <= 12 |
||||
? endpointChainId |
||||
: `${endpointChainId.slice(0, 9)}...`, |
||||
]); |
||||
} |
||||
|
||||
if (errorKey) { |
||||
this.setErrorTo('chainId', { |
||||
key: errorKey, |
||||
msg: errorMessage, |
||||
}); |
||||
return false; |
||||
} |
||||
|
||||
this.setErrorEmpty('chainId'); |
||||
return true; |
||||
}; |
||||
|
||||
isValidWhenAppended = (url) => { |
||||
const appendedRpc = `http://${url}`; |
||||
return validUrl.isWebUri(appendedRpc) && !url.match(/^https?:\/\/$/u); |
||||
}; |
||||
|
||||
validateBlockExplorerURL = (url, stateKey) => { |
||||
const { t } = this.context; |
||||
if (!validUrl.isWebUri(url) && url !== '') { |
||||
let errorKey; |
||||
let errorMessage; |
||||
|
||||
if (this.isValidWhenAppended(url)) { |
||||
errorKey = 'urlErrorMsg'; |
||||
errorMessage = t('urlErrorMsg'); |
||||
} else { |
||||
errorKey = 'invalidBlockExplorerURL'; |
||||
errorMessage = t('invalidBlockExplorerURL'); |
||||
} |
||||
|
||||
this.setErrorTo(stateKey, { |
||||
key: errorKey, |
||||
msg: errorMessage, |
||||
}); |
||||
} else { |
||||
this.setErrorEmpty(stateKey); |
||||
} |
||||
}; |
||||
|
||||
validateUrlRpcUrl = (url, stateKey) => { |
||||
const { t } = this.context; |
||||
const { networksToRender } = this.props; |
||||
const { chainId: stateChainId } = this.state; |
||||
const isValidUrl = validUrl.isWebUri(url); |
||||
const chainIdFetchFailed = this.hasError('chainId', 'failedToFetchChainId'); |
||||
const [matchingRPCUrl] = networksToRender.filter((e) => e.rpcUrl === url); |
||||
|
||||
if (!isValidUrl && url !== '') { |
||||
let errorKey; |
||||
let errorMessage; |
||||
if (this.isValidWhenAppended(url)) { |
||||
errorKey = 'urlErrorMsg'; |
||||
errorMessage = t('urlErrorMsg'); |
||||
} else { |
||||
errorKey = 'invalidRPC'; |
||||
errorMessage = t('invalidRPC'); |
||||
} |
||||
this.setErrorTo(stateKey, { |
||||
key: errorKey, |
||||
msg: errorMessage, |
||||
}); |
||||
} else if (matchingRPCUrl) { |
||||
this.setErrorTo(stateKey, { |
||||
key: 'urlExistsErrorMsg', |
||||
msg: t('urlExistsErrorMsg', [ |
||||
matchingRPCUrl.label ?? matchingRPCUrl.labelKey, |
||||
]), |
||||
}); |
||||
} else { |
||||
this.setErrorEmpty(stateKey); |
||||
} |
||||
|
||||
// Re-validate the chain id if it could not be found with previous rpc url
|
||||
if (stateChainId && isValidUrl && chainIdFetchFailed) { |
||||
const formChainId = stateChainId.trim().toLowerCase(); |
||||
const chainId = this.prefixChainId(formChainId); |
||||
this.validateChainIdOnSubmit(formChainId, chainId, url); |
||||
} |
||||
}; |
||||
|
||||
renderAddNetworkForm() { |
||||
const { t } = this.context; |
||||
const { |
||||
networkName, |
||||
rpcUrl, |
||||
chainId = '', |
||||
ticker, |
||||
blockExplorerUrl, |
||||
} = this.state; |
||||
|
||||
const isSubmitDisabled = |
||||
this.hasErrors() || this.isSubmitting() || !rpcUrl || !chainId; |
||||
|
||||
return ( |
||||
<div className="add-network-form__body"> |
||||
<div className="add-network-form__subheader"> |
||||
<span className="add-network-form__sub-header-text"> |
||||
{t('networks')} |
||||
</span> |
||||
<span>{' > '}</span> |
||||
<div className="add-network-form__subheader--break"> |
||||
{t('addANetwork')} |
||||
</div> |
||||
</div> |
||||
<div className="add-network-form__content"> |
||||
<div className="add-network-form__content--warning"> |
||||
{t('onlyAddTrustedNetworks')} |
||||
</div> |
||||
<div className="add-network-form__form-column"> |
||||
<div className="add-network-form__form-row"> |
||||
{this.renderFormTextField({ |
||||
className: 'add-network-form__network-form-row', |
||||
fieldKey: 'networkName', |
||||
textFieldId: 'network-name', |
||||
onChange: this.setStateWithValue('networkName'), |
||||
value: networkName, |
||||
autoFocus: true, |
||||
})} |
||||
{this.renderFormTextField({ |
||||
className: 'add-network-form__network-form-row', |
||||
fieldKey: 'rpcUrl', |
||||
textFieldId: 'rpc-url', |
||||
onChange: this.setStateWithValue( |
||||
'rpcUrl', |
||||
this.validateUrlRpcUrl, |
||||
), |
||||
value: rpcUrl, |
||||
})} |
||||
</div> |
||||
<div className="add-network-form__form-row"> |
||||
{this.renderFormTextField({ |
||||
className: 'add-network-form__network-form-row', |
||||
fieldKey: 'chainId', |
||||
textFieldId: 'chainId', |
||||
onChange: this.setStateWithValue( |
||||
'chainId', |
||||
this.validateChainIdOnChange.bind(this, rpcUrl), |
||||
), |
||||
value: chainId, |
||||
tooltipText: t('networkSettingsChainIdDescription'), |
||||
})} |
||||
{this.renderFormTextField({ |
||||
className: 'add-network-form__network-form-row', |
||||
fieldKey: 'symbol', |
||||
textFieldId: 'network-ticker', |
||||
onChange: this.setStateWithValue('ticker'), |
||||
value: ticker, |
||||
optionalTextFieldKey: 'optionalCurrencySymbol', |
||||
})} |
||||
</div> |
||||
<div className="add-network-form__form-row"> |
||||
{this.renderFormTextField({ |
||||
className: 'add-network-form__network-form-row', |
||||
fieldKey: 'blockExplorerUrl', |
||||
textFieldId: 'block-explorer-url', |
||||
onChange: this.setStateWithValue( |
||||
'blockExplorerUrl', |
||||
this.validateBlockExplorerURL, |
||||
), |
||||
value: blockExplorerUrl, |
||||
optionalTextFieldKey: 'optionalBlockExplorerUrl', |
||||
})} |
||||
</div> |
||||
</div> |
||||
<div className="add-network-form__footer"> |
||||
<Button |
||||
type="secondary" |
||||
onClick={this.onCancel} |
||||
className="add-network-form__footer-cancel-button" |
||||
> |
||||
{t('cancel')} |
||||
</Button> |
||||
<Button |
||||
type="primary" |
||||
disabled={isSubmitDisabled} |
||||
onClick={this.onSubmit} |
||||
className="add-network-form__footer-submit-button" |
||||
> |
||||
{t('save')} |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
renderNetworkForm() { |
||||
const { t } = this.context; |
||||
const { viewOnly, isCurrentRpcTarget } = this.props; |
||||
const { |
||||
networkName, |
||||
rpcUrl, |
||||
chainId = '', |
||||
ticker, |
||||
blockExplorerUrl, |
||||
} = this.state; |
||||
|
||||
const deletable = !isCurrentRpcTarget && !viewOnly; |
||||
|
||||
const isSubmitDisabled = |
||||
this.hasErrors() || |
||||
this.isSubmitting() || |
||||
this.stateIsUnchanged() || |
||||
!rpcUrl || |
||||
!chainId; |
||||
|
||||
return ( |
||||
<div className="networks-tab__network-form"> |
||||
{this.renderFormTextField({ |
||||
className: 'networks-tab__network-form-row', |
||||
fieldKey: 'networkName', |
||||
textFieldId: 'network-name', |
||||
onChange: this.setStateWithValue('networkName'), |
||||
value: networkName, |
||||
})} |
||||
{this.renderFormTextField({ |
||||
className: 'networks-tab__network-form-row', |
||||
fieldKey: 'rpcUrl', |
||||
textFieldId: 'rpc-url', |
||||
onChange: this.setStateWithValue('rpcUrl', this.validateUrlRpcUrl), |
||||
value: rpcUrl, |
||||
})} |
||||
{this.renderFormTextField({ |
||||
className: 'networks-tab__network-form-row', |
||||
fieldKey: 'chainId', |
||||
textFieldId: 'chainId', |
||||
onChange: this.setStateWithValue( |
||||
'chainId', |
||||
this.validateChainIdOnChange.bind(this, rpcUrl), |
||||
), |
||||
value: chainId, |
||||
tooltipText: viewOnly ? null : t('networkSettingsChainIdDescription'), |
||||
})} |
||||
{this.renderFormTextField({ |
||||
className: 'networks-tab__network-form-row', |
||||
fieldKey: 'symbol', |
||||
textFieldId: 'network-ticker', |
||||
onChange: this.setStateWithValue('ticker'), |
||||
value: ticker, |
||||
optionalTextFieldKey: 'optionalCurrencySymbol', |
||||
})} |
||||
{this.renderFormTextField({ |
||||
className: 'networks-tab__network-form-row', |
||||
fieldKey: 'blockExplorerUrl', |
||||
textFieldId: 'block-explorer-url', |
||||
onChange: this.setStateWithValue( |
||||
'blockExplorerUrl', |
||||
this.validateBlockExplorerURL, |
||||
), |
||||
value: blockExplorerUrl, |
||||
optionalTextFieldKey: 'optionalBlockExplorerUrl', |
||||
})} |
||||
<div className="network-form__footer"> |
||||
{!viewOnly && ( |
||||
<> |
||||
{deletable && ( |
||||
<Button type="danger" onClick={this.onDelete}> |
||||
{t('delete')} |
||||
</Button> |
||||
)} |
||||
<Button |
||||
type="secondary" |
||||
onClick={this.onCancel} |
||||
disabled={this.stateIsUnchanged()} |
||||
> |
||||
{t('cancel')} |
||||
</Button> |
||||
<Button |
||||
type="primary" |
||||
disabled={isSubmitDisabled} |
||||
onClick={this.onSubmit} |
||||
> |
||||
{t('save')} |
||||
</Button> |
||||
</> |
||||
)} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
render() { |
||||
const { addNewNetwork } = this.props; |
||||
return addNewNetwork |
||||
? this.renderAddNetworkForm() |
||||
: this.renderNetworkForm(); |
||||
} |
||||
} |
@ -0,0 +1 @@ |
||||
export { default } from './networks-form'; |
@ -0,0 +1,575 @@ |
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'; |
||||
import { useHistory } from 'react-router-dom'; |
||||
import { useDispatch } from 'react-redux'; |
||||
import PropTypes from 'prop-types'; |
||||
import validUrl from 'valid-url'; |
||||
import log from 'loglevel'; |
||||
import classnames from 'classnames'; |
||||
import { useI18nContext } from '../../../../hooks/useI18nContext'; |
||||
import { |
||||
isPrefixedFormattedHexString, |
||||
isSafeChainId, |
||||
} from '../../../../../shared/modules/network.utils'; |
||||
import { jsonRpcRequest } from '../../../../../shared/modules/rpc.utils'; |
||||
import ActionableMessage from '../../../../components/ui/actionable-message'; |
||||
import Button from '../../../../components/ui/button'; |
||||
import FormField from '../../../../components/ui/form-field'; |
||||
import { decimalToHex } from '../../../../helpers/utils/conversions.util'; |
||||
import { |
||||
setSelectedSettingsRpcUrl, |
||||
updateAndSetCustomRpc, |
||||
editRpc, |
||||
showModal, |
||||
setNewNetworkAdded, |
||||
} from '../../../../store/actions'; |
||||
import { |
||||
DEFAULT_ROUTE, |
||||
NETWORKS_ROUTE, |
||||
} from '../../../../helpers/constants/routes'; |
||||
|
||||
/** |
||||
* Attempts to convert the given chainId to a decimal string, for display |
||||
* purposes. |
||||
* |
||||
* Should be called with the props chainId whenever it is used to set the |
||||
* component's state. |
||||
* |
||||
* @param {unknown} chainId - The chainId to convert. |
||||
* @returns {string} The props chainId in decimal, or the original value if |
||||
* it can't be converted. |
||||
*/ |
||||
const getDisplayChainId = (chainId) => { |
||||
if (!chainId || typeof chainId !== 'string' || !chainId.startsWith('0x')) { |
||||
return chainId; |
||||
} |
||||
return parseInt(chainId, 16).toString(10); |
||||
}; |
||||
|
||||
/** |
||||
* Prefixes a given id with '0x' if the prefix does not exist |
||||
* |
||||
* @param {string} chainId - The chainId to prefix |
||||
* @returns {string} The chainId, prefixed with '0x' |
||||
*/ |
||||
const prefixChainId = (chainId) => { |
||||
let prefixedChainId = chainId; |
||||
if (!chainId.startsWith('0x')) { |
||||
prefixedChainId = `0x${parseInt(chainId, 10).toString(16)}`; |
||||
} |
||||
return prefixedChainId; |
||||
}; |
||||
|
||||
const isValidWhenAppended = (url) => { |
||||
const appendedRpc = `http://${url}`; |
||||
return validUrl.isWebUri(appendedRpc) && !url.match(/^https?:\/\/$/u); |
||||
}; |
||||
|
||||
const NetworksForm = ({ |
||||
addNewNetwork, |
||||
isCurrentRpcTarget, |
||||
networksToRender, |
||||
selectedNetwork, |
||||
}) => { |
||||
const t = useI18nContext(); |
||||
const history = useHistory(); |
||||
const dispatch = useDispatch(); |
||||
const { label, labelKey, viewOnly, rpcPrefs } = selectedNetwork; |
||||
const selectedNetworkName = label || (labelKey && t(labelKey)); |
||||
const [networkName, setNetworkName] = useState(selectedNetworkName || ''); |
||||
const [rpcUrl, setRpcUrl] = useState(selectedNetwork?.rpcUrl || ''); |
||||
const [chainId, setChainId] = useState(selectedNetwork?.chainId || ''); |
||||
const [ticker, setTicker] = useState(selectedNetwork?.ticker || ''); |
||||
const [blockExplorerUrl, setBlockExplorerUrl] = useState( |
||||
selectedNetwork?.blockExplorerUrl || '', |
||||
); |
||||
const [errors, setErrors] = useState({}); |
||||
const [isSubmitting, setIsSubmitting] = useState(false); |
||||
|
||||
const resetForm = useCallback(() => { |
||||
setNetworkName(selectedNetworkName || ''); |
||||
setRpcUrl(selectedNetwork.rpcUrl); |
||||
setChainId(getDisplayChainId(selectedNetwork.chainId)); |
||||
setTicker(selectedNetwork?.ticker); |
||||
setBlockExplorerUrl(selectedNetwork?.blockExplorerUrl); |
||||
setErrors({}); |
||||
setIsSubmitting(false); |
||||
}, [selectedNetwork, selectedNetworkName]); |
||||
|
||||
const stateIsUnchanged = () => { |
||||
// These added conditions are in case the saved chainId is invalid, which
|
||||
// was possible in versions <8.1 of the extension.
|
||||
// Basically, we always want to be able to overwrite an invalid chain ID.
|
||||
const chainIdIsUnchanged = |
||||
typeof selectedNetwork.chainId === 'string' && |
||||
selectedNetwork.chainId.toLowerCase().startsWith('0x') && |
||||
chainId === getDisplayChainId(selectedNetwork.chainId); |
||||
return ( |
||||
rpcUrl === selectedNetwork.rpcUrl && |
||||
chainIdIsUnchanged && |
||||
ticker === selectedNetwork.ticker && |
||||
networkName === selectedNetworkName && |
||||
blockExplorerUrl === selectedNetwork.blockExplorerUrl |
||||
); |
||||
}; |
||||
|
||||
const prevAddNewNetwork = useRef(); |
||||
const prevNetworkName = useRef(); |
||||
const prevChainId = useRef(); |
||||
const prevRpcUrl = useRef(); |
||||
const prevTicker = useRef(); |
||||
const prevBlockExplorerUrl = useRef(); |
||||
useEffect(() => { |
||||
if (!prevAddNewNetwork.current && addNewNetwork) { |
||||
setNetworkName(''); |
||||
setRpcUrl(''); |
||||
setChainId(''); |
||||
setTicker(''); |
||||
setBlockExplorerUrl(''); |
||||
setErrors({}); |
||||
setIsSubmitting(false); |
||||
} else if ( |
||||
prevNetworkName.current !== selectedNetworkName || |
||||
prevRpcUrl.current !== selectedNetwork.rpcUrl || |
||||
prevChainId.current !== selectedNetwork.chainId || |
||||
prevTicker.current !== selectedNetwork.ticker || |
||||
prevBlockExplorerUrl.current !== selectedNetwork.blockExplorerUrl |
||||
) { |
||||
resetForm(selectedNetwork); |
||||
} |
||||
}, [ |
||||
selectedNetwork, |
||||
selectedNetworkName, |
||||
addNewNetwork, |
||||
setNetworkName, |
||||
setRpcUrl, |
||||
setChainId, |
||||
setTicker, |
||||
setBlockExplorerUrl, |
||||
setErrors, |
||||
setIsSubmitting, |
||||
resetForm, |
||||
]); |
||||
|
||||
useEffect(() => { |
||||
return () => { |
||||
setNetworkName(''); |
||||
setRpcUrl(''); |
||||
setChainId(''); |
||||
setTicker(''); |
||||
setBlockExplorerUrl(''); |
||||
setErrors({}); |
||||
dispatch(setSelectedSettingsRpcUrl('')); |
||||
}; |
||||
}, [ |
||||
setNetworkName, |
||||
setRpcUrl, |
||||
setChainId, |
||||
setTicker, |
||||
setBlockExplorerUrl, |
||||
setErrors, |
||||
dispatch, |
||||
]); |
||||
|
||||
const setErrorTo = (errorKey, errorVal) => { |
||||
setErrors({ ...errors, [errorKey]: errorVal }); |
||||
}; |
||||
|
||||
const setErrorEmpty = (errorKey) => { |
||||
setErrors({ |
||||
...errors, |
||||
[errorKey]: { |
||||
msg: '', |
||||
key: '', |
||||
}, |
||||
}); |
||||
}; |
||||
|
||||
const hasError = (errorKey, errorKeyVal) => { |
||||
return errors[errorKey]?.key === errorKeyVal; |
||||
}; |
||||
|
||||
const hasErrors = () => { |
||||
return Object.keys(errors).some((key) => { |
||||
const error = errors[key]; |
||||
// Do not factor in duplicate chain id error for submission disabling
|
||||
if (key === 'chainId' && error.key === 'chainIdExistsErrorMsg') { |
||||
return false; |
||||
} |
||||
return error.key && error.msg; |
||||
}); |
||||
}; |
||||
|
||||
const validateChainIdOnChange = (chainArg = '') => { |
||||
const formChainId = chainArg.trim(); |
||||
let errorKey = ''; |
||||
let errorMessage = ''; |
||||
let radix = 10; |
||||
let hexChainId = formChainId; |
||||
|
||||
if (!hexChainId.startsWith('0x')) { |
||||
try { |
||||
hexChainId = `0x${decimalToHex(hexChainId)}`; |
||||
} catch (err) { |
||||
setErrorTo('chainId', { |
||||
key: 'invalidHexNumber', |
||||
msg: t('invalidHexNumber'), |
||||
}); |
||||
return; |
||||
} |
||||
} |
||||
|
||||
const [matchingChainId] = networksToRender.filter( |
||||
(e) => e.chainId === hexChainId && e.rpcUrl !== rpcUrl, |
||||
); |
||||
|
||||
if (formChainId === '') { |
||||
setErrorEmpty('chainId'); |
||||
return; |
||||
} else if (matchingChainId) { |
||||
errorKey = 'chainIdExistsErrorMsg'; |
||||
errorMessage = t('chainIdExistsErrorMsg', [ |
||||
matchingChainId.label ?? matchingChainId.labelKey, |
||||
]); |
||||
} else if (formChainId.startsWith('0x')) { |
||||
radix = 16; |
||||
if (!/^0x[0-9a-f]+$/iu.test(formChainId)) { |
||||
errorKey = 'invalidHexNumber'; |
||||
errorMessage = t('invalidHexNumber'); |
||||
} else if (!isPrefixedFormattedHexString(formChainId)) { |
||||
errorMessage = t('invalidHexNumberLeadingZeros'); |
||||
} |
||||
} else if (!/^[0-9]+$/u.test(formChainId)) { |
||||
errorKey = 'invalidNumber'; |
||||
errorMessage = t('invalidNumber'); |
||||
} else if (formChainId.startsWith('0')) { |
||||
errorKey = 'invalidNumberLeadingZeros'; |
||||
errorMessage = t('invalidNumberLeadingZeros'); |
||||
} else if (!isSafeChainId(parseInt(formChainId, radix))) { |
||||
errorKey = 'invalidChainIdTooBig'; |
||||
errorMessage = t('invalidChainIdTooBig'); |
||||
} |
||||
|
||||
setErrorTo('chainId', { |
||||
key: errorKey, |
||||
msg: errorMessage, |
||||
}); |
||||
}; |
||||
|
||||
/** |
||||
* Validates the chain ID by checking it against the `eth_chainId` return |
||||
* value from the given RPC URL. |
||||
* Assumes that all strings are non-empty and correctly formatted. |
||||
* |
||||
* @param {string} formChainId - Non-empty, hex or decimal number string from |
||||
* the form. |
||||
* @param {string} parsedChainId - The parsed, hex string chain ID. |
||||
* @param {string} formRpcUrl - The RPC URL from the form. |
||||
*/ |
||||
const validateChainIdOnSubmit = async ( |
||||
formChainId, |
||||
parsedChainId, |
||||
formRpcUrl, |
||||
) => { |
||||
let errorKey; |
||||
let errorMessage; |
||||
let endpointChainId; |
||||
let providerError; |
||||
|
||||
try { |
||||
endpointChainId = await jsonRpcRequest(formRpcUrl, 'eth_chainId'); |
||||
} catch (err) { |
||||
log.warn('Failed to fetch the chainId from the endpoint.', err); |
||||
providerError = err; |
||||
} |
||||
|
||||
if (providerError || typeof endpointChainId !== 'string') { |
||||
errorKey = 'failedToFetchChainId'; |
||||
errorMessage = t('failedToFetchChainId'); |
||||
} else if (parsedChainId !== endpointChainId) { |
||||
// Here, we are in an error state. The endpoint should always return a
|
||||
// hexadecimal string. If the user entered a decimal string, we attempt
|
||||
// to convert the endpoint's return value to decimal before rendering it
|
||||
// in an error message in the form.
|
||||
if (!formChainId.startsWith('0x')) { |
||||
try { |
||||
endpointChainId = parseInt(endpointChainId, 16).toString(10); |
||||
} catch (err) { |
||||
log.warn( |
||||
'Failed to convert endpoint chain ID to decimal', |
||||
endpointChainId, |
||||
); |
||||
} |
||||
} |
||||
|
||||
errorKey = 'endpointReturnedDifferentChainId'; |
||||
errorMessage = t('endpointReturnedDifferentChainId', [ |
||||
endpointChainId.length <= 12 |
||||
? endpointChainId |
||||
: `${endpointChainId.slice(0, 9)}...`, |
||||
]); |
||||
} |
||||
|
||||
if (errorKey) { |
||||
setErrorTo('chainId', { |
||||
key: errorKey, |
||||
msg: errorMessage, |
||||
}); |
||||
return false; |
||||
} |
||||
|
||||
setErrorEmpty('chainId'); |
||||
return true; |
||||
}; |
||||
|
||||
const validateBlockExplorerURL = (url) => { |
||||
if (!validUrl.isWebUri(url) && url !== '') { |
||||
let errorKey; |
||||
let errorMessage; |
||||
|
||||
if (isValidWhenAppended(url)) { |
||||
errorKey = 'urlErrorMsg'; |
||||
errorMessage = t('urlErrorMsg'); |
||||
} else { |
||||
errorKey = 'invalidBlockExplorerURL'; |
||||
errorMessage = t('invalidBlockExplorerURL'); |
||||
} |
||||
|
||||
setErrorTo('blockExplorerUrl', { |
||||
key: errorKey, |
||||
msg: errorMessage, |
||||
}); |
||||
} else { |
||||
setErrorEmpty('blockExplorerUrl'); |
||||
} |
||||
}; |
||||
|
||||
const validateUrlRpcUrl = (url) => { |
||||
const isValidUrl = validUrl.isWebUri(url); |
||||
const chainIdFetchFailed = hasError('chainId', 'failedToFetchChainId'); |
||||
const [matchingRPCUrl] = networksToRender.filter((e) => e.rpcUrl === url); |
||||
|
||||
if (!isValidUrl && url !== '') { |
||||
let errorKey; |
||||
let errorMessage; |
||||
if (isValidWhenAppended(url)) { |
||||
errorKey = 'urlErrorMsg'; |
||||
errorMessage = t('urlErrorMsg'); |
||||
} else { |
||||
errorKey = 'invalidRPC'; |
||||
errorMessage = t('invalidRPC'); |
||||
} |
||||
setErrorTo('rpcUrl', { |
||||
key: errorKey, |
||||
msg: errorMessage, |
||||
}); |
||||
} else if (matchingRPCUrl) { |
||||
setErrorTo('rpcUrl', { |
||||
key: 'urlExistsErrorMsg', |
||||
msg: t('urlExistsErrorMsg', [ |
||||
matchingRPCUrl.label ?? matchingRPCUrl.labelKey, |
||||
]), |
||||
}); |
||||
} else { |
||||
setErrorEmpty('rpcUrl'); |
||||
} |
||||
|
||||
// Re-validate the chain id if it could not be found with previous rpc url
|
||||
if (chainId && isValidUrl && chainIdFetchFailed) { |
||||
const formChainId = chainId.trim().toLowerCase(); |
||||
const prefixedChainId = prefixChainId(formChainId); |
||||
validateChainIdOnSubmit(formChainId, prefixedChainId, url); |
||||
} |
||||
}; |
||||
|
||||
const onSubmit = async () => { |
||||
setIsSubmitting(true); |
||||
try { |
||||
const formChainId = chainId.trim().toLowerCase(); |
||||
const prefixedChainId = prefixChainId(formChainId); |
||||
|
||||
if ( |
||||
!(await validateChainIdOnSubmit(formChainId, prefixedChainId, rpcUrl)) |
||||
) { |
||||
setIsSubmitting(false); |
||||
return; |
||||
} |
||||
|
||||
// After this point, isSubmitting will be reset in componentDidUpdate
|
||||
if (selectedNetwork.rpcUrl && rpcUrl !== selectedNetwork.rpcUrl) { |
||||
await dispatch( |
||||
editRpc( |
||||
selectedNetwork.rpcUrl, |
||||
rpcUrl, |
||||
prefixedChainId, |
||||
ticker, |
||||
networkName, |
||||
{ |
||||
...rpcPrefs, |
||||
blockExplorerUrl: blockExplorerUrl || rpcPrefs.blockExplorerUrl, |
||||
}, |
||||
), |
||||
); |
||||
} else { |
||||
await dispatch( |
||||
updateAndSetCustomRpc(rpcUrl, prefixedChainId, ticker, networkName, { |
||||
...rpcPrefs, |
||||
blockExplorerUrl: blockExplorerUrl || rpcPrefs?.blockExplorerUrl, |
||||
}), |
||||
); |
||||
} |
||||
|
||||
if (addNewNetwork) { |
||||
dispatch(setNewNetworkAdded(networkName)); |
||||
history.push(DEFAULT_ROUTE); |
||||
} |
||||
} catch (error) { |
||||
setIsSubmitting(false); |
||||
throw error; |
||||
} |
||||
}; |
||||
|
||||
const onCancel = () => { |
||||
if (addNewNetwork) { |
||||
dispatch(setSelectedSettingsRpcUrl('')); |
||||
history.push(NETWORKS_ROUTE); |
||||
} else { |
||||
resetForm(); |
||||
} |
||||
}; |
||||
|
||||
const onDelete = () => { |
||||
dispatch( |
||||
showModal({ |
||||
name: 'CONFIRM_DELETE_NETWORK', |
||||
target: selectedNetwork.rpcUrl, |
||||
onConfirm: () => { |
||||
resetForm(); |
||||
dispatch(setSelectedSettingsRpcUrl('')); |
||||
history.push(NETWORKS_ROUTE); |
||||
}, |
||||
}), |
||||
); |
||||
}; |
||||
const deletable = !isCurrentRpcTarget && !viewOnly && !addNewNetwork; |
||||
const stateUnchanged = stateIsUnchanged(); |
||||
const isSubmitDisabled = |
||||
hasErrors() || isSubmitting || stateUnchanged || !rpcUrl || !chainId; |
||||
|
||||
return ( |
||||
<div |
||||
className={classnames({ |
||||
'networks-tab__network-form': !addNewNetwork, |
||||
'networks-tab__add-network-form': addNewNetwork, |
||||
})} |
||||
> |
||||
{addNewNetwork ? ( |
||||
<ActionableMessage |
||||
type="warning" |
||||
message={t('onlyAddTrustedNetworks')} |
||||
iconFillColor="#f8c000" |
||||
useIcon |
||||
withRightButton |
||||
/> |
||||
) : null} |
||||
<div |
||||
className={classnames({ |
||||
'networks-tab__network-form-body': !addNewNetwork, |
||||
'networks-tab__network-form-body__view-only': viewOnly, |
||||
'networks-tab__add-network-form-body': addNewNetwork, |
||||
})} |
||||
> |
||||
<FormField |
||||
autoFocus |
||||
error={errors.networkName?.msg || ''} |
||||
onChange={setNetworkName} |
||||
titleText={t('networkName')} |
||||
value={networkName} |
||||
disabled={viewOnly} |
||||
/> |
||||
<FormField |
||||
error={errors.rpcUrl?.msg || ''} |
||||
onChange={(value) => { |
||||
setRpcUrl(value); |
||||
validateUrlRpcUrl(value); |
||||
}} |
||||
titleText={t('rpcUrl')} |
||||
value={rpcUrl} |
||||
disabled={viewOnly} |
||||
/> |
||||
<FormField |
||||
error={errors.chainId?.msg || ''} |
||||
onChange={(value) => { |
||||
setChainId(value); |
||||
validateChainIdOnChange(value); |
||||
}} |
||||
titleText={t('chainId')} |
||||
value={chainId} |
||||
disabled={viewOnly} |
||||
tooltipText={viewOnly ? null : t('networkSettingsChainIdDescription')} |
||||
/> |
||||
<FormField |
||||
error={errors.ticker?.msg || ''} |
||||
onChange={setTicker} |
||||
titleText={t('currencySymbol')} |
||||
titleUnit={t('optionalWithParanthesis')} |
||||
value={ticker} |
||||
disabled={viewOnly} |
||||
/> |
||||
<FormField |
||||
error={errors.blockExplorerUrl?.msg || ''} |
||||
onChange={(value) => { |
||||
setBlockExplorerUrl(value); |
||||
validateBlockExplorerURL(value); |
||||
}} |
||||
titleText={t('blockExplorerUrl')} |
||||
titleUnit={t('optionalWithParanthesis')} |
||||
value={blockExplorerUrl} |
||||
disabled={viewOnly} |
||||
/> |
||||
</div> |
||||
<div |
||||
className={classnames({ |
||||
'networks-tab__network-form-footer': !addNewNetwork, |
||||
'networks-tab__add-network-form-footer': addNewNetwork, |
||||
})} |
||||
> |
||||
{!viewOnly && ( |
||||
<> |
||||
{deletable && ( |
||||
<Button type="danger" onClick={onDelete}> |
||||
{t('delete')} |
||||
</Button> |
||||
)} |
||||
<Button |
||||
type="secondary" |
||||
onClick={onCancel} |
||||
disabled={stateUnchanged} |
||||
> |
||||
{t('cancel')} |
||||
</Button> |
||||
<Button |
||||
type="primary" |
||||
disabled={isSubmitDisabled} |
||||
onClick={onSubmit} |
||||
> |
||||
{t('save')} |
||||
</Button> |
||||
</> |
||||
)} |
||||
</div> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
NetworksForm.propTypes = { |
||||
addNewNetwork: PropTypes.bool, |
||||
isCurrentRpcTarget: PropTypes.bool, |
||||
networksToRender: PropTypes.array.isRequired, |
||||
selectedNetwork: PropTypes.object, |
||||
}; |
||||
|
||||
NetworksForm.defaultProps = { |
||||
selectedNetwork: {}, |
||||
}; |
||||
|
||||
export default NetworksForm; |
@ -0,0 +1 @@ |
||||
export { default } from './networks-list-item'; |
@ -0,0 +1,81 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import classnames from 'classnames'; |
||||
import { useDispatch, useSelector } from 'react-redux'; |
||||
import { useHistory } from 'react-router-dom'; |
||||
import { useI18nContext } from '../../../../hooks/useI18nContext'; |
||||
import { NETWORK_TYPE_RPC } from '../../../../../shared/constants/network'; |
||||
import { SIZES } from '../../../../helpers/constants/design-system'; |
||||
import ColorIndicator from '../../../../components/ui/color-indicator'; |
||||
import LockIcon from '../../../../components/ui/lock-icon'; |
||||
import { NETWORKS_FORM_ROUTE } from '../../../../helpers/constants/routes'; |
||||
import { setSelectedSettingsRpcUrl } from '../../../../store/actions'; |
||||
import { getEnvironmentType } from '../../../../../app/scripts/lib/util'; |
||||
import { ENVIRONMENT_TYPE_FULLSCREEN } from '../../../../../shared/constants/app'; |
||||
import { getProvider } from '../../../../selectors'; |
||||
|
||||
const NetworksListItem = ({ network, networkIsSelected, selectedRpcUrl }) => { |
||||
const t = useI18nContext(); |
||||
const history = useHistory(); |
||||
const dispatch = useDispatch(); |
||||
const environmentType = getEnvironmentType(); |
||||
const isFullScreen = environmentType === ENVIRONMENT_TYPE_FULLSCREEN; |
||||
const provider = useSelector(getProvider); |
||||
const { |
||||
label, |
||||
labelKey, |
||||
rpcUrl, |
||||
providerType: currentProviderType, |
||||
} = network; |
||||
|
||||
const listItemNetworkIsSelected = selectedRpcUrl && selectedRpcUrl === rpcUrl; |
||||
const listItemUrlIsProviderUrl = rpcUrl === provider.rpcUrl; |
||||
const listItemTypeIsProviderNonRpcType = |
||||
provider.type !== NETWORK_TYPE_RPC && currentProviderType === provider.type; |
||||
const listItemNetworkIsCurrentProvider = |
||||
!networkIsSelected && |
||||
(listItemUrlIsProviderUrl || listItemTypeIsProviderNonRpcType); |
||||
const displayNetworkListItemAsSelected = |
||||
listItemNetworkIsSelected || listItemNetworkIsCurrentProvider; |
||||
|
||||
return ( |
||||
<div |
||||
key={`settings-network-list-item:${rpcUrl}`} |
||||
className="networks-tab__networks-list-item" |
||||
onClick={() => { |
||||
dispatch(setSelectedSettingsRpcUrl(rpcUrl)); |
||||
if (!isFullScreen) { |
||||
history.push(NETWORKS_FORM_ROUTE); |
||||
} |
||||
}} |
||||
> |
||||
<ColorIndicator |
||||
color={labelKey} |
||||
type={ColorIndicator.TYPES.FILLED} |
||||
size={SIZES.LG} |
||||
/> |
||||
<div |
||||
className={classnames('networks-tab__networks-list-name', { |
||||
'networks-tab__networks-list-name--selected': displayNetworkListItemAsSelected, |
||||
'networks-tab__networks-list-name--disabled': |
||||
currentProviderType !== NETWORK_TYPE_RPC && |
||||
!displayNetworkListItemAsSelected, |
||||
})} |
||||
> |
||||
{label || t(labelKey)} |
||||
{currentProviderType !== NETWORK_TYPE_RPC && ( |
||||
<LockIcon width="14px" height="17px" fill="#cdcdcd" /> |
||||
)} |
||||
</div> |
||||
<div className="networks-tab__networks-list-arrow" /> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
NetworksListItem.propTypes = { |
||||
network: PropTypes.object.isRequired, |
||||
networkIsSelected: PropTypes.bool, |
||||
selectedRpcUrl: PropTypes.string, |
||||
}; |
||||
|
||||
export default NetworksListItem; |
@ -0,0 +1,51 @@ |
||||
import React from 'react'; |
||||
import configureMockStore from 'redux-mock-store'; |
||||
import { renderWithProvider } from '../../../../../test/jest/rendering'; |
||||
import { defaultNetworksData } from '../networks-tab.constants'; |
||||
import NetworksListItem from '.'; |
||||
|
||||
const mockState = { |
||||
metamask: { |
||||
provider: { |
||||
chainId: '0x4', |
||||
nickname: '', |
||||
rpcPrefs: {}, |
||||
rpcUrl: 'https://rinkeby.infura.io/v3/undefined', |
||||
ticker: 'ETH', |
||||
type: 'rinkeby', |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
const renderComponent = (props) => { |
||||
const store = configureMockStore([])(mockState); |
||||
return renderWithProvider(<NetworksListItem {...props} />, store); |
||||
}; |
||||
|
||||
const defaultNetworks = defaultNetworksData.map((network) => ({ |
||||
...network, |
||||
viewOnly: true, |
||||
})); |
||||
|
||||
const MainnetProps = { |
||||
network: defaultNetworks[0], |
||||
networkIsSelected: false, |
||||
selectedRpcUrl: 'http://localhost:8545', |
||||
}; |
||||
const testNetProps = { |
||||
network: defaultNetworks[1], |
||||
networkIsSelected: false, |
||||
selectedRpcUrl: 'http://localhost:8545', |
||||
}; |
||||
|
||||
describe('NetworksListItem Component', () => { |
||||
it('should render a Mainnet network item correctly', () => { |
||||
const { queryByText } = renderComponent(MainnetProps); |
||||
expect(queryByText('Ethereum Mainnet')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should render a test network item correctly', () => { |
||||
const { queryByText } = renderComponent(testNetProps); |
||||
expect(queryByText('Ropsten Test Network')).toBeInTheDocument(); |
||||
}); |
||||
}); |
@ -0,0 +1 @@ |
||||
export { default } from './networks-list'; |
@ -0,0 +1,38 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import classnames from 'classnames'; |
||||
import NetworksListItem from '../networks-list-item'; |
||||
|
||||
const NetworksList = ({ |
||||
networkIsSelected, |
||||
networksToRender, |
||||
networkDefaultedToProvider, |
||||
selectedRpcUrl, |
||||
}) => { |
||||
return ( |
||||
<div |
||||
className={classnames('networks-tab__networks-list', { |
||||
'networks-tab__networks-list--selection': |
||||
networkIsSelected && !networkDefaultedToProvider, |
||||
})} |
||||
> |
||||
{networksToRender.map((network) => ( |
||||
<NetworksListItem |
||||
key={`settings-network-list:${network.rpcUrl}`} |
||||
network={network} |
||||
networkIsSelected={networkIsSelected} |
||||
selectedRpcUrl={selectedRpcUrl} |
||||
/> |
||||
))} |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
NetworksList.propTypes = { |
||||
networkDefaultedToProvider: PropTypes.bool, |
||||
networkIsSelected: PropTypes.bool, |
||||
networksToRender: PropTypes.arrayOf(PropTypes.object).isRequired, |
||||
selectedRpcUrl: PropTypes.string, |
||||
}; |
||||
|
||||
export default NetworksList; |
@ -0,0 +1,47 @@ |
||||
import React from 'react'; |
||||
import configureMockStore from 'redux-mock-store'; |
||||
import { renderWithProvider } from '../../../../../test/jest/rendering'; |
||||
import { defaultNetworksData } from '../networks-tab.constants'; |
||||
import NetworksList from '.'; |
||||
|
||||
const mockState = { |
||||
metamask: { |
||||
provider: { |
||||
chainId: '0x4', |
||||
nickname: '', |
||||
rpcPrefs: {}, |
||||
rpcUrl: 'https://rinkeby.infura.io/v3/undefined', |
||||
ticker: 'ETH', |
||||
type: 'rinkeby', |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
const renderComponent = (props) => { |
||||
const store = configureMockStore([])(mockState); |
||||
return renderWithProvider(<NetworksList {...props} />, store); |
||||
}; |
||||
|
||||
const defaultNetworks = defaultNetworksData.map((network) => ({ |
||||
...network, |
||||
viewOnly: true, |
||||
})); |
||||
|
||||
const props = { |
||||
networkDefaultedToProvider: false, |
||||
networkIsSelected: false, |
||||
networksToRender: defaultNetworks, |
||||
selectedRpcUrl: 'http://localhost:8545', |
||||
}; |
||||
|
||||
describe('NetworksList Component', () => { |
||||
it('should render a list of networks correctly', () => { |
||||
const { queryByText } = renderComponent(props); |
||||
|
||||
expect(queryByText('Ethereum Mainnet')).toBeInTheDocument(); |
||||
expect(queryByText('Ropsten Test Network')).toBeInTheDocument(); |
||||
expect(queryByText('Rinkeby Test Network')).toBeInTheDocument(); |
||||
expect(queryByText('Goerli Test Network')).toBeInTheDocument(); |
||||
expect(queryByText('Kovan Test Network')).toBeInTheDocument(); |
||||
}); |
||||
}); |
@ -0,0 +1 @@ |
||||
export { default } from './networks-tab-content'; |
@ -0,0 +1,43 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import { useSelector } from 'react-redux'; |
||||
import NetworksForm from '../networks-form'; |
||||
import NetworksList from '../networks-list'; |
||||
import { getProvider } from '../../../../selectors'; |
||||
|
||||
const NetworksTabContent = ({ |
||||
networkDefaultedToProvider, |
||||
networkIsSelected, |
||||
networksToRender, |
||||
selectedNetwork, |
||||
shouldRenderNetworkForm, |
||||
}) => { |
||||
const provider = useSelector(getProvider); |
||||
|
||||
return ( |
||||
<> |
||||
<NetworksList |
||||
networkDefaultedToProvider={networkDefaultedToProvider} |
||||
networkIsSelected={networkIsSelected} |
||||
networksToRender={networksToRender} |
||||
selectedRpcUrl={selectedNetwork.rpcUrl} |
||||
/> |
||||
{shouldRenderNetworkForm ? ( |
||||
<NetworksForm |
||||
isCurrentRpcTarget={provider.rpcUrl === selectedNetwork.rpcUrl} |
||||
networksToRender={networksToRender} |
||||
selectedNetwork={selectedNetwork} |
||||
/> |
||||
) : null} |
||||
</> |
||||
); |
||||
}; |
||||
NetworksTabContent.propTypes = { |
||||
networkDefaultedToProvider: PropTypes.bool, |
||||
networkIsSelected: PropTypes.bool, |
||||
networksToRender: PropTypes.arrayOf(PropTypes.object).isRequired, |
||||
selectedNetwork: PropTypes.object, |
||||
shouldRenderNetworkForm: PropTypes.bool.isRequired, |
||||
}; |
||||
|
||||
export default NetworksTabContent; |
@ -0,0 +1,92 @@ |
||||
import React from 'react'; |
||||
import configureMockStore from 'redux-mock-store'; |
||||
import { fireEvent } from '@testing-library/react'; |
||||
import { renderWithProvider } from '../../../../../test/jest/rendering'; |
||||
import { defaultNetworksData } from '../networks-tab.constants'; |
||||
import NetworksTabContent from '.'; |
||||
|
||||
const mockState = { |
||||
metamask: { |
||||
provider: { |
||||
chainId: '0x539', |
||||
nickname: '', |
||||
rpcPrefs: {}, |
||||
rpcUrl: 'http://localhost:8545', |
||||
ticker: 'ETH', |
||||
type: 'localhost', |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
const renderComponent = (props) => { |
||||
const store = configureMockStore([])(mockState); |
||||
return renderWithProvider(<NetworksTabContent {...props} />, store); |
||||
}; |
||||
|
||||
const defaultNetworks = defaultNetworksData.map((network) => ({ |
||||
...network, |
||||
viewOnly: true, |
||||
})); |
||||
|
||||
const props = { |
||||
networkDefaultedToProvider: false, |
||||
networkIsSelected: true, |
||||
networksToRender: defaultNetworks, |
||||
selectedNetwork: { |
||||
rpcUrl: 'http://localhost:8545', |
||||
chainId: '1337', |
||||
ticker: 'ETH', |
||||
label: 'LocalHost', |
||||
blockExplorerUrl: '', |
||||
viewOnly: false, |
||||
rpcPrefs: {}, |
||||
}, |
||||
shouldRenderNetworkForm: true, |
||||
}; |
||||
|
||||
describe('NetworksTabContent Component', () => { |
||||
it('should render networks tab content correctly', () => { |
||||
const { queryByText, getByDisplayValue } = renderComponent(props); |
||||
|
||||
expect(queryByText('Ethereum Mainnet')).toBeInTheDocument(); |
||||
expect(queryByText('Ropsten Test Network')).toBeInTheDocument(); |
||||
expect(queryByText('Rinkeby Test Network')).toBeInTheDocument(); |
||||
expect(queryByText('Goerli Test Network')).toBeInTheDocument(); |
||||
expect(queryByText('Kovan Test Network')).toBeInTheDocument(); |
||||
|
||||
expect(queryByText('Network Name')).toBeInTheDocument(); |
||||
expect(queryByText('New RPC URL')).toBeInTheDocument(); |
||||
expect(queryByText('Chain ID')).toBeInTheDocument(); |
||||
expect(queryByText('Currency Symbol')).toBeInTheDocument(); |
||||
expect(queryByText('Block Explorer URL')).toBeInTheDocument(); |
||||
expect(queryByText('Cancel')).toBeInTheDocument(); |
||||
expect(queryByText('Save')).toBeInTheDocument(); |
||||
|
||||
expect(getByDisplayValue(props.selectedNetwork.label)).toBeInTheDocument(); |
||||
expect(getByDisplayValue(props.selectedNetwork.rpcUrl)).toBeInTheDocument(); |
||||
expect( |
||||
getByDisplayValue(props.selectedNetwork.chainId), |
||||
).toBeInTheDocument(); |
||||
expect(getByDisplayValue(props.selectedNetwork.ticker)).toBeInTheDocument(); |
||||
expect( |
||||
getByDisplayValue(props.selectedNetwork.blockExplorerUrl), |
||||
).toBeInTheDocument(); |
||||
fireEvent.change(getByDisplayValue(props.selectedNetwork.label), { |
||||
target: { value: 'LocalHost 8545' }, |
||||
}); |
||||
expect(getByDisplayValue('LocalHost 8545')).toBeInTheDocument(); |
||||
fireEvent.change(getByDisplayValue(props.selectedNetwork.chainId), { |
||||
target: { value: '1' }, |
||||
}); |
||||
expect( |
||||
queryByText('This Chain ID is currently used by the mainnet network.'), |
||||
).toBeInTheDocument(); |
||||
|
||||
fireEvent.change(getByDisplayValue(props.selectedNetwork.rpcUrl), { |
||||
target: { value: 'test' }, |
||||
}); |
||||
expect( |
||||
queryByText('URLs require the appropriate HTTP/HTTPS prefix.'), |
||||
).toBeInTheDocument(); |
||||
}); |
||||
}); |
@ -0,0 +1 @@ |
||||
export { default } from './networks-tab-subheader'; |
@ -0,0 +1,39 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import { useHistory } from 'react-router-dom'; |
||||
import { useI18nContext } from '../../../../hooks/useI18nContext'; |
||||
import { ADD_NETWORK_ROUTE } from '../../../../helpers/constants/routes'; |
||||
import Button from '../../../../components/ui/button'; |
||||
|
||||
const NetworksFormSubheader = ({ addNewNetwork }) => { |
||||
const t = useI18nContext(); |
||||
const history = useHistory(); |
||||
return addNewNetwork ? ( |
||||
<div className="networks-tab__subheader"> |
||||
<span className="networks-tab__sub-header-text">{t('networks')}</span> |
||||
<span>{' > '}</span> |
||||
<div className="networks-tab__subheader--break">{t('addANetwork')}</div> |
||||
</div> |
||||
) : ( |
||||
<div className="settings-page__sub-header"> |
||||
<span className="settings-page__sub-header-text">{t('networks')}</span> |
||||
<div className="networks-tab__add-network-header-button-wrapper"> |
||||
<Button |
||||
type="primary" |
||||
onClick={(event) => { |
||||
event.preventDefault(); |
||||
history.push(ADD_NETWORK_ROUTE); |
||||
}} |
||||
> |
||||
{t('addANetwork')} |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
NetworksFormSubheader.propTypes = { |
||||
addNewNetwork: PropTypes.bool.isRequired, |
||||
}; |
||||
|
||||
export default NetworksFormSubheader; |
@ -0,0 +1,46 @@ |
||||
import React from 'react'; |
||||
import configureMockStore from 'redux-mock-store'; |
||||
import { renderWithProvider } from '../../../../../test/jest/rendering'; |
||||
import NetworksTabSubheader from '.'; |
||||
|
||||
const mockState = { |
||||
metamask: { |
||||
provider: { |
||||
chainId: '0x539', |
||||
nickname: '', |
||||
rpcPrefs: {}, |
||||
rpcUrl: 'http://localhost:8545', |
||||
ticker: 'ETH', |
||||
type: 'localhost', |
||||
}, |
||||
frequentRpcListDetail: [], |
||||
}, |
||||
appState: { |
||||
networksTabSelectedRpcUrl: 'http://localhost:8545', |
||||
}, |
||||
}; |
||||
|
||||
const renderComponent = (props) => { |
||||
const store = configureMockStore([])(mockState); |
||||
return renderWithProvider(<NetworksTabSubheader {...props} />, store); |
||||
}; |
||||
|
||||
describe('NetworksTabSubheader Component', () => { |
||||
it('should render network subheader correctly', () => { |
||||
const { queryByText, getByRole } = renderComponent({ |
||||
addNewNetwork: false, |
||||
}); |
||||
|
||||
expect(queryByText('Networks')).toBeInTheDocument(); |
||||
expect(queryByText('Add a network')).toBeInTheDocument(); |
||||
expect(getByRole('button', { text: 'Add a network' })).toBeDefined(); |
||||
}); |
||||
it('should render add network form subheader correctly', () => { |
||||
const { queryByText } = renderComponent({ |
||||
addNewNetwork: true, |
||||
}); |
||||
expect(queryByText('Networks')).toBeInTheDocument(); |
||||
expect(queryByText('>')).toBeInTheDocument(); |
||||
expect(queryByText('Add a network')).toBeInTheDocument(); |
||||
}); |
||||
}); |
@ -1,259 +0,0 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import classnames from 'classnames'; |
||||
import { NETWORK_TYPE_RPC } from '../../../../shared/constants/network'; |
||||
import Button from '../../../components/ui/button'; |
||||
import LockIcon from '../../../components/ui/lock-icon'; |
||||
import { |
||||
NETWORKS_ROUTE, |
||||
NETWORKS_FORM_ROUTE, |
||||
DEFAULT_ROUTE, |
||||
ADD_NETWORK_ROUTE, |
||||
} from '../../../helpers/constants/routes'; |
||||
import ColorIndicator from '../../../components/ui/color-indicator'; |
||||
import { SIZES } from '../../../helpers/constants/design-system'; |
||||
import NetworkForm from './network-form'; |
||||
|
||||
export default class NetworksTab extends PureComponent { |
||||
static contextTypes = { |
||||
t: PropTypes.func.isRequired, |
||||
metricsEvent: PropTypes.func.isRequired, |
||||
}; |
||||
|
||||
static propTypes = { |
||||
editRpc: PropTypes.func.isRequired, |
||||
location: PropTypes.object.isRequired, |
||||
networkIsSelected: PropTypes.bool, |
||||
networksToRender: PropTypes.arrayOf(PropTypes.object).isRequired, |
||||
selectedNetwork: PropTypes.object, |
||||
setRpcTarget: PropTypes.func.isRequired, |
||||
setSelectedSettingsRpcUrl: PropTypes.func.isRequired, |
||||
showConfirmDeleteNetworkModal: PropTypes.func.isRequired, |
||||
providerUrl: PropTypes.string, |
||||
providerType: PropTypes.string, |
||||
networkDefaultedToProvider: PropTypes.bool, |
||||
history: PropTypes.object.isRequired, |
||||
shouldRenderNetworkForm: PropTypes.bool.isRequired, |
||||
isFullScreen: PropTypes.bool.isRequired, |
||||
setNewNetworkAdded: PropTypes.func.isRequired, |
||||
addNewNetwork: PropTypes.bool, |
||||
}; |
||||
|
||||
componentWillUnmount() { |
||||
this.props.setSelectedSettingsRpcUrl(''); |
||||
} |
||||
|
||||
isCurrentPath(pathname) { |
||||
return this.props.location.pathname === pathname; |
||||
} |
||||
|
||||
renderSubHeader() { |
||||
const { history } = this.props; |
||||
|
||||
return ( |
||||
<div className="settings-page__sub-header"> |
||||
<span className="settings-page__sub-header-text"> |
||||
{this.context.t('networks')} |
||||
</span> |
||||
<div className="networks-tab__add-network-header-button-wrapper"> |
||||
<Button |
||||
type="primary" |
||||
onClick={(event) => { |
||||
event.preventDefault(); |
||||
history.push(ADD_NETWORK_ROUTE); |
||||
}} |
||||
className="add-network-form__header-add-network-button" |
||||
> |
||||
{this.context.t('addANetwork')} |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
renderNetworkListItem(network, selectRpcUrl) { |
||||
const { |
||||
setSelectedSettingsRpcUrl, |
||||
networkIsSelected, |
||||
providerUrl, |
||||
providerType, |
||||
history, |
||||
isFullScreen, |
||||
} = this.props; |
||||
const { |
||||
label, |
||||
labelKey, |
||||
rpcUrl, |
||||
providerType: currentProviderType, |
||||
} = network; |
||||
|
||||
const listItemNetworkIsSelected = selectRpcUrl && selectRpcUrl === rpcUrl; |
||||
const listItemUrlIsProviderUrl = rpcUrl === providerUrl; |
||||
const listItemTypeIsProviderNonRpcType = |
||||
providerType !== NETWORK_TYPE_RPC && currentProviderType === providerType; |
||||
const listItemNetworkIsCurrentProvider = |
||||
!networkIsSelected && |
||||
(listItemUrlIsProviderUrl || listItemTypeIsProviderNonRpcType); |
||||
const displayNetworkListItemAsSelected = |
||||
listItemNetworkIsSelected || listItemNetworkIsCurrentProvider; |
||||
|
||||
return ( |
||||
<div |
||||
key={`settings-network-list-item:${rpcUrl}`} |
||||
className="networks-tab__networks-list-item" |
||||
onClick={() => { |
||||
setSelectedSettingsRpcUrl(rpcUrl); |
||||
if (!isFullScreen) { |
||||
history.push(NETWORKS_FORM_ROUTE); |
||||
} |
||||
}} |
||||
> |
||||
<ColorIndicator |
||||
color={labelKey} |
||||
type={ColorIndicator.TYPES.FILLED} |
||||
size={SIZES.LG} |
||||
/> |
||||
<div |
||||
className={classnames('networks-tab__networks-list-name', { |
||||
'networks-tab__networks-list-name--selected': displayNetworkListItemAsSelected, |
||||
'networks-tab__networks-list-name--disabled': |
||||
currentProviderType !== NETWORK_TYPE_RPC && |
||||
!displayNetworkListItemAsSelected, |
||||
})} |
||||
> |
||||
{label || this.context.t(labelKey)} |
||||
{currentProviderType !== NETWORK_TYPE_RPC && ( |
||||
<LockIcon width="14px" height="17px" fill="#cdcdcd" /> |
||||
)} |
||||
</div> |
||||
<div className="networks-tab__networks-list-arrow" /> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
renderNetworksList() { |
||||
const { |
||||
networksToRender, |
||||
selectedNetwork, |
||||
networkIsSelected, |
||||
networkDefaultedToProvider, |
||||
} = this.props; |
||||
|
||||
return ( |
||||
<div |
||||
className={classnames('networks-tab__networks-list', { |
||||
'networks-tab__networks-list--selection': |
||||
networkIsSelected && !networkDefaultedToProvider, |
||||
})} |
||||
> |
||||
{networksToRender.map((network) => |
||||
this.renderNetworkListItem(network, selectedNetwork.rpcUrl), |
||||
)} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
renderNetworksTabContent() { |
||||
const { t } = this.context; |
||||
const { |
||||
setRpcTarget, |
||||
showConfirmDeleteNetworkModal, |
||||
setSelectedSettingsRpcUrl, |
||||
selectedNetwork: { |
||||
labelKey, |
||||
label, |
||||
rpcUrl, |
||||
chainId, |
||||
ticker, |
||||
viewOnly, |
||||
rpcPrefs, |
||||
blockExplorerUrl, |
||||
}, |
||||
editRpc, |
||||
providerUrl, |
||||
networksToRender, |
||||
history, |
||||
isFullScreen, |
||||
shouldRenderNetworkForm, |
||||
} = this.props; |
||||
|
||||
return ( |
||||
<> |
||||
{this.renderNetworksList()} |
||||
{shouldRenderNetworkForm ? ( |
||||
<NetworkForm |
||||
setRpcTarget={setRpcTarget} |
||||
editRpc={editRpc} |
||||
networkName={label || (labelKey && t(labelKey)) || ''} |
||||
rpcUrl={rpcUrl} |
||||
chainId={chainId} |
||||
networksToRender={networksToRender} |
||||
ticker={ticker} |
||||
onClear={(shouldUpdateHistory = true) => { |
||||
setSelectedSettingsRpcUrl(''); |
||||
if (shouldUpdateHistory) { |
||||
history.push(NETWORKS_ROUTE); |
||||
} |
||||
}} |
||||
showConfirmDeleteNetworkModal={showConfirmDeleteNetworkModal} |
||||
viewOnly={viewOnly} |
||||
isCurrentRpcTarget={providerUrl === rpcUrl} |
||||
rpcPrefs={rpcPrefs} |
||||
blockExplorerUrl={blockExplorerUrl} |
||||
isFullScreen={isFullScreen} |
||||
/> |
||||
) : null} |
||||
</> |
||||
); |
||||
} |
||||
|
||||
render() { |
||||
const { |
||||
history, |
||||
isFullScreen, |
||||
shouldRenderNetworkForm, |
||||
setRpcTarget, |
||||
networksToRender, |
||||
setNewNetworkAdded, |
||||
selectedNetwork: { rpcPrefs }, |
||||
addNewNetwork, |
||||
} = this.props; |
||||
return addNewNetwork ? ( |
||||
<NetworkForm |
||||
setRpcTarget={setRpcTarget} |
||||
onClear={(shouldUpdateHistory = true) => { |
||||
if (shouldUpdateHistory) { |
||||
history.push(NETWORKS_ROUTE); |
||||
} |
||||
}} |
||||
onAddNetwork={() => { |
||||
history.push(DEFAULT_ROUTE); |
||||
}} |
||||
rpcPrefs={rpcPrefs} |
||||
networksToRender={networksToRender} |
||||
setNewNetworkAdded={setNewNetworkAdded} |
||||
addNewNetwork={addNewNetwork} |
||||
/> |
||||
) : ( |
||||
<div className="networks-tab__body"> |
||||
{isFullScreen ? this.renderSubHeader() : null} |
||||
<div className="networks-tab__content"> |
||||
{this.renderNetworksTabContent()} |
||||
{!isFullScreen && !shouldRenderNetworkForm ? ( |
||||
<div className="networks-tab__networks-list-popup-footer"> |
||||
<Button |
||||
type="primary" |
||||
onClick={(event) => { |
||||
event.preventDefault(); |
||||
global.platform.openExtensionInBrowser(ADD_NETWORK_ROUTE); |
||||
}} |
||||
> |
||||
{this.context.t('addNetwork')} |
||||
</Button> |
||||
</div> |
||||
) : null} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
} |
@ -1,117 +0,0 @@ |
||||
import { compose } from 'redux'; |
||||
import { connect } from 'react-redux'; |
||||
import { withRouter } from 'react-router-dom'; |
||||
import { |
||||
setSelectedSettingsRpcUrl, |
||||
updateAndSetCustomRpc, |
||||
displayWarning, |
||||
editRpc, |
||||
showModal, |
||||
setNewNetworkAdded, |
||||
} from '../../../store/actions'; |
||||
import { |
||||
ADD_NETWORK_ROUTE, |
||||
NETWORKS_FORM_ROUTE, |
||||
} from '../../../helpers/constants/routes'; |
||||
import { ENVIRONMENT_TYPE_FULLSCREEN } from '../../../../shared/constants/app'; |
||||
import { NETWORK_TYPE_RPC } from '../../../../shared/constants/network'; |
||||
import { getEnvironmentType } from '../../../../app/scripts/lib/util'; |
||||
import NetworksTab from './networks-tab.component'; |
||||
import { defaultNetworksData } from './networks-tab.constants'; |
||||
|
||||
const defaultNetworks = defaultNetworksData.map((network) => ({ |
||||
...network, |
||||
viewOnly: true, |
||||
})); |
||||
|
||||
const mapStateToProps = (state, ownProps) => { |
||||
const { |
||||
location: { pathname }, |
||||
} = ownProps; |
||||
|
||||
const environmentType = getEnvironmentType(); |
||||
const isFullScreen = environmentType === ENVIRONMENT_TYPE_FULLSCREEN; |
||||
const shouldRenderNetworkForm = |
||||
isFullScreen || Boolean(pathname.match(NETWORKS_FORM_ROUTE)); |
||||
const addNewNetwork = Boolean(pathname.match(ADD_NETWORK_ROUTE)); |
||||
|
||||
const { frequentRpcListDetail, provider } = state.metamask; |
||||
const { networksTabSelectedRpcUrl } = state.appState; |
||||
const frequentRpcNetworkListDetails = frequentRpcListDetail.map((rpc) => { |
||||
return { |
||||
label: rpc.nickname, |
||||
iconColor: '#6A737D', |
||||
providerType: NETWORK_TYPE_RPC, |
||||
rpcUrl: rpc.rpcUrl, |
||||
chainId: rpc.chainId, |
||||
ticker: rpc.ticker, |
||||
blockExplorerUrl: rpc.rpcPrefs?.blockExplorerUrl || '', |
||||
}; |
||||
}); |
||||
|
||||
const networksToRender = [ |
||||
...defaultNetworks, |
||||
...frequentRpcNetworkListDetails, |
||||
]; |
||||
let selectedNetwork = |
||||
networksToRender.find( |
||||
(network) => network.rpcUrl === networksTabSelectedRpcUrl, |
||||
) || {}; |
||||
const networkIsSelected = Boolean(selectedNetwork.rpcUrl); |
||||
|
||||
let networkDefaultedToProvider = false; |
||||
if (!networkIsSelected) { |
||||
selectedNetwork = |
||||
networksToRender.find((network) => { |
||||
return ( |
||||
network.rpcUrl === provider.rpcUrl || |
||||
(network.providerType !== NETWORK_TYPE_RPC && |
||||
network.providerType === provider.type) |
||||
); |
||||
}) || {}; |
||||
networkDefaultedToProvider = true; |
||||
} |
||||
|
||||
return { |
||||
selectedNetwork, |
||||
networksToRender, |
||||
networkIsSelected, |
||||
providerType: provider.type, |
||||
providerUrl: provider.rpcUrl, |
||||
networkDefaultedToProvider, |
||||
isFullScreen, |
||||
shouldRenderNetworkForm, |
||||
addNewNetwork, |
||||
}; |
||||
}; |
||||
|
||||
const mapDispatchToProps = (dispatch) => { |
||||
return { |
||||
setSelectedSettingsRpcUrl: (newRpcUrl) => |
||||
dispatch(setSelectedSettingsRpcUrl(newRpcUrl)), |
||||
setRpcTarget: (newRpc, chainId, ticker, nickname, rpcPrefs) => { |
||||
return dispatch( |
||||
updateAndSetCustomRpc(newRpc, chainId, ticker, nickname, rpcPrefs), |
||||
); |
||||
}, |
||||
showConfirmDeleteNetworkModal: ({ target, onConfirm }) => { |
||||
return dispatch( |
||||
showModal({ name: 'CONFIRM_DELETE_NETWORK', target, onConfirm }), |
||||
); |
||||
}, |
||||
displayWarning: (warning) => dispatch(displayWarning(warning)), |
||||
editRpc: (oldRpc, newRpc, chainId, ticker, nickname, rpcPrefs) => { |
||||
return dispatch( |
||||
editRpc(oldRpc, newRpc, chainId, ticker, nickname, rpcPrefs), |
||||
); |
||||
}, |
||||
setNewNetworkAdded: (newNetwork) => { |
||||
dispatch(setNewNetworkAdded(newNetwork)); |
||||
}, |
||||
}; |
||||
}; |
||||
|
||||
export default compose( |
||||
withRouter, |
||||
connect(mapStateToProps, mapDispatchToProps), |
||||
)(NetworksTab); |
@ -0,0 +1,129 @@ |
||||
import React, { useEffect } from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import { useLocation } from 'react-router-dom'; |
||||
import { useDispatch, useSelector } from 'react-redux'; |
||||
import { useI18nContext } from '../../../hooks/useI18nContext'; |
||||
import { |
||||
ADD_NETWORK_ROUTE, |
||||
NETWORKS_FORM_ROUTE, |
||||
} from '../../../helpers/constants/routes'; |
||||
import { setSelectedSettingsRpcUrl } from '../../../store/actions'; |
||||
import Button from '../../../components/ui/button'; |
||||
import { getEnvironmentType } from '../../../../app/scripts/lib/util'; |
||||
import { ENVIRONMENT_TYPE_FULLSCREEN } from '../../../../shared/constants/app'; |
||||
import { |
||||
getFrequentRpcListDetail, |
||||
getNetworksTabSelectedRpcUrl, |
||||
getProvider, |
||||
} from '../../../selectors'; |
||||
import { NETWORK_TYPE_RPC } from '../../../../shared/constants/network'; |
||||
import { defaultNetworksData } from './networks-tab.constants'; |
||||
import NetworksTabContent from './networks-tab-content'; |
||||
import NetworksForm from './networks-form'; |
||||
import NetworksFormSubheader from './networks-tab-subheader'; |
||||
|
||||
const defaultNetworks = defaultNetworksData.map((network) => ({ |
||||
...network, |
||||
viewOnly: true, |
||||
})); |
||||
|
||||
const NetworksTab = ({ addNewNetwork }) => { |
||||
const t = useI18nContext(); |
||||
const dispatch = useDispatch(); |
||||
const { pathname } = useLocation(); |
||||
|
||||
const environmentType = getEnvironmentType(); |
||||
const isFullScreen = environmentType === ENVIRONMENT_TYPE_FULLSCREEN; |
||||
const shouldRenderNetworkForm = |
||||
isFullScreen || Boolean(pathname.match(NETWORKS_FORM_ROUTE)); |
||||
|
||||
const frequentRpcListDetail = useSelector(getFrequentRpcListDetail); |
||||
const provider = useSelector(getProvider); |
||||
const networksTabSelectedRpcUrl = useSelector(getNetworksTabSelectedRpcUrl); |
||||
|
||||
const frequentRpcNetworkListDetails = frequentRpcListDetail.map((rpc) => { |
||||
return { |
||||
label: rpc.nickname, |
||||
iconColor: '#6A737D', |
||||
providerType: NETWORK_TYPE_RPC, |
||||
rpcUrl: rpc.rpcUrl, |
||||
chainId: rpc.chainId, |
||||
ticker: rpc.ticker, |
||||
blockExplorerUrl: rpc.rpcPrefs?.blockExplorerUrl || '', |
||||
}; |
||||
}); |
||||
|
||||
const networksToRender = [ |
||||
...defaultNetworks, |
||||
...frequentRpcNetworkListDetails, |
||||
]; |
||||
let selectedNetwork = |
||||
networksToRender.find( |
||||
(network) => network.rpcUrl === networksTabSelectedRpcUrl, |
||||
) || {}; |
||||
const networkIsSelected = Boolean(selectedNetwork.rpcUrl); |
||||
|
||||
let networkDefaultedToProvider = false; |
||||
if (!networkIsSelected) { |
||||
selectedNetwork = |
||||
networksToRender.find((network) => { |
||||
return ( |
||||
network.rpcUrl === provider.rpcUrl || |
||||
(network.providerType !== NETWORK_TYPE_RPC && |
||||
network.providerType === provider.type) |
||||
); |
||||
}) || {}; |
||||
networkDefaultedToProvider = true; |
||||
} |
||||
|
||||
useEffect(() => { |
||||
return () => { |
||||
dispatch(setSelectedSettingsRpcUrl('')); |
||||
}; |
||||
}, [dispatch]); |
||||
|
||||
return ( |
||||
<div className="networks-tab__body"> |
||||
{isFullScreen ? ( |
||||
<NetworksFormSubheader addNewNetwork={addNewNetwork} /> |
||||
) : null} |
||||
<div className="networks-tab__content"> |
||||
{addNewNetwork ? ( |
||||
<NetworksForm |
||||
networksToRender={networksToRender} |
||||
addNewNetwork={addNewNetwork} |
||||
/> |
||||
) : ( |
||||
<> |
||||
<NetworksTabContent |
||||
networkDefaultedToProvider={networkDefaultedToProvider} |
||||
networkIsSelected={networkIsSelected} |
||||
networksToRender={networksToRender} |
||||
providerUrl={provider.rpcUrl} |
||||
selectedNetwork={selectedNetwork} |
||||
shouldRenderNetworkForm={shouldRenderNetworkForm} |
||||
/> |
||||
{!isFullScreen && !shouldRenderNetworkForm ? ( |
||||
<div className="networks-tab__networks-list-popup-footer"> |
||||
<Button |
||||
type="primary" |
||||
onClick={(event) => { |
||||
event.preventDefault(); |
||||
global.platform.openExtensionInBrowser(ADD_NETWORK_ROUTE); |
||||
}} |
||||
> |
||||
{t('addNetwork')} |
||||
</Button> |
||||
</div> |
||||
) : null} |
||||
</> |
||||
)} |
||||
</div> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
NetworksTab.propTypes = { |
||||
addNewNetwork: PropTypes.bool, |
||||
}; |
||||
export default NetworksTab; |
@ -0,0 +1,53 @@ |
||||
import React from 'react'; |
||||
import configureMockStore from 'redux-mock-store'; |
||||
import { renderWithProvider } from '../../../../test/jest/rendering'; |
||||
import NetworksTab from '.'; |
||||
|
||||
const mockState = { |
||||
metamask: { |
||||
provider: { |
||||
chainId: '0x539', |
||||
nickname: '', |
||||
rpcPrefs: {}, |
||||
rpcUrl: 'http://localhost:8545', |
||||
ticker: 'ETH', |
||||
type: 'localhost', |
||||
}, |
||||
frequentRpcListDetail: [], |
||||
}, |
||||
appState: { |
||||
networksTabSelectedRpcUrl: 'http://localhost:8545', |
||||
}, |
||||
}; |
||||
|
||||
const renderComponent = (props) => { |
||||
const store = configureMockStore([])(mockState); |
||||
return renderWithProvider(<NetworksTab {...props} />, store); |
||||
}; |
||||
|
||||
describe('NetworksTab Component', () => { |
||||
it('should render networks tab content correctly', () => { |
||||
const { queryByText } = renderComponent({ |
||||
addNewNetwork: false, |
||||
}); |
||||
|
||||
expect(queryByText('Ethereum Mainnet')).toBeInTheDocument(); |
||||
expect(queryByText('Ropsten Test Network')).toBeInTheDocument(); |
||||
expect(queryByText('Rinkeby Test Network')).toBeInTheDocument(); |
||||
expect(queryByText('Goerli Test Network')).toBeInTheDocument(); |
||||
expect(queryByText('Kovan Test Network')).toBeInTheDocument(); |
||||
expect(queryByText('Add Network')).toBeInTheDocument(); |
||||
}); |
||||
it('should render add network form correctly', () => { |
||||
const { queryByText } = renderComponent({ |
||||
addNewNetwork: true, |
||||
}); |
||||
expect(queryByText('Network Name')).toBeInTheDocument(); |
||||
expect(queryByText('New RPC URL')).toBeInTheDocument(); |
||||
expect(queryByText('Chain ID')).toBeInTheDocument(); |
||||
expect(queryByText('Currency Symbol')).toBeInTheDocument(); |
||||
expect(queryByText('Block Explorer URL')).toBeInTheDocument(); |
||||
expect(queryByText('Cancel')).toBeInTheDocument(); |
||||
expect(queryByText('Save')).toBeInTheDocument(); |
||||
}); |
||||
}); |
Loading…
Reference in new issue