diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index a14cbc3e1..64fe6aa05 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -2184,6 +2184,9 @@ "networkIsBusy": { "message": "Network is busy. Gas prices are high and estimates are less accurate." }, + "networkMenuHeading": { + "message": "Select a network" + }, "networkName": { "message": "Network name" }, diff --git a/ui/components/multichain/index.js b/ui/components/multichain/index.js index debaef289..a4703b158 100644 --- a/ui/components/multichain/index.js +++ b/ui/components/multichain/index.js @@ -8,3 +8,5 @@ export { MultichainImportTokenLink } from './multichain-import-token-link'; export { MultichainTokenListItem } from './multichain-token-list-item'; export { AddressCopyButton } from './address-copy-button'; export { MultichainConnectedSiteMenu } from './multichain-connected-site-menu'; +export { NetworkListItem } from './network-list-item'; +export { NetworkListMenu } from './network-list-menu'; diff --git a/ui/components/multichain/multichain-components.scss b/ui/components/multichain/multichain-components.scss index ea41f990e..63b19d690 100644 --- a/ui/components/multichain/multichain-components.scss +++ b/ui/components/multichain/multichain-components.scss @@ -9,4 +9,7 @@ @import 'account-list-menu/index'; @import 'account-picker/index'; @import 'multichain-connected-site-menu/index'; +@import 'account-list-menu/'; @import 'multichain-token-list-item/multichain-token-list-item'; +@import 'network-list-item/'; +@import 'network-list-menu/'; diff --git a/ui/components/multichain/network-list-item/__snapshots__/network-list-item.test.js.snap b/ui/components/multichain/network-list-item/__snapshots__/network-list-item.test.js.snap new file mode 100644 index 000000000..f71dd7605 --- /dev/null +++ b/ui/components/multichain/network-list-item/__snapshots__/network-list-item.test.js.snap @@ -0,0 +1,41 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NetworkListItem renders properly 1`] = ` +
+
+
+ Polygon logo +
+
+ +
+ +
+
+`; diff --git a/ui/components/multichain/network-list-item/index.js b/ui/components/multichain/network-list-item/index.js new file mode 100644 index 000000000..7fc23e245 --- /dev/null +++ b/ui/components/multichain/network-list-item/index.js @@ -0,0 +1 @@ +export { NetworkListItem } from './network-list-item'; diff --git a/ui/components/multichain/network-list-item/index.scss b/ui/components/multichain/network-list-item/index.scss new file mode 100644 index 000000000..a7a1e6aa1 --- /dev/null +++ b/ui/components/multichain/network-list-item/index.scss @@ -0,0 +1,51 @@ +.multichain-network-list-item { + position: relative; + cursor: pointer; + + &:not(.multichain-network-list-item--selected) { + &:hover, + &:focus-within { + background: var(--color-background-default-hover); + } + } + + a:hover, + a:focus { + color: inherit; + } + + &:hover, + &:focus, + &:focus-within { + .multichain-network-list-item__delete { + visibility: visible; + } + } + + &__network-name { + width: 100%; + flex: 1; + overflow: hidden; + text-align: start; + + button:hover { + opacity: 1; + } + } + + &__tooltip { + display: inline; + } + + &__selected-indicator { + width: 4px; + height: calc(100% - 8px); + position: absolute; + 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 new file mode 100644 index 000000000..02f030e83 --- /dev/null +++ b/ui/components/multichain/network-list-item/network-list-item.js @@ -0,0 +1,108 @@ +import React from 'react'; +import classnames from 'classnames'; +import PropTypes from 'prop-types'; +import Box from '../../ui/box/box'; +import { + AlignItems, + IconColor, + BorderRadius, + Color, + Size, + JustifyContent, + TextColor, + BLOCK_SIZES, +} from '../../../helpers/constants/design-system'; +import { + AvatarNetwork, + ButtonIcon, + ButtonLink, + ICON_NAMES, +} from '../../component-library'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import Tooltip from '../../ui/tooltip/tooltip'; + +const MAXIMUM_CHARACTERS_WITHOUT_TOOLTIP = 17; + +export const NetworkListItem = ({ + name, + iconSrc, + selected = false, + onClick, + onDeleteClick, +}) => { + const t = useI18nContext(); + return ( + + {selected && ( + + )} + + + + {name.length > MAXIMUM_CHARACTERS_WITHOUT_TOOLTIP ? ( + + {name} + + ) : ( + name + )} + + + {onDeleteClick ? ( + { + e.stopPropagation(); + onDeleteClick(); + }} + /> + ) : null} + + ); +}; + +NetworkListItem.propTypes = { + /** + * The name of the network + */ + name: PropTypes.string.isRequired, + /** + * Path to the Icon image + */ + iconSrc: PropTypes.string, + /** + * Represents if the network item is selected + */ + selected: PropTypes.bool, + /** + * Executes when the item is clicked + */ + onClick: PropTypes.func.isRequired, + /** + * Executes when the delete icon is clicked + */ + onDeleteClick: PropTypes.func, +}; diff --git a/ui/components/multichain/network-list-item/network-list-item.stories.js b/ui/components/multichain/network-list-item/network-list-item.stories.js new file mode 100644 index 000000000..51b15f982 --- /dev/null +++ b/ui/components/multichain/network-list-item/network-list-item.stories.js @@ -0,0 +1,67 @@ +import React from 'react'; +import { NetworkListItem } from '.'; + +export default { + title: 'Components/Multichain/NetworkListItem', + component: NetworkListItem, + argTypes: { + name: { + control: 'text', + }, + selected: { + control: 'boolean', + }, + onClick: { + action: 'onClick', + }, + onDeleteClick: { + action: 'onDeleteClick', + }, + iconSrc: { + action: 'text', + }, + }, + args: { + name: 'Ethereum', + iconSrc: '', + selected: false, + }, +}; + +export const DefaultStory = (args) => ( +
+ +
+); + +export const IconStory = (args) => ( +
+ +
+); +IconStory.args = { iconSrc: './images/matic-token.png', name: 'Polygon' }; + +export const SelectedStory = (args) => ( +
+ +
+); +SelectedStory.args = { selected: true }; + +export const ChaosStory = (args) => ( +
+ +
+); +ChaosStory.args = { + name: 'This is a super long network name that should be ellipsized', + selected: true, +}; 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 new file mode 100644 index 000000000..4c4215d02 --- /dev/null +++ b/ui/components/multichain/network-list-item/network-list-item.test.js @@ -0,0 +1,81 @@ +/* eslint-disable jest/require-top-level-describe */ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { + MATIC_TOKEN_IMAGE_URL, + POLYGON_DISPLAY_NAME, +} from '../../../../shared/constants/network'; +import { NetworkListItem } from '.'; + +const DEFAULT_PROPS = { + name: POLYGON_DISPLAY_NAME, + iconSrc: MATIC_TOKEN_IMAGE_URL, + selected: false, + onClick: () => undefined, + onDeleteClick: () => undefined, +}; + +describe('NetworkListItem', () => { + it('renders properly', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it('does not render the delete icon when no onDeleteClick is clicked', () => { + const { container } = render( + , + ); + expect( + container.querySelector('.multichain-network-list-item__delete'), + ).toBeNull(); + }); + + it('shows as selected when selected', () => { + const { container } = render( + , + ); + expect( + container.querySelector( + '.multichain-network-list-item__selected-indicator', + ), + ).toBeInTheDocument(); + }); + + it('renders a tooltip when the network name is very long', () => { + const { container } = render( + , + ); + expect( + container.querySelector('.multichain-network-list-item__tooltip'), + ).toBeInTheDocument(); + }); + + it('executes onClick when the item is clicked', () => { + const onClick = jest.fn(); + const { container } = render( + , + ); + fireEvent.click(container.querySelector('.multichain-network-list-item')); + expect(onClick).toHaveBeenCalled(); + }); + + it('executes onDeleteClick when the delete button is clicked', () => { + const onDeleteClick = jest.fn(); + const onClick = jest.fn(); + const { container } = render( + , + ); + fireEvent.click( + container.querySelector('.multichain-network-list-item__delete'), + ); + expect(onDeleteClick).toHaveBeenCalledTimes(1); + expect(onClick).toHaveBeenCalledTimes(0); + }); +}); diff --git a/ui/components/multichain/network-list-menu/index.js b/ui/components/multichain/network-list-menu/index.js new file mode 100644 index 000000000..6e11c426a --- /dev/null +++ b/ui/components/multichain/network-list-menu/index.js @@ -0,0 +1 @@ +export { NetworkListMenu } from './network-list-menu'; diff --git a/ui/components/multichain/network-list-menu/index.scss b/ui/components/multichain/network-list-menu/index.scss new file mode 100644 index 000000000..fbd2ed7ba --- /dev/null +++ b/ui/components/multichain/network-list-menu/index.scss @@ -0,0 +1,4 @@ +.multichain-network-list-menu { + max-height: 200px; + overflow: auto; +} diff --git a/ui/components/multichain/network-list-menu/network-list-menu.js b/ui/components/multichain/network-list-menu/network-list-menu.js new file mode 100644 index 000000000..0cf1818ca --- /dev/null +++ b/ui/components/multichain/network-list-menu/network-list-menu.js @@ -0,0 +1,122 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory } from 'react-router-dom'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import Popover from '../../ui/popover/popover.component'; +import { NetworkListItem } from '../network-list-item'; +import { + setActiveNetwork, + showModal, + setShowTestNetworks, + setProviderType, +} from '../../../store/actions'; +import { CHAIN_IDS, TEST_CHAINS } from '../../../../shared/constants/network'; +import { + getShowTestNetworks, + getAllNetworks, + getCurrentChainId, +} from '../../../selectors'; +import Box from '../../ui/box/box'; +import ToggleButton from '../../ui/toggle-button'; +import { + DISPLAY, + JustifyContent, +} from '../../../helpers/constants/design-system'; +import { Button, BUTTON_TYPES, Text } from '../../component-library'; +import { ADD_POPULAR_CUSTOM_NETWORK } from '../../../helpers/constants/routes'; +import { getEnvironmentType } from '../../../../app/scripts/lib/util'; +import { ENVIRONMENT_TYPE_FULLSCREEN } from '../../../../shared/constants/app'; + +const UNREMOVABLE_CHAIN_IDS = [CHAIN_IDS.MAINNET, ...TEST_CHAINS]; + +export const NetworkListMenu = ({ closeMenu }) => { + const t = useI18nContext(); + const networks = useSelector(getAllNetworks); + const showTestNetworks = useSelector(getShowTestNetworks); + const currentChainId = useSelector(getCurrentChainId); + const dispatch = useDispatch(); + const history = useHistory(); + + const environmentType = getEnvironmentType(); + const isFullScreen = environmentType === ENVIRONMENT_TYPE_FULLSCREEN; + + return ( + + <> + + {networks.map((network) => { + const isCurrentNetwork = currentChainId === network.chainId; + const canDeleteNetwork = + !isCurrentNetwork && + !UNREMOVABLE_CHAIN_IDS.includes(network.chainId); + + return ( + { + if (network.providerType) { + dispatch(setProviderType(network.providerType)); + } else { + dispatch(setActiveNetwork(network.id)); + } + closeMenu(); + }} + onDeleteClick={ + canDeleteNetwork + ? () => { + dispatch( + showModal({ + name: 'CONFIRM_DELETE_NETWORK', + target: network.id || network.chainId, + onConfirm: () => undefined, + }), + ); + closeMenu(); + } + : null + } + /> + ); + })} + + + {t('showTestnetNetworks')} + dispatch(setShowTestNetworks(!value))} + /> + + + + + + + ); +}; + +NetworkListMenu.propTypes = { + /** + * Executes when the menu should be closed + */ + closeMenu: PropTypes.func.isRequired, +}; diff --git a/ui/components/multichain/network-list-menu/network-list-menu.stories.js b/ui/components/multichain/network-list-menu/network-list-menu.stories.js new file mode 100644 index 000000000..0629cd8e6 --- /dev/null +++ b/ui/components/multichain/network-list-menu/network-list-menu.stories.js @@ -0,0 +1,58 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import testData from '../../../../.storybook/test-data'; +import configureStore from '../../../store/store'; +import { + OPTIMISM_DISPLAY_NAME, + CHAIN_IDS, + OPTIMISM_TOKEN_IMAGE_URL, + BSC_DISPLAY_NAME, + BNB_TOKEN_IMAGE_URL, +} from '../../../../shared/constants/network'; +import { NetworkListMenu } from '.'; + +const customNetworkStore = configureStore({ + ...testData, + metamask: { + ...testData.metamask, + preferences: { + showTestNetworks: true, + }, + networkConfigurations: { + ...testData.metamask.networkConfigurations, + ...{ + 'test-networkConfigurationId-3': { + rpcUrl: 'https://testrpc.com', + chainId: CHAIN_IDS.OPTIMISM, + nickname: OPTIMISM_DISPLAY_NAME, + rpcPrefs: { imageUrl: OPTIMISM_TOKEN_IMAGE_URL }, + }, + 'test-networkConfigurationId-4': { + rpcUrl: 'https://testrpc.com', + chainId: CHAIN_IDS.BSC, + nickname: BSC_DISPLAY_NAME, + rpcPrefs: { imageUrl: BNB_TOKEN_IMAGE_URL }, + }, + }, + }, + }, +}); + +export default { + title: 'Components/Multichain/NetworkListMenu', + component: NetworkListMenu, + argTypes: { + closeMenu: { + action: 'closeMenu', + }, + }, +}; + +export const DefaultStory = (args) => ; +DefaultStory.decorators = [ + (Story) => ( + + + + ), +]; diff --git a/ui/components/multichain/network-list-menu/network-list-menu.test.js b/ui/components/multichain/network-list-menu/network-list-menu.test.js new file mode 100644 index 000000000..e87876f39 --- /dev/null +++ b/ui/components/multichain/network-list-menu/network-list-menu.test.js @@ -0,0 +1,61 @@ +/* eslint-disable jest/require-top-level-describe */ +import React from 'react'; +import { fireEvent, renderWithProvider } from '../../../../test/jest'; +import configureStore from '../../../store/store'; +import mockState from '../../../../test/data/mock-state.json'; +import { + MAINNET_DISPLAY_NAME, + SEPOLIA_DISPLAY_NAME, +} from '../../../../shared/constants/network'; +import { NetworkListMenu } from '.'; + +const mockSetShowTestNetworks = jest.fn(); +const mockSetProviderType = jest.fn(); +jest.mock('../../../store/actions.ts', () => ({ + setShowTestNetworks: () => mockSetShowTestNetworks, + setProviderType: () => mockSetProviderType, +})); + +const render = (showTestNetworks = false) => { + const store = configureStore({ + metamask: { + ...mockState.metamask, + preferences: { + showTestNetworks, + }, + }, + }); + return renderWithProvider(, store); +}; + +describe('NetworkListMenu', () => { + it('displays important controls', () => { + const { getByText } = render(); + + expect(getByText('Add network')).toBeInTheDocument(); + expect(getByText('Show test networks')).toBeInTheDocument(); + }); + + it('renders mainnet item', () => { + const { getByText } = render(); + expect(getByText(MAINNET_DISPLAY_NAME)).toBeInTheDocument(); + }); + + it('renders test networks when it should', () => { + const { getByText } = render(true); + expect(getByText(SEPOLIA_DISPLAY_NAME)).toBeInTheDocument(); + }); + + it('toggles showTestNetworks when toggle is clicked', () => { + const { queryAllByRole } = render(); + const [testNetworkToggle] = queryAllByRole('checkbox'); + fireEvent.click(testNetworkToggle); + expect(mockSetShowTestNetworks).toHaveBeenCalled(); + }); + + it('switches networks when an item is clicked', () => { + const { getByText } = render(); + fireEvent.click(getByText(MAINNET_DISPLAY_NAME)); + expect(mockSetProviderType).toHaveBeenCalled(); + }); +}); diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index be2ced685..7c07865be 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -25,6 +25,10 @@ import { CHAIN_IDS, NETWORK_TYPES, NetworkStatus, + SEPOLIA_DISPLAY_NAME, + GOERLI_DISPLAY_NAME, + ETH_TOKEN_IMAGE_URL, + LINEA_TESTNET_DISPLAY_NAME, } from '../../shared/constants/network'; import { WebHIDConnectedStatuses, @@ -1116,6 +1120,63 @@ export function getNetworkConfigurations(state) { return state.metamask.networkConfigurations; } +export function getAllNetworks(state) { + const networkConfigurations = getNetworkConfigurations(state) || {}; + const showTestnetNetworks = getShowTestNetworks(state); + const localhostFilter = (network) => network.chainId === CHAIN_IDS.LOCALHOST; + + const networks = []; + // Mainnet always first + networks.push({ + chainId: CHAIN_IDS.MAINNET, + nickname: MAINNET_DISPLAY_NAME, + rpcUrl: CHAIN_ID_TO_RPC_URL_MAP[CHAIN_IDS.MAINNET], + rpcPrefs: { + imageUrl: ETH_TOKEN_IMAGE_URL, + }, + providerType: NETWORK_TYPES.MAINNET, + }); + // Custom networks added + networks.push( + ...Object.entries(networkConfigurations) + .filter( + ([, network]) => + !localhostFilter(network) && network.chainId !== CHAIN_IDS.MAINNET, + ) + .map(([, network]) => network), + ); + // Test networks if flag is on + if (showTestnetNetworks) { + networks.push( + ...[ + { + chainId: CHAIN_IDS.GOERLI, + nickname: GOERLI_DISPLAY_NAME, + rpcUrl: CHAIN_ID_TO_RPC_URL_MAP[CHAIN_IDS.GOERLI], + providerType: NETWORK_TYPES.GOERLI, + }, + { + chainId: CHAIN_IDS.SEPOLIA, + nickname: SEPOLIA_DISPLAY_NAME, + rpcUrl: CHAIN_ID_TO_RPC_URL_MAP[CHAIN_IDS.SEPOLIA], + providerType: NETWORK_TYPES.SEPOLIA, + }, + { + chainId: CHAIN_IDS.LINEA_TESTNET, + nickname: LINEA_TESTNET_DISPLAY_NAME, + rpcUrl: CHAIN_ID_TO_RPC_URL_MAP[CHAIN_IDS.LINEA_TESTNET], + provderType: NETWORK_TYPES.LINEA_TESTNET, + }, + ], // Localhosts + ...Object.entries(networkConfigurations) + .filter(([, network]) => localhostFilter(network)) + .map(([, network]) => network), + ); + } + + return networks; +} + export function getIsOptimism(state) { return ( getCurrentChainId(state) === CHAIN_IDS.OPTIMISM || diff --git a/ui/selectors/selectors.test.js b/ui/selectors/selectors.test.js index f1f7bfe5e..edd495ec8 100644 --- a/ui/selectors/selectors.test.js +++ b/ui/selectors/selectors.test.js @@ -1,5 +1,10 @@ import mockState from '../../test/data/mock-state.json'; import { KeyringType } from '../../shared/constants/keyring'; +import { + CHAIN_IDS, + LOCALHOST_DISPLAY_NAME, + MAINNET_DISPLAY_NAME, +} from '../../shared/constants/network'; import * as selectors from './selectors'; describe('Selectors', () => { @@ -103,6 +108,51 @@ describe('Selectors', () => { }); }); + describe('#getAllNetworks', () => { + it('returns an array even if there are no custom networks', () => { + const networks = selectors.getAllNetworks({ + metamask: { + preferences: { + showTestNetworks: false, + }, + }, + }); + expect(networks instanceof Array).toBe(true); + // The only returning item should be Ethereum Mainnet + expect(networks).toHaveLength(1); + expect(networks[0].nickname).toStrictEqual(MAINNET_DISPLAY_NAME); + }); + + it('returns more test networks with showTestNetworks on', () => { + const networks = selectors.getAllNetworks({ + metamask: { + preferences: { + showTestNetworks: true, + }, + }, + }); + expect(networks.length).toBeGreaterThan(1); + }); + + it('sorts Localhost to the bottom of the test lists', () => { + const networks = selectors.getAllNetworks({ + metamask: { + preferences: { + showTestNetworks: true, + }, + networkConfigurations: { + 'some-config-name': { + chainId: CHAIN_IDS.LOCALHOST, + nickname: LOCALHOST_DISPLAY_NAME, + }, + }, + }, + }); + const lastItem = networks.pop(); + expect(lastItem.nickname.toLowerCase()).toContain('localhost'); + }); + }); + describe('#isHardwareWallet', () => { it('returns false if it is not a HW wallet', () => { mockState.metamask.keyrings[0].type = KeyringType.imported;