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

Move "Import NFTs" to Modal (#19806)

* moved import nft to modal

* fixed modal state

* updated port-nft-popup

* updated onChange for import nft modal

* updated tests

* updated tests

* updated tests

* added story and updated spec file

* updated spec file

* updated spec file

* updated spec file for import-nft

* added focus to form field

* added autofocus to tokenId
This commit is contained in:
Nidhi Kumari 2023-07-14 21:48:41 +05:30 committed by GitHub
parent 7bdd76a4ad
commit 5bc0ba7f3a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 473 additions and 316 deletions

View File

@ -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",

View File

@ -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 cant 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);
},

View File

@ -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 cant 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);
},

View File

@ -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();
}}

View File

@ -1,6 +1,4 @@
.nfts-detection-notice {
margin: 16px 16px 0 16px;
&__message {
position: relative;
padding: 0.75rem 0.75rem 1rem 0.75rem !important;

View File

@ -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 ? <NftsDetectionNotice /> : null}{' '}
{isMainnet && !useNftDetection ? (
<Box padding={4}>
<NftsDetectionNotice />
</Box>
) : null}
<Box
padding={12}
display={Display.Flex}
@ -90,7 +94,6 @@ export default function NftsTab({ onAddNFT }) {
</Text>
<ButtonLink
size={Size.MD}
data-testid="import-nft-button"
href={ZENDESK_URLS.NFT_TOKENS}
externalLink
>
@ -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')}
</ButtonLink>
@ -149,7 +154,3 @@ export default function NftsTab({ onAddNFT }) {
</Box>
);
}
NftsTab.propTypes = {
onAddNFT: PropTypes.func.isRequired,
};

View File

@ -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(<NftsTab onAddNFT={onAddNFT} />, store);
return renderWithProvider(<NftsTab />, 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);
});
});
});

View File

@ -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 (
<Modal
isOpen
onClose={() => {
onClose();
}}
className="import-nfts-modal"
>
<ModalOverlay />
<ModalContent>
<ModalHeader
onClose={() => {
onClose();
}}
>
{t('importNFT')}
</ModalHeader>
<Box>
{isMainnet && !useNftDetection ? (
<Box marginTop={6}>
<NftsDetectionNotice />
</Box>
) : null}
{nftAddFailed && (
<Box marginTop={6}>
<BannerAlert
severity={Severity.Danger}
onClose={() => setNftAddFailed(false)}
closeButtonProps={{ 'data-testid': 'add-nft-error-close' }}
>
{t('nftAddFailedMessage')}
</BannerAlert>
</Box>
)}
<Box
display={Display.Flex}
flexDirection={FlexDirection.Column}
gap={6}
marginTop={6}
marginBottom={6}
>
<Box>
<Box
display={Display.Flex}
justifyContent={JustifyContent.spaceBetween}
alignItems={AlignItems.flexEnd}
>
<Box display={Display.Flex} alignItems={AlignItems.center}>
<Label htmlFor="address">{t('address')}</Label>
<Tooltip
title={t('importNFTAddressToolTip')}
position="bottom"
>
<Icon
name={IconName.Info}
size={IconSize.Sm}
marginLeft={1}
color={IconColor.iconAlternative}
/>
</Tooltip>
</Box>
</Box>
<FormTextField
autoFocus
dataTestId="address"
id="address"
placeholder="0x..."
value={nftAddress}
onChange={(e) => {
validateAndSetAddress(e.target.value);
setNftAddFailed(false);
}}
/>
</Box>
<Box>
<Box
display={Display.Flex}
justifyContent={JustifyContent.spaceBetween}
alignItems={AlignItems.flexEnd}
>
<Box display={Display.Flex} alignItems={AlignItems.center}>
<Label htmlFor="token-id">{t('tokenId')}</Label>
<Tooltip
title={t('importNFTTokenIdToolTip')}
position="bottom"
>
<Icon
name={IconName.Info}
size={IconSize.Sm}
marginLeft={1}
color={IconColor.iconAlternative}
/>
</Tooltip>
</Box>
</Box>
<FormTextField
autoFocus
dataTestId="token-id"
id="token-id"
placeholder={t('nftTokenIdPlaceholder')}
value={tokenId}
onChange={(e) => {
validateAndSetTokenId(e.target.value);
setNftAddFailed(false);
}}
/>
</Box>
</Box>
</Box>
<Box
display={Display.Flex}
flexDirection={FlexDirection.Row}
justifyContent={JustifyContent.spaceBetween}
gap={4}
paddingTop={4}
paddingBottom={4}
>
<ButtonSecondary
size={Size.LG}
onClick={() => onClose()}
block
className="import-nfts-modal__cancel-button"
>
{t('cancel')}
</ButtonSecondary>
<ButtonPrimary
size={Size.LG}
onClick={() => handleAddNft()}
disabled={disabled}
block
data-testid="import-nfts-modal-import-button"
>
{t('import')}
</ButtonPrimary>
</Box>
</ModalContent>
</Modal>
);
};
ImportNftsModal.propTypes = {
onClose: PropTypes.func.isRequired,
};

View File

