diff --git a/development/ts-migration-dashboard/files-to-convert.json b/development/ts-migration-dashboard/files-to-convert.json index 08d1a48e1..0ba074760 100644 --- a/development/ts-migration-dashboard/files-to-convert.json +++ b/development/ts-migration-dashboard/files-to-convert.json @@ -1162,9 +1162,6 @@ "ui/hooks/useUserPreferencedCurrency.test.js", "ui/index.js", "ui/index.test.js", - "ui/pages/add-nft/add-nft.js", - "ui/pages/add-nft/add-nft.test.js", - "ui/pages/add-nft/index.js", "ui/pages/asset/asset.js", "ui/pages/asset/components/asset-breadcrumb.js", "ui/pages/asset/components/asset-navigation.js", diff --git a/test/e2e/nft/import-erc1155.spec.js b/test/e2e/nft/import-erc1155.spec.js index e27155e3c..516b82aba 100644 --- a/test/e2e/nft/import-erc1155.spec.js +++ b/test/e2e/nft/import-erc1155.spec.js @@ -38,9 +38,11 @@ describe('Import ERC1155 NFT', function () { await driver.clickElement({ text: 'Import NFT', tag: 'button' }); // Enter a valid NFT that belongs to user and check success message appears - await driver.fill('[data-testid="address"]', contractAddress); - await driver.fill('[data-testid="token-id"]', '1'); - await driver.clickElement({ text: 'Add', tag: 'button' }); + await driver.fill('#address', contractAddress); + await driver.fill('#token-id', '1'); + await driver.clickElement( + '[data-testid="import-nfts-modal-import-button"]', + ); const newNftNotification = await driver.findVisibleElement({ text: 'NFT was successfully added!', @@ -86,14 +88,15 @@ describe('Import ERC1155 NFT', function () { await driver.clickElement({ text: 'Import NFT', tag: 'button' }); // Enter an NFT that not belongs to user with a valid address and an invalid token id - await driver.fill('[data-testid="address"]', contractAddress); - await driver.fill('[data-testid="token-id"]', '4'); - await driver.clickElement({ text: 'Add', tag: 'button' }); - + await driver.fill('#address', contractAddress); + await driver.fill('#token-id', '4'); + await driver.clickElement( + '[data-testid="import-nfts-modal-import-button"]', + ); // Check error message appears const invalidNftNotification = await driver.findElement({ text: 'NFT can’t be added as the ownership details do not match. Make sure you have entered correct information.', - tag: 'h6', + tag: 'p', }); assert.equal(await invalidNftNotification.isDisplayed(), true); }, diff --git a/test/e2e/nft/import-nft.spec.js b/test/e2e/nft/import-nft.spec.js index 373edc70a..c0fc22876 100644 --- a/test/e2e/nft/import-nft.spec.js +++ b/test/e2e/nft/import-nft.spec.js @@ -38,9 +38,11 @@ describe('Import NFT', function () { await driver.clickElement({ text: 'Import NFT', tag: 'button' }); // Enter a valid NFT that belongs to user and check success message appears - await driver.fill('[data-testid="address"]', contractAddress); - await driver.fill('[data-testid="token-id"]', '1'); - await driver.clickElement({ text: 'Add', tag: 'button' }); + await driver.fill('#address', contractAddress); + await driver.fill('#token-id', '1'); + await driver.clickElement( + '[data-testid="import-nfts-modal-import-button"]', + ); const newNftNotification = await driver.findElement({ text: 'NFT was successfully added!', @@ -85,14 +87,16 @@ describe('Import NFT', function () { await driver.clickElement({ text: 'Import NFT', tag: 'button' }); // Enter an NFT that not belongs to user with a valid address and an invalid token id - await driver.fill('[data-testid="address"]', contractAddress); - await driver.fill('[data-testid="token-id"]', '2'); - await driver.clickElement({ text: 'Add', tag: 'button' }); + await driver.fill('#address', contractAddress); + await driver.fill('#token-id', '2'); + await driver.clickElement( + '[data-testid="import-nfts-modal-import-button"]', + ); // Check error message appears const invalidNftNotification = await driver.findElement({ text: 'NFT can’t be added as the ownership details do not match. Make sure you have entered correct information.', - tag: 'h6', + tag: 'p', }); assert.equal(await invalidNftNotification.isDisplayed(), true); }, diff --git a/ui/components/app/modals/convert-token-to-nft-modal/convert-token-to-nft-modal.js b/ui/components/app/modals/convert-token-to-nft-modal/convert-token-to-nft-modal.js index c818c80f1..9311153fc 100644 --- a/ui/components/app/modals/convert-token-to-nft-modal/convert-token-to-nft-modal.js +++ b/ui/components/app/modals/convert-token-to-nft-modal/convert-token-to-nft-modal.js @@ -7,12 +7,9 @@ import Typography from '../../../ui/typography'; import { TypographyVariant } from '../../../../helpers/constants/design-system'; import withModalProps from '../../../../helpers/higher-order-components/with-modal-props'; import { useI18nContext } from '../../../../hooks/useI18nContext'; -import { - ADD_NFT_ROUTE, - ASSET_ROUTE, -} from '../../../../helpers/constants/routes'; +import { ASSET_ROUTE } from '../../../../helpers/constants/routes'; import { getNfts } from '../../../../ducks/metamask/metamask'; -import { ignoreTokens } from '../../../../store/actions'; +import { ignoreTokens, showImportNftsModal } from '../../../../store/actions'; import { isEqualCaseInsensitive } from '../../../../../shared/modules/string-utils'; const ConvertTokenToNFTModal = ({ hideModal, tokenAddress }) => { @@ -39,10 +36,7 @@ const ConvertTokenToNFTModal = ({ hideModal, tokenAddress }) => { pathname: `${ASSET_ROUTE}/${tokenAddress}/${tokenId}`, }); } else { - history.push({ - pathname: ADD_NFT_ROUTE, - state: { tokenAddress }, - }); + dispatch(showImportNftsModal()); } hideModal(); }} diff --git a/ui/components/app/nfts-detection-notice/index.scss b/ui/components/app/nfts-detection-notice/index.scss index 95d13a24c..5c792262e 100644 --- a/ui/components/app/nfts-detection-notice/index.scss +++ b/ui/components/app/nfts-detection-notice/index.scss @@ -1,6 +1,4 @@ .nfts-detection-notice { - margin: 16px 16px 0 16px; - &__message { position: relative; padding: 0.75rem 0.75rem 1rem 0.75rem !important; diff --git a/ui/components/app/nfts-tab/nfts-tab.js b/ui/components/app/nfts-tab/nfts-tab.js index 69f9b3e11..2e1918c0d 100644 --- a/ui/components/app/nfts-tab/nfts-tab.js +++ b/ui/components/app/nfts-tab/nfts-tab.js @@ -1,5 +1,4 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { useDispatch, useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; import NftsItems from '../nfts-items'; @@ -19,13 +18,14 @@ import { EXPERIMENTAL_ROUTE } from '../../../helpers/constants/routes'; import { checkAndUpdateAllNftsOwnershipStatus, detectNfts, + showImportNftsModal, } from '../../../store/actions'; import { useNftsCollections } from '../../../hooks/useNftsCollections'; import { Box, ButtonLink, IconName, Text } from '../../component-library'; import NftsDetectionNotice from '../nfts-detection-notice'; import ZENDESK_URLS from '../../../helpers/constants/zendesk-url'; -export default function NftsTab({ onAddNFT }) { +export default function NftsTab() { const useNftDetection = useSelector(getUseNftDetection); const isMainnet = useSelector(getIsMainnet); const history = useHistory(); @@ -60,7 +60,11 @@ export default function NftsTab({ onAddNFT }) { /> ) : ( <> - {isMainnet && !useNftDetection ? : null}{' '} + {isMainnet && !useNftDetection ? ( + + + + ) : null} @@ -113,7 +116,9 @@ export default function NftsTab({ onAddNFT }) { size={Size.MD} data-testid="import-nft-button" startIconName={IconName.Add} - onClick={onAddNFT} + onClick={() => { + dispatch(showImportNftsModal()); + }} > {t('importNFT')} @@ -149,7 +154,3 @@ export default function NftsTab({ onAddNFT }) { ); } - -NftsTab.propTypes = { - onAddNFT: PropTypes.func.isRequired, -}; diff --git a/ui/components/app/nfts-tab/nfts-tab.test.js b/ui/components/app/nfts-tab/nfts-tab.test.js index 2cc2048d1..1bdd19e8a 100644 --- a/ui/components/app/nfts-tab/nfts-tab.test.js +++ b/ui/components/app/nfts-tab/nfts-tab.test.js @@ -150,7 +150,6 @@ const render = ({ selectedAddress, chainId = '0x1', useNftDetection, - onAddNFT = jest.fn(), }) => { const store = configureStore({ metamask: { @@ -170,7 +169,7 @@ const render = ({ nftsDropdownState, }, }); - return renderWithProvider(, store); + return renderWithProvider(, store); }; describe('NFT Items', () => { @@ -295,16 +294,5 @@ describe('NFT Items', () => { expect(historyPushMock).toHaveBeenCalledTimes(1); expect(historyPushMock).toHaveBeenCalledWith(EXPERIMENTAL_ROUTE); }); - it('should render a link "Import NFTs" when some NFTs are present, which, when clicked calls the passed in onAddNFT method', () => { - const onAddNFTStub = jest.fn(); - render({ - selectedAddress: ACCOUNT_1, - nfts: NFTS, - onAddNFT: onAddNFTStub, - }); - expect(onAddNFTStub).toHaveBeenCalledTimes(0); - fireEvent.click(screen.queryByText('Import NFT')); - expect(onAddNFTStub).toHaveBeenCalledTimes(1); - }); }); }); diff --git a/ui/components/multichain/import-nfts-modal/import-nfts-modal.js b/ui/components/multichain/import-nfts-modal/import-nfts-modal.js new file mode 100644 index 000000000..20e14d83f --- /dev/null +++ b/ui/components/multichain/import-nfts-modal/import-nfts-modal.js @@ -0,0 +1,286 @@ +import React, { useContext, useState } from 'react'; +import { useHistory } from 'react-router-dom'; +import { useDispatch, useSelector } from 'react-redux'; +import { isValidHexAddress } from '@metamask/controller-utils'; +import PropTypes from 'prop-types'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import { DEFAULT_ROUTE } from '../../../helpers/constants/routes'; +import { + AlignItems, + Display, + FlexDirection, + IconColor, + JustifyContent, + Severity, + Size, +} from '../../../helpers/constants/design-system'; + +import { + addNftVerifyOwnership, + getTokenStandardAndDetails, + ignoreTokens, + setNewNftAddedMessage, + updateNftDropDownState, +} from '../../../store/actions'; +import { + getCurrentChainId, + getIsMainnet, + getSelectedAddress, + getUseNftDetection, +} from '../../../selectors'; +import { getNftsDropdownState } from '../../../ducks/metamask/metamask'; +import NftsDetectionNotice from '../../app/nfts-detection-notice'; +import { MetaMetricsContext } from '../../../contexts/metametrics'; +import { AssetType } from '../../../../shared/constants/transaction'; +import { + MetaMetricsEventName, + MetaMetricsTokenEventSource, +} from '../../../../shared/constants/metametrics'; +import { + IconName, + ModalContent, + ModalOverlay, + ModalHeader, + Modal, + ButtonPrimary, + ButtonSecondary, + Box, + FormTextField, + Label, + Icon, + IconSize, + BannerAlert, +} from '../../component-library'; +import Tooltip from '../../ui/tooltip'; + +export const ImportNftsModal = ({ onClose }) => { + const t = useI18nContext(); + const history = useHistory(); + const dispatch = useDispatch(); + const useNftDetection = useSelector(getUseNftDetection); + const isMainnet = useSelector(getIsMainnet); + const nftsDropdownState = useSelector(getNftsDropdownState); + const selectedAddress = useSelector(getSelectedAddress); + const chainId = useSelector(getCurrentChainId); + const addressEnteredOnImportTokensPage = + history?.location?.state?.addressEnteredOnImportTokensPage; + const contractAddressToConvertFromTokenToNft = + history?.location?.state?.tokenAddress; + + const [nftAddress, setNftAddress] = useState( + addressEnteredOnImportTokensPage ?? + contractAddressToConvertFromTokenToNft ?? + '', + ); + const [tokenId, setTokenId] = useState(''); + const [disabled, setDisabled] = useState(true); + const [nftAddFailed, setNftAddFailed] = useState(false); + const trackEvent = useContext(MetaMetricsContext); + + const handleAddNft = async () => { + try { + await dispatch(addNftVerifyOwnership(nftAddress, tokenId)); + const newNftDropdownState = { + ...nftsDropdownState, + [selectedAddress]: { + ...nftsDropdownState?.[selectedAddress], + [chainId]: { + ...nftsDropdownState?.[selectedAddress]?.[chainId], + [nftAddress]: true, + }, + }, + }; + + dispatch(updateNftDropDownState(newNftDropdownState)); + } catch (error) { + const { message } = error; + dispatch(setNewNftAddedMessage(message)); + setNftAddFailed(true); + return; + } + if (contractAddressToConvertFromTokenToNft) { + await dispatch( + ignoreTokens({ + tokensToIgnore: contractAddressToConvertFromTokenToNft, + dontShowLoadingIndicator: true, + }), + ); + } + dispatch(setNewNftAddedMessage('success')); + + const tokenDetails = await getTokenStandardAndDetails( + nftAddress, + null, + tokenId.toString(), + ); + + trackEvent({ + event: MetaMetricsEventName.TokenAdded, + category: 'Wallet', + sensitiveProperties: { + token_contract_address: nftAddress, + token_symbol: tokenDetails?.symbol, + tokenId: tokenId.toString(), + asset_type: AssetType.NFT, + token_standard: tokenDetails?.standard, + source_connection_method: MetaMetricsTokenEventSource.Custom, + }, + }); + + history.push(DEFAULT_ROUTE); + onClose(); + }; + + const validateAndSetAddress = (val) => { + setDisabled(!isValidHexAddress(val) || !tokenId); + setNftAddress(val); + }; + + const validateAndSetTokenId = (val) => { + setDisabled(!isValidHexAddress(nftAddress) || !val || isNaN(Number(val))); + setTokenId(val); + }; + + return ( + { + onClose(); + }} + className="import-nfts-modal" + > + + + { + onClose(); + }} + > + {t('importNFT')} + + + {isMainnet && !useNftDetection ? ( + + + + ) : null} + {nftAddFailed && ( + + setNftAddFailed(false)} + closeButtonProps={{ 'data-testid': 'add-nft-error-close' }} + > + {t('nftAddFailedMessage')} + + + )} + + + + + + + + + + + { + validateAndSetAddress(e.target.value); + setNftAddFailed(false); + }} + /> + + + + + + + + + + + { + validateAndSetTokenId(e.target.value); + setNftAddFailed(false); + }} + /> + + + + + onClose()} + block + className="import-nfts-modal__cancel-button" + > + {t('cancel')} + + handleAddNft()} + disabled={disabled} + block + data-testid="import-nfts-modal-import-button" + > + {t('import')} + + + + + ); +}; + +ImportNftsModal.propTypes = { + onClose: PropTypes.func.isRequired, +}; diff --git a/ui/components/multichain/import-nfts-modal/import-nfts-modal.stories.js b/ui/components/multichain/import-nfts-modal/import-nfts-modal.stories.js new file mode 100644 index 000000000..f6e6139ae --- /dev/null +++ b/ui/components/multichain/import-nfts-modal/import-nfts-modal.stories.js @@ -0,0 +1,38 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import configureStore from '../../../store/store'; +import testData from '../../../../.storybook/test-data'; +import { CHAIN_IDS } from '../../../../shared/constants/network'; +import { ImportNftsModal } from '.'; + +const createStore = (chainId = CHAIN_IDS.MAINNET, useTokenDetection = true) => { + return configureStore({ + ...testData, + metamask: { + ...testData.metamask, + useTokenDetection, + providerConfig: { chainId }, + }, + }); +}; + +export default { + title: 'Components/Multichain/ImportNftsModal', + component: ImportNftsModal, + argTypes: { + onClose: { + action: 'onClose', + }, + }, +}; + +export const DefaultStory = (args) => ; +DefaultStory.decorators = [ + (Story) => ( + + + + ), +]; + +DefaultStory.storyName = 'Default'; diff --git a/ui/pages/add-nft/add-nft.test.js b/ui/components/multichain/import-nfts-modal/import-nfts-modal.test.js similarity index 56% rename from ui/pages/add-nft/add-nft.test.js rename to ui/components/multichain/import-nfts-modal/import-nfts-modal.test.js index cb76d1a16..e251fc7d7 100644 --- a/ui/pages/add-nft/add-nft.test.js +++ b/ui/components/multichain/import-nfts-modal/import-nfts-modal.test.js @@ -3,16 +3,16 @@ import { fireEvent, waitFor } from '@testing-library/react'; import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { useHistory } from 'react-router-dom'; -import { renderWithProvider } from '../../../test/jest/rendering'; -import mockState from '../../../test/data/mock-state.json'; -import { DEFAULT_ROUTE } from '../../helpers/constants/routes'; +import { renderWithProvider } from '../../../../test/jest/rendering'; +import mockState from '../../../../test/data/mock-state.json'; +import { DEFAULT_ROUTE } from '../../../helpers/constants/routes'; import { addNftVerifyOwnership, ignoreTokens, setNewNftAddedMessage, updateNftDropDownState, -} from '../../store/actions'; -import AddNft from '.'; +} from '../../../store/actions'; +import { ImportNftsModal } from '.'; const VALID_ADDRESS = '0x312BE6a98441F9F6e3F6246B13CA19701e0AC3B9'; const INVALID_ADDRESS = 'aoinsafasdfa'; @@ -33,7 +33,7 @@ jest.mock('react-router-dom', () => ({ ), })); -jest.mock('../../store/actions.ts', () => ({ +jest.mock('../../../store/actions.ts', () => ({ addNftVerifyOwnership: jest .fn() .mockReturnValue(jest.fn().mockResolvedValue()), @@ -47,56 +47,72 @@ jest.mock('../../store/actions.ts', () => ({ .mockReturnValue(jest.fn().mockResolvedValue()), })); -describe('AddNft', () => { +describe('ImportNftsModal', () => { const store = configureMockStore([thunk])(mockState); beforeEach(() => { jest.restoreAllMocks(); }); - it('should enable the "Add" button when valid entries are input into both Address and TokenId fields', () => { - const { getByTestId, getByText } = renderWithProvider(, store); - expect(getByText('Add')).not.toBeEnabled(); - fireEvent.change(getByTestId('address'), { + it('should enable the "Import" button when valid entries are input into both Address and TokenId fields', () => { + const { getByText, getByPlaceholderText } = renderWithProvider( + , + store, + ); + expect(getByText('Import')).not.toBeEnabled(); + const addressInput = getByPlaceholderText('0x...'); + const tokenIdInput = getByPlaceholderText('Enter the token id'); + fireEvent.change(addressInput, { target: { value: VALID_ADDRESS }, }); - fireEvent.change(getByTestId('token-id'), { + fireEvent.change(tokenIdInput, { target: { value: VALID_TOKENID }, }); - expect(getByText('Add')).toBeEnabled(); + expect(getByText('Import')).toBeEnabled(); }); - it('should not enable the "Add" button when an invalid entry is input into one or both Address and TokenId fields', () => { - const { getByTestId, getByText } = renderWithProvider(, store); - expect(getByText('Add')).not.toBeEnabled(); - fireEvent.change(getByTestId('address'), { + it('should not enable the "Import" button when an invalid entry is input into one or both Address and TokenId fields', () => { + const { getByText, getByPlaceholderText } = renderWithProvider( + , + store, + ); + expect(getByText('Import')).not.toBeEnabled(); + const addressInput = getByPlaceholderText('0x...'); + const tokenIdInput = getByPlaceholderText('Enter the token id'); + fireEvent.change(addressInput, { target: { value: INVALID_ADDRESS }, }); - fireEvent.change(getByTestId('token-id'), { + fireEvent.change(tokenIdInput, { target: { value: VALID_TOKENID }, }); - expect(getByText('Add')).not.toBeEnabled(); - fireEvent.change(getByTestId('address'), { + expect(getByText('Import')).not.toBeEnabled(); + fireEvent.change(addressInput, { target: { value: VALID_ADDRESS }, }); - expect(getByText('Add')).toBeEnabled(); - fireEvent.change(getByTestId('token-id'), { + expect(getByText('Import')).toBeEnabled(); + fireEvent.change(tokenIdInput, { target: { value: INVALID_TOKENID }, }); - expect(getByText('Add')).not.toBeEnabled(); + expect(getByText('Import')).not.toBeEnabled(); }); it('should call addNftVerifyOwnership, updateNftDropDownState, setNewNftAddedMessage, and ignoreTokens action with correct values (tokenId should not be in scientific notation)', async () => { - const { getByTestId, getByText } = renderWithProvider(, store); - fireEvent.change(getByTestId('address'), { + const onClose = jest.fn(); + const { getByPlaceholderText, getByText } = renderWithProvider( + , + store, + ); + const addressInput = getByPlaceholderText('0x...'); + const tokenIdInput = getByPlaceholderText('Enter the token id'); + fireEvent.change(addressInput, { target: { value: VALID_ADDRESS }, }); const LARGE_TOKEN_ID = Number.MAX_SAFE_INTEGER + 1; - fireEvent.change(getByTestId('token-id'), { + fireEvent.change(tokenIdInput, { target: { value: LARGE_TOKEN_ID }, }); - fireEvent.click(getByText('Add')); + fireEvent.click(getByText('Import')); await waitFor(() => { expect(addNftVerifyOwnership).toHaveBeenCalledWith( @@ -127,16 +143,21 @@ describe('AddNft', () => { jest.fn().mockRejectedValue(new Error('error')), ); - const { getByTestId, getByText } = renderWithProvider(, store); - fireEvent.change(getByTestId('address'), { + const { getByTestId, getByText, getByPlaceholderText } = renderWithProvider( + , + store, + ); + const addressInput = getByPlaceholderText('0x...'); + const tokenIdInput = getByPlaceholderText('Enter the token id'); + fireEvent.change(addressInput, { target: { value: VALID_ADDRESS }, }); const LARGE_TOKEN_ID = Number.MAX_SAFE_INTEGER + 1; - fireEvent.change(getByTestId('token-id'), { + fireEvent.change(tokenIdInput, { target: { value: LARGE_TOKEN_ID }, }); - fireEvent.click(getByText('Add')); + fireEvent.click(getByText('Import')); await waitFor(() => { expect(setNewNftAddedMessage).toHaveBeenCalledWith('error'); @@ -148,19 +169,23 @@ describe('AddNft', () => { }); it('should route to default route when cancel button is clicked', () => { - const { queryByTestId } = renderWithProvider(, store); + const onClose = jest.fn(); + const { getByText } = renderWithProvider( + , + store, + ); - const cancelButton = queryByTestId('page-container-footer-cancel'); + const cancelButton = getByText('Cancel'); fireEvent.click(cancelButton); expect(useHistory().push).toHaveBeenCalledWith(DEFAULT_ROUTE); }); it('should route to default route when close button is clicked', () => { - const { queryByLabelText } = renderWithProvider(, store); + const onClose = jest.fn(); + renderWithProvider(, store); - const closeButton = queryByLabelText('close'); - fireEvent.click(closeButton); + fireEvent.click(document.querySelector('button[aria-label="Close"]')); expect(useHistory().push).toHaveBeenCalledWith(DEFAULT_ROUTE); }); diff --git a/ui/components/multichain/import-nfts-modal/index.js b/ui/components/multichain/import-nfts-modal/index.js new file mode 100644 index 000000000..659f65ac0 --- /dev/null +++ b/ui/components/multichain/import-nfts-modal/index.js @@ -0,0 +1 @@ +export { ImportNftsModal } from './import-nfts-modal'; diff --git a/ui/pages/add-nft/index.scss b/ui/components/multichain/import-nfts-modal/index.scss similarity index 100% rename from ui/pages/add-nft/index.scss rename to ui/components/multichain/import-nfts-modal/index.scss diff --git a/ui/components/multichain/index.js b/ui/components/multichain/index.js index fa535a66e..0f6edcf25 100644 --- a/ui/components/multichain/index.js +++ b/ui/components/multichain/index.js @@ -15,3 +15,4 @@ export { ProductTour } from './product-tour-popover'; export { AccountDetails } from './account-details'; export { CreateAccount } from './create-account'; export { ImportAccount } from './import-account'; +export { ImportNftsModal } from './import-nfts-modal'; diff --git a/ui/components/multichain/multichain-components.scss b/ui/components/multichain/multichain-components.scss index a93926b22..00cea05fa 100644 --- a/ui/components/multichain/multichain-components.scss +++ b/ui/components/multichain/multichain-components.scss @@ -5,6 +5,7 @@ * unintended overrides. **/ @import 'address-copy-button/index'; +@import 'import-nfts-modal/index'; @import 'account-list-item/index'; @import 'account-list-item-menu/index'; @import 'account-list-menu/index'; diff --git a/ui/ducks/app/app.ts b/ui/ducks/app/app.ts index e6ea054db..e2b3a151e 100644 --- a/ui/ducks/app/app.ts +++ b/ui/ducks/app/app.ts @@ -26,6 +26,7 @@ interface AppState { values?: { address?: string | null }; } | null; networkDropdownOpen: boolean; + importNftsModalOpen: boolean; accountDetail: { subview?: string; accountExport?: string; @@ -94,6 +95,7 @@ const initialState: AppState = { alertMessage: null, qrCodeData: null, networkDropdownOpen: false, + importNftsModalOpen: false, accountDetail: { privateKey: '', }, @@ -163,6 +165,17 @@ export default function reduceApp( networkDropdownOpen: false, }; + case actionConstants.IMPORT_NFTS_MODAL_OPEN: + return { + ...appState, + importNftsModalOpen: true, + }; + + case actionConstants.IMPORT_NFTS_MODAL_CLOSE: + return { + ...appState, + importNftsModalOpen: false, + }; // alert methods case actionConstants.ALERT_OPEN: return { diff --git a/ui/helpers/constants/routes.ts b/ui/helpers/constants/routes.ts index 3e2e4e1b1..53b864cb8 100644 --- a/ui/helpers/constants/routes.ts +++ b/ui/helpers/constants/routes.ts @@ -66,7 +66,6 @@ const SMART_TRANSACTION_STATUS_ROUTE = '/swaps/smart-transaction-status'; const AWAITING_SWAP_ROUTE = '/swaps/awaiting-swap'; const SWAPS_ERROR_ROUTE = '/swaps/swaps-error'; const SWAPS_MAINTENANCE_ROUTE = '/swaps/maintenance'; -const ADD_NFT_ROUTE = '/add-nft'; const ONBOARDING_ROUTE = '/onboarding'; const ONBOARDING_REVIEW_SRP_ROUTE = '/onboarding/review-recovery-phrase'; @@ -273,7 +272,6 @@ export { SWAPS_ERROR_ROUTE, SWAPS_MAINTENANCE_ROUTE, SMART_TRANSACTION_STATUS_ROUTE, - ADD_NFT_ROUTE, ONBOARDING_ROUTE, ONBOARDING_HELP_US_IMPROVE_ROUTE, ONBOARDING_CREATE_PASSWORD_ROUTE, diff --git a/ui/pages/add-nft/add-nft.js b/ui/pages/add-nft/add-nft.js deleted file mode 100644 index 9772c9c25..000000000 --- a/ui/pages/add-nft/add-nft.js +++ /dev/null @@ -1,206 +0,0 @@ -import React, { useContext, useState } from 'react'; -import { useHistory } from 'react-router-dom'; -import { useDispatch, useSelector } from 'react-redux'; -import { isValidHexAddress } from '@metamask/controller-utils'; -import { useI18nContext } from '../../hooks/useI18nContext'; -import { DEFAULT_ROUTE } from '../../helpers/constants/routes'; -import { - DISPLAY, - FONT_WEIGHT, - TypographyVariant, -} from '../../helpers/constants/design-system'; - -import Box from '../../components/ui/box'; -import Typography from '../../components/ui/typography'; -import ActionableMessage from '../../components/ui/actionable-message'; -import PageContainer from '../../components/ui/page-container'; -import { - addNftVerifyOwnership, - getTokenStandardAndDetails, - ignoreTokens, - setNewNftAddedMessage, - updateNftDropDownState, -} from '../../store/actions'; -import FormField from '../../components/ui/form-field'; -import { - getCurrentChainId, - getIsMainnet, - getSelectedAddress, - getUseNftDetection, -} from '../../selectors'; -import { getNftsDropdownState } from '../../ducks/metamask/metamask'; -import NftsDetectionNotice from '../../components/app/nfts-detection-notice'; -import { MetaMetricsContext } from '../../contexts/metametrics'; -import { AssetType } from '../../../shared/constants/transaction'; -import { - MetaMetricsEventName, - MetaMetricsTokenEventSource, -} from '../../../shared/constants/metametrics'; -import { - ButtonIcon, - IconName, - ButtonIconSize, -} from '../../components/component-library'; - -export default function AddNft() { - const t = useI18nContext(); - const history = useHistory(); - const dispatch = useDispatch(); - const useNftDetection = useSelector(getUseNftDetection); - const isMainnet = useSelector(getIsMainnet); - const nftsDropdownState = useSelector(getNftsDropdownState); - const selectedAddress = useSelector(getSelectedAddress); - const chainId = useSelector(getCurrentChainId); - const addressEnteredOnImportTokensPage = - history?.location?.state?.addressEnteredOnImportTokensPage; - const contractAddressToConvertFromTokenToNft = - history?.location?.state?.tokenAddress; - - const [nftAddress, setNftAddress] = useState( - addressEnteredOnImportTokensPage ?? - contractAddressToConvertFromTokenToNft ?? - '', - ); - const [tokenId, setTokenId] = useState(''); - const [disabled, setDisabled] = useState(true); - const [nftAddFailed, setNftAddFailed] = useState(false); - const trackEvent = useContext(MetaMetricsContext); - - const handleAddNft = async () => { - try { - await dispatch(addNftVerifyOwnership(nftAddress, tokenId)); - const newNftDropdownState = { - ...nftsDropdownState, - [selectedAddress]: { - ...nftsDropdownState?.[selectedAddress], - [chainId]: { - ...nftsDropdownState?.[selectedAddress]?.[chainId], - [nftAddress]: true, - }, - }, - }; - - dispatch(updateNftDropDownState(newNftDropdownState)); - } catch (error) { - const { message } = error; - dispatch(setNewNftAddedMessage(message)); - setNftAddFailed(true); - return; - } - if (contractAddressToConvertFromTokenToNft) { - await dispatch( - ignoreTokens({ - tokensToIgnore: contractAddressToConvertFromTokenToNft, - dontShowLoadingIndicator: true, - }), - ); - } - dispatch(setNewNftAddedMessage('success')); - - const tokenDetails = await getTokenStandardAndDetails( - nftAddress, - null, - tokenId.toString(), - ); - - trackEvent({ - event: MetaMetricsEventName.TokenAdded, - category: 'Wallet', - sensitiveProperties: { - token_contract_address: nftAddress, - token_symbol: tokenDetails?.symbol, - tokenId: tokenId.toString(), - asset_type: AssetType.NFT, - token_standard: tokenDetails?.standard, - source_connection_method: MetaMetricsTokenEventSource.Custom, - }, - }); - - history.push(DEFAULT_ROUTE); - }; - - const validateAndSetAddress = (val) => { - setDisabled(!isValidHexAddress(val) || !tokenId); - setNftAddress(val); - }; - - const validateAndSetTokenId = (val) => { - setDisabled(!isValidHexAddress(nftAddress) || !val || isNaN(Number(val))); - setTokenId(val); - }; - - return ( - { - handleAddNft(); - }} - submitText={t('add')} - onCancel={() => { - history.push(DEFAULT_ROUTE); - }} - onClose={() => { - history.push(DEFAULT_ROUTE); - }} - disabled={disabled} - contentComponent={ - - {isMainnet && !useNftDetection ? : null} - {nftAddFailed && ( - - - - {t('nftAddFailedMessage')} - - setNftAddFailed(false)} - /> - - } - /> - - )} - - { - validateAndSetAddress(val); - setNftAddFailed(false); - }} - tooltipText={t('importNFTAddressToolTip')} - autoFocus - /> - { - validateAndSetTokenId(val); - setNftAddFailed(false); - }} - tooltipText={t('importNFTTokenIdToolTip')} - /> - - - } - /> - ); -} diff --git a/ui/pages/add-nft/index.js b/ui/pages/add-nft/index.js deleted file mode 100644 index 603e8378b..000000000 --- a/ui/pages/add-nft/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './add-nft'; diff --git a/ui/pages/home/home.component.js b/ui/pages/home/home.component.js index 602ce34cd..6bd76ea55 100644 --- a/ui/pages/home/home.component.js +++ b/ui/pages/home/home.component.js @@ -62,7 +62,6 @@ import { BUILD_QUOTE_ROUTE, VIEW_QUOTE_ROUTE, CONFIRMATION_V_NEXT_ROUTE, - ADD_NFT_ROUTE, ONBOARDING_SECURE_YOUR_WALLET_ROUTE, ///: BEGIN:ONLY_INCLUDE_IN(build-mmi) CONFIRM_INSTITUTIONAL_FEATURE_CONNECT, @@ -879,11 +878,7 @@ export default class Home extends PureComponent { name={this.context.t('nfts')} tabKey="nfts" > - { - history.push(ADD_NFT_ROUTE); - }} - /> + ///: END:ONLY_INCLUDE_IN } diff --git a/ui/pages/import-token/import-token.component.js b/ui/pages/import-token/import-token.component.js index ffda0ca12..20a00d1aa 100644 --- a/ui/pages/import-token/import-token.component.js +++ b/ui/pages/import-token/import-token.component.js @@ -8,7 +8,6 @@ import { } from '../../helpers/utils/util'; import { tokenInfoGetter } from '../../helpers/utils/token-util'; import { - ADD_NFT_ROUTE, CONFIRM_IMPORT_TOKEN_ROUTE, SECURITY_ROUTE, } from '../../helpers/constants/routes'; @@ -60,6 +59,11 @@ class ImportToken extends Component { */ clearPendingTokens: PropTypes.func, + /** + * Clear the list of pending tokens. Called when closing the modal. + */ + showImportNftsModal: PropTypes.func, + /** * The list of already added tokens. */ @@ -314,21 +318,15 @@ class ImportToken extends Component { nftAddressError: this.context.t('nftAddressError', [ - this.props.history.push({ - pathname: ADD_NFT_ROUTE, - state: { - addressEnteredOnImportTokensPage: this.state.customAddress, - }, - }) - } + onClick={() => { + this.props.showImportNftsModal(); + }} key="nftAddressError" > {this.context.t('importNFTPage')} , ]), }); - break; case isMainnetToken && !isMainnetNetwork: this.setState({ diff --git a/ui/pages/import-token/import-token.container.js b/ui/pages/import-token/import-token.container.js index 566783bc5..9d238d646 100644 --- a/ui/pages/import-token/import-token.container.js +++ b/ui/pages/import-token/import-token.container.js @@ -4,6 +4,7 @@ import { setPendingTokens, clearPendingTokens, getTokenStandardAndDetails, + showImportNftsModal, } from '../../store/actions'; import { getMostRecentOverviewPage } from '../../ducks/history/history'; import { getProviderConfig } from '../../ducks/metamask/metamask'; @@ -58,6 +59,7 @@ const mapDispatchToProps = (dispatch) => { return { setPendingTokens: (tokens) => dispatch(setPendingTokens(tokens)), clearPendingTokens: () => dispatch(clearPendingTokens()), + showImportNftsModal: () => dispatch(showImportNftsModal()), getTokenStandardAndDetails: (address, selectedAddress) => getTokenStandardAndDetails(address, selectedAddress, null), }; diff --git a/ui/pages/pages.scss b/ui/pages/pages.scss index 313f1f538..b582d5fa5 100644 --- a/ui/pages/pages.scss +++ b/ui/pages/pages.scss @@ -1,5 +1,4 @@ /** Please import your files in alphabetical order **/ -@import 'add-nft/index'; @import 'keyring-snaps/index'; @import 'import-token/index'; @import 'asset/asset'; diff --git a/ui/pages/routes/routes.component.js b/ui/pages/routes/routes.component.js index e9386e606..681f05a51 100644 --- a/ui/pages/routes/routes.component.js +++ b/ui/pages/routes/routes.component.js @@ -19,7 +19,6 @@ import PermissionsConnect from '../permissions-connect'; import RestoreVaultPage from '../keychains/restore-vault'; import RevealSeedConfirmation from '../keychains/reveal-seed'; import ImportTokenPage from '../import-token'; -import AddNftPage from '../add-nft'; import ConfirmImportTokenPage from '../confirm-import-token'; import ConfirmAddSuggestedTokenPage from '../confirm-add-suggested-token'; import CreateAccountPage from '../create-account/create-account.component'; @@ -33,6 +32,7 @@ import { AccountListMenu, NetworkListMenu, AccountDetails, + ImportNftsModal, } from '../../components/multichain'; import UnlockPage from '../unlock-page'; import Alerts from '../../components/app/alerts'; @@ -80,7 +80,6 @@ import { CONFIRMATION_V_NEXT_ROUTE, CONFIRM_IMPORT_TOKEN_ROUTE, ONBOARDING_ROUTE, - ADD_NFT_ROUTE, ONBOARDING_UNLOCK_ROUTE, TOKEN_DETAILS, ///: BEGIN:ONLY_INCLUDE_IN(build-mmi) @@ -162,6 +161,8 @@ export default class Routes extends Component { isNetworkMenuOpen: PropTypes.bool, toggleNetworkMenu: PropTypes.func, accountDetailsAddress: PropTypes.string, + isImportNftsModalOpen: PropTypes.bool.isRequired, + hideImportNftsModal: PropTypes.func.isRequired, }; static contextTypes = { @@ -284,7 +285,6 @@ export default class Routes extends Component { component={ImportTokenPage} exact /> - ) : null} + {isImportNftsModalOpen ? ( + hideImportNftsModal()} /> + ) : null} {isLoading ? : null} {!isLoading && isNetworkLoading ? : null} diff --git a/ui/pages/routes/routes.container.js b/ui/pages/routes/routes.container.js index ba1c33777..5f31829d1 100644 --- a/ui/pages/routes/routes.container.js +++ b/ui/pages/routes/routes.container.js @@ -15,6 +15,7 @@ import { } from '../../selectors'; import { lockMetamask, + hideImportNftsModal, setCurrentCurrency, setLastActiveTime, setMouseUserState, @@ -63,6 +64,7 @@ function mapStateToProps(state) { isAccountMenuOpen: state.metamask.isAccountMenuOpen, isNetworkMenuOpen: state.metamask.isNetworkMenuOpen, accountDetailsAddress: state.appState.accountDetailsAddress, + isImportNftsModalOpen: state.appState.importNftsModalOpen, }; } @@ -77,6 +79,7 @@ function mapDispatchToProps(dispatch) { prepareToLeaveSwaps: () => dispatch(prepareToLeaveSwaps()), toggleAccountMenu: () => dispatch(toggleAccountMenu()), toggleNetworkMenu: () => dispatch(toggleNetworkMenu()), + hideImportNftsModal: () => dispatch(hideImportNftsModal()), }; } diff --git a/ui/store/actionConstants.ts b/ui/store/actionConstants.ts index 73d488a81..951b6d710 100644 --- a/ui/store/actionConstants.ts +++ b/ui/store/actionConstants.ts @@ -9,6 +9,8 @@ export const QR_CODE_DETECTED = 'UI_QR_CODE_DETECTED'; // network dropdown open export const NETWORK_DROPDOWN_OPEN = 'UI_NETWORK_DROPDOWN_OPEN'; export const NETWORK_DROPDOWN_CLOSE = 'UI_NETWORK_DROPDOWN_CLOSE'; +export const IMPORT_NFTS_MODAL_OPEN = 'UI_IMPORT_NFTS_MODAL_OPEN'; +export const IMPORT_NFTS_MODAL_CLOSE = 'UI_IMPORT_NFTS_MODAL_CLOSE'; // remote state export const UPDATE_METAMASK_STATE = 'UPDATE_METAMASK_STATE'; export const SELECTED_ADDRESS_CHANGED = 'SELECTED_ADDRESS_CHANGED'; diff --git a/ui/store/actions.ts b/ui/store/actions.ts index 8708c0fd4..46ef92d12 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -2402,6 +2402,18 @@ export function hideModal(): Action { }; } +export function showImportNftsModal(): Action { + return { + type: actionConstants.IMPORT_NFTS_MODAL_OPEN, + }; +} + +export function hideImportNftsModal(): Action { + return { + type: actionConstants.IMPORT_NFTS_MODAL_CLOSE, + }; +} + export function closeCurrentNotificationWindow(): ThunkAction< void, MetaMaskReduxState,