diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index be8313a1fb4a..ed694b49e987 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -2885,6 +2885,9 @@ "networkNameZkSyncEra": { "message": "zkSync Era" }, + "networkOptions": { + "message": "Network options" + }, "networkProvider": { "message": "Network provider" }, @@ -2953,6 +2956,9 @@ "newNetworkAdded": { "message": "“$1” was successfully added!" }, + "newNetworkEdited": { + "message": "“$1” was successfully edited!" + }, "newNftAddedMessage": { "message": "NFT was successfully added!" }, @@ -5240,6 +5246,9 @@ "message": "Suggested by $1", "description": "$1 is the snap name" }, + "suggestedTokenName": { + "message": "Suggested name:" + }, "suggestedTokenSymbol": { "message": "Suggested ticker symbol:" }, @@ -6347,6 +6356,9 @@ "whatsThis": { "message": "What's this?" }, + "wrongNetworkName": { + "message": "According to our records, the network name may not correctly match this chain ID." + }, "xOfYPending": { "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" diff --git a/ui/components/multichain/multichain-components.scss b/ui/components/multichain/multichain-components.scss index 8917088b40b4..c4b043c69b1f 100644 --- a/ui/components/multichain/multichain-components.scss +++ b/ui/components/multichain/multichain-components.scss @@ -19,6 +19,7 @@ @import 'connected-site-menu'; @import 'token-list-item'; @import 'network-list-item'; +@import 'network-list-item-menu'; @import 'network-list-menu'; @import 'product-tour-popover'; @import 'nft-item'; diff --git a/ui/components/multichain/network-list-item-menu/index.js b/ui/components/multichain/network-list-item-menu/index.js new file mode 100644 index 000000000000..b21bbd464bd7 --- /dev/null +++ b/ui/components/multichain/network-list-item-menu/index.js @@ -0,0 +1 @@ +export { NetworkListItemMenu } from './network-list-item-menu'; diff --git a/ui/components/multichain/network-list-item-menu/index.scss b/ui/components/multichain/network-list-item-menu/index.scss new file mode 100644 index 000000000000..62685b151700 --- /dev/null +++ b/ui/components/multichain/network-list-item-menu/index.scss @@ -0,0 +1,8 @@ +@use "design-system"; + +.multichain-network-list-item-menu__popover { + z-index: design-system.$popover-in-modal-z-index; + overflow: hidden; + min-width: 225px; + max-width: 225px; +} diff --git a/ui/components/multichain/network-list-item-menu/network-list-item-menu.js b/ui/components/multichain/network-list-item-menu/network-list-item-menu.js new file mode 100644 index 000000000000..51a77c851a72 --- /dev/null +++ b/ui/components/multichain/network-list-item-menu/network-list-item-menu.js @@ -0,0 +1,98 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import { + IconName, + ModalFocus, + Popover, + PopoverPosition, + PopoverRole, + Text, +} from '../../component-library'; +import { MenuItem } from '../../ui/menu'; +import { IconColor, TextColor } from '../../../helpers/constants/design-system'; + +export const NetworkListItemMenu = ({ + anchorElement, + onClose, + onEditClick, + onDeleteClick, + isOpen, +}) => { + const t = useI18nContext(); + + return ( + + +
+ {onEditClick ? ( + { + e.stopPropagation(); + + // Pass network info? + onEditClick(); + }} + data-testid="network-list-item-options-edit" + > + {t('edit')} + + ) : null} + {onDeleteClick ? ( + { + e.stopPropagation(); + + // Pass network info? + onDeleteClick(); + }} + data-testid="network-list-item-options-delete" + > + {t('delete')} + + ) : null} +
+
+
+ ); +}; + +NetworkListItemMenu.propTypes = { + /** + * Element that the menu should display next to + */ + anchorElement: PropTypes.instanceOf(window.Element), + /** + * Function that executes when the menu is closed + */ + onClose: PropTypes.func.isRequired, + /** + * Function that executes when the Edit menu item is clicked + */ + onEditClick: PropTypes.func, + /** + * Function that executes when the Delete menu item is closed + */ + onDeleteClick: PropTypes.func, + /** + * Represents if the menu is open or not + * + * @type {boolean} + */ + isOpen: PropTypes.bool.isRequired, +}; diff --git a/ui/components/multichain/network-list-item/index.scss b/ui/components/multichain/network-list-item/index.scss index a7a1e6aa158e..ae68ea52dc3c 100644 --- a/ui/components/multichain/network-list-item/index.scss +++ b/ui/components/multichain/network-list-item/index.scss @@ -14,14 +14,6 @@ color: inherit; } - &:hover, - &:focus, - &:focus-within { - .multichain-network-list-item__delete { - visibility: visible; - } - } - &__network-name { width: 100%; flex: 1; @@ -44,8 +36,4 @@ top: 4px; left: 4px; } - - &__delete { - visibility: hidden; - } } diff --git a/ui/components/multichain/network-list-item/network-list-item.js b/ui/components/multichain/network-list-item/network-list-item.js index 6fe9336f434a..4e76258584bc 100644 --- a/ui/components/multichain/network-list-item/network-list-item.js +++ b/ui/components/multichain/network-list-item/network-list-item.js @@ -1,6 +1,7 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import classnames from 'classnames'; import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; import { AlignItems, BackgroundColor, @@ -8,21 +9,24 @@ import { BorderRadius, Color, Display, - IconColor, JustifyContent, - Size, TextColor, + Size, + IconColor, } from '../../../helpers/constants/design-system'; import { AvatarNetwork, Box, ButtonIcon, + ButtonIconSize, IconName, Text, } from '../../component-library'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { getAvatarNetworkColor } from '../../../helpers/utils/accounts'; import Tooltip from '../../ui/tooltip/tooltip'; +import { NetworkListItemMenu } from '../network-list-item-menu'; +import { getLocalNetworkMenuRedesignFeatureFlag } from '../../../helpers/utils/feature-flags'; const MAXIMUM_CHARACTERS_WITHOUT_TOOLTIP = 20; @@ -33,10 +37,52 @@ export const NetworkListItem = ({ focus = true, onClick, onDeleteClick, + onEditClick, }) => { const t = useI18nContext(); const networkRef = useRef(); + const [networkListItemMenuElement, setNetworkListItemMenuElement] = + useState(); + const setNetworkListItemMenuRef = (ref) => { + setNetworkListItemMenuElement(ref); + }; + const [networkOptionsMenuOpen, setNetworkOptionsMenuOpen] = useState(false); + const networkMenuRedesign = useSelector( + getLocalNetworkMenuRedesignFeatureFlag, + ); + + const renderButton = () => { + if (networkMenuRedesign) { + return onDeleteClick || onEditClick ? ( + { + e.stopPropagation(); + setNetworkOptionsMenuOpen(true); + }} + size={ButtonIconSize.Sm} + /> + ) : null; + } + + return onDeleteClick ? ( + { + e.stopPropagation(); + onDeleteClick(); + }} + /> + ) : null; + }; useEffect(() => { if (networkRef.current && focus) { networkRef.current.focus(); @@ -103,19 +149,14 @@ export const NetworkListItem = ({ )} - {onDeleteClick ? ( - { - e.stopPropagation(); - onDeleteClick(); - }} - /> - ) : null} + {renderButton()} + setNetworkOptionsMenuOpen(false)} + /> ); }; @@ -141,6 +182,10 @@ NetworkListItem.propTypes = { * Executes when the delete icon is clicked */ onDeleteClick: PropTypes.func, + /** + * Executes when the edit icon is clicked + */ + onEditClick: PropTypes.func, /** * Represents if the network item should be keyboard selected */ diff --git a/ui/components/multichain/network-list-item/network-list-item.test.js b/ui/components/multichain/network-list-item/network-list-item.test.js index 4c4215d02b10..323e432e0e33 100644 --- a/ui/components/multichain/network-list-item/network-list-item.test.js +++ b/ui/components/multichain/network-list-item/network-list-item.test.js @@ -1,10 +1,12 @@ /* eslint-disable jest/require-top-level-describe */ import React from 'react'; import { fireEvent, render } from '@testing-library/react'; +import { useSelector } from 'react-redux'; import { MATIC_TOKEN_IMAGE_URL, POLYGON_DISPLAY_NAME, } from '../../../../shared/constants/network'; +import { getLocalNetworkMenuRedesignFeatureFlag } from '../../../helpers/utils/feature-flags'; import { NetworkListItem } from '.'; const DEFAULT_PROPS = { @@ -15,13 +17,35 @@ const DEFAULT_PROPS = { onDeleteClick: () => undefined, }; +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +const generateUseSelectorRouter = (opts) => (selector) => { + if (selector === getLocalNetworkMenuRedesignFeatureFlag) { + return opts.networkMenuRedesign ?? false; + } + return undefined; +}; + describe('NetworkListItem', () => { it('renders properly', () => { + useSelector.mockImplementation( + generateUseSelectorRouter({ + networkMenuRedesign: false, + }), + ); const { container } = render(); expect(container).toMatchSnapshot(); }); it('does not render the delete icon when no onDeleteClick is clicked', () => { + useSelector.mockImplementation( + generateUseSelectorRouter({ + networkMenuRedesign: false, + }), + ); const { container } = render( , ); @@ -31,6 +55,11 @@ describe('NetworkListItem', () => { }); it('shows as selected when selected', () => { + useSelector.mockImplementation( + generateUseSelectorRouter({ + networkMenuRedesign: false, + }), + ); const { container } = render( , ); @@ -42,6 +71,11 @@ describe('NetworkListItem', () => { }); it('renders a tooltip when the network name is very long', () => { + useSelector.mockImplementation( + generateUseSelectorRouter({ + networkMenuRedesign: false, + }), + ); const { container } = render( { }); it('executes onClick when the item is clicked', () => { + useSelector.mockImplementation( + generateUseSelectorRouter({ + networkMenuRedesign: false, + }), + ); const onClick = jest.fn(); const { container } = render( , @@ -63,18 +102,25 @@ describe('NetworkListItem', () => { }); it('executes onDeleteClick when the delete button is clicked', () => { + useSelector.mockImplementation( + generateUseSelectorRouter({ + networkMenuRedesign: true, + }), + ); const onDeleteClick = jest.fn(); const onClick = jest.fn(); - const { container } = render( + + const { getByTestId } = render( , ); - fireEvent.click( - container.querySelector('.multichain-network-list-item__delete'), - ); + + fireEvent.click(getByTestId('network-list-item-options-button')); + + fireEvent.click(getByTestId('network-list-item-options-delete')); expect(onDeleteClick).toHaveBeenCalledTimes(1); expect(onClick).toHaveBeenCalledTimes(0); }); diff --git a/ui/components/multichain/network-list-menu/network-list-menu.js b/ui/components/multichain/network-list-menu/network-list-menu.js index 6c276e326062..c481b163f8a0 100644 --- a/ui/components/multichain/network-list-menu/network-list-menu.js +++ b/ui/components/multichain/network-list-menu/network-list-menu.js @@ -84,6 +84,8 @@ export const NetworkListMenu = ({ onClose }) => { const t = useI18nContext(); const [actionMode, setActionMode] = useState(ACTION_MODES.LIST); + const [modalTitle, setModalTitle] = useState(t('networkMenuHeading')); + const [networkToEdit, setNetworkToEdit] = useState(null); const nonTestNetworks = useSelector(getNonTestNetworks); const testNetworks = useSelector(getTestNetworks); const showTestNetworks = useSelector(getShowTestNetworks); @@ -110,13 +112,6 @@ export const NetworkListMenu = ({ onClose }) => { const isUnlocked = useSelector(getIsUnlocked); - let title = t('networkMenuHeading'); - if (actionMode === ACTION_MODES.ADD) { - title = t('addCustomNetwork'); - } else if (actionMode === ACTION_MODES.EDIT) { - title = currentNetwork.nickname; - } - const orderedNetworksList = useSelector(getOrderedNetworksList); const networkConfigurationChainIds = Object.values(networkConfigurations).map( @@ -249,60 +244,29 @@ export const NetworkListMenu = ({ onClose }) => { ); } - const generateNetworkListItem = ({ - network, - isCurrentNetwork, - canDeleteNetwork, - }) => { - return ( - { - dispatch(toggleNetworkMenu()); - if (network.providerType) { - dispatch(setProviderType(network.providerType)); - } else { - dispatch(setActiveNetwork(network.id)); - } - - // If presently on a dapp, communicate a change to - // the dapp via silent switchEthereumChain that the - // network has changed due to user action - if (useRequestQueue && selectedTabOrigin) { - setNetworkClientIdForDomain(selectedTabOrigin, network.id); - } + const getOnDeleteCallback = (networkId) => { + return () => { + dispatch(toggleNetworkMenu()); + dispatch( + showModal({ + name: 'CONFIRM_DELETE_NETWORK', + target: networkId, + onConfirm: () => undefined, + }), + ); + }; + }; - trackEvent({ - event: MetaMetricsEventName.NavNetworkSwitched, - category: MetaMetricsEventCategory.Network, - properties: { - location: 'Network Menu', - chain_id: currentChainId, - from_network: currentChainId, - to_network: network.chainId, - }, - }); - }} - onDeleteClick={ - canDeleteNetwork - ? () => { - dispatch(toggleNetworkMenu()); - dispatch( - showModal({ - name: 'CONFIRM_DELETE_NETWORK', - target: network.id, - onConfirm: () => undefined, - }), - ); - } - : null - } - /> - ); + const getOnEditCallback = (network) => { + return () => { + const networkToUse = { + ...network, + label: network.nickname, + }; + setModalTitle(network.nickname); + setNetworkToEdit(networkToUse); + setActionMode(ACTION_MODES.EDIT); + }; }; const generateMenuItems = (desiredNetworks) => { @@ -314,11 +278,37 @@ export const NetworkListMenu = ({ onClose }) => { const canDeleteNetwork = isUnlocked && !isCurrentNetwork && network.removable; - return generateNetworkListItem({ - network, - isCurrentNetwork, - canDeleteNetwork, - }); + return ( + { + dispatch(toggleNetworkMenu()); + if (network.providerType) { + dispatch(setProviderType(network.providerType)); + } else { + dispatch(setActiveNetwork(network.id)); + } + trackEvent({ + event: MetaMetricsEventName.NavNetworkSwitched, + category: MetaMetricsEventCategory.Network, + properties: { + location: 'Network Menu', + chain_id: currentChainId, + from_network: currentChainId, + to_network: network.chainId, + }, + }); + }} + onDeleteClick={ + canDeleteNetwork ? getOnDeleteCallback(network.id) : null + } + onEditClick={getOnEditCallback(network)} + /> + ); }); }; @@ -333,39 +323,17 @@ export const NetworkListMenu = ({ onClose }) => { } }; - const headerAdditionalProps = - actionMode === ACTION_MODES.LIST - ? {} - : { onBack: () => setActionMode(ACTION_MODES.LIST) }; - - return ( - - - - - {title} - - {actionMode === ACTION_MODES.LIST ? ( - <> - + const renderListNetworks = () => { + if (actionMode === ACTION_MODES.LIST) { + return ( + <> + + + {showBanner ? ( { !isCurrentNetwork && network.removable; - const networkListItem = generateNetworkListItem({ - network, - isCurrentNetwork, - canDeleteNetwork, - }); - return ( { {...providedDrag.draggableProps} {...providedDrag.dragHandleProps} > - {networkListItem} + { + dispatch(toggleNetworkMenu()); + if (network.providerType) { + dispatch( + setProviderType(network.providerType), + ); + } else { + dispatch(setActiveNetwork(network.id)); + } + + // If presently on a dapp, communicate a change to + // the dapp via silent switchEthereumChain that the + // network has changed due to user action + if ( + useRequestQueue && + selectedTabOrigin + ) { + setNetworkClientIdForDomain( + selectedTabOrigin, + network.id, + ); + } + + trackEvent({ + event: + MetaMetricsEventName.NavNetworkSwitched, + category: + MetaMetricsEventCategory.Network, + properties: { + location: 'Network Menu', + chain_id: currentChainId, + from_network: currentChainId, + to_network: network.chainId, + }, + }); + }} + onDeleteClick={ + canDeleteNetwork + ? getOnDeleteCallback(network.id) + : null + } + onEditClick={getOnEditCallback(network)} + /> )} @@ -459,6 +469,7 @@ export const NetworkListMenu = ({ onClose }) => { {networkMenuRedesign ? ( ) : null} { ) : null} - - { - if (!networkMenuRedesign) { - if (isFullScreen) { - if (completedOnboarding) { - history.push(ADD_POPULAR_CUSTOM_NETWORK); - } else { - dispatch(showModal({ name: 'ONBOARDING_ADD_NETWORK' })); - } + + + + { + if (!networkMenuRedesign) { + if (isFullScreen) { + if (completedOnboarding) { + history.push(ADD_POPULAR_CUSTOM_NETWORK); } else { - global.platform.openExtensionInBrowser( - ADD_POPULAR_CUSTOM_NETWORK, - ); + dispatch(showModal({ name: 'ONBOARDING_ADD_NETWORK' })); } - dispatch(toggleNetworkMenu()); - return; + } else { + global.platform.openExtensionInBrowser( + ADD_POPULAR_CUSTOM_NETWORK, + ); } - trackEvent({ - event: MetaMetricsEventName.AddNetworkButtonClick, - category: MetaMetricsEventCategory.Network, - }); - setActionMode(ACTION_MODES.ADD); - }} - > - {t('addNetwork')} - - - - ) : ( - - )} + dispatch(toggleNetworkMenu()); + return; + } + trackEvent({ + event: MetaMetricsEventName.AddNetworkButtonClick, + category: MetaMetricsEventCategory.Network, + }); + setActionMode(ACTION_MODES.ADD); + setModalTitle(t('addCustomNetwork')); + }} + > + {t('addNetwork')} + + + + ); + } else if (actionMode === ACTION_MODES.ADD) { + return ; + } + return ( + + ); + }; + + const headerAdditionalProps = + actionMode === ACTION_MODES.LIST + ? {} + : { onBack: () => setActionMode(ACTION_MODES.LIST) }; + + return ( + + + + + {modalTitle} + + {renderListNetworks()} ); diff --git a/ui/ducks/app/app.ts b/ui/ducks/app/app.ts index 5c63ac25dcb7..db1e67f662c9 100644 --- a/ui/ducks/app/app.ts +++ b/ui/ducks/app/app.ts @@ -82,6 +82,7 @@ type AppState = { newNftAddedMessage: string; removeNftMessage: string; newNetworkAddedName: string; + editedNetwork: string; newNetworkAddedConfigurationId: string; selectedNetworkConfigurationId: string; sendInputCurrencySwitched: boolean; @@ -164,6 +165,7 @@ const initialState: AppState = { newNftAddedMessage: '', removeNftMessage: '', newNetworkAddedName: '', + editedNetwork: '', newNetworkAddedConfigurationId: '', selectedNetworkConfigurationId: '', sendInputCurrencySwitched: false, @@ -490,6 +492,13 @@ export default function reduceApp( newNetworkAddedConfigurationId: networkConfigurationId, }; } + case actionConstants.SET_EDIT_NETWORK: { + const { nickname } = action.payload; + return { + ...appState, + editedNetwork: nickname, + }; + } case actionConstants.SET_NEW_TOKENS_IMPORTED: return { ...appState, diff --git a/ui/helpers/utils/network-helper.test.ts b/ui/helpers/utils/network-helper.test.ts index fef6711dbd93..188cd235953a 100644 --- a/ui/helpers/utils/network-helper.test.ts +++ b/ui/helpers/utils/network-helper.test.ts @@ -1,4 +1,8 @@ -import { getMatchedChain, getMatchedSymbols } from './network-helper'; +import { + getMatchedChain, + getMatchedNames, + getMatchedSymbols, +} from './network-helper'; describe('netwotkHelper', () => { describe('getMatchedChain', () => { @@ -76,4 +80,53 @@ describe('netwotkHelper', () => { expect(result).toEqual([]); }); }); + + describe('getMatchedName', () => { + it('should return an array of symbols that match the given decimalChainId', () => { + const chains = [ + { + chainId: '1', + name: 'Ethereum', + nativeCurrency: { symbol: 'ETH', name: 'Ethereum' }, + }, + { + chainId: '3', + name: 'tEthereum', + nativeCurrency: { symbol: 'tETH', name: 'tEthereum' }, + }, + { + chainId: '1', + name: 'WEthereum', + nativeCurrency: { symbol: 'WETH', name: 'WEthereum' }, + }, + ]; + const decimalChainId = '1'; + const expected = ['Ethereum', 'WEthereum']; + + const result = getMatchedNames(decimalChainId, chains); + + expect(result).toEqual(expect.arrayContaining(expected)); + expect(result.length).toBe(expected.length); + }); + + it('should return an empty array if no symbols match the given decimalChainId', () => { + const chains = [ + { + chainId: '1', + name: 'Ethereum', + nativeCurrency: { symbol: 'ETH', name: 'Ethereum' }, + }, + { + chainId: '3', + name: 'tEthereum', + nativeCurrency: { symbol: 'tETH', name: 'tEthereum' }, + }, + ]; + const decimalChainId = '2'; // No matching chainId + + const result = getMatchedNames(decimalChainId, chains); + + expect(result).toEqual([]); + }); + }); }); diff --git a/ui/helpers/utils/network-helper.ts b/ui/helpers/utils/network-helper.ts index 840dc8dfbf8a..960f2979cc0e 100644 --- a/ui/helpers/utils/network-helper.ts +++ b/ui/helpers/utils/network-helper.ts @@ -26,3 +26,19 @@ export const getMatchedSymbols = ( return accumulator; }, []); }; + +export const getMatchedNames = ( + decimalChainId: string, + safeChainsList: { + chainId: string; + name: string; + nativeCurrency: { symbol: string; name: string }; + }[], +): string[] => { + return safeChainsList.reduce((accumulator, currentNetwork) => { + if (currentNetwork.chainId.toString() === decimalChainId) { + accumulator.push(currentNetwork?.name); + } + return accumulator; + }, []); +}; diff --git a/ui/pages/home/home.component.js b/ui/pages/home/home.component.js index aba0f4f57d59..d43616dc230d 100644 --- a/ui/pages/home/home.component.js +++ b/ui/pages/home/home.component.js @@ -181,6 +181,7 @@ export default class Home extends PureComponent { showOutdatedBrowserWarning: PropTypes.bool.isRequired, setOutdatedBrowserWarningLastShown: PropTypes.func.isRequired, newNetworkAddedName: PropTypes.string, + editedNetwork: PropTypes.string, // This prop is used in the `shouldCloseNotificationPopup` function // eslint-disable-next-line react/no-unused-prop-types isSigningQRHardwareTransaction: PropTypes.bool.isRequired, @@ -194,6 +195,7 @@ export default class Home extends PureComponent { setNewTokensImported: PropTypes.func.isRequired, setNewTokensImportedError: PropTypes.func.isRequired, clearNewNetworkAdded: PropTypes.func, + clearEditedNetwork: PropTypes.func, setActiveNetwork: PropTypes.func, // eslint-disable-next-line react/no-unused-prop-types setTokenAutodetectModal: PropTypes.func, @@ -469,6 +471,7 @@ export default class Home extends PureComponent { newNftAddedMessage, setNewNftAddedMessage, newNetworkAddedName, + editedNetwork, removeNftMessage, setRemoveNftMessage, newTokensImported, @@ -477,6 +480,7 @@ export default class Home extends PureComponent { setNewTokensImportedError, newNetworkAddedConfigurationId, clearNewNetworkAdded, + clearEditedNetwork, setActiveNetwork, } = this.props; @@ -485,6 +489,7 @@ export default class Home extends PureComponent { setRemoveNftMessage(''); setNewTokensImported(''); // Added this so we dnt see the notif if user does not close it setNewTokensImportedError(''); + clearEditedNetwork({}); }; const autoHideDelay = 5 * SECOND; @@ -591,6 +596,29 @@ export default class Home extends PureComponent { } /> ) : null} + {editedNetwork ? ( + + + + {t('newNetworkEdited', [editedNetwork])} + + clearEditedNetwork()} + className="home__new-network-notification-close" + /> + + } + /> + ) : null} {newTokensImported ? ( { getIsBrowserDeprecated() && getShowOutdatedBrowserWarning(state), seedPhraseBackedUp, newNetworkAddedName: getNewNetworkAdded(state), + editedNetwork: getEditedNetwork(state), isSigningQRHardwareTransaction: getIsSigningQRHardwareTransaction(state), newNftAddedMessage: getNewNftAddedMessage(state), removeNftMessage: getRemoveNftMessage(state), @@ -266,6 +269,9 @@ const mapDispatchToProps = (dispatch) => { clearNewNetworkAdded: () => { dispatch(setNewNetworkAdded({})); }, + clearEditedNetwork: () => { + dispatch(setEditedNetwork({})); + }, setActiveNetwork: (networkConfigurationId) => { dispatch(setActiveNetwork(networkConfigurationId)); }, diff --git a/ui/pages/onboarding-flow/add-network-modal/__snapshots__/add-network-modal.test.js.snap b/ui/pages/onboarding-flow/add-network-modal/__snapshots__/add-network-modal.test.js.snap index e422b5d8216b..05128f7c75ca 100644 --- a/ui/pages/onboarding-flow/add-network-modal/__snapshots__/add-network-modal.test.js.snap +++ b/ui/pages/onboarding-flow/add-network-modal/__snapshots__/add-network-modal.test.js.snap @@ -67,36 +67,26 @@ exports[`Add Network Modal should render 1`] = ` +

+ Default RPC URL +

-
-
- - Suggested ticker symbol: - -
`; - -exports[`Add Network Modal should render 2`] = `
`; diff --git a/ui/pages/onboarding-flow/add-network-modal/index.js b/ui/pages/onboarding-flow/add-network-modal/index.js index 9ee4f9d80c51..c031739b68b6 100644 --- a/ui/pages/onboarding-flow/add-network-modal/index.js +++ b/ui/pages/onboarding-flow/add-network-modal/index.js @@ -12,16 +12,24 @@ import { TypographyVariant, FONT_WEIGHT, } from '../../../helpers/constants/design-system'; - import NetworksForm from '../../settings/networks-tab/networks-form/networks-form'; -export default function AddNetworkModal({ showHeader = true }) { +export default function AddNetworkModal({ + showHeader = false, + isNewNetworkFlow = false, + addNewNetwork = true, + networkToEdit = null, +}) { const dispatch = useDispatch(); const t = useI18nContext(); const closeCallback = () => dispatch(hideModal({ name: 'ONBOARDING_ADD_NETWORK' })); + const additionalProps = networkToEdit + ? { selectedNetwork: networkToEdit } + : {}; + return ( <> {showHeader ? ( @@ -36,12 +44,14 @@ export default function AddNetworkModal({ showHeader = true }) { ) : null} ); @@ -49,8 +59,14 @@ export default function AddNetworkModal({ showHeader = true }) { AddNetworkModal.propTypes = { showHeader: PropTypes.bool, + isNewNetworkFlow: PropTypes.bool, + addNewNetwork: PropTypes.bool, + networkToEdit: PropTypes.object, }; AddNetworkModal.defaultProps = { - showHeader: true, + showHeader: false, + isNewNetworkFlow: false, + addNewNetwork: true, + networkToEdit: null, }; diff --git a/ui/pages/settings/networks-tab/networks-form/networks-form.js b/ui/pages/settings/networks-tab/networks-form/networks-form.js index e66481453d32..298a063f98b6 100644 --- a/ui/pages/settings/networks-tab/networks-form/networks-form.js +++ b/ui/pages/settings/networks-tab/networks-form/networks-form.js @@ -10,6 +10,8 @@ import React, { useState, } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import { ORIGIN_METAMASK } from '@metamask/approval-controller'; +import { ApprovalType } from '@metamask/controller-utils'; import { isWebUrl } from '../../../../../app/scripts/lib/util'; import { MetaMetricsEventCategory, @@ -18,6 +20,7 @@ import { } from '../../../../../shared/constants/metametrics'; import { BUILT_IN_NETWORKS, + CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP, CHAIN_IDS, CHAINLIST_CURRENCY_SYMBOLS_MAP_NETWORK_COLLISION, FEATURED_RPCS, @@ -40,6 +43,8 @@ import { usePrevious } from '../../../../hooks/usePrevious'; import { useSafeChainsListValidationSelector } from '../../../../selectors'; import { editAndSetNetworkConfiguration, + requestUserApproval, + setEditedNetwork, setNewNetworkAdded, setSelectedNetworkConfigurationId, showDeprecatedNetworkModal, @@ -68,6 +73,7 @@ import { } from '../../../../helpers/constants/design-system'; import { getMatchedChain, + getMatchedNames, getMatchedSymbols, } from '../../../../helpers/utils/network-helper'; import { getLocalNetworkMenuRedesignFeatureFlag } from '../../../../helpers/utils/feature-flags'; @@ -118,6 +124,7 @@ const NetworksForm = ({ const t = useI18nContext(); const dispatch = useDispatch(); const DEFAULT_SUGGESTED_TICKER = []; + const DEFAULT_SUGGESTED_NAME = []; const { label, labelKey, viewOnly, rpcPrefs } = selectedNetwork; const selectedNetworkName = label || (labelKey && t(getNetworkLabelKey(labelKey))); @@ -139,6 +146,7 @@ const NetworksForm = ({ ); const [isEditing, setIsEditing] = useState(Boolean(addNewNetwork)); const [previousNetwork, setPreviousNetwork] = useState(selectedNetwork); + const [suggestedNames, setSuggestedNames] = useState(DEFAULT_SUGGESTED_NAME); const trackEvent = useContext(MetaMetricsContext); @@ -200,6 +208,7 @@ const NetworksForm = ({ setErrors({}); setWarnings({}); setSuggestedTicker([]); + setSuggestedNames([]); setIsSubmitting(false); setIsEditing(false); setPreviousNetwork(selectedNetwork); @@ -321,6 +330,33 @@ const NetworksForm = ({ setSuggestedTicker([...matchedSymbol]); }, []); + const autoSuggestName = useCallback((formChainId) => { + const decimalChainId = getDisplayChainId(formChainId); + if (decimalChainId.trim() === '' || safeChainsList.current.length === 0) { + setSuggestedNames([]); + return; + } + const matchedChain = safeChainsList.current?.find( + (chain) => chain.chainId.toString() === decimalChainId, + ); + + const matchedNames = safeChainsList.current?.reduce( + (accumulator, currentNetwork) => { + if (currentNetwork.chainId.toString() === decimalChainId) { + accumulator.push(currentNetwork?.name); + } + return accumulator; + }, + [], + ); + + if (matchedChain === undefined) { + setSuggestedNames([]); + return; + } + setSuggestedNames([...matchedNames]); + }, []); + const hasErrors = () => { return Object.keys(errors).some((key) => { const error = errors[key]; @@ -461,6 +497,7 @@ const NetworksForm = ({ }; } autoSuggestTicker(formChainId); + autoSuggestName(formChainId); return null; }, [rpcUrl, networksToRender, t], @@ -523,6 +560,57 @@ const NetworksForm = ({ [t], ); + const validateNetworkName = useCallback( + async (formChainId, formName) => { + let warningKey; + let warningMessage; + const decimalChainId = getDisplayChainId(formChainId); + + if (!decimalChainId || !formName) { + setSuggestedNames([]); + return null; + } + + if (safeChainsList.current.length === 0) { + warningKey = 'failedToFetchTickerSymbolData'; + warningMessage = t('failedToFetchTickerSymbolData'); + } else { + const matchedChain = getMatchedChain( + decimalChainId, + safeChainsList.current, + ); + + const matchedNames = getMatchedNames( + decimalChainId, + safeChainsList.current, + ); + setSuggestedNames([...matchedNames]); + + if (matchedChain === undefined) { + warningKey = 'failedToFetchTickerSymbolData'; + warningMessage = t('failedToFetchTickerSymbolData'); + } else if ( + !matchedNames.some( + (name) => name?.toLowerCase() === formName.toLowerCase(), + ) + ) { + warningKey = 'wrongNetworkName'; + warningMessage = t('wrongNetworkName'); + } + } + + if (warningKey) { + return { + key: warningKey, + msg: warningMessage, + }; + } + + return null; + }, + [t], + ); + const validateRPCUrl = useCallback( (url) => { const [ @@ -580,18 +668,22 @@ const NetworksForm = ({ const { error: chainIdError, warning: chainIdWarning } = (await validateChainId(chainId)) || {}; const tickerWarning = await validateTickerSymbol(chainId, ticker); + const nameWarning = await validateNetworkName(chainId, networkName); const blockExplorerError = validateBlockExplorerURL(blockExplorerUrl); const rpcUrlError = validateRPCUrl(rpcUrl); + setErrors({ ...errors, blockExplorerUrl: blockExplorerError, rpcUrl: rpcUrlError, chainId: chainIdError, }); + setWarnings({ ...warnings, chainId: chainIdWarning, ticker: tickerWarning, + networkName: nameWarning, }); } @@ -604,6 +696,7 @@ const NetworksForm = ({ ticker, blockExplorerUrl, viewOnly, + networkName, label, previousRpcUrl, previousChainId, @@ -613,10 +706,35 @@ const NetworksForm = ({ validateChainId, validateTickerSymbol, validateRPCUrl, + validateNetworkName, ]); const onSubmit = async () => { setIsSubmitting(true); + if (networkMenuRedesign && addNewNetwork) { + dispatch(toggleNetworkMenu()); + await dispatch( + requestUserApproval({ + origin: ORIGIN_METAMASK, + type: ApprovalType.AddEthereumChain, + requestData: { + chainId: prefixChainId(chainId), + rpcUrl, + ticker, + imageUrl: + CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP[prefixChainId(chainId)] ?? '', + chainName: networkName, + rpcPrefs: { + ...rpcPrefs, + blockExplorerUrl: blockExplorerUrl || rpcPrefs?.blockExplorerUrl, + }, + referrer: ORIGIN_METAMASK, + source: MetaMetricsNetworkEventSource.NewAddNetworkFlow, + }, + }), + ); + return; + } try { const formChainId = chainId.trim().toLowerCase(); const prefixedChainId = prefixChainId(formChainId); @@ -676,6 +794,9 @@ const NetworksForm = ({ token_symbol: ticker, }, }); + if (networkMenuRedesign) { + dispatch(setEditedNetwork({ nickname: networkName })); + } } if ( @@ -774,7 +895,37 @@ const NetworksForm = ({ disabled={viewOnly} dataTestId="network-form-network-name" /> - {window.metamaskFeatureFlags?.networkMenuRedesign ? ( + {suggestedNames && + suggestedNames.length > 0 && + !suggestedNames.some( + (nameSuggested) => nameSuggested === networkName, + ) ? ( + + {t('suggestedTokenName')} + {suggestedNames.map((suggestedName, i) => ( + { + setNetworkName(suggestedName); + }} + paddingLeft={1} + paddingRight={1} + style={{ verticalAlign: 'baseline' }} + key={i} + > + {suggestedName} + + ))} + + ) : null} + {networkMenuRedesign ? ( ) : ( 0 && !suggestedTicker.some( (symbolSuggested) => symbolSuggested === ticker, ) ? ( @@ -890,7 +1042,9 @@ const NetworksForm = ({ disabled={isSubmitDisabled} onClick={() => { onSubmit(); - dispatch(toggleNetworkMenu()); + if (!networkMenuRedesign || !addNewNetwork) { + dispatch(toggleNetworkMenu()); + } }} size={ButtonPrimarySize.Lg} width={BlockSize.Full} diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index 55c9576d1bce..d484ce026ab0 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -2036,6 +2036,10 @@ export function getNewNetworkAdded(state) { return state.appState.newNetworkAddedName; } +export function getEditedNetwork(state) { + return state.appState.editedNetwork; +} + export function getNetworksTabSelectedNetworkConfigurationId(state) { return state.appState.selectedNetworkConfigurationId; } diff --git a/ui/selectors/selectors.test.js b/ui/selectors/selectors.test.js index f40a8497a86b..1287aff781b6 100644 --- a/ui/selectors/selectors.test.js +++ b/ui/selectors/selectors.test.js @@ -510,6 +510,20 @@ describe('Selectors', () => { }); }); + describe('#getEditedNetwork', () => { + it('returns undefined if getEditedNetwork is undefined', () => { + expect(selectors.getNewNetworkAdded({ appState: {} })).toBeUndefined(); + }); + + it('returns getEditedNetwork', () => { + expect( + selectors.getEditedNetwork({ + appState: { editedNetwork: 'test-chain' }, + }), + ).toStrictEqual('test-chain'); + }); + }); + describe('#getRpcPrefsForCurrentProvider', () => { it('returns an empty object if state.metamask.providerConfig is empty', () => { expect( diff --git a/ui/store/actionConstants.ts b/ui/store/actionConstants.ts index 658e5b30c296..251196f70a73 100644 --- a/ui/store/actionConstants.ts +++ b/ui/store/actionConstants.ts @@ -118,6 +118,7 @@ export const SET_SHOW_TOKEN_AUTO_DETECT_MODAL_UPGRADE = export const SET_SELECTED_NETWORK_CONFIGURATION_ID = 'SET_SELECTED_NETWORK_CONFIGURATION_ID'; export const SET_NEW_NETWORK_ADDED = 'SET_NEW_NETWORK_ADDED'; +export const SET_EDIT_NETWORK = 'SET_EDIT_NETWORK'; export const SET_NEW_NFT_ADDED_MESSAGE = 'SET_NEW_NFT_ADDED_MESSAGE'; export const SET_REMOVE_NFT_MESSAGE = 'SET_REMOVE_NFT_MESSAGE'; diff --git a/ui/store/actions.test.js b/ui/store/actions.test.js index d153d1888018..813153d33ca3 100644 --- a/ui/store/actions.test.js +++ b/ui/store/actions.test.js @@ -1389,6 +1389,25 @@ describe('Actions', () => { }); }); + describe('#setEditedNetwork', () => { + it('sets appState.setEditedNetwork to provided value', async () => { + const store = mockStore(); + + const newNetworkAddedDetails = { + nickname: 'test-chain', + }; + + store.dispatch(actions.setEditedNetwork(newNetworkAddedDetails)); + + const resultantActions = store.getActions(); + + expect(resultantActions[0]).toStrictEqual({ + type: 'SET_EDIT_NETWORK', + payload: newNetworkAddedDetails, + }); + }); + }); + describe('#addToAddressBook', () => { it('calls setAddressBook', async () => { const store = mockStore(); diff --git a/ui/store/actions.ts b/ui/store/actions.ts index c57776fd45ea..cc0dd1507bc3 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -4149,6 +4149,18 @@ export function setNewNetworkAdded({ }; } +export function setEditedNetwork({ + nickname, +}: { + networkConfigurationId: string; + nickname: string; +}): PayloadAction { + return { + type: actionConstants.SET_EDIT_NETWORK, + payload: { nickname }, + }; +} + export function setNewNftAddedMessage( newNftAddedMessage: string, ): PayloadAction {