1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-12-23 01:39:44 +01:00

Add modal with directions to re-add token as NFT (#13291)

* Add modal pop for NFTs previously added as tokens - with directions to re-add as NFTs
This commit is contained in:
Alex Donesky 2022-01-19 12:42:41 -06:00 committed by GitHub
parent 5b92dc4cf0
commit f087e501a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 383 additions and 123 deletions

View File

@ -1306,9 +1306,6 @@
"history": {
"message": "Ιστορικό"
},
"id": {
"message": "Αναγνωριστικό"
},
"import": {
"message": "Εισαγωγή",
"description": "Button to import an account from a selected file"

View File

@ -596,6 +596,9 @@
"contractInteraction": {
"message": "Contract Interaction"
},
"convertTokenToNFTDescription": {
"message": "We've detected that this asset is an NFT. Metamask now has full native support for NFTs. Would you like to remove it from your token list and add it as an NFT?"
},
"copiedExclamation": {
"message": "Copied!"
},
@ -1367,9 +1370,6 @@
"history": {
"message": "History"
},
"id": {
"message": "ID"
},
"import": {
"message": "Import",
"description": "Button to import an account from a selected file"
@ -1406,9 +1406,15 @@
"importNFT": {
"message": "Import NFT"
},
"importNFTAddressToolTip": {
"message": "On OpenSea, for example, on the NFT's page under Details, there is a blue hyperlinked value labeled 'Contract Address'. If you click on this, it will take you to the contract's address on Etherscan; at the top-left of that page, there should be an icon labeled 'Contract', and to the right, a long string of letters and numbers. This is the address of the contract that created your NFT. Click on the 'copy' icon to the right of the address, and you'll have it on your clipboard."
},
"importNFTPage": {
"message": "Import NFT page"
},
"importNFTTokenIdToolTip": {
"message": "A collectible's ID is a unique identifier since no two NFTs are alike. Again, on OpenSea this number is under 'Details'. Make a note of it, or copy it onto your clipboard."
},
"importNFTs": {
"message": "Import NFTs"
},
@ -1462,6 +1468,9 @@
"invalidAddressRecipientNotEthNetwork": {
"message": "Not ETH network, set to lowercase"
},
"invalidAssetType": {
"message": "This asset is an NFT and needs to be re-added on the Import NFTs page found under the NFTs tab"
},
"invalidBlockExplorerURL": {
"message": "Invalid Block Explorer URL"
},
@ -1914,7 +1923,7 @@
"description": "The next nonce according to MetaMask's internal logic"
},
"nftTokenIdPlaceholder": {
"message": "Enter the collectible ID"
"message": "Enter the Token ID"
},
"nfts": {
"message": "NFTs"
@ -3531,6 +3540,9 @@
"message": "$1 of $2 pending",
"description": "$1 and $2 are intended to be two numbers, where $2 is a total number of pending confirmations, and $1 is a count towards that total"
},
"yes": {
"message": "Yes"
},
"yesLetsTry": {
"message": "Yes, let's try"
},

View File

@ -1306,9 +1306,6 @@
"history": {
"message": "Historique"
},
"id": {
"message": "ID"
},
"import": {
"message": "Importer",
"description": "Button to import an account from a selected file"

View File

@ -1306,9 +1306,6 @@
"history": {
"message": "इतिहास"
},
"id": {
"message": "ID"
},
"import": {
"message": "आयात करें",
"description": "Button to import an account from a selected file"

View File

@ -1306,9 +1306,6 @@
"history": {
"message": "Riwayat"
},
"id": {
"message": "ID"
},
"import": {
"message": "Impor",
"description": "Button to import an account from a selected file"

View File

@ -1306,9 +1306,6 @@
"history": {
"message": "履歴"
},
"id": {
"message": "ID"
},
"import": {
"message": "インポート",
"description": "Button to import an account from a selected file"

View File

@ -1306,9 +1306,6 @@
"history": {
"message": "기록"
},
"id": {
"message": "ID"
},
"import": {
"message": "가져오기",
"description": "Button to import an account from a selected file"

View File

@ -1306,9 +1306,6 @@
"history": {
"message": "История"
},
"id": {
"message": "Ид."
},
"import": {
"message": "Импорт",
"description": "Button to import an account from a selected file"

View File

@ -1306,9 +1306,6 @@
"history": {
"message": "History"
},
"id": {
"message": "ID"
},
"import": {
"message": "Mag-import",
"description": "Button to import an account from a selected file"

View File

@ -1306,9 +1306,6 @@
"history": {
"message": "Geçmiş"
},
"id": {
"message": "Kimlik"
},
"import": {
"message": "Al",
"description": "Button to import an account from a selected file"

View File

@ -1306,9 +1306,6 @@
"history": {
"message": "Lịch sử"
},
"id": {
"message": "ID"
},
"import": {
"message": "Nhập",
"description": "Button to import an account from a selected file"

View File

@ -1306,9 +1306,6 @@
"history": {
"message": "历史记录"
},
"id": {
"message": "ID"
},
"import": {
"message": "导入",
"description": "Button to import an account from a selected file"

View File

@ -13,6 +13,7 @@ import { useMetricEvent } from '../../../hooks/useMetricEvent';
import { ASSET_TYPES, updateSendAsset } from '../../../ducks/send';
import { SEND_ROUTE } from '../../../helpers/constants/routes';
import { SEVERITIES } from '../../../helpers/constants/design-system';
import { INVALID_ASSET_TYPE } from '../../../helpers/constants/error-keys';
const AssetListItem = ({
className,
@ -65,21 +66,26 @@ const AssetListItem = ({
<Button
type="link"
className="asset-list-item__send-token-button"
onClick={(e) => {
onClick={async (e) => {
e.stopPropagation();
sendTokenEvent();
dispatch(
updateSendAsset({
type: ASSET_TYPES.TOKEN,
details: {
address: tokenAddress,
decimals: tokenDecimals,
symbol: tokenSymbol,
},
}),
).then(() => {
try {
await dispatch(
updateSendAsset({
type: ASSET_TYPES.TOKEN,
details: {
address: tokenAddress,
decimals: tokenDecimals,
symbol: tokenSymbol,
},
}),
);
history.push(SEND_ROUTE);
});
} catch (err) {
if (!err.message.includes(INVALID_ASSET_TYPE)) {
throw err;
}
}
}}
>
{t('sendSpecifiedTokens', [tokenSymbol])}

View File

@ -20,7 +20,7 @@ export default function CollectiblesDetectionNotice() {
const history = useHistory();
return (
<Box marginBottom={8} className="collectibles-detection-notice">
<Box marginBottom={4} className="collectibles-detection-notice">
<Dialog type="message" className="collectibles-detection-notice__message">
<button
onClick={() => setCollectiblesDetectionNoticeDismissed()}

View File

@ -22,7 +22,7 @@
a.collectibles-detection-notice__message__link {
@include H6;
width: 60%;
width: 100%;
padding: 0;
justify-content: flex-start;
font-weight: bold;

View File

@ -0,0 +1,46 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useHistory } from 'react-router-dom';
import Modal from '../../modal';
import Typography from '../../../ui/typography';
import { TYPOGRAPHY } from '../../../../helpers/constants/design-system';
import withModalProps from '../../../../helpers/higher-order-components/with-modal-props';
import { useI18nContext } from '../../../../hooks/useI18nContext';
import { ADD_COLLECTIBLE_ROUTE } from '../../../../helpers/constants/routes';
const ConvertTokenToNFTModal = ({ hideModal, tokenAddress }) => {
const history = useHistory();
const t = useI18nContext();
return (
<Modal
onSubmit={() => {
history.push({
pathname: ADD_COLLECTIBLE_ROUTE,
state: { tokenAddress },
});
hideModal();
}}
submitText={t('yes')}
onCancel={() => hideModal()}
cancelText={t('cancel')}
>
<div className="convert-token-to-nft-modal">
<Typography
variant={TYPOGRAPHY.H6}
boxProps={{
marginTop: 2,
}}
>
{t('convertTokenToNFTDescription')}
</Typography>
</div>
</Modal>
);
};
ConvertTokenToNFTModal.propTypes = {
hideModal: PropTypes.func.isRequired,
tokenAddress: PropTypes.string,
};
export default withModalProps(ConvertTokenToNFTModal);

View File

@ -0,0 +1,4 @@
.convert-token-to-nft-modal {
display: flex;
flex-flow: column nowrap;
}

View File

@ -12,6 +12,7 @@
@import 'qr-scanner/index';
@import 'transaction-confirmed/index';
@import 'customize-nonce/index';
@import 'convert-token-to-nft-modal/index';
.modal {
z-index: 1050;

View File

@ -30,6 +30,7 @@ import AddToAddressBookModal from './add-to-addressbook-modal';
import EditApprovalPermission from './edit-approval-permission';
import NewAccountModal from './new-account-modal';
import CustomizeNonceModal from './customize-nonce';
import ConvertTokenToNftModal from './convert-token-to-nft-modal/convert-token-to-nft-modal';
const modalContainerBaseStyle = {
transform: 'translate3d(-50%, 0, 0px)',
@ -237,6 +238,19 @@ const MODALS = {
},
},
CONVERT_TOKEN_TO_NFT: {
contents: <ConvertTokenToNftModal />,
mobileModalStyle: {
...modalContainerMobileStyle,
},
laptopModalStyle: {
...modalContainerLaptopStyle,
},
contentStyle: {
borderRadius: '8px',
},
},
CONFIRM_DELETE_NETWORK: {
contents: <ConfirmDeleteNetwork />,
mobileModalStyle: {

View File

@ -1,4 +1,4 @@
import React, { useContext } from 'react';
import React, { useContext, useEffect } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
@ -29,6 +29,8 @@ import SwapIcon from '../../ui/icon/swap-icon.component';
import SendIcon from '../../ui/icon/overview-send-icon.component';
import IconButton from '../../ui/icon-button';
import { INVALID_ASSET_TYPE } from '../../../helpers/constants/error-keys';
import { showModal } from '../../../store/actions';
import WalletOverview from './wallet-overview';
const TokenOverview = ({ className, token }) => {
@ -59,6 +61,17 @@ const TokenOverview = ({ className, token }) => {
category: 'swaps',
});
useEffect(() => {
if (token.isERC721) {
dispatch(
showModal({
name: 'CONVERT_TOKEN_TO_NFT',
tokenAddress: token.address,
}),
);
}
}, [token.isERC721, token.address, dispatch]);
return (
<WalletOverview
balance={
@ -81,16 +94,21 @@ const TokenOverview = ({ className, token }) => {
<>
<IconButton
className="token-overview__button"
onClick={() => {
onClick={async () => {
sendTokenEvent();
dispatch(
updateSendAsset({
type: ASSET_TYPES.TOKEN,
details: token,
}),
).then(() => {
try {
await dispatch(
updateSendAsset({
type: ASSET_TYPES.TOKEN,
details: token,
}),
);
history.push(SEND_ROUTE);
});
} catch (err) {
if (!err.message.includes(INVALID_ASSET_TYPE)) {
throw err;
}
}
}}
Icon={SendIcon}
label={t('send')}

View File

@ -0,0 +1,74 @@
import React from 'react';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { renderWithProvider } from '../../../../test/jest/rendering';
import TokenOverview from './token-overview';
describe('TokenOverview', () => {
const mockStore = {
metamask: {
provider: {
type: 'test',
},
preferences: {
useNativeCurrencyAsPrimaryCurrency: true,
},
identities: {
'0x1': {
address: '0x1',
},
},
selectedAddress: '0x1',
keyrings: [
{
type: 'HD Key Tree',
accounts: ['0x1', '0x2'],
},
{
type: 'Ledger Hardware',
accounts: [],
},
],
contractExchangeRates: {},
},
};
const store = configureMockStore([thunk])(mockStore);
afterEach(() => {
store.clearActions();
});
describe('TokenOverview', () => {
it('should not show a modal when token passed in props is not an ERC721', () => {
const token = {
name: 'test',
isERC721: false,
address: '0x01',
symbol: 'test',
};
renderWithProvider(<TokenOverview token={token} />, store);
const actions = store.getActions();
expect(actions).toHaveLength(0);
});
it('should show ConvertTokenToNFT modal when token passed in props is an ERC721', () => {
const token = {
name: 'test',
isERC721: true,
address: '0x01',
symbol: 'test',
};
renderWithProvider(<TokenOverview token={token} />, store);
const actions = store.getActions();
expect(actions).toHaveLength(1);
expect(actions[0].type).toBe('UI_MODAL_OPEN');
expect(actions[0].payload).toStrictEqual({
name: 'CONVERT_TOKEN_TO_NFT',
tokenAddress: '0x01',
});
});
});
});

View File

@ -30,6 +30,7 @@ export default function FormField({
password,
allowDecimals,
disabled,
placeholder,
}) {
return (
<div
@ -85,6 +86,7 @@ export default function FormField({
allowDecimals={allowDecimals}
disabled={disabled}
dataTestId={dataTestId}
placeholder={placeholder}
/>
) : (
<input
@ -97,6 +99,7 @@ export default function FormField({
autoFocus={autoFocus}
disabled={disabled}
data-testid={dataTestId}
placeholder={placeholder}
/>
)}
{error && (
@ -170,6 +173,10 @@ FormField.propTypes = {
* Check if the form disabled
*/
disabled: PropTypes.bool,
/**
* Set the placeholder text for the input field
*/
placeholder: PropTypes.string,
};
FormField.defaultProps = {

View File

@ -30,6 +30,7 @@ export default {
password: { control: 'boolean' },
allowDecimals: { control: 'boolean' },
disabled: { control: 'boolean' },
placeholder: { control: 'text' },
},
};

View File

@ -15,6 +15,7 @@ export default function NumericInput({
allowDecimals = true,
disabled = false,
dataTestId,
placeholder,
}) {
return (
<div
@ -40,6 +41,7 @@ export default function NumericInput({
autoFocus={autoFocus}
disabled={disabled}
data-testid={dataTestId}
placeholder={placeholder}
/>
{detailText && (
<Typography color={COLORS.UI4} variant={TYPOGRAPHY.H7} tag="span">
@ -59,4 +61,5 @@ NumericInput.propTypes = {
allowDecimals: PropTypes.bool,
disabled: PropTypes.bool,
dataTestId: PropTypes.string,
placeholder: PropTypes.string,
};

View File

@ -53,11 +53,12 @@ import {
hideLoadingIndication,
showConfTxPage,
showLoadingIndication,
updateTokenType,
updateTransaction,
addPollingTokenToAppState,
removePollingTokenFromAppState,
isCollectibleOwner,
getTokenStandardAndDetails,
showModal,
} from '../../store/actions';
import { setCustomGasLimit } from '../gas/gas.duck';
import {
@ -91,9 +92,16 @@ import {
isValidHexAddress,
} from '../../../shared/modules/hexstring-utils';
import { CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP } from '../../../shared/constants/network';
import { ERC20, ETH, GWEI } from '../../helpers/constants/common';
import {
ERC20,
ERC721,
ERC1155,
ETH,
GWEI,
} from '../../helpers/constants/common';
import { TRANSACTION_ENVELOPE_TYPES } from '../../../shared/constants/transaction';
import { readAddressAsContract } from '../../../shared/modules/contract-utils';
import { INVALID_ASSET_TYPE } from '../../helpers/constants/error-keys';
// typedefs
/**
* @typedef {import('@reduxjs/toolkit').PayloadAction} PayloadAction
@ -607,6 +615,8 @@ export const initialState = {
// In the case of tokens, the address, decimals and symbol of the token
// will be included in details
details: null,
// error to display when there is an issue with the asset
error: null,
},
draftTransaction: {
// The metamask internal id of the transaction. Only populated in the EDIT
@ -736,6 +746,7 @@ const slice = createSlice({
state.amount.value = action.payload.amount;
state.gas.error = null;
state.amount.error = null;
state.asset.error = null;
state.recipient.address = action.payload.address;
state.recipient.nickname = action.payload.nickname;
state.draftTransaction.id = action.payload.id;
@ -887,6 +898,7 @@ const slice = createSlice({
updateAsset: (state, action) => {
state.asset.type = action.payload.type;
state.asset.balance = action.payload.balance;
state.asset.error = action.payload.error;
if (
state.asset.type === ASSET_TYPES.TOKEN ||
state.asset.type === ASSET_TYPES.COLLECTIBLE
@ -1146,7 +1158,7 @@ const slice = createSlice({
},
validateSendState: (state) => {
switch (true) {
// 1 + 2. State is invalid when either gas or amount fields have errors
// 1 + 2. State is invalid when either gas or amount or asset fields have errors
// 3. State is invalid if asset type is a token and the token details
// are unknown.
// 4. State is invalid if no recipient has been added
@ -1156,6 +1168,7 @@ const slice = createSlice({
// 8. State is invalid if the selected asset is a ERC721
case Boolean(state.amount.error):
case Boolean(state.gas.error):
case Boolean(state.asset.error):
case state.asset.type === ASSET_TYPES.TOKEN &&
state.asset.details === null:
case state.stage === SEND_STAGES.ADD_RECIPIENT:
@ -1166,10 +1179,6 @@ const slice = createSlice({
):
state.status = SEND_STATUSES.INVALID;
break;
case state.asset.type === ASSET_TYPES.TOKEN &&
state.asset.details.isERC721 === true:
state.status = SEND_STATUSES.INVALID;
break;
default:
state.status = SEND_STATUSES.VALID;
// Recompute the draftTransaction object
@ -1419,31 +1428,43 @@ export function updateSendAmount(amount) {
export function updateSendAsset({ type, details }) {
return async (dispatch, getState) => {
const state = getState();
let { balance } = state.send.asset;
let { balance, error } = state.send.asset;
const userAddress = state.send.account.address ?? getSelectedAddress(state);
if (type === ASSET_TYPES.TOKEN) {
// if changing to a token, get the balance from the network. The asset
// overview page and asset list on the wallet overview page contain
// send buttons that call this method before initialization occurs.
// When this happens we don't yet have an account.address so default to
// the currently active account. In addition its possible for the balance
// check to take a decent amount of time, so we display a loading
// indication so that that immediate feedback is displayed to the user.
await dispatch(showLoadingIndication());
balance = await getERC20Balance(
details,
state.send.account.address ?? getSelectedAddress(state),
);
// TODO remove along with migration of isERC721 tokens and stripping away this designation
if (details) {
if (details.isERC721 === undefined) {
const updatedAssetDetails = await updateTokenType(details.address);
details.isERC721 = updatedAssetDetails.isERC721;
}
if (details.standard === undefined) {
details.standard = ERC20;
await dispatch(showLoadingIndication());
const { standard } = await getTokenStandardAndDetails(
details.address,
userAddress,
);
if (standard === ERC721 || standard === ERC1155) {
await dispatch(hideLoadingIndication());
dispatch(
showModal({
name: 'CONVERT_TOKEN_TO_NFT',
tokenAddress: details.address,
}),
);
error = INVALID_ASSET_TYPE;
throw new Error(error);
}
details.standard = standard;
}
// if changing to a token, get the balance from the network. The asset
// overview page and asset list on the wallet overview page contain
// send buttons that call this method before initialization occurs.
// When this happens we don't yet have an account.address so default to
// the currently active account. In addition its possible for the balance
// check to take a decent amount of time, so we display a loading
// indication so that that immediate feedback is displayed to the user.
if (details.standard === ERC20) {
error = null;
balance = await getERC20Balance(details, userAddress);
}
await dispatch(hideLoadingIndication());
}
await dispatch(hideLoadingIndication());
} else if (type === ASSET_TYPES.COLLECTIBLE) {
let isCurrentOwner = true;
try {
@ -1452,16 +1473,17 @@ export function updateSendAsset({ type, details }) {
details.address,
details.tokenId,
);
} catch (error) {
if (error.message.includes('Unable to verify ownership.')) {
} catch (err) {
if (err.message.includes('Unable to verify ownership.')) {
// this would indicate that either our attempts to verify ownership failed because of network issues,
// or, somehow a token has been added to collectibles state with an incorrect chainId.
} else {
// Any other error is unexpected and should be surfaced.
dispatch(displayWarning(error.message));
dispatch(displayWarning(err.message));
}
}
if (isCurrentOwner) {
error = null;
balance = '0x1';
} else {
throw new Error(
@ -1469,12 +1491,13 @@ export function updateSendAsset({ type, details }) {
);
}
} else {
error = null;
// if changing to native currency, get it from the account key in send
// state which is kept in sync when accounts change.
balance = state.send.account.balance;
}
// update the asset in state which will re-run amount and gas validation
await dispatch(actions.updateAsset({ type, details, balance }));
await dispatch(actions.updateAsset({ type, details, balance, error }));
await dispatch(computeEstimatedGasLimit());
};
}
@ -1870,7 +1893,6 @@ export function getGasInputMode(state) {
}
// Asset Selectors
export function getSendAsset(state) {
return state[name].asset;
}
@ -1886,6 +1908,10 @@ export function getIsAssetSendable(state) {
return state[name].asset.details.isERC721 === false;
}
export function getAssetError(state) {
return state[name].asset.error;
}
// Amount Selectors
export function getSendAmount(state) {
return state[name].amount.value;

View File

@ -19,6 +19,7 @@ import {
TRANSACTION_ENVELOPE_TYPES,
TRANSACTION_TYPES,
} from '../../../shared/constants/transaction';
import * as Actions from '../../store/actions';
import sendReducer, {
initialState,
initializeSendState,
@ -67,17 +68,6 @@ import sendReducer, {
const mockStore = createMockStore([thunk]);
jest.mock('../../store/actions', () => {
const actual = jest.requireActual('../../store/actions');
return {
...actual,
estimateGas: jest.fn(() => Promise.resolve('0x0')),
getGasFeeEstimatesAndStartPolling: jest.fn(() => Promise.resolve()),
updateTokenType: jest.fn(() => Promise.resolve({ isERC721: false })),
isCollectibleOwner: jest.fn(() => Promise.resolve(true)),
};
});
jest.mock('./send', () => {
const actual = jest.requireActual('./send');
return {
@ -88,6 +78,25 @@ jest.mock('./send', () => {
});
describe('Send Slice', () => {
let getTokenStandardAndDetailsStub;
beforeEach(() => {
getTokenStandardAndDetailsStub = jest
.spyOn(Actions, 'getTokenStandardAndDetails')
.mockImplementation(() => Promise.resolve({ standard: 'ERC20' }));
jest
.spyOn(Actions, 'estimateGas')
.mockImplementation(() => Promise.resolve('0x0'));
jest
.spyOn(Actions, 'getGasFeeEstimatesAndStartPolling')
.mockImplementation(() => Promise.resolve());
jest
.spyOn(Actions, 'updateTokenType')
.mockImplementation(() => Promise.resolve({ isERC721: false }));
jest
.spyOn(Actions, 'isCollectibleOwner')
.mockImplementation(() => Promise.resolve(true));
});
describe('Reducers', () => {
describe('updateSendAmount', () => {
it('should', async () => {
@ -1457,6 +1466,7 @@ describe('Send Slice', () => {
expect(actionResult[0].payload).toStrictEqual({
...newSendAsset,
balance: '',
error: null,
});
expect(actionResult[1].type).toStrictEqual(
@ -1499,6 +1509,7 @@ describe('Send Slice', () => {
expect(actionResult[2].payload).toStrictEqual({
...newSendAsset,
balance: '0x0',
error: null,
});
expect(actionResult[3].type).toStrictEqual(
@ -1511,6 +1522,37 @@ describe('Send Slice', () => {
'send/computeEstimatedGasLimit/fulfilled',
);
});
it('should show ConvertTokenToNFT modal and throw "invalidAssetType" error when token passed in props is an ERC721 or ERC1155', async () => {
getTokenStandardAndDetailsStub.mockImplementation(() =>
Promise.resolve({ standard: 'ERC1155' }),
);
const store = mockStore(defaultSendAssetState);
const newSendAsset = {
type: ASSET_TYPES.TOKEN,
details: {
address: 'tokenAddress',
symbol: 'tokenSymbol',
decimals: 'tokenDecimals',
},
};
await expect(() =>
store.dispatch(updateSendAsset(newSendAsset)),
).rejects.toThrow('invalidAssetType');
const actionResult = store.getActions();
expect(actionResult).toHaveLength(3);
expect(actionResult[0].type).toStrictEqual('SHOW_LOADING_INDICATION');
expect(actionResult[1].type).toStrictEqual('HIDE_LOADING_INDICATION');
expect(actionResult[2]).toStrictEqual({
payload: {
name: 'CONVERT_TOKEN_TO_NFT',
tokenAddress: 'tokenAddress',
},
type: 'UI_MODAL_OPEN',
});
});
});
describe('updateRecipientUserInput', () => {
@ -2231,6 +2273,7 @@ describe('Send Slice', () => {
expect(actionResult[0].payload).toStrictEqual({
balance: '0x1',
type: ASSET_TYPES.COLLECTIBLE,
error: null,
details: {
address: '0xTokenAddress',
description: 'A test NFT dispensed from faucet.paradigm.xyz.',
@ -2361,11 +2404,11 @@ describe('Send Slice', () => {
expect(actionResult[2].payload).toStrictEqual({
balance: '0x0',
type: ASSET_TYPES.TOKEN,
error: null,
details: {
address: '0xTokenAddress',
decimals: 18,
symbol: 'SYMB',
isERC721: false,
standard: 'ERC20',
},
});

View File

@ -7,6 +7,7 @@ export const SECONDARY = 'SECONDARY';
export const ERC20 = 'ERC20';
export const ERC721 = 'ERC721';
export const ERC1155 = 'ERC1155';
export const GAS_ESTIMATE_TYPES = {
SLOW: 'SLOW',

View File

@ -7,3 +7,4 @@ export const GAS_PRICE_FETCH_FAILURE_ERROR_KEY = 'gasPriceFetchFailed';
export const GAS_PRICE_EXCESSIVE_ERROR_KEY = 'gasPriceExcessive';
export const UNSENDABLE_ASSET_ERROR_KEY = 'unsendableAsset';
export const INSUFFICIENT_FUNDS_FOR_GAS_ERROR_KEY = 'insufficientFundsForGas';
export const INVALID_ASSET_TYPE = 'invalidAssetType';

View File

@ -17,6 +17,8 @@ import {
getShouldShowFiat,
getPreferences,
txDataSelector,
getCurrentKeyring,
getTokenExchangeRates,
} from '../../selectors';
import { ETH } from '../../helpers/constants/common';
@ -140,6 +142,12 @@ export const generateUseSelectorRouter = ({
if (selector === getEIP1559V2Enabled) {
return eip1559V2Enabled;
}
if (selector === getCurrentKeyring) {
return { type: '' };
}
if (selector === getTokenExchangeRates) {
return { '0x1': '1' };
}
return undefined;
};

View File

@ -1,41 +1,60 @@
import React, { useState } from 'react';
import { useHistory } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { util } from '@metamask/controllers';
import { useI18nContext } from '../../hooks/useI18nContext';
import { DEFAULT_ROUTE } from '../../helpers/constants/routes';
import Box from '../../components/ui/box';
import TextField from '../../components/ui/text-field';
import PageContainer from '../../components/ui/page-container';
import {
addCollectibleVerifyOwnership,
removeToken,
setNewCollectibleAddedMessage,
} from '../../store/actions';
import FormField from '../../components/ui/form-field';
import { getIsMainnet, getUseCollectibleDetection } from '../../selectors';
import { getCollectiblesDetectionNoticeDismissed } from '../../ducks/metamask/metamask';
import CollectiblesDetectionNotice from '../../components/app/collectibles-detection-notice';
export default function AddCollectible() {
const t = useI18nContext();
const history = useHistory();
const dispatch = useDispatch();
const useCollectibleDetection = useSelector(getUseCollectibleDetection);
const isMainnet = useSelector(getIsMainnet);
const collectibleDetectionNoticeDismissed = useSelector(
getCollectiblesDetectionNoticeDismissed,
);
const addressEnteredOnImportTokensPage =
history?.location?.state?.addressEnteredOnImportTokensPage;
const contractAddressToConvertFromTokenToCollectible =
history?.location?.state?.tokenAddress;
const [address, setAddress] = useState(
addressEnteredOnImportTokensPage ?? '',
addressEnteredOnImportTokensPage ??
contractAddressToConvertFromTokenToCollectible ??
'',
);
const [tokenId, setTokenId] = useState('');
const [disabled, setDisabled] = useState(true);
const handleAddCollectible = async () => {
try {
await dispatch(addCollectibleVerifyOwnership(address, tokenId));
await dispatch(
addCollectibleVerifyOwnership(address, tokenId.toString()),
);
} catch (error) {
const { message } = error;
dispatch(setNewCollectibleAddedMessage(message));
history.push(DEFAULT_ROUTE);
return;
}
if (contractAddressToConvertFromTokenToCollectible) {
await dispatch(
removeToken(contractAddressToConvertFromTokenToCollectible),
);
}
dispatch(setNewCollectibleAddedMessage('success'));
history.push(DEFAULT_ROUTE);
};
@ -66,29 +85,33 @@ export default function AddCollectible() {
disabled={disabled}
contentComponent={
<Box padding={4}>
{isMainnet &&
!useCollectibleDetection &&
!collectibleDetectionNoticeDismissed ? (
<CollectiblesDetectionNotice />
) : null}
<Box>
<TextField
<FormField
id="address"
label={t('address')}
titleText={t('address')}
placeholder="0x..."
type="text"
value={address}
onChange={(e) => validateAndSetAddress(e.target.value)}
fullWidth
onChange={(val) => validateAndSetAddress(val)}
tooltipText={t('importNFTAddressToolTip')}
autoFocus
margin="normal"
/>
</Box>
<Box>
<TextField
<FormField
id="token-id"
label={t('id')}
titleText={t('tokenId')}
placeholder={t('nftTokenIdPlaceholder')}
type="number"
value={tokenId}
onChange={(e) => validateAndSetTokenId(e.target.value)}
fullWidth
margin="normal"
onChange={(val) => {
validateAndSetTokenId(val);
}}
tooltipText={t('importNFTTokenIdToolTip')}
numeric
/>
</Box>
</Box>

View File

@ -21,7 +21,7 @@ const Asset = () => {
const collectible = collectibles.find(
({ address, tokenId }) =>
isEqualCaseInsensitive(address, asset) && id === tokenId,
isEqualCaseInsensitive(address, asset) && id === tokenId.toString(),
);
useEffect(() => {

View File

@ -216,7 +216,7 @@ class ImportToken extends Component {
const isMainnetNetwork = this.props.chainId === '0x1';
let standard;
if (addressIsValid) {
if (addressIsValid && process.env.COLLECTIBLES_V1) {
try {
({ standard } = await this.props.getTokenStandardAndDetails(
standardAddress,

View File

@ -37,6 +37,7 @@ export default class SendContent extends Component {
getIsBalanceInsufficient: PropTypes.bool,
asset: PropTypes.object,
to: PropTypes.string,
assetError: PropTypes.string,
};
render() {
@ -49,6 +50,7 @@ export default class SendContent extends Component {
networkOrAccountNotSupports1559,
getIsBalanceInsufficient,
asset,
assetError,
} = this.props;
let gasError;
@ -67,6 +69,7 @@ export default class SendContent extends Component {
return (
<PageContainerContent>
<div className="send-v2__form">
{assetError ? this.renderError(assetError) : null}
{gasError ? this.renderError(gasError) : null}
{isEthGasPrice
? this.renderWarning(ETH_GAS_PRICE_FETCH_WARNING_KEY)

View File

@ -10,6 +10,7 @@ import {
getIsBalanceInsufficient,
getSendTo,
getSendAsset,
getAssetError,
} from '../../../ducks/send';
import SendContent from './send-content.component';
@ -32,6 +33,7 @@ function mapStateToProps(state) {
),
getIsBalanceInsufficient: getIsBalanceInsufficient(state),
asset: getSendAsset(state),
assetError: getAssetError(state),
};
}