@ -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) => <ImportNftsModal {...args} />;
DefaultStory.decorators = [
(Story) => (
<Provider store={createStore()}>
<Story />
</Provider>
),
];
DefaultStory.storyName = 'Default';

View File

@ -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(<AddNft />, 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(
<ImportNftsModal />,
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(<AddNft />, 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(
<ImportNftsModal />,
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(<AddNft />, store);
fireEvent.change(getByTestId('address'), {
const onClose = jest.fn();
const { getByPlaceholderText, getByText } = renderWithProvider(
<ImportNftsModal onClose={onClose} />,
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(<AddNft />, store);
fireEvent.change(getByTestId('address'), {
const { getByTestId, getByText, getByPlaceholderText } = renderWithProvider(
<ImportNftsModal />,
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(<AddNft />, store);
const onClose = jest.fn();
const { getByText } = renderWithProvider(
<ImportNftsModal onClose={onClose} />,
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(<AddNft />, store);
const onClose = jest.fn();
renderWithProvider(<ImportNftsModal onClose={onClose} />, store);
const closeButton = queryByLabelText('close');
fireEvent.click(closeButton);
fireEvent.click(document.querySelector('button[aria-label="Close"]'));
expect(useHistory().push).toHaveBeenCalledWith(DEFAULT_ROUTE);
});

View File

@ -0,0 +1 @@
export { ImportNftsModal } from './import-nfts-modal';

View File

@ -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';

View File

@ -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';

View File

@ -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 {

View File

@ -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,

View File

@ -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 (
<PageContainer
title={t('importNFT')}
onSubmit={() => {
handleAddNft();
}}
submitText={t('add')}
onCancel={() => {
history.push(DEFAULT_ROUTE);
}}
onClose={() => {
history.push(DEFAULT_ROUTE);
}}
disabled={disabled}
contentComponent={
<Box>
{isMainnet && !useNftDetection ? <NftsDetectionNotice /> : null}
{nftAddFailed && (
<Box marginLeft={4} marginRight={4}>
<ActionableMessage
type="danger"
useIcon
iconFillColor="var(--color-error-default)"
message={
<Box display={DISPLAY.INLINE_FLEX}>
<Typography
variant={TypographyVariant.H7}
fontWeight={FONT_WEIGHT.NORMAL}
marginTop={0}
>
{t('nftAddFailedMessage')}
</Typography>
<ButtonIcon
className="add-nft__close"
iconName={IconName.Close}
size={ButtonIconSize.Sm}
ariaLabel={t('close')}
data-testid="add-nft-error-close"
onClick={() => setNftAddFailed(false)}
/>
</Box>
}
/>
</Box>
)}
<Box margin={4}>
<FormField
dataTestId="address"
titleText={t('address')}
placeholder="0x..."
value={nftAddress}
onChange={(val) => {
validateAndSetAddress(val);
setNftAddFailed(false);
}}
tooltipText={t('importNFTAddressToolTip')}
autoFocus
/>
<FormField
dataTestId="token-id"
titleText={t('tokenId')}
placeholder={t('nftTokenIdPlaceholder')}
value={tokenId}
onChange={(val) => {
validateAndSetTokenId(val);
setNftAddFailed(false);
}}
tooltipText={t('importNFTTokenIdToolTip')}
/>
</Box>
</Box>
}
/>
);
}

View File

@ -1 +0,0 @@
export { default } from './add-nft';

View File

@ -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"
>
<NftsTab
onAddNFT={() => {
history.push(ADD_NFT_ROUTE);
}}
/>
<NftsTab />
</Tab>
///: END:ONLY_INCLUDE_IN
}

View File

@ -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', [
<a
className="import-token__nft-address-error-link"
onClick={() =>
this.props.history.push({
pathname: ADD_NFT_ROUTE,
state: {
addressEnteredOnImportTokensPage: this.state.customAddress,
},
})
}
onClick={() => {
this.props.showImportNftsModal();
}}
key="nftAddressError"
>
{this.context.t('importNFTPage')}
</a>,
]),
});
break;
case isMainnetToken && !isMainnetNetwork:
this.setState({

View File

@ -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),
};

View File

@ -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';

View File

@ -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
/>
<Authenticated path={ADD_NFT_ROUTE} component={AddNftPage} exact />
<Authenticated
path={CONFIRM_IMPORT_TOKEN_ROUTE}
component={ConfirmImportTokenPage}
@ -525,6 +525,8 @@ export default class Routes extends Component {
toggleNetworkMenu,
accountDetailsAddress,
location,
isImportNftsModalOpen,
hideImportNftsModal,
} = this.props;
const loadMessage =
loadingMessage || isNetworkLoading
@ -583,6 +585,9 @@ export default class Routes extends Component {
{accountDetailsAddress ? (
<AccountDetails address={accountDetailsAddress} />
) : null}
{isImportNftsModalOpen ? (
<ImportNftsModal onClose={() => hideImportNftsModal()} />
) : null}
<Box className="main-container-wrapper">
{isLoading ? <Loading loadingMessage={loadMessage} /> : null}
{!isLoading && isNetworkLoading ? <LoadingNetwork /> : null}

View File

@ -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()),
};
}

View File

@ -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';

View File

@ -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,