Refactor send page state management (#10965)
parent
85f17831a2
commit
e17325c38a
@ -0,0 +1,219 @@ |
||||
const { strict: assert } = require('assert'); |
||||
const { withFixtures, regularDelayMs } = require('../helpers'); |
||||
|
||||
describe('Send ETH from inside MetaMask using default gas', function () { |
||||
const ganacheOptions = { |
||||
accounts: [ |
||||
{ |
||||
secretKey: |
||||
'0x7C9529A67102755B7E6102D6D950AC5D5863C98713805CEC576B945B15B71EAC', |
||||
balance: 25000000000000000000, |
||||
}, |
||||
], |
||||
}; |
||||
it('finds the transaction in the transactions list', async function () { |
||||
await withFixtures( |
||||
{ |
||||
fixtures: 'imported-account', |
||||
ganacheOptions, |
||||
title: this.test.title, |
||||
}, |
||||
async ({ driver }) => { |
||||
await driver.navigate(); |
||||
await driver.fill('#password', 'correct horse battery staple'); |
||||
await driver.press('#password', driver.Key.ENTER); |
||||
|
||||
await driver.clickElement('[data-testid="eth-overview-send"]'); |
||||
|
||||
await driver.fill( |
||||
'input[placeholder="Search, public address (0x), or ENS"]', |
||||
'0x2f318C334780961FB129D2a6c30D0763d9a5C970', |
||||
); |
||||
|
||||
const inputAmount = await driver.findElement('.unit-input__input'); |
||||
await inputAmount.fill('1000'); |
||||
|
||||
const errorAmount = await driver.findElement('.send-v2__error-amount'); |
||||
assert.equal( |
||||
await errorAmount.getText(), |
||||
'Insufficient funds.', |
||||
'send screen should render an insufficient fund error message', |
||||
); |
||||
|
||||
await inputAmount.press(driver.Key.BACK_SPACE); |
||||
await inputAmount.press(driver.Key.BACK_SPACE); |
||||
await inputAmount.press(driver.Key.BACK_SPACE); |
||||
await driver.delay(regularDelayMs); |
||||
|
||||
await driver.assertElementNotPresent('.send-v2__error-amount'); |
||||
|
||||
const amountMax = await driver.findClickableElement( |
||||
'.send-v2__amount-max', |
||||
); |
||||
await amountMax.click(); |
||||
|
||||
let inputValue = await inputAmount.getAttribute('value'); |
||||
|
||||
assert(Number(inputValue) > 24); |
||||
|
||||
await amountMax.click(); |
||||
|
||||
assert.equal(await inputAmount.isEnabled(), true); |
||||
|
||||
await inputAmount.fill('1'); |
||||
|
||||
inputValue = await inputAmount.getAttribute('value'); |
||||
assert.equal(inputValue, '1'); |
||||
|
||||
// Continue to next screen
|
||||
await driver.clickElement({ text: 'Next', tag: 'button' }); |
||||
|
||||
await driver.clickElement({ text: 'Confirm', tag: 'button' }); |
||||
|
||||
await driver.clickElement('[data-testid="home__activity-tab"]'); |
||||
await driver.wait(async () => { |
||||
const confirmedTxes = await driver.findElements( |
||||
'.transaction-list__completed-transactions .transaction-list-item', |
||||
); |
||||
return confirmedTxes.length === 1; |
||||
}, 10000); |
||||
|
||||
await driver.waitForSelector({ |
||||
css: '.transaction-list-item__primary-currency', |
||||
text: '-1 ETH', |
||||
}); |
||||
}, |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
describe('Send ETH from inside MetaMask using fast gas option', function () { |
||||
const ganacheOptions = { |
||||
accounts: [ |
||||
{ |
||||
secretKey: |
||||
'0x7C9529A67102755B7E6102D6D950AC5D5863C98713805CEC576B945B15B71EAC', |
||||
balance: 25000000000000000000, |
||||
}, |
||||
], |
||||
}; |
||||
it('finds the transaction in the transactions list', async function () { |
||||
await withFixtures( |
||||
{ |
||||
fixtures: 'imported-account', |
||||
ganacheOptions, |
||||
title: this.test.title, |
||||
}, |
||||
async ({ driver }) => { |
||||
await driver.navigate(); |
||||
await driver.fill('#password', 'correct horse battery staple'); |
||||
await driver.press('#password', driver.Key.ENTER); |
||||
|
||||
await driver.clickElement('[data-testid="eth-overview-send"]'); |
||||
|
||||
await driver.fill( |
||||
'input[placeholder="Search, public address (0x), or ENS"]', |
||||
'0x2f318C334780961FB129D2a6c30D0763d9a5C970', |
||||
); |
||||
|
||||
const inputAmount = await driver.findElement('.unit-input__input'); |
||||
await inputAmount.fill('1'); |
||||
|
||||
const inputValue = await inputAmount.getAttribute('value'); |
||||
assert.equal(inputValue, '1'); |
||||
|
||||
// Set the gas price
|
||||
await driver.clickElement({ text: 'Fast', tag: 'button/div/div' }); |
||||
|
||||
// Continue to next screen
|
||||
await driver.clickElement({ text: 'Next', tag: 'button' }); |
||||
|
||||
await driver.clickElement({ text: 'Confirm', tag: 'button' }); |
||||
|
||||
await driver.waitForSelector( |
||||
'.transaction-list__completed-transactions .transaction-list-item', |
||||
); |
||||
await driver.waitForSelector({ |
||||
css: '.transaction-list-item__primary-currency', |
||||
text: '-1 ETH', |
||||
}); |
||||
}, |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
describe('Send ETH from inside MetaMask using advanced gas modal', function () { |
||||
const ganacheOptions = { |
||||
accounts: [ |
||||
{ |
||||
secretKey: |
||||
'0x7C9529A67102755B7E6102D6D950AC5D5863C98713805CEC576B945B15B71EAC', |
||||
balance: 25000000000000000000, |
||||
}, |
||||
], |
||||
}; |
||||
it('finds the transaction in the transactions list', async function () { |
||||
await withFixtures( |
||||
{ |
||||
fixtures: 'imported-account', |
||||
ganacheOptions, |
||||
title: this.test.title, |
||||
}, |
||||
async ({ driver }) => { |
||||
await driver.navigate(); |
||||
await driver.fill('#password', 'correct horse battery staple'); |
||||
await driver.press('#password', driver.Key.ENTER); |
||||
|
||||
await driver.clickElement('[data-testid="eth-overview-send"]'); |
||||
|
||||
await driver.fill( |
||||
'input[placeholder="Search, public address (0x), or ENS"]', |
||||
'0x2f318C334780961FB129D2a6c30D0763d9a5C970', |
||||
); |
||||
|
||||
const inputAmount = await driver.findElement('.unit-input__input'); |
||||
await inputAmount.fill('1'); |
||||
|
||||
const inputValue = await inputAmount.getAttribute('value'); |
||||
assert.equal(inputValue, '1'); |
||||
|
||||
// Set the gas limit
|
||||
await driver.clickElement('.advanced-gas-options-btn'); |
||||
|
||||
// wait for gas modal to be visible
|
||||
const gasModal = await driver.findVisibleElement('span .modal'); |
||||
|
||||
await driver.clickElement({ text: 'Save', tag: 'button' }); |
||||
|
||||
// Wait for gas modal to be removed from DOM
|
||||
await gasModal.waitForElementState('hidden'); |
||||
|
||||
// Continue to next screen
|
||||
await driver.clickElement({ text: 'Next', tag: 'button' }); |
||||
|
||||
const transactionAmounts = await driver.findElements( |
||||
'.currency-display-component__text', |
||||
); |
||||
const transactionAmount = transactionAmounts[0]; |
||||
assert.equal(await transactionAmount.getText(), '1'); |
||||
|
||||
await driver.clickElement({ text: 'Confirm', tag: 'button' }); |
||||
|
||||
await driver.wait(async () => { |
||||
const confirmedTxes = await driver.findElements( |
||||
'.transaction-list__completed-transactions .transaction-list-item', |
||||
); |
||||
return confirmedTxes.length === 1; |
||||
}, 10000); |
||||
|
||||
await driver.waitForSelector( |
||||
{ |
||||
css: '.transaction-list-item__primary-currency', |
||||
text: '-1 ETH', |
||||
}, |
||||
{ timeout: 10000 }, |
||||
); |
||||
}, |
||||
); |
||||
}); |
||||
}); |
@ -0,0 +1,197 @@ |
||||
import { createSlice } from '@reduxjs/toolkit'; |
||||
import ENS from 'ethjs-ens'; |
||||
import log from 'loglevel'; |
||||
import networkMap from 'ethereum-ens-network-map'; |
||||
import { isConfusing } from 'unicode-confusables'; |
||||
import { isHexString } from 'ethereumjs-util'; |
||||
|
||||
import { getCurrentChainId } from '../selectors'; |
||||
import { |
||||
CHAIN_ID_TO_NETWORK_ID_MAP, |
||||
MAINNET_NETWORK_ID, |
||||
} from '../../shared/constants/network'; |
||||
import { |
||||
CONFUSING_ENS_ERROR, |
||||
ENS_ILLEGAL_CHARACTER, |
||||
ENS_NOT_FOUND_ON_NETWORK, |
||||
ENS_NOT_SUPPORTED_ON_NETWORK, |
||||
ENS_NO_ADDRESS_FOR_NAME, |
||||
ENS_REGISTRATION_ERROR, |
||||
ENS_UNKNOWN_ERROR, |
||||
} from '../pages/send/send.constants'; |
||||
import { isValidDomainName } from '../helpers/utils/util'; |
||||
import { CHAIN_CHANGED } from '../store/actionConstants'; |
||||
import { |
||||
BURN_ADDRESS, |
||||
isBurnAddress, |
||||
isValidHexAddress, |
||||
} from '../../shared/modules/hexstring-utils'; |
||||
|
||||
// Local Constants
|
||||
const ZERO_X_ERROR_ADDRESS = '0x'; |
||||
|
||||
const initialState = { |
||||
stage: 'UNINITIALIZED', |
||||
resolution: null, |
||||
error: null, |
||||
warning: null, |
||||
network: null, |
||||
}; |
||||
|
||||
export const ensInitialState = initialState; |
||||
|
||||
const name = 'ENS'; |
||||
|
||||
let ens = null; |
||||
|
||||
const slice = createSlice({ |
||||
name, |
||||
initialState, |
||||
reducers: { |
||||
ensLookup: (state, action) => { |
||||
// first clear out the previous state
|
||||
state.resolution = null; |
||||
state.error = null; |
||||
state.warning = null; |
||||
const { address, ensName, error, network } = action.payload; |
||||
|
||||
if (error) { |
||||
if ( |
||||
isValidDomainName(ensName) && |
||||
error.message === 'ENS name not defined.' |
||||
) { |
||||
state.error = |
||||
network === MAINNET_NETWORK_ID |
||||
? ENS_NO_ADDRESS_FOR_NAME |
||||
: ENS_NOT_FOUND_ON_NETWORK; |
||||
} else if (error.message === 'Illegal Character for ENS.') { |
||||
state.error = ENS_ILLEGAL_CHARACTER; |
||||
} else { |
||||
log.error(error); |
||||
state.error = ENS_UNKNOWN_ERROR; |
||||
} |
||||
} else if (address) { |
||||
if (address === BURN_ADDRESS) { |
||||
state.error = ENS_NO_ADDRESS_FOR_NAME; |
||||
} else if (address === ZERO_X_ERROR_ADDRESS) { |
||||
state.error = ENS_REGISTRATION_ERROR; |
||||
} else { |
||||
state.resolution = address; |
||||
} |
||||
if (isValidDomainName(address) && isConfusing(address)) { |
||||
state.warning = CONFUSING_ENS_ERROR; |
||||
} |
||||
} |
||||
}, |
||||
enableEnsLookup: (state, action) => { |
||||
state.stage = 'INITIALIZED'; |
||||
state.error = null; |
||||
state.resolution = null; |
||||
state.warning = null; |
||||
state.network = action.payload; |
||||
}, |
||||
disableEnsLookup: (state) => { |
||||
state.stage = 'NO_NETWORK_SUPPORT'; |
||||
state.error = ENS_NOT_SUPPORTED_ON_NETWORK; |
||||
state.warning = null; |
||||
state.resolution = null; |
||||
state.network = null; |
||||
}, |
||||
resetResolution: (state) => { |
||||
state.resolution = null; |
||||
state.warning = null; |
||||
state.error = |
||||
state.stage === 'NO_NETWORK_SUPPORT' |
||||
? ENS_NOT_SUPPORTED_ON_NETWORK |
||||
: null; |
||||
}, |
||||
}, |
||||
extraReducers: (builder) => { |
||||
builder.addCase(CHAIN_CHANGED, (state, action) => { |
||||
if (action.payload !== state.currentChainId) { |
||||
state.stage = 'UNINITIALIZED'; |
||||
ens = null; |
||||
} |
||||
}); |
||||
}, |
||||
}); |
||||
|
||||
const { reducer, actions } = slice; |
||||
export default reducer; |
||||
|
||||
const { |
||||
disableEnsLookup, |
||||
ensLookup, |
||||
enableEnsLookup, |
||||
resetResolution, |
||||
} = actions; |
||||
export { resetResolution }; |
||||
|
||||
export function initializeEnsSlice() { |
||||
return (dispatch, getState) => { |
||||
const state = getState(); |
||||
const chainId = getCurrentChainId(state); |
||||
const network = CHAIN_ID_TO_NETWORK_ID_MAP[chainId]; |
||||
const networkIsSupported = Boolean(networkMap[network]); |
||||
if (networkIsSupported) { |
||||
ens = new ENS({ provider: global.ethereumProvider, network }); |
||||
dispatch(enableEnsLookup(network)); |
||||
} else { |
||||
ens = null; |
||||
dispatch(disableEnsLookup()); |
||||
} |
||||
}; |
||||
} |
||||
|
||||
export function lookupEnsName(ensName) { |
||||
return async (dispatch, getState) => { |
||||
const trimmedEnsName = ensName.trim(); |
||||
let state = getState(); |
||||
if (state[name].stage === 'UNINITIALIZED') { |
||||
await dispatch(initializeEnsSlice()); |
||||
} |
||||
state = getState(); |
||||
if ( |
||||
state[name].stage === 'NO_NETWORK_SUPPORT' && |
||||
!( |
||||
isBurnAddress(trimmedEnsName) === false && |
||||
isValidHexAddress(trimmedEnsName, { mixedCaseUseChecksum: true }) |
||||
) && |
||||
!isHexString(trimmedEnsName) |
||||
) { |
||||
await dispatch(resetResolution()); |
||||
} else { |
||||
log.info(`ENS attempting to resolve name: ${trimmedEnsName}`); |
||||
let address; |
||||
let error; |
||||
try { |
||||
address = await ens.lookup(trimmedEnsName); |
||||
} catch (err) { |
||||
error = err; |
||||
} |
||||
const chainId = getCurrentChainId(state); |
||||
const network = CHAIN_ID_TO_NETWORK_ID_MAP[chainId]; |
||||
await dispatch( |
||||
ensLookup({ |
||||
ensName: trimmedEnsName, |
||||
address, |
||||
error, |
||||
chainId, |
||||
network, |
||||
}), |
||||
); |
||||
} |
||||
}; |
||||
} |
||||
|
||||
export function getEnsResolution(state) { |
||||
return state[name].resolution; |
||||
} |
||||
|
||||
export function getEnsError(state) { |
||||
return state[name].error; |
||||
} |
||||
|
||||
export function getEnsWarning(state) { |
||||
return state[name].warning; |
||||
} |
@ -0,0 +1,14 @@ |
||||
// This file has been separated because it is required in both the gas and send
|
||||
// slices. This created a circular dependency problem as both slices also
|
||||
// import from the actions and selectors files. This easiest path for
|
||||
// untangling is having the constants separate.
|
||||
|
||||
// Actions
|
||||
export const BASIC_GAS_ESTIMATE_STATUS = |
||||
'metamask/gas/BASIC_GAS_ESTIMATE_STATUS'; |
||||
export const RESET_CUSTOM_DATA = 'metamask/gas/RESET_CUSTOM_DATA'; |
||||
export const SET_BASIC_GAS_ESTIMATE_DATA = |
||||
'metamask/gas/SET_BASIC_GAS_ESTIMATE_DATA'; |
||||
export const SET_CUSTOM_GAS_LIMIT = 'metamask/gas/SET_CUSTOM_GAS_LIMIT'; |
||||
export const SET_CUSTOM_GAS_PRICE = 'metamask/gas/SET_CUSTOM_GAS_PRICE'; |
||||
export const SET_ESTIMATE_SOURCE = 'metamask/gas/SET_ESTIMATE_SOURCE'; |
@ -0,0 +1 @@ |
||||
export * from './send'; |
@ -1,142 +0,0 @@ |
||||
import SendReducer, { |
||||
openToDropdown, |
||||
closeToDropdown, |
||||
updateSendErrors, |
||||
showGasButtonGroup, |
||||
hideGasButtonGroup, |
||||
} from './send.duck'; |
||||
|
||||
describe('Send Duck', () => { |
||||
const mockState = { |
||||
mockProp: 123, |
||||
}; |
||||
const initState = { |
||||
toDropdownOpen: false, |
||||
gasButtonGroupShown: true, |
||||
errors: {}, |
||||
gasLimit: null, |
||||
gasPrice: null, |
||||
gasTotal: null, |
||||
tokenBalance: '0x0', |
||||
from: '', |
||||
to: '', |
||||
amount: '0', |
||||
memo: '', |
||||
maxModeOn: false, |
||||
editingTransactionId: null, |
||||
toNickname: '', |
||||
ensResolution: null, |
||||
ensResolutionError: '', |
||||
gasIsLoading: false, |
||||
}; |
||||
const OPEN_TO_DROPDOWN = 'metamask/send/OPEN_TO_DROPDOWN'; |
||||
const CLOSE_TO_DROPDOWN = 'metamask/send/CLOSE_TO_DROPDOWN'; |
||||
const UPDATE_SEND_ERRORS = 'metamask/send/UPDATE_SEND_ERRORS'; |
||||
const RESET_SEND_STATE = 'metamask/send/RESET_SEND_STATE'; |
||||
const SHOW_GAS_BUTTON_GROUP = 'metamask/send/SHOW_GAS_BUTTON_GROUP'; |
||||
const HIDE_GAS_BUTTON_GROUP = 'metamask/send/HIDE_GAS_BUTTON_GROUP'; |
||||
|
||||
describe('SendReducer()', () => { |
||||
it('should initialize state', () => { |
||||
expect(SendReducer(undefined, {})).toStrictEqual(initState); |
||||
}); |
||||
|
||||
it('should return state unchanged if it does not match a dispatched actions type', () => { |
||||
expect( |
||||
SendReducer(mockState, { |
||||
type: 'someOtherAction', |
||||
value: 'someValue', |
||||
}), |
||||
).toStrictEqual(mockState); |
||||
}); |
||||
|
||||
it('should set toDropdownOpen to true when receiving a OPEN_TO_DROPDOWN action', () => { |
||||
expect( |
||||
SendReducer(mockState, { |
||||
type: OPEN_TO_DROPDOWN, |
||||
}), |
||||
).toStrictEqual({ toDropdownOpen: true, ...mockState }); |
||||
}); |
||||
|
||||
it('should set toDropdownOpen to false when receiving a CLOSE_TO_DROPDOWN action', () => { |
||||
expect( |
||||
SendReducer(mockState, { |
||||
type: CLOSE_TO_DROPDOWN, |
||||
}), |
||||
).toStrictEqual({ toDropdownOpen: false, ...mockState }); |
||||
}); |
||||
|
||||
it('should set gasButtonGroupShown to true when receiving a SHOW_GAS_BUTTON_GROUP action', () => { |
||||
expect( |
||||
SendReducer( |
||||
{ ...mockState, gasButtonGroupShown: false }, |
||||
{ type: SHOW_GAS_BUTTON_GROUP }, |
||||
), |
||||
).toStrictEqual({ gasButtonGroupShown: true, ...mockState }); |
||||
}); |
||||
|
||||
it('should set gasButtonGroupShown to false when receiving a HIDE_GAS_BUTTON_GROUP action', () => { |
||||
expect( |
||||
SendReducer(mockState, { type: HIDE_GAS_BUTTON_GROUP }), |
||||
).toStrictEqual({ gasButtonGroupShown: false, ...mockState }); |
||||
}); |
||||
|
||||
it('should extend send.errors with the value of a UPDATE_SEND_ERRORS action', () => { |
||||
const modifiedMockState = { |
||||
...mockState, |
||||
errors: { |
||||
someError: false, |
||||
}, |
||||
}; |
||||
expect( |
||||
SendReducer(modifiedMockState, { |
||||
type: UPDATE_SEND_ERRORS, |
||||
value: { someOtherError: true }, |
||||
}), |
||||
).toStrictEqual({ |
||||
...modifiedMockState, |
||||
errors: { |
||||
someError: false, |
||||
someOtherError: true, |
||||
}, |
||||
}); |
||||
}); |
||||
|
||||
it('should return the initial state in response to a RESET_SEND_STATE action', () => { |
||||
expect( |
||||
SendReducer(mockState, { |
||||
type: RESET_SEND_STATE, |
||||
}), |
||||
).toStrictEqual(initState); |
||||
}); |
||||
}); |
||||
|
||||
describe('Send Duck Actions', () => { |
||||
it('calls openToDropdown action', () => { |
||||
expect(openToDropdown()).toStrictEqual({ type: OPEN_TO_DROPDOWN }); |
||||
}); |
||||
|
||||
it('calls closeToDropdown action', () => { |
||||
expect(closeToDropdown()).toStrictEqual({ type: CLOSE_TO_DROPDOWN }); |
||||
}); |
||||
|
||||
it('calls showGasButtonGroup action', () => { |
||||
expect(showGasButtonGroup()).toStrictEqual({ |
||||
type: SHOW_GAS_BUTTON_GROUP, |
||||
}); |
||||
}); |
||||
|
||||
it('calls hideGasButtonGroup action', () => { |
||||
expect(hideGasButtonGroup()).toStrictEqual({ |
||||
type: HIDE_GAS_BUTTON_GROUP, |
||||
}); |
||||
}); |
||||
|
||||
it('calls updateSendErrors action', () => { |
||||
expect(updateSendErrors('mockErrorObject')).toStrictEqual({ |
||||
type: UPDATE_SEND_ERRORS, |
||||
value: 'mockErrorObject', |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
@ -1,382 +0,0 @@ |
||||
import log from 'loglevel'; |
||||
import { estimateGas } from '../../store/actions'; |
||||
import { setCustomGasLimit } from '../gas/gas.duck'; |
||||
import { |
||||
estimateGasForSend, |
||||
calcTokenBalance, |
||||
} from '../../pages/send/send.utils'; |
||||
|
||||
// Actions
|
||||
const OPEN_TO_DROPDOWN = 'metamask/send/OPEN_TO_DROPDOWN'; |
||||
const CLOSE_TO_DROPDOWN = 'metamask/send/CLOSE_TO_DROPDOWN'; |
||||
const UPDATE_SEND_ERRORS = 'metamask/send/UPDATE_SEND_ERRORS'; |
||||
const RESET_SEND_STATE = 'metamask/send/RESET_SEND_STATE'; |
||||
const SHOW_GAS_BUTTON_GROUP = 'metamask/send/SHOW_GAS_BUTTON_GROUP'; |
||||
const HIDE_GAS_BUTTON_GROUP = 'metamask/send/HIDE_GAS_BUTTON_GROUP'; |
||||
const UPDATE_GAS_LIMIT = 'UPDATE_GAS_LIMIT'; |
||||
const UPDATE_GAS_PRICE = 'UPDATE_GAS_PRICE'; |
||||
const UPDATE_GAS_TOTAL = 'UPDATE_GAS_TOTAL'; |
||||
const UPDATE_SEND_HEX_DATA = 'UPDATE_SEND_HEX_DATA'; |
||||
const UPDATE_SEND_TOKEN_BALANCE = 'UPDATE_SEND_TOKEN_BALANCE'; |
||||
const UPDATE_SEND_TO = 'UPDATE_SEND_TO'; |
||||
const UPDATE_SEND_AMOUNT = 'UPDATE_SEND_AMOUNT'; |
||||
const UPDATE_MAX_MODE = 'UPDATE_MAX_MODE'; |
||||
const UPDATE_SEND = 'UPDATE_SEND'; |
||||
const UPDATE_SEND_TOKEN = 'UPDATE_SEND_TOKEN'; |
||||
const CLEAR_SEND = 'CLEAR_SEND'; |
||||
const GAS_LOADING_STARTED = 'GAS_LOADING_STARTED'; |
||||
const GAS_LOADING_FINISHED = 'GAS_LOADING_FINISHED'; |
||||
const UPDATE_SEND_ENS_RESOLUTION = 'UPDATE_SEND_ENS_RESOLUTION'; |
||||
const UPDATE_SEND_ENS_RESOLUTION_ERROR = 'UPDATE_SEND_ENS_RESOLUTION_ERROR'; |
||||
|
||||
const initState = { |
||||
toDropdownOpen: false, |
||||
gasButtonGroupShown: true, |
||||
errors: {}, |
||||
gasLimit: null, |
||||
gasPrice: null, |
||||
gasTotal: null, |
||||
tokenBalance: '0x0', |
||||
from: '', |
||||
to: '', |
||||
amount: '0', |
||||
memo: '', |
||||
maxModeOn: false, |
||||
editingTransactionId: null, |
||||
toNickname: '', |
||||
ensResolution: null, |
||||
ensResolutionError: '', |
||||
gasIsLoading: false, |
||||
}; |
||||
|
||||
// Reducer
|
||||
export default function reducer(state = initState, action) { |
||||
switch (action.type) { |
||||
case OPEN_TO_DROPDOWN: |
||||
return { |
||||
...state, |
||||
toDropdownOpen: true, |
||||
}; |
||||
case CLOSE_TO_DROPDOWN: |
||||
return { |
||||
...state, |
||||
toDropdownOpen: false, |
||||
}; |
||||
case UPDATE_SEND_ERRORS: |
||||
return { |
||||
...state, |
||||
errors: { |
||||
...state.errors, |
||||
...action.value, |
||||
}, |
||||
}; |
||||
case SHOW_GAS_BUTTON_GROUP: |
||||
return { |
||||
...state, |
||||
gasButtonGroupShown: true, |
||||
}; |
||||
case HIDE_GAS_BUTTON_GROUP: |
||||
return { |
||||
...state, |
||||
gasButtonGroupShown: false, |
||||
}; |
||||
case UPDATE_GAS_LIMIT: |
||||
return { |
||||
...state, |
||||
gasLimit: action.value, |
||||
}; |
||||
case UPDATE_GAS_PRICE: |
||||
return { |
||||
...state, |
||||
gasPrice: action.value, |
||||
}; |
||||
case RESET_SEND_STATE: |
||||
return { ...initState }; |
||||
case UPDATE_GAS_TOTAL: |
||||
return { |
||||
...state, |
||||
gasTotal: action.value, |
||||
}; |
||||
case UPDATE_SEND_TOKEN_BALANCE: |
||||
return { |
||||
...state, |
||||
tokenBalance: action.value, |
||||
}; |
||||
case UPDATE_SEND_HEX_DATA: |
||||
return { |
||||
...state, |
||||
data: action.value, |
||||
}; |
||||
case UPDATE_SEND_TO: |
||||
return { |
||||
...state, |
||||
to: action.value.to, |
||||
toNickname: action.value.nickname, |
||||
}; |
||||
case UPDATE_SEND_AMOUNT: |
||||
return { |
||||
...state, |
||||
amount: action.value, |
||||
}; |
||||
case UPDATE_MAX_MODE: |
||||
return { |
||||
...state, |
||||
maxModeOn: action.value, |
||||
}; |
||||
case UPDATE_SEND: |
||||
return Object.assign(state, action.value); |
||||
case UPDATE_SEND_TOKEN: { |
||||
const newSend = { |
||||
...state, |
||||
token: action.value, |
||||
}; |
||||
// erase token-related state when switching back to native currency
|
||||
if (newSend.editingTransactionId && !newSend.token) { |
||||
const unapprovedTx = |
||||
newSend?.unapprovedTxs?.[newSend.editingTransactionId] || {}; |
||||
const txParams = unapprovedTx.txParams || {}; |
||||
Object.assign(newSend, { |
||||
tokenBalance: null, |
||||
balance: '0', |
||||
from: unapprovedTx.from || '', |
||||
unapprovedTxs: { |
||||
...newSend.unapprovedTxs, |
||||
[newSend.editingTransactionId]: { |
||||
...unapprovedTx, |
||||
txParams: { |
||||
...txParams, |
||||
data: '', |
||||
}, |
||||
}, |
||||
}, |
||||
}); |
||||
} |
||||
return Object.assign(state, newSend); |
||||
} |
||||
case UPDATE_SEND_ENS_RESOLUTION: |
||||
return { |
||||
...state, |
||||
ensResolution: action.payload, |
||||
ensResolutionError: '', |
||||
}; |
||||
case UPDATE_SEND_ENS_RESOLUTION_ERROR: |
||||
return { |
||||
...state, |
||||
ensResolution: null, |
||||
ensResolutionError: action.payload, |
||||
}; |
||||
case CLEAR_SEND: |
||||
return { |
||||
...state, |
||||
gasLimit: null, |
||||
gasPrice: null, |
||||
gasTotal: null, |
||||
tokenBalance: null, |
||||
from: '', |
||||
to: '', |
||||
amount: '0x0', |
||||
memo: '', |
||||
errors: {}, |
||||
maxModeOn: false, |
||||
editingTransactionId: null, |
||||
toNickname: '', |
||||
}; |
||||
case GAS_LOADING_STARTED: |
||||
return { |
||||
...state, |
||||
gasIsLoading: true, |
||||
}; |
||||
|
||||
case GAS_LOADING_FINISHED: |
||||
return { |
||||
...state, |
||||
gasIsLoading: false, |
||||
}; |
||||
default: |
||||
return state; |
||||
} |
||||
} |
||||
|
||||
// Action Creators
|
||||
export function openToDropdown() { |
||||
return { type: OPEN_TO_DROPDOWN }; |
||||
} |
||||
|
||||
export function closeToDropdown() { |
||||
return { type: CLOSE_TO_DROPDOWN }; |
||||
} |
||||
|
||||
export function showGasButtonGroup() { |
||||
return { type: SHOW_GAS_BUTTON_GROUP }; |
||||
} |
||||
|
||||
export function hideGasButtonGroup() { |
||||
return { type: HIDE_GAS_BUTTON_GROUP }; |
||||
} |
||||
|
||||
export function updateSendErrors(errorObject) { |
||||
return { |
||||
type: UPDATE_SEND_ERRORS, |
||||
value: errorObject, |
||||
}; |
||||
} |
||||
|
||||
export function resetSendState() { |
||||
return { type: RESET_SEND_STATE }; |
||||
} |
||||
|
||||
export function setGasLimit(gasLimit) { |
||||
return { |
||||
type: UPDATE_GAS_LIMIT, |
||||
value: gasLimit, |
||||
}; |
||||
} |
||||
|
||||
export function setGasPrice(gasPrice) { |
||||
return { |
||||
type: UPDATE_GAS_PRICE, |
||||
value: gasPrice, |
||||
}; |
||||
} |
||||
|
||||
export function setGasTotal(gasTotal) { |
||||
return { |
||||
type: UPDATE_GAS_TOTAL, |
||||
value: gasTotal, |
||||
}; |
||||
} |
||||
|
||||
export function updateGasData({ |
||||
gasPrice, |
||||
blockGasLimit, |
||||
selectedAddress, |
||||
sendToken, |
||||
to, |
||||
value, |
||||
data, |
||||
}) { |
||||
return (dispatch) => { |
||||
dispatch(gasLoadingStarted()); |
||||
return estimateGasForSend({ |
||||
estimateGasMethod: estimateGas, |
||||
blockGasLimit, |
||||
selectedAddress, |
||||
sendToken, |
||||
to, |
||||
value, |
||||
estimateGasPrice: gasPrice, |
||||
data, |
||||
}) |
||||
.then((gas) => { |
||||
dispatch(setGasLimit(gas)); |
||||
dispatch(setCustomGasLimit(gas)); |
||||
dispatch(updateSendErrors({ gasLoadingError: null })); |
||||
dispatch(gasLoadingFinished()); |
||||
}) |
||||
.catch((err) => { |
||||
log.error(err); |
||||
dispatch(updateSendErrors({ gasLoadingError: 'gasLoadingError' })); |
||||
dispatch(gasLoadingFinished()); |
||||
}); |
||||
}; |
||||
} |
||||
|
||||
export function gasLoadingStarted() { |
||||
return { |
||||
type: GAS_LOADING_STARTED, |
||||
}; |
||||
} |
||||
|
||||
export function gasLoadingFinished() { |
||||
return { |
||||
type: GAS_LOADING_FINISHED, |
||||
}; |
||||
} |
||||
|
||||
export function updateSendTokenBalance({ sendToken, tokenContract, address }) { |
||||
return (dispatch) => { |
||||
const tokenBalancePromise = tokenContract |
||||
? tokenContract.balanceOf(address) |
||||
: Promise.resolve(); |
||||
return tokenBalancePromise |
||||
.then((usersToken) => { |
||||
if (usersToken) { |
||||
const newTokenBalance = calcTokenBalance({ sendToken, usersToken }); |
||||
dispatch(setSendTokenBalance(newTokenBalance)); |
||||
} |
||||
}) |
||||
.catch((err) => { |
||||
log.error(err); |
||||
updateSendErrors({ tokenBalance: 'tokenBalanceError' }); |
||||
}); |
||||
}; |
||||
} |
||||
|
||||
export function setSendTokenBalance(tokenBalance) { |
||||
return { |
||||
type: UPDATE_SEND_TOKEN_BALANCE, |
||||
value: tokenBalance, |
||||
}; |
||||
} |
||||
|
||||
export function updateSendHexData(value) { |
||||
return { |
||||
type: UPDATE_SEND_HEX_DATA, |
||||
value, |
||||
}; |
||||
} |
||||
|
||||
export function updateSendTo(to, nickname = '') { |
||||
return { |
||||
type: UPDATE_SEND_TO, |
||||
value: { to, nickname }, |
||||
}; |
||||
} |
||||
|
||||
export function updateSendAmount(amount) { |
||||
return { |
||||
type: UPDATE_SEND_AMOUNT, |
||||
value: amount, |
||||
}; |
||||
} |
||||
|
||||
export function setMaxModeTo(bool) { |
||||
return { |
||||
type: UPDATE_MAX_MODE, |
||||
value: bool, |
||||
}; |
||||
} |
||||
|
||||
export function updateSend(newSend) { |
||||
return { |
||||
type: UPDATE_SEND, |
||||
value: newSend, |
||||
}; |
||||
} |
||||
|
||||
export function updateSendToken(token) { |
||||
return { |
||||
type: UPDATE_SEND_TOKEN, |
||||
value: token, |
||||
}; |
||||
} |
||||
|
||||
export function clearSend() { |
||||
return { |
||||
type: CLEAR_SEND, |
||||
}; |
||||
} |
||||
|
||||
export function updateSendEnsResolution(ensResolution) { |
||||
return { |
||||
type: UPDATE_SEND_ENS_RESOLUTION, |
||||
payload: ensResolution, |
||||
}; |
||||
} |
||||
|
||||
export function updateSendEnsResolutionError(errorMessage) { |
||||
return { |
||||
type: UPDATE_SEND_ENS_RESOLUTION_ERROR, |
||||
payload: errorMessage, |
||||
}; |
||||
} |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1 +1 @@ |
||||
export { default } from './send.container'; |
||||
export { default } from './send'; |
||||
|
@ -1,56 +0,0 @@ |
||||
import contractMap from '@metamask/contract-metadata'; |
||||
import { isConfusing } from 'unicode-confusables'; |
||||
import { |
||||
REQUIRED_ERROR, |
||||
INVALID_RECIPIENT_ADDRESS_ERROR, |
||||
KNOWN_RECIPIENT_ADDRESS_ERROR, |
||||
INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR, |
||||
CONFUSING_ENS_ERROR, |
||||
CONTRACT_ADDRESS_ERROR, |
||||
} from '../../send.constants'; |
||||
|
||||
import { |
||||
checkExistingAddresses, |
||||
isValidDomainName, |
||||
isOriginContractAddress, |
||||
isDefaultMetaMaskChain, |
||||
} from '../../../../helpers/utils/util'; |
||||
import { |
||||
isBurnAddress, |
||||
isValidHexAddress, |
||||
toChecksumHexAddress, |
||||
} from '../../../../../shared/modules/hexstring-utils'; |
||||
|
||||
export function getToErrorObject(to, sendTokenAddress, chainId) { |
||||
let toError = null; |
||||
if (!to) { |
||||
toError = REQUIRED_ERROR; |
||||
} else if ( |
||||
isBurnAddress(to) || |
||||
(!isValidHexAddress(to, { mixedCaseUseChecksum: true }) && |
||||
!isValidDomainName(to)) |
||||
) { |
||||
toError = isDefaultMetaMaskChain(chainId) |
||||
? INVALID_RECIPIENT_ADDRESS_ERROR |
||||
: INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR; |
||||
} else if (isOriginContractAddress(to, sendTokenAddress)) { |
||||
toError = CONTRACT_ADDRESS_ERROR; |
||||
} |
||||
|
||||
return { to: toError }; |
||||
} |
||||
|
||||
export function getToWarningObject(to, tokens = [], sendToken = null) { |
||||
let toWarning = null; |
||||
if ( |
||||
sendToken && |
||||
(toChecksumHexAddress(to) in contractMap || |
||||
checkExistingAddresses(to, tokens)) |
||||
) { |
||||
toWarning = KNOWN_RECIPIENT_ADDRESS_ERROR; |
||||
} else if (isValidDomainName(to) && isConfusing(to)) { |
||||
toWarning = CONFUSING_ENS_ERROR; |
||||
} |
||||
|
||||
return { to: toWarning }; |
||||
} |
@ -1,115 +0,0 @@ |
||||
import { |
||||
REQUIRED_ERROR, |
||||
INVALID_RECIPIENT_ADDRESS_ERROR, |
||||
KNOWN_RECIPIENT_ADDRESS_ERROR, |
||||
CONFUSING_ENS_ERROR, |
||||
CONTRACT_ADDRESS_ERROR, |
||||
} from '../../send.constants'; |
||||
import { getToErrorObject, getToWarningObject } from './add-recipient'; |
||||
|
||||
jest.mock('../../../../helpers/utils/util', () => ({ |
||||
isDefaultMetaMaskChain: jest.fn().mockReturnValue(true), |
||||
isEthNetwork: jest.fn().mockReturnValue(true), |
||||
checkExistingAddresses: jest.fn().mockReturnValue(true), |
||||
isValidDomainName: jest.requireActual('../../../../helpers/utils/util') |
||||
.isValidDomainName, |
||||
isOriginContractAddress: jest.requireActual('../../../../helpers/utils/util') |
||||
.isOriginContractAddress, |
||||
})); |
||||
|
||||
jest.mock('../../../../../shared/modules/hexstring-utils', () => ({ |
||||
isValidHexAddress: jest.fn((to) => |
||||
Boolean(to.match(/^[0xabcdef123456798]+$/u)), |
||||
), |
||||
isBurnAddress: jest.fn(() => false), |
||||
toChecksumHexAddress: jest.fn((input) => input), |
||||
})); |
||||
|
||||
describe('add-recipient utils', () => { |
||||
describe('getToErrorObject()', () => { |
||||
it('should return a required error if "to" is falsy', () => { |
||||
expect(getToErrorObject(null)).toStrictEqual({ |
||||
to: REQUIRED_ERROR, |
||||
}); |
||||
}); |
||||
|
||||
it('should return an invalid recipient error if "to" is truthy but invalid', () => { |
||||
expect(getToErrorObject('mockInvalidTo')).toStrictEqual({ |
||||
to: INVALID_RECIPIENT_ADDRESS_ERROR, |
||||
}); |
||||
}); |
||||
|
||||
it('should return null if "to" is truthy and valid', () => { |
||||
expect(getToErrorObject('0xabc123')).toStrictEqual({ |
||||
to: null, |
||||
}); |
||||
}); |
||||
|
||||
it('should return a contract address error if the recipient is the same as the tokens contract address', () => { |
||||
expect(getToErrorObject('0xabc123', '0xabc123')).toStrictEqual({ |
||||
to: CONTRACT_ADDRESS_ERROR, |
||||
}); |
||||
}); |
||||
|
||||
it('should return null if the recipient address is not the token contract address', () => { |
||||
expect(getToErrorObject('0xabc123', '0xabc456')).toStrictEqual({ |
||||
to: null, |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('getToWarningObject()', () => { |
||||
it('should return a known address recipient error if "to" is a token address', () => { |
||||
expect( |
||||
getToWarningObject('0xabc123', [{ address: '0xabc123' }], { |
||||
address: '0xabc123', |
||||
}), |
||||
).toStrictEqual({ |
||||
to: KNOWN_RECIPIENT_ADDRESS_ERROR, |
||||
}); |
||||
}); |
||||
|
||||
it('should null if "to" is a token address but sendToken is falsy', () => { |
||||
expect( |
||||
getToWarningObject('0xabc123', [{ address: '0xabc123' }]), |
||||
).toStrictEqual({ |
||||
to: null, |
||||
}); |
||||
}); |
||||
|
||||
it('should return a known address recipient error if "to" is part of contract metadata', () => { |
||||
expect( |
||||
getToWarningObject( |
||||
'0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359', |
||||
[{ address: '0xabc123' }], |
||||
{ address: '0xabc123' }, |
||||
), |
||||
).toStrictEqual({ |
||||
to: KNOWN_RECIPIENT_ADDRESS_ERROR, |
||||
}); |
||||
}); |
||||
it('should null if "to" is part of contract metadata but sendToken is falsy', () => { |
||||
expect( |
||||
getToWarningObject( |
||||
'0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359', |
||||
[{ address: '0xabc123' }], |
||||
{ address: '0xabc123' }, |
||||
), |
||||
).toStrictEqual({ |
||||
to: KNOWN_RECIPIENT_ADDRESS_ERROR, |
||||
}); |
||||
}); |
||||
|
||||
it('should warn if name is a valid domain and confusable', () => { |
||||
expect(getToWarningObject('demo.eth')).toStrictEqual({ |
||||
to: CONFUSING_ENS_ERROR, |
||||
}); |
||||
}); |
||||
|
||||
it('should not warn if name is a valid domain and not confusable', () => { |
||||
expect(getToWarningObject('vitalik.eth')).toStrictEqual({ |
||||
to: null, |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
@ -1,20 +1,18 @@ |
||||
import { debounce } from 'lodash'; |
||||
import { connect } from 'react-redux'; |
||||
import { CHAIN_ID_TO_NETWORK_ID_MAP } from '../../../../../shared/constants/network'; |
||||
import { |
||||
getSendTo, |
||||
getSendToNickname, |
||||
getAddressBookEntry, |
||||
getCurrentChainId, |
||||
} from '../../../../selectors'; |
||||
lookupEnsName, |
||||
initializeEnsSlice, |
||||
resetResolution, |
||||
} from '../../../../ducks/ens'; |
||||
import EnsInput from './ens-input.component'; |
||||
|
||||
export default connect((state) => { |
||||
const selectedAddress = getSendTo(state); |
||||
const chainId = getCurrentChainId(state); |
||||
function mapDispatchToProps(dispatch) { |
||||
return { |
||||
network: CHAIN_ID_TO_NETWORK_ID_MAP[chainId], |
||||
selectedAddress, |
||||
selectedName: getSendToNickname(state), |
||||
contact: getAddressBookEntry(state, selectedAddress), |
||||
lookupEnsName: debounce((ensName) => dispatch(lookupEnsName(ensName)), 150), |
||||
initializeEnsSlice: () => dispatch(initializeEnsSlice()), |
||||
resetEnsResolution: debounce(() => dispatch(resetResolution()), 300), |
||||
}; |
||||
})(EnsInput); |
||||
} |
||||
|
||||
export default connect(null, mapDispatchToProps)(EnsInput); |
||||
|
@ -1,93 +0,0 @@ |
||||
import React from 'react'; |
||||
import { shallow } from 'enzyme'; |
||||
import sinon from 'sinon'; |
||||
import AmountMaxButton from './amount-max-button.component'; |
||||
|
||||
describe('AmountMaxButton Component', () => { |
||||
let wrapper; |
||||
let instance; |
||||
|
||||
const propsMethodSpies = { |
||||
setAmountToMax: sinon.spy(), |
||||
setMaxModeTo: sinon.spy(), |
||||
}; |
||||
|
||||
const MOCK_EVENT = { preventDefault: () => undefined }; |
||||
|
||||
beforeAll(() => { |
||||
sinon.spy(AmountMaxButton.prototype, 'setMaxAmount'); |
||||
}); |
||||
|
||||
beforeEach(() => { |
||||
wrapper = shallow( |
||||
<AmountMaxButton |
||||
balance="mockBalance" |
||||
gasTotal="mockGasTotal" |
||||
maxModeOn={false} |
||||
sendToken={{ address: 'mockTokenAddress' }} |
||||
setAmountToMax={propsMethodSpies.setAmountToMax} |
||||
setMaxModeTo={propsMethodSpies.setMaxModeTo} |
||||
tokenBalance="mockTokenBalance" |
||||
/>, |
||||
{ |
||||
context: { |
||||
t: (str) => `${str}_t`, |
||||
metricsEvent: () => undefined, |
||||
}, |
||||
}, |
||||
); |
||||
instance = wrapper.instance(); |
||||
}); |
||||
|
||||
afterEach(() => { |
||||
propsMethodSpies.setAmountToMax.resetHistory(); |
||||
propsMethodSpies.setMaxModeTo.resetHistory(); |
||||
AmountMaxButton.prototype.setMaxAmount.resetHistory(); |
||||
}); |
||||
|
||||
afterAll(() => { |
||||
sinon.restore(); |
||||
}); |
||||
|
||||
describe('setMaxAmount', () => { |
||||
it('should call setAmountToMax with the correct params', () => { |
||||
expect(propsMethodSpies.setAmountToMax.callCount).toStrictEqual(0); |
||||
instance.setMaxAmount(); |
||||
expect(propsMethodSpies.setAmountToMax.callCount).toStrictEqual(1); |
||||
expect(propsMethodSpies.setAmountToMax.getCall(0).args).toStrictEqual([ |
||||
{ |
||||
balance: 'mockBalance', |
||||
gasTotal: 'mockGasTotal', |
||||
sendToken: { address: 'mockTokenAddress' }, |
||||
tokenBalance: 'mockTokenBalance', |
||||
}, |
||||
]); |
||||
}); |
||||
}); |
||||
|
||||
describe('render', () => { |
||||
it('should render an element with a send-v2__amount-max class', () => { |
||||
expect(wrapper.find('.send-v2__amount-max')).toHaveLength(1); |
||||
}); |
||||
|
||||
it('should call setMaxModeTo and setMaxAmount when the checkbox is checked', () => { |
||||
const { onClick } = wrapper.find('.send-v2__amount-max').props(); |
||||
|
||||
expect(AmountMaxButton.prototype.setMaxAmount.callCount).toStrictEqual(0); |
||||
expect(propsMethodSpies.setMaxModeTo.callCount).toStrictEqual(0); |
||||
onClick(MOCK_EVENT); |
||||
expect(AmountMaxButton.prototype.setMaxAmount.callCount).toStrictEqual(1); |
||||
expect(propsMethodSpies.setMaxModeTo.callCount).toStrictEqual(1); |
||||
expect(propsMethodSpies.setMaxModeTo.getCall(0).args).toStrictEqual([ |
||||
true, |
||||
]); |
||||
}); |
||||
|
||||
it('should render the expected text when maxModeOn is false', () => { |
||||
wrapper.setProps({ maxModeOn: false }); |
||||
expect(wrapper.find('.send-v2__amount-max').text()).toStrictEqual( |
||||
'max_t', |
||||
); |
||||
}); |
||||
}); |
||||
}); |
@ -1,42 +0,0 @@ |
||||
import { connect } from 'react-redux'; |
||||
import { |
||||
getGasTotal, |
||||
getSendToken, |
||||
getSendFromBalance, |
||||
getTokenBalance, |
||||
getSendMaxModeState, |
||||
getBasicGasEstimateLoadingStatus, |
||||
} from '../../../../../selectors'; |
||||
import { |
||||
updateSendErrors, |
||||
updateSendAmount, |
||||
setMaxModeTo, |
||||
} from '../../../../../ducks/send/send.duck'; |
||||
import { calcMaxAmount } from './amount-max-button.utils'; |
||||
import AmountMaxButton from './amount-max-button.component'; |
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(AmountMaxButton); |
||||
|
||||
function mapStateToProps(state) { |
||||
return { |
||||
balance: getSendFromBalance(state), |
||||
buttonDataLoading: getBasicGasEstimateLoadingStatus(state), |
||||
gasTotal: getGasTotal(state), |
||||
maxModeOn: getSendMaxModeState(state), |
||||
sendToken: getSendToken(state), |
||||
tokenBalance: getTokenBalance(state), |
||||
}; |
||||
} |
||||
|
||||
function mapDispatchToProps(dispatch) { |
||||
return { |
||||
setAmountToMax: (maxAmountDataObject) => { |
||||
dispatch(updateSendErrors({ amount: null })); |
||||
dispatch(updateSendAmount(calcMaxAmount(maxAmountDataObject))); |
||||
}, |
||||
clearMaxAmount: () => { |
||||
dispatch(updateSendAmount('0')); |
||||
}, |
||||
setMaxModeTo: (bool) => dispatch(setMaxModeTo(bool)), |
||||
}; |
||||
} |
@ -1,83 +0,0 @@ |
||||
import sinon from 'sinon'; |
||||
|
||||
import { |
||||
updateSendErrors, |
||||
setMaxModeTo, |
||||
updateSendAmount, |
||||
} from '../../../../../ducks/send/send.duck'; |
||||
|
||||
let mapStateToProps; |
||||
let mapDispatchToProps; |
||||
|
||||
jest.mock('react-redux', () => ({ |
||||
connect: (ms, md) => { |
||||
mapStateToProps = ms; |
||||
mapDispatchToProps = md; |
||||
return () => ({}); |
||||
}, |
||||
})); |
||||
|
||||
jest.mock('../../../../../selectors', () => ({ |
||||
getGasTotal: (s) => `mockGasTotal:${s}`, |
||||
getSendToken: (s) => `mockSendToken:${s}`, |
||||
getSendFromBalance: (s) => `mockBalance:${s}`, |
||||
getTokenBalance: (s) => `mockTokenBalance:${s}`, |
||||
getSendMaxModeState: (s) => `mockMaxModeOn:${s}`, |
||||
getBasicGasEstimateLoadingStatus: (s) => `mockButtonDataLoading:${s}`, |
||||
})); |
||||
|
||||
jest.mock('./amount-max-button.utils.js', () => ({ |
||||
calcMaxAmount: (mockObj) => mockObj.val + 1, |
||||
})); |
||||
|
||||
jest.mock('../../../../../ducks/send/send.duck', () => ({ |
||||
setMaxModeTo: jest.fn(), |
||||
updateSendAmount: jest.fn(), |
||||
updateSendErrors: jest.fn(), |
||||
})); |
||||
|
||||
require('./amount-max-button.container.js'); |
||||
|
||||
describe('amount-max-button container', () => { |
||||
describe('mapStateToProps()', () => { |
||||
it('should map the correct properties to props', () => { |
||||
expect(mapStateToProps('mockState')).toStrictEqual({ |
||||
balance: 'mockBalance:mockState', |
||||
buttonDataLoading: 'mockButtonDataLoading:mockState', |
||||
gasTotal: 'mockGasTotal:mockState', |
||||
maxModeOn: 'mockMaxModeOn:mockState', |
||||
sendToken: 'mockSendToken:mockState', |
||||
tokenBalance: 'mockTokenBalance:mockState', |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('mapDispatchToProps()', () => { |
||||
let dispatchSpy; |
||||
let mapDispatchToPropsObject; |
||||
|
||||
beforeEach(() => { |
||||
dispatchSpy = sinon.spy(); |
||||
mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy); |
||||
}); |
||||
|
||||
describe('setAmountToMax()', () => { |
||||
it('should dispatch an action', () => { |
||||
mapDispatchToPropsObject.setAmountToMax({ val: 11, foo: 'bar' }); |
||||
expect(dispatchSpy.calledTwice).toStrictEqual(true); |
||||
expect(updateSendErrors).toHaveBeenCalled(); |
||||
expect(updateSendErrors).toHaveBeenCalledWith({ amount: null }); |
||||
expect(updateSendAmount).toHaveBeenCalled(); |
||||
expect(updateSendAmount).toHaveBeenCalledWith(12); |
||||
}); |
||||
}); |
||||
|
||||
describe('setMaxModeTo()', () => { |
||||
it('should dispatch an action', () => { |
||||
mapDispatchToPropsObject.setMaxModeTo('mockVal'); |
||||
expect(dispatchSpy.calledOnce).toStrictEqual(true); |
||||
expect(setMaxModeTo).toHaveBeenCalledWith('mockVal'); |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,49 @@ |
||||
import React from 'react'; |
||||
import classnames from 'classnames'; |
||||
import { useDispatch, useSelector } from 'react-redux'; |
||||
import { getBasicGasEstimateLoadingStatus } from '../../../../../selectors'; |
||||
import { |
||||
getSendMaxModeState, |
||||
isSendFormInvalid, |
||||
toggleSendMaxMode, |
||||
} from '../../../../../ducks/send'; |
||||
import { useI18nContext } from '../../../../../hooks/useI18nContext'; |
||||
import { useMetricEvent } from '../../../../../hooks/useMetricEvent'; |
||||
|
||||
export default function AmountMaxButton() { |
||||
const buttonDataLoading = useSelector(getBasicGasEstimateLoadingStatus); |
||||
const isDraftTransactionInvalid = useSelector(isSendFormInvalid); |
||||
const maxModeOn = useSelector(getSendMaxModeState); |
||||
const dispatch = useDispatch(); |
||||
const trackClickedMax = useMetricEvent({ |
||||
eventOpts: { |
||||
category: 'Transactions', |
||||
action: 'Edit Screen', |
||||
name: 'Clicked "Amount Max"', |
||||
}, |
||||
}); |
||||
const t = useI18nContext(); |
||||
|
||||
const onMaxClick = () => { |
||||
trackClickedMax(); |
||||
dispatch(toggleSendMaxMode()); |
||||
}; |
||||
|
||||
return ( |
||||
<button |
||||
className="send-v2__amount-max" |
||||
disabled={buttonDataLoading || isDraftTransactionInvalid} |
||||
onClick={onMaxClick} |
||||
> |
||||
<input type="checkbox" checked={maxModeOn} readOnly /> |
||||
<div |
||||
className={classnames('send-v2__amount-max__button', { |
||||
'send-v2__amount-max__button__disabled': |
||||
buttonDataLoading || isDraftTransactionInvalid, |
||||
})} |
||||
> |
||||
{t('max')} |
||||
</div> |
||||
</button> |
||||
); |
||||
} |
@ -0,0 +1,61 @@ |
||||
import React from 'react'; |
||||
import configureMockStore from 'redux-mock-store'; |
||||
import thunk from 'redux-thunk'; |
||||
|
||||
import { fireEvent } from '@testing-library/react'; |
||||
import { initialState, SEND_STATUSES } from '../../../../../ducks/send'; |
||||
import { renderWithProvider } from '../../../../../../test/jest'; |
||||
import AmountMaxButton from './amount-max-button'; |
||||
|
||||
const middleware = [thunk]; |
||||
|
||||
describe('AmountMaxButton Component', () => { |
||||
describe('render', () => { |
||||
it('should render a "Max" button', () => { |
||||
const { getByText } = renderWithProvider( |
||||
<AmountMaxButton />, |
||||
configureMockStore(middleware)({ |
||||
send: initialState, |
||||
gas: { basicEstimateStatus: 'LOADING' }, |
||||
}), |
||||
); |
||||
expect(getByText('Max')).toBeTruthy(); |
||||
}); |
||||
|
||||
it('should dispatch action to set mode to MAX', () => { |
||||
const store = configureMockStore(middleware)({ |
||||
send: { ...initialState, status: SEND_STATUSES.VALID }, |
||||
gas: { basicEstimateStatus: 'READY' }, |
||||
}); |
||||
const { getByText } = renderWithProvider(<AmountMaxButton />, store); |
||||
|
||||
const expectedActions = [ |
||||
{ type: 'send/updateAmountMode', payload: 'MAX' }, |
||||
]; |
||||
|
||||
fireEvent.click(getByText('Max'), { bubbles: true }); |
||||
const actions = store.getActions(); |
||||
expect(actions).toStrictEqual(expectedActions); |
||||
}); |
||||
|
||||
it('should dispatch action to set amount mode to INPUT', () => { |
||||
const store = configureMockStore(middleware)({ |
||||
send: { |
||||
...initialState, |
||||
status: SEND_STATUSES.VALID, |
||||
amount: { ...initialState.amount, mode: 'MAX' }, |
||||
}, |
||||
gas: { basicEstimateStatus: 'READY' }, |
||||
}); |
||||
const { getByText } = renderWithProvider(<AmountMaxButton />, store); |
||||
|
||||
const expectedActions = [ |
||||
{ type: 'send/updateAmountMode', payload: 'INPUT' }, |
||||
]; |
||||
|
||||
fireEvent.click(getByText('Max'), { bubbles: true }); |
||||
const actions = store.getActions(); |
||||
expect(actions).toStrictEqual(expectedActions); |
||||
}); |
||||
}); |
||||
}); |
@ -1,22 +0,0 @@ |
||||
import { |
||||
multiplyCurrencies, |
||||
subtractCurrencies, |
||||
} from '../../../../../helpers/utils/conversion-util'; |
||||
import { addHexPrefix } from '../../../../../../app/scripts/lib/util'; |
||||
|
||||
export function calcMaxAmount({ balance, gasTotal, sendToken, tokenBalance }) { |
||||
const { decimals } = sendToken || {}; |
||||
const multiplier = Math.pow(10, Number(decimals || 0)); |
||||
|
||||
return sendToken |
||||
? multiplyCurrencies(tokenBalance, multiplier, { |
||||
toNumericBase: 'hex', |
||||
multiplicandBase: 16, |
||||
multiplierBase: 10, |
||||
}) |
||||
: subtractCurrencies(addHexPrefix(balance), addHexPrefix(gasTotal), { |
||||
toNumericBase: 'hex', |
||||
aBase: 16, |
||||
bBase: 16, |
||||
}); |
||||
} |
@ -1,26 +0,0 @@ |
||||
import { calcMaxAmount } from './amount-max-button.utils'; |
||||
|
||||
describe('amount-max-button utils', () => { |
||||
describe('calcMaxAmount()', () => { |
||||
it('should calculate the correct amount when no sendToken defined', () => { |
||||
expect( |
||||
calcMaxAmount({ |
||||
balance: 'ffffff', |
||||
gasTotal: 'ff', |
||||
sendToken: false, |
||||
}), |
||||
).toStrictEqual('ffff00'); |
||||
}); |
||||
|
||||
it('should calculate the correct amount when a sendToken is defined', () => { |
||||
expect( |
||||
calcMaxAmount({ |
||||
sendToken: { |
||||
decimals: 10, |
||||
}, |
||||
tokenBalance: '64', |
||||
}), |
||||
).toStrictEqual('e8d4a51000'); |
||||
}); |
||||
}); |
||||
}); |
@ -1 +1 @@ |
||||
export { default } from './amount-max-button.container'; |
||||
export { default } from './amount-max-button'; |
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue