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

Add error that redirects users to Import NFT page when they attempt to add an NFT on the Import Token page (#13271)

* Add error that redirects users to Import NFT page when they attempt to add an NFT on the Import Token page
This commit is contained in:
Alex Donesky 2022-01-19 08:38:33 -06:00 committed by GitHub
parent 9a3c917a48
commit f7849a0b7c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 261 additions and 132 deletions

View File

@ -140,9 +140,6 @@
"addMemo": {
"message": "Προσθήκη σημειώματος"
},
"addNFT": {
"message": "Προσθήκη NFT"
},
"addNetwork": {
"message": "Προσθήκη Δικτύου"
},

View File

@ -140,9 +140,6 @@
"addMemo": {
"message": "Add memo"
},
"addNFT": {
"message": "Add NFT"
},
"addNetwork": {
"message": "Add Network"
},
@ -458,6 +455,10 @@
"close": {
"message": "Close"
},
"collectibleAddressError": {
"message": "This token is an NFT. Add on the $1",
"description": "$1 is a clickable link with text defined by the 'importNFTPage' key"
},
"confirm": {
"message": "Confirm"
},
@ -1402,6 +1403,12 @@
"importMyWallet": {
"message": "Import My Wallet"
},
"importNFT": {
"message": "Import NFT"
},
"importNFTPage": {
"message": "Import NFT page"
},
"importNFTs": {
"message": "Import NFTs"
},

View File

@ -140,9 +140,6 @@
"addMemo": {
"message": "Ajouter un mémo"
},
"addNFT": {
"message": "Ajouter un NFT"
},
"addNetwork": {
"message": "Ajouter un réseau"
},

View File

@ -140,9 +140,6 @@
"addMemo": {
"message": "मेमो जोड़ें"
},
"addNFT": {
"message": "NFT जोड़ें"
},
"addNetwork": {
"message": "नेटवर्क जोड़ें"
},

View File

@ -140,9 +140,6 @@
"addMemo": {
"message": "Tambahkan memo"
},
"addNFT": {
"message": "Tambahkan NFT"
},
"addNetwork": {
"message": "Tambahkan Jaringan"
},

View File

@ -140,9 +140,6 @@
"addMemo": {
"message": "メモを追加"
},
"addNFT": {
"message": "NFTを追加"
},
"addNetwork": {
"message": "ネットワークの追加"
},

View File

@ -140,9 +140,6 @@
"addMemo": {
"message": "메모 추가"
},
"addNFT": {
"message": "NFT 추가"
},
"addNetwork": {
"message": "네트워크 추가"
},

View File

@ -140,9 +140,6 @@
"addMemo": {
"message": "Добавить примечание"
},
"addNFT": {
"message": "Добавить NFT"
},
"addNetwork": {
"message": "Добавить сеть"
},

View File

@ -140,9 +140,6 @@
"addMemo": {
"message": "Magdagdag ng memo"
},
"addNFT": {
"message": "Magdagdag ng NFT"
},
"addNetwork": {
"message": "Magdagdag ng Network"
},

View File

@ -140,9 +140,6 @@
"addMemo": {
"message": "Not ekleyin"
},
"addNFT": {
"message": "NFT ekleyin"
},
"addNetwork": {
"message": "Ağ ekleyin"
},

View File

@ -140,9 +140,6 @@
"addMemo": {
"message": "Thêm bản ghi nhớ"
},
"addNFT": {
"message": "Thêm NFT"
},
"addNetwork": {
"message": "Thêm mạng"
},

View File

@ -140,9 +140,6 @@
"addMemo": {
"message": "添加备忘录"
},
"addNFT": {
"message": "添加NFT"
},
"addNetwork": {
"message": "添加网络"
},

View File

@ -220,22 +220,22 @@ export default class MetamaskController extends EventEmitter {
onNetworkStateChange: this.networkController.store.subscribe.bind(
this.networkController.store,
),
getAssetName: this.assetsContractController.getAssetName.bind(
getERC721AssetName: this.assetsContractController.getERC721AssetName.bind(
this.assetsContractController,
),
getAssetSymbol: this.assetsContractController.getAssetSymbol.bind(
getERC721AssetSymbol: this.assetsContractController.getERC721AssetSymbol.bind(
this.assetsContractController,
),
getCollectibleTokenURI: this.assetsContractController.getCollectibleTokenURI.bind(
getERC721TokenURI: this.assetsContractController.getERC721TokenURI.bind(
this.assetsContractController,
),
getOwnerOf: this.assetsContractController.getOwnerOf.bind(
getERC721OwnerOf: this.assetsContractController.getERC721OwnerOf.bind(
this.assetsContractController,
),
balanceOfERC1155Collectible: this.assetsContractController.balanceOfERC1155Collectible.bind(
getERC1155BalanceOf: this.assetsContractController.getERC1155BalanceOf.bind(
this.assetsContractController,
),
uriERC1155Collectible: this.assetsContractController.uriERC1155Collectible.bind(
getERC1155TokenURI: this.assetsContractController.getERC1155TokenURI.bind(
this.assetsContractController,
),
},
@ -1040,6 +1040,7 @@ export default class MetamaskController extends EventEmitter {
appStateController,
collectiblesController,
collectibleDetectionController,
assetsContractController,
currencyRateController,
detectTokensController,
ensController,
@ -1192,6 +1193,11 @@ export default class MetamaskController extends EventEmitter {
preferencesController,
),
// AssetsContractController
getTokenStandardAndDetails: assetsContractController.getTokenStandardAndDetails.bind(
assetsContractController,
),
// CollectiblesController
addCollectible: collectiblesController.addCollectible.bind(
collectiblesController,

View File

@ -107,7 +107,7 @@
"@keystonehq/metamask-airgapped-keyring": "0.2.1",
"@material-ui/core": "^4.11.0",
"@metamask/contract-metadata": "^1.31.0",
"@metamask/controllers": "^24.0.0",
"@metamask/controllers": "^25.0.0",
"@metamask/eth-ledger-bridge-keyring": "^0.10.0",
"@metamask/eth-token-tracker": "^3.0.1",
"@metamask/etherscan-link": "^2.1.0",

View File

@ -28,6 +28,7 @@
right: 16px;
cursor: pointer;
overflow: hidden;
background-color: transparent;
&::after {
content: '\00D7';

View File

@ -47,9 +47,10 @@ export default class PageContainerHeader extends Component {
return (
onClose && (
<div
<button
className="page-container__header-close"
onClick={() => onClose()}
aria-label="close"
/>
)
);

View File

@ -246,7 +246,7 @@ TextField.propTypes = {
/**
* Show error message
*/
error: PropTypes.string,
error: PropTypes.oneOfType([PropTypes.string, PropTypes.element]),
/**
* Add custom CSS class
*/

View File

@ -17,8 +17,13 @@ export default function AddCollectible() {
const t = useI18nContext();
const history = useHistory();
const dispatch = useDispatch();
const addressEnteredOnImportTokensPage =
history?.location?.state?.addressEnteredOnImportTokensPage;
const [address, setAddress] = useState(
addressEnteredOnImportTokensPage ?? '',
);
const [address, setAddress] = useState('');
const [tokenId, setTokenId] = useState('');
const [disabled, setDisabled] = useState(true);
@ -47,7 +52,7 @@ export default function AddCollectible() {
return (
<PageContainer
title={t('addNFT')}
title={t('importNFT')}
onSubmit={() => {
handleAddCollectible();
}}

View File

@ -8,6 +8,7 @@ import {
} from '../../helpers/utils/util';
import { tokenInfoGetter } from '../../helpers/utils/token-util';
import {
ADD_COLLECTIBLE_ROUTE,
CONFIRM_IMPORT_TOKEN_ROUTE,
EXPERIMENTAL_ROUTE,
} from '../../helpers/constants/routes';
@ -46,6 +47,8 @@ class ImportToken extends Component {
rpcPrefs: PropTypes.object,
tokenList: PropTypes.object,
useTokenDetection: PropTypes.bool,
getTokenStandardAndDetails: PropTypes.func,
selectedAddress: PropTypes.string,
};
static defaultProps = {
@ -62,6 +65,7 @@ class ImportToken extends Component {
customAddressError: null,
customSymbolError: null,
customDecimalsError: null,
collectibleAddressError: null,
forceEditSymbol: false,
symbolAutoFilled: false,
decimalAutoFilled: false,
@ -126,13 +130,15 @@ class ImportToken extends Component {
customAddressError,
customSymbolError,
customDecimalsError,
collectibleAddressError,
} = this.state;
return (
tokenSelectorError ||
customAddressError ||
customSymbolError ||
customDecimalsError
customDecimalsError ||
collectibleAddressError
);
}
@ -186,11 +192,12 @@ class ImportToken extends Component {
this.handleCustomDecimalsChange(decimals);
}
handleCustomAddressChange(value) {
async handleCustomAddressChange(value) {
const customAddress = value.trim();
this.setState({
customAddress,
customAddressError: null,
collectibleAddressError: null,
tokenSelectorError: null,
symbolAutoFilled: false,
decimalAutoFilled: false,
@ -208,8 +215,23 @@ class ImportToken extends Component {
const isMainnetNetwork = this.props.chainId === '0x1';
let standard;
if (addressIsValid) {
try {
({ standard } = await this.props.getTokenStandardAndDetails(
standardAddress,
this.props.selectedAddress,
));
} catch (error) {
// ignore
}
}
const addressIsEmpty =
customAddress.length === 0 || customAddress === emptyAddr;
switch (true) {
case !addressIsValid:
case !addressIsValid && !addressIsEmpty:
this.setState({
customAddressError: this.context.t('invalidAddress'),
customSymbol: '',
@ -218,6 +240,28 @@ class ImportToken extends Component {
customDecimalsError: null,
});
break;
case process.env.COLLECTIBLES_V1 &&
(standard === 'ERC1155' || standard === 'ERC721'):
this.setState({
collectibleAddressError: this.context.t('collectibleAddressError', [
<a
className="import-token__collectible-address-error-link"
onClick={() =>
this.props.history.push({
pathname: ADD_COLLECTIBLE_ROUTE,
state: {
addressEnteredOnImportTokensPage: this.state.customAddress,
},
})
}
key="collectibleAddressError"
>
{this.context.t('importNFTPage')}
</a>,
]),
});
break;
case isMainnetToken && !isMainnetNetwork:
this.setState({
@ -242,7 +286,7 @@ class ImportToken extends Component {
break;
default:
if (customAddress !== emptyAddr) {
if (!addressIsEmpty) {
this.attemptToAutoFillTokenParams(customAddress);
}
}
@ -290,6 +334,7 @@ class ImportToken extends Component {
symbolAutoFilled,
decimalAutoFilled,
mainnetTokenWarning,
collectibleAddressError,
} = this.state;
const { chainId, rpcPrefs } = this.props;
@ -330,7 +375,9 @@ class ImportToken extends Component {
type="text"
value={customAddress}
onChange={(e) => this.handleCustomAddressChange(e.target.value)}
error={customAddressError || mainnetTokenWarning}
error={
customAddressError || mainnetTokenWarning || collectibleAddressError
}
fullWidth
autoFocus
margin="normal"

View File

@ -1,6 +1,10 @@
import { connect } from 'react-redux';
import { setPendingTokens, clearPendingTokens } from '../../store/actions';
import {
setPendingTokens,
clearPendingTokens,
getTokenStandardAndDetails,
} from '../../store/actions';
import { getMostRecentOverviewPage } from '../../ducks/history/history';
import {
getRpcPrefsForCurrentProvider,
@ -17,6 +21,7 @@ const mapStateToProps = (state) => {
provider: { chainId },
useTokenDetection,
tokenList,
selectedAddress,
},
} = state;
const showSearchTabCustomNetwork =
@ -33,13 +38,15 @@ const mapStateToProps = (state) => {
rpcPrefs: getRpcPrefsForCurrentProvider(state),
tokenList,
useTokenDetection,
selectedAddress,
};
};
const mapDispatchToProps = (dispatch) => {
return {
setPendingTokens: (tokens) => dispatch(setPendingTokens(tokens)),
clearPendingTokens: () => dispatch(clearPendingTokens()),
getTokenStandardAndDetails: (address, selectedAddress) =>
getTokenStandardAndDetails(address, selectedAddress, null),
};
};

View File

@ -1,110 +1,177 @@
import React from 'react';
import { Provider } from 'react-redux';
import sinon from 'sinon';
import configureMockStore from 'redux-mock-store';
import { mountWithRouter } from '../../../test/lib/render-helpers';
import { fireEvent } from '@testing-library/react';
import { renderWithProvider } from '../../../test/lib/render-helpers';
import configureStore from '../../store/store';
import {
setPendingTokens,
clearPendingTokens,
getTokenStandardAndDetails,
} from '../../store/actions';
import ImportToken from './import-token.container';
jest.mock('../../store/actions', () => ({
getTokenStandardAndDetails: jest
.fn()
.mockImplementation(() => Promise.resolve({ standard: 'ERC20' })),
setPendingTokens: jest
.fn()
.mockImplementation(() => ({ type: 'SET_PENDING_TOKENS' })),
clearPendingTokens: jest
.fn()
.mockImplementation(() => ({ type: 'CLEAR_PENDING_TOKENS' })),
}));
describe('Import Token', () => {
let wrapper;
const state = {
metamask: {
tokens: [],
},
};
const store = configureMockStore()(state);
const historyStub = jest.fn();
const props = {
history: {
push: sinon.stub().callsFake(() => undefined),
push: historyStub,
},
setPendingTokens: sinon.spy(),
clearPendingTokens: sinon.spy(),
tokens: [],
identities: {},
mostRecentOverviewPage: '/',
showSearchTab: true,
tokenList: {},
};
const render = () => {
const baseStore = {
metamask: {
tokens: [],
provider: { chainId: '0x1' },
frequentRpcListDetail: [],
identities: {},
selectedAddress: '0x1231231',
},
history: {
mostRecentOverviewPage: '/',
},
};
const store = configureStore(baseStore);
return renderWithProvider(<ImportToken {...props} />, store);
};
describe('Import Token', () => {
beforeAll(() => {
wrapper = mountWithRouter(
<Provider store={store}>
<ImportToken.WrappedComponent {...props} />
</Provider>,
store,
);
it('add Custom Token button is disabled when no fields are populated', () => {
const { getByText } = render();
const customTokenButton = getByText('Custom Token');
fireEvent.click(customTokenButton);
const submit = getByText('Add Custom Token');
wrapper.find({ name: 'customToken' }).simulate('click');
});
afterEach(() => {
props.history.push.reset();
});
it('next button is disabled when no fields are populated', () => {
const nextButton = wrapper.find(
'.button.btn-primary.page-container__footer-button',
);
expect(nextButton.props().disabled).toStrictEqual(true);
expect(submit).toBeDisabled();
});
it('edits token address', () => {
const { getByText } = render();
const customTokenButton = getByText('Custom Token');
fireEvent.click(customTokenButton);
const tokenAddress = '0x617b3f8050a0BD94b6b1da02B4384eE5B4DF13F4';
const event = { target: { value: tokenAddress } };
const customAddress = wrapper.find('input#custom-address');
fireEvent.change(document.getElementById('custom-address'), event);
customAddress.simulate('change', event);
expect(
wrapper.find('ImportToken').instance().state.customAddress,
).toStrictEqual(tokenAddress);
expect(document.getElementById('custom-address').value).toStrictEqual(
tokenAddress,
);
});
it('edits token symbol', () => {
const { getByText } = render();
const customTokenButton = getByText('Custom Token');
fireEvent.click(customTokenButton);
const tokenSymbol = 'META';
const event = { target: { value: tokenSymbol } };
const customAddress = wrapper.find('#custom-symbol');
customAddress.last().simulate('change', event);
fireEvent.change(document.getElementById('custom-symbol'), event);
expect(
wrapper.find('ImportToken').instance().state.customSymbol,
).toStrictEqual(tokenSymbol);
expect(document.getElementById('custom-symbol').value).toStrictEqual(
tokenSymbol,
);
});
it('edits token decimal precision', () => {
const { getByText } = render();
const customTokenButton = getByText('Custom Token');
fireEvent.click(customTokenButton);
const tokenPrecision = '2';
const event = { target: { value: tokenPrecision } };
const customAddress = wrapper.find('#custom-decimals');
customAddress.last().simulate('change', event);
fireEvent.change(document.getElementById('custom-decimals'), event);
expect(
wrapper.find('ImportToken').instance().state.customDecimals,
).toStrictEqual(Number(tokenPrecision));
});
it('next', () => {
const nextButton = wrapper.find(
'.button.btn-primary.page-container__footer-button',
);
nextButton.simulate('click');
expect(props.setPendingTokens.calledOnce).toStrictEqual(true);
expect(props.history.push.calledOnce).toStrictEqual(true);
expect(props.history.push.getCall(0).args[0]).toStrictEqual(
'/confirm-import-token',
expect(document.getElementById('custom-decimals').value).toStrictEqual(
tokenPrecision,
);
});
it('cancels', () => {
const cancelButton = wrapper.find('.page-container__header-close');
cancelButton.simulate('click');
it('adds custom tokens successfully', async () => {
const { getByText } = render();
const customTokenButton = getByText('Custom Token');
fireEvent.click(customTokenButton);
expect(props.clearPendingTokens.calledOnce).toStrictEqual(true);
expect(props.history.push.getCall(0).args[0]).toStrictEqual('/');
const submit = getByText('Add Custom Token');
expect(submit).toBeDisabled();
const tokenAddress = '0x617b3f8050a0BD94b6b1da02B4384eE5B4DF13F4';
fireEvent.change(document.getElementById('custom-address'), {
target: { value: tokenAddress },
});
expect(submit).not.toBeDisabled();
const tokenSymbol = 'META';
fireEvent.change(document.getElementById('custom-symbol'), {
target: { value: tokenSymbol },
});
const tokenPrecision = '2';
await fireEvent.change(document.getElementById('custom-decimals'), {
target: { value: tokenPrecision },
});
expect(submit).not.toBeDisabled();
fireEvent.click(submit);
expect(setPendingTokens).toHaveBeenCalledWith({
customToken: {
address: tokenAddress,
decimals: Number(tokenPrecision),
symbol: tokenSymbol,
},
selectedTokens: {},
tokenAddressList: [],
});
expect(historyStub).toHaveBeenCalledWith('/confirm-import-token');
});
it('cancels out of import token flow', () => {
const { getByRole } = render();
const closeButton = getByRole('button', { name: 'close' });
fireEvent.click(closeButton);
expect(clearPendingTokens).toHaveBeenCalled();
expect(historyStub).toHaveBeenCalledWith('/');
});
it('sets and error when a token is an NFT', async () => {
process.env.COLLECTIBLES_V1 = true;
getTokenStandardAndDetails.mockImplementation(() =>
Promise.resolve({ standard: 'ERC721' }),
);
const { getByText } = render();
const customTokenButton = getByText('Custom Token');
fireEvent.click(customTokenButton);
const submit = getByText('Add Custom Token');
expect(submit).toBeDisabled();
const tokenAddress = '0x617b3f8050a0BD94b6b1da02B4384eE5B4DF13F4';
await fireEvent.change(document.getElementById('custom-address'), {
target: { value: tokenAddress },
});
expect(submit).toBeDisabled();
// The last part of this error message won't be found by getByText because it is wrapped as a link.
const errorMessage = getByText('This token is an NFT. Add on the');
expect(errorMessage).toBeInTheDocument();
});
});
});

View File

@ -6,12 +6,12 @@
&__custom-token-form {
padding: 8px 16px 16px;
input[type="number"]::-webkit-inner-spin-button {
input[type='number']::-webkit-inner-spin-button {
-webkit-appearance: none;
display: none;
}
input[type="number"]:hover::-webkit-inner-spin-button {
input[type='number']:hover::-webkit-inner-spin-button {
-webkit-appearance: none;
display: none;
}
@ -59,4 +59,13 @@
margin-bottom: 16px;
margin-top: 0;
}
&__collectible-address-error-link {
color: var(--primary-blue);
cursor: pointer;
&:hover {
color: var(--Blue-400);
}
}
}

View File

@ -1418,6 +1418,18 @@ export async function checkAndUpdateSingleCollectibleOwnershipStatus(
);
}
export async function getTokenStandardAndDetails(
address,
userAddress,
tokenId,
) {
return await promisifiedBackground.getTokenStandardAndDetails(
address,
userAddress,
tokenId,
);
}
export function removeToken(address) {
return async (dispatch) => {
dispatch(showLoadingIndication());

View File

@ -2652,10 +2652,10 @@
web3 "^0.20.7"
web3-provider-engine "^16.0.3"
"@metamask/controllers@^24.0.0":
version "24.0.0"
resolved "https://registry.yarnpkg.com/@metamask/controllers/-/controllers-24.0.0.tgz#380d4e10bd9b903afc38b041ea7e64b30ae34dab"
integrity sha512-gOTpvxQTNTrXOUpGOiRKcqHUgnjSiTgJcwNLxenZWqPIixz7eI2qHnjO7ZaGbV/baK7SOa4rRGokvsCNV0mafA==
"@metamask/controllers@^25.0.0":
version "25.0.0"
resolved "https://registry.yarnpkg.com/@metamask/controllers/-/controllers-25.0.0.tgz#764ba46a169f197253e35ca5273720c91eae7662"
integrity sha512-BfTRaK87Iha+EXlEsu330Jp9Wtx0gks2vA+q3Lu/AKGbcz2J0cokECNwN7uEe4GQLPLsfCIAkUIAlP3pRiCvjg==
dependencies:
"@ethereumjs/common" "^2.3.1"
"@ethereumjs/tx" "^3.2.1"