diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index b04c11548697..c81df4fabd16 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -1712,6 +1712,9 @@ "editGasTooLow": { "message": "Unknown processing time" }, + "editNetworkLink": { + "message": "edit the original network" + }, "editNonceField": { "message": "Edit nonce" }, @@ -1881,6 +1884,12 @@ "etherscanViewOn": { "message": "View on Etherscan" }, + "existingChainId": { + "message": "The information you have entered is associated with an existing chain ID." + }, + "existingRpcUrl": { + "message": "This URL is associated with another chain ID." + }, "expandView": { "message": "Expand view" }, @@ -1935,6 +1944,9 @@ "message": "File import not working? Click here!", "description": "Helps user import their account from a JSON file" }, + "findTheRightChainId": { + "message": "Find the right one on:" + }, "flaskWelcomeUninstall": { "message": "you should uninstall this extension", "description": "This request is shown on the Flask Welcome screen. It is intended for non-developers, and will be bolded." @@ -6158,6 +6170,9 @@ "message": "U2F", "description": "A name on an API for the browser to interact with devices that support the U2F protocol. On some browsers we use it to connect MetaMask to Ledger devices." }, + "unMatchedChain": { + "message": "According to our records, this URL does not match a known provider for this chain ID." + }, "unapproved": { "message": "Unapproved" }, @@ -6209,6 +6224,9 @@ "update": { "message": "Update" }, + "updateOrEditNetworkInformations": { + "message": "Update your information or" + }, "updateRequest": { "message": "Update request" }, @@ -6436,6 +6454,9 @@ "whatsThis": { "message": "What's this?" }, + "wrongChainId": { + "message": "This chain ID doesn’t match the network name." + }, "wrongNetworkName": { "message": "According to our records, the network name may not correctly match this chain ID." }, diff --git a/test/e2e/playwright/shared/pageObjects/network-controller-page.ts b/test/e2e/playwright/shared/pageObjects/network-controller-page.ts index 86a83d0312c7..aed0e7377b16 100644 --- a/test/e2e/playwright/shared/pageObjects/network-controller-page.ts +++ b/test/e2e/playwright/shared/pageObjects/network-controller-page.ts @@ -9,8 +9,6 @@ export class NetworkController { readonly addNetworkManuallyButton: Locator; - readonly networkTickerInput: Locator; - readonly approveBtn: Locator; readonly saveBtn: Locator; @@ -21,6 +19,14 @@ export class NetworkController { readonly networkSearch: Locator; + readonly networkName: Locator; + + readonly networkRpc: Locator; + + readonly networkChainId: Locator; + + readonly networkTicker: Locator; + constructor(page: Page) { this.page = page; this.networkDisplay = this.page.getByTestId('network-display'); @@ -28,9 +34,6 @@ export class NetworkController { this.addNetworkManuallyButton = this.page.getByTestId( 'add-network-manually', ); - this.networkTickerInput = this.page.getByTestId( - 'network-form-ticker-input', - ); this.saveBtn = this.page.getByRole('button', { name: 'Save' }); this.approveBtn = this.page.getByTestId('confirmation-submit-button'); this.switchToNetworkBtn = this.page.locator('button', { @@ -38,6 +41,10 @@ export class NetworkController { }); this.gotItBtn = this.page.getByRole('button', { name: 'Got it' }); this.networkSearch = this.page.locator('input[type="search"]'); + this.networkName = this.page.getByTestId('network-form-network-name'); + this.networkRpc = this.page.getByTestId('network-form-rpc-url'); + this.networkChainId = this.page.getByTestId('network-form-chain-id'); + this.networkTicker = this.page.getByTestId('network-form-ticker-input'); } async addCustomNetwork(options: { @@ -50,11 +57,11 @@ export class NetworkController { await this.addNetworkButton.click(); await this.addNetworkManuallyButton.click(); - const formField = await this.page.$$('.form-field__input'); - await formField[0].fill(options.name); - await formField[1].fill(options.url); - await formField[2].fill(options.chainID); - await this.networkTickerInput.fill(options.symbol); + await this.networkName.waitFor(); + await this.networkName.fill(options.name); + await this.networkRpc.fill(options.url); + await this.networkChainId.fill(options.chainID); + await this.networkTicker.fill(options.symbol); await this.saveBtn.click(); await this.switchToNetworkBtn.click(); } diff --git a/test/e2e/tests/network/add-custom-network.spec.js b/test/e2e/tests/network/add-custom-network.spec.js index fd133fa47e47..0baab9c94bc5 100644 --- a/test/e2e/tests/network/add-custom-network.spec.js +++ b/test/e2e/tests/network/add-custom-network.spec.js @@ -79,11 +79,9 @@ const selectors = { saveButton: { text: 'Save', tag: 'button' }, updatedNetworkDropDown: { tag: 'span', text: 'Update Network' }, errorMessageInvalidUrl: { - tag: 'h6', text: 'URLs require the appropriate HTTP/HTTPS prefix.', }, warningSymbol: { - tag: 'h6', text: 'URLs require the appropriate HTTP/HTTPS prefix.', }, suggestedTicker: '[data-testid="network-form-ticker-suggestion"]', @@ -802,6 +800,8 @@ describe('Custom network', function () { ); await driver.fill(selectors.chainIdInputField, '1'); await driver.fill(selectors.tickerInputField, 'TST'); + // fix flaky test + await driver.delay(regularDelayMs); await driver.fill(selectors.explorerInputField, 'https://test.com'); const suggestedTicker = await driver.isElementPresent( @@ -849,6 +849,9 @@ describe('Custom network', function () { inputData.networkName, ); await driver.fill(selectors.rpcUrlInputField, inputData.rpcUrl); + + // fix flaky test + await driver.delay(regularDelayMs); await driver.fill(selectors.chainIdInputField, inputData.chainId); await driver.fill(selectors.tickerInputField, inputData.ticker); @@ -945,7 +948,6 @@ async function failCandidateNetworkValidation(driver) { const chainIdValidationMessageRawLocator = { text: 'Could not fetch chain ID. Is your RPC URL correct?', - tag: 'h6', }; await driver.waitForSelector(chainIdValidationMessageRawLocator); await driver.waitForSelector('[data-testid="network-form-ticker-warning"]'); @@ -1047,6 +1049,8 @@ async function candidateNetworkIsNotValidated(driver) { await driver.fill('[data-testid="network-form-ticker-input"]', 'cTH'); await blockExplorerURLInputEl.fill('https://block-explorer.url'); + // fix flaky test + await driver.delay(regularDelayMs); const saveButtonRawLocator = { text: 'Save', tag: 'button', diff --git a/test/e2e/tests/network/custom-rpc-history.spec.js b/test/e2e/tests/network/custom-rpc-history.spec.js index 7df8746a2e62..b2a45a968f49 100644 --- a/test/e2e/tests/network/custom-rpc-history.spec.js +++ b/test/e2e/tests/network/custom-rpc-history.spec.js @@ -102,7 +102,6 @@ describe('Custom RPC history', function () { await rpcUrlInput.sendKeys(duplicateRpcUrl); await driver.findElement({ text: 'This URL is currently used by the mainnet network.', - tag: 'h6', }); }, ); @@ -144,7 +143,6 @@ describe('Custom RPC history', function () { await chainIdInput.sendKeys(duplicateChainId); await driver.findElement({ text: 'This Chain ID is currently used by the mainnet network.', - tag: 'h6', }); await rpcUrlInput.clear(); @@ -160,7 +158,6 @@ describe('Custom RPC history', function () { await driver.findElement({ text: 'Could not fetch chain ID. Is your RPC URL correct?', - tag: 'h6', }); }, ); @@ -294,7 +291,9 @@ describe('Custom RPC history', function () { value: customNetworkName, }); // delete custom network in a modal - await driver.clickElement('.networks-tab__network-form .btn-danger'); + await driver.clickElement( + '.networks-tab__network-form-footer .btn-danger', + ); await driver.findVisibleElement( '[data-testid="confirm-delete-network-modal"]', ); diff --git a/test/e2e/tests/network/update-network.spec.ts b/test/e2e/tests/network/update-network.spec.ts index 1c09b88a621d..4242dfe1533f 100644 --- a/test/e2e/tests/network/update-network.spec.ts +++ b/test/e2e/tests/network/update-network.spec.ts @@ -22,7 +22,6 @@ const selectors = { saveButton: { text: 'Save', tag: 'button' }, updatedNetworkDropDown: { tag: 'span', text: 'Update Network' }, errorMessageInvalidUrl: { - tag: 'h6', text: 'URLs require the appropriate HTTP/HTTPS prefix.', }, networkNameInputField: '[data-testid="network-form-network-name"]', 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 index 51a77c851a72..32b648eebaab 100644 --- 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 @@ -2,6 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { + Box, IconName, ModalFocus, Popover, @@ -36,7 +37,7 @@ export const NetworkListItemMenu = ({ flip > -
+ {onEditClick ? ( - {t('edit')} + {t('edit')} ) : null} {onDeleteClick ? ( @@ -66,7 +67,7 @@ export const NetworkListItemMenu = ({ {t('delete')} ) : null} -
+
); 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 index df225a439aed..ede77cf934c3 100644 --- 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 @@ -3,7 +3,7 @@ exports[`NetworkListItem renders properly 1`] = `
+
+
+ +
+ +
+
+ +
+
+`; diff --git a/ui/components/multichain/network-list-menu/add-rpc-url-modal/add-rpc-url-modal.test.tsx b/ui/components/multichain/network-list-menu/add-rpc-url-modal/add-rpc-url-modal.test.tsx new file mode 100644 index 000000000000..65b8e889e94b --- /dev/null +++ b/ui/components/multichain/network-list-menu/add-rpc-url-modal/add-rpc-url-modal.test.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useI18nContext } from '../../../../hooks/useI18nContext'; +import AddRpcUrlModal from './add-rpc-url-modal'; + +jest.mock('../../../../hooks/useI18nContext', () => ({ + useI18nContext: jest.fn(), +})); + +describe('AddRpcUrlModal', () => { + const useI18nContextMock = useI18nContext as jest.Mock; + + beforeEach(() => { + useI18nContextMock.mockReturnValue((key: string) => key); + jest.clearAllMocks(); + }); + + it('should render correctly', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it('should render the input field with the correct label', () => { + render(); + const inputLabel = screen.getByLabelText('additionalRpcUrl'); + expect(inputLabel).toBeInTheDocument(); + }); + + it('should render the "Add URL" button with correct text', () => { + render(); + const addButton = screen.getByRole('button', { name: 'addUrl' }); + expect(addButton).toBeInTheDocument(); + }); + + it('should call the appropriate function when "Add URL" button is clicked', () => { + const mockAddUrl = jest.fn(); + render(); + const addButton = screen.getByRole('button', { name: 'addUrl' }); + userEvent.click(addButton); + expect(mockAddUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/components/multichain/network-list-menu/add-rpc-url-modal/add-rpc-url-modal.tsx b/ui/components/multichain/network-list-menu/add-rpc-url-modal/add-rpc-url-modal.tsx index 8e4269928bc8..8a9ff53a74b5 100644 --- a/ui/components/multichain/network-list-menu/add-rpc-url-modal/add-rpc-url-modal.tsx +++ b/ui/components/multichain/network-list-menu/add-rpc-url-modal/add-rpc-url-modal.tsx @@ -33,6 +33,7 @@ const AddRpcUrlModal = () => { marginTop={8} marginLeft={'auto'} marginRight={'auto'} + onClick={() => ({})} > {t('addUrl')} 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 6f511b6a8bcd..8c44bf368132 100644 --- a/ui/components/multichain/network-list-menu/network-list-menu.js +++ b/ui/components/multichain/network-list-menu/network-list-menu.js @@ -55,6 +55,7 @@ import { IconName, ModalContent, ModalHeader, + AvatarNetworkSize, } from '../../component-library'; import { ADD_POPULAR_CUSTOM_NETWORK } from '../../../helpers/constants/routes'; import { getEnvironmentType } from '../../../../app/scripts/lib/util'; @@ -74,7 +75,7 @@ import PopularNetworkList from './popular-network-list/popular-network-list'; import NetworkListSearch from './network-list-search/network-list-search'; import AddRpcUrlModal from './add-rpc-url-modal/add-rpc-url-modal'; -const ACTION_MODES = { +export const ACTION_MODES = { // Displays the search box and network list LIST: 'list', // Displays the Add form @@ -121,6 +122,13 @@ export const NetworkListMenu = ({ onClose }) => { const [actionMode, setActionMode] = useState( editedNetwork ? ACTION_MODES.EDIT : ACTION_MODES.LIST, ); + const [networkFormInformation, setNetworkFormInformation] = useState({ + networkNameForm: '', + networkChainIdForm: '', + networkTickerForm: '', + }); + + const [prevActionMode, setPrevActionMode] = useState(null); const networkToEdit = useMemo(() => { const network = [...nonTestNetworks, ...testNetworks].find( @@ -167,6 +175,7 @@ export const NetworkListMenu = ({ onClose }) => { useEffect(() => { setActionMode(ACTION_MODES.LIST); + setPrevActionMode(null); if (currentlyOnTestNetwork) { dispatch(setShowTestNetworks(currentlyOnTestNetwork)); } @@ -268,6 +277,7 @@ export const NetworkListMenu = ({ onClose }) => { }), ); setActionMode(ACTION_MODES.EDIT); + setPrevActionMode(ACTION_MODES.LIST); }; const getOnEdit = (network) => { @@ -283,6 +293,9 @@ export const NetworkListMenu = ({ onClose }) => { { } }; + const goToRpcFormEdit = () => { + setActionMode(ACTION_MODES.ADD_RPC); + setPrevActionMode(ACTION_MODES.EDIT); + }; + const goToRpcFormAdd = () => { + setActionMode(ACTION_MODES.ADD_RPC); + setPrevActionMode(ACTION_MODES.ADD); + }; + const renderListNetworks = () => { if (actionMode === ACTION_MODES.LIST) { return ( @@ -376,6 +398,7 @@ export const NetworkListMenu = ({ onClose }) => { marginLeft={4} marginRight={4} marginBottom={4} + marginTop={2} backgroundColor={BackgroundColor.backgroundAlternative} startAccessory={ { /> ) : null} - - {t('enabledNetworks')} - - {searchResults.length === 0 && focusSearch ? ( + {searchResults.length > 0 ? ( + + + {t('enabledNetworks')} + + + ) : null} + + {searchResults.length === 0 && + searchAddNetworkResults.length === 0 && + searchTestNetworkResults.length === 0 && + focusSearch ? ( { data-testid="add-popular-network-view" /> ) : null} - - {t('showTestnetNetworks')} - - + + {searchTestNetworkResults.length > 0 ? ( + + + {t('showTestnetNetworks')} + + + + ) : null} + {showTestNetworks || currentlyOnTestNetwork ? ( {generateMenuItems(searchTestNetworkResults)} @@ -512,9 +549,10 @@ export const NetworkListMenu = ({ onClose }) => { category: MetaMetricsEventCategory.Network, }); setActionMode(ACTION_MODES.ADD); + setPrevActionMode(ACTION_MODES.LIST); }} > - {t('addNetwork')} + {networkMenuRedesign ? t('addCustomNetwork') : t('addNetwork')} @@ -525,6 +563,10 @@ export const NetworkListMenu = ({ onClose }) => { isNewNetworkFlow addNewNetwork getOnEditCallback={getOnEdit} + onRpcUrlAdd={goToRpcFormAdd} + prevActionMode={prevActionMode} + networkFormInformation={networkFormInformation} + setNetworkFormInformation={setNetworkFormInformation} /> ); } else if (actionMode === ACTION_MODES.EDIT) { @@ -533,7 +575,7 @@ export const NetworkListMenu = ({ onClose }) => { isNewNetworkFlow addNewNetwork={false} networkToEdit={networkToEdit} - onRpcUrlAdd={() => setActionMode(ACTION_MODES.ADD_RPC)} + onRpcUrlAdd={goToRpcFormEdit} /> ); } else if (actionMode === ACTION_MODES.ADD_RPC) { @@ -546,8 +588,16 @@ export const NetworkListMenu = ({ onClose }) => { let onBack; if (actionMode === ACTION_MODES.EDIT || actionMode === ACTION_MODES.ADD) { onBack = () => setActionMode(ACTION_MODES.LIST); - } else if (actionMode === ACTION_MODES.ADD_RPC) { + } else if ( + actionMode === ACTION_MODES.ADD_RPC && + prevActionMode === ACTION_MODES.EDIT + ) { onBack = () => setActionMode(ACTION_MODES.EDIT); + } else if ( + actionMode === ACTION_MODES.ADD_RPC && + prevActionMode === ACTION_MODES.ADD + ) { + onBack = () => setActionMode(ACTION_MODES.ADD); } // Modal title @@ -556,8 +606,10 @@ export const NetworkListMenu = ({ onClose }) => { title = t('networkMenuHeading'); } else if (actionMode === ACTION_MODES.ADD) { title = t('addCustomNetwork'); + } else if (actionMode === ACTION_MODES.ADD_RPC) { + title = t('addRpcUrl'); } else { - title = editedNetwork.nickname; + title = editedNetwork?.nickname ?? ''; } return ( @@ -575,7 +627,6 @@ export const NetworkListMenu = ({ onClose }) => { 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 index 678745defa25..544b0880048e 100644 --- a/ui/components/multichain/network-list-menu/network-list-menu.test.js +++ b/ui/components/multichain/network-list-menu/network-list-menu.test.js @@ -14,7 +14,6 @@ import { NetworkListMenu } from '.'; const mockSetShowTestNetworks = jest.fn(); const mockSetProviderType = jest.fn(); const mockToggleNetworkMenu = jest.fn(); -const mockNetworkMenuRedesignToggle = jest.fn(); const mockSetNetworkClientIdForDomain = jest.fn(); const mockSetActiveNetwork = jest.fn(); @@ -27,11 +26,6 @@ jest.mock('../../../store/actions.ts', () => ({ mockSetNetworkClientIdForDomain(network, id), })); -jest.mock('../../../helpers/utils/feature-flags', () => ({ - ...jest.requireActual('../../../helpers/utils/feature-flags'), - getLocalNetworkMenuRedesignFeatureFlag: () => mockNetworkMenuRedesignToggle, -})); - const MOCK_ORIGIN = 'https://portfolio.metamask.io'; const render = ({ @@ -66,7 +60,7 @@ const render = ({ describe('NetworkListMenu', () => { beforeEach(() => { - mockNetworkMenuRedesignToggle.mockReturnValue(false); + process.env.ENABLE_NETWORK_UI_REDESIGN = 'false'; }); it('renders properly', () => { @@ -185,12 +179,16 @@ describe('NetworkListMenu', () => { describe('NetworkListMenu with ENABLE_NETWORK_UI_REDESIGN', () => { // Set the environment variable before tests run beforeEach(() => { - process.env.ENABLE_NETWORK_UI_REDESIGN = 'true'; + window.metamaskFeatureFlags = { + networkMenuRedesign: true, + }; }); // Reset the environment variable after tests complete afterEach(() => { - delete process.env.ENABLE_NETWORK_UI_REDESIGN; + window.metamaskFeatureFlags = { + networkMenuRedesign: false, + }; }); it('should display "Arbitrum" when ENABLE_NETWORK_UI_REDESIGN is true', async () => { diff --git a/ui/components/multichain/network-list-menu/network-list-search/__snapshots__/network-list-search.test.tsx.snap b/ui/components/multichain/network-list-menu/network-list-search/__snapshots__/network-list-search.test.tsx.snap index 6b2597430661..ca09bf0c145a 100644 --- a/ui/components/multichain/network-list-menu/network-list-search/__snapshots__/network-list-search.test.tsx.snap +++ b/ui/components/multichain/network-list-menu/network-list-search/__snapshots__/network-list-search.test.tsx.snap @@ -3,10 +3,10 @@ exports[`NetworkListSearch renders search list component 1`] = `
- - - - -
- A malicious network provider can lie about the state of the blockchain and record your network activity. Only add custom networks you trust. -
-
-
- -
-

- Default RPC URL -

-
-

- + A malicious network provider can lie about the state of the blockchain and record your network activity. Only add custom networks you trust. +

-
+
+
- - - - -
-
- +
+
- Currency symbol - + +
+ +
+
- + +
+ +
-
-
- +
diff --git a/ui/pages/onboarding-flow/add-network-modal/add-network-modal.test.js b/ui/pages/onboarding-flow/add-network-modal/add-network-modal.test.js index 16909bc8010b..615c1790530e 100644 --- a/ui/pages/onboarding-flow/add-network-modal/add-network-modal.test.js +++ b/ui/pages/onboarding-flow/add-network-modal/add-network-modal.test.js @@ -23,7 +23,7 @@ describe('Add Network Modal', () => { mockNetworkMenuRedesignToggle.mockImplementation(() => false); const mockStore = configureMockStore([])({ - metamask: { useSafeChainsListValidation: true }, + metamask: { useSafeChainsListValidation: true, orderedNetworkList: {} }, }); const { container } = renderWithProvider( @@ -40,7 +40,7 @@ describe('Add Network Modal', () => { mockNetworkMenuRedesignToggle.mockReturnValue(true); const mockStore = configureMockStore([thunk])({ - metamask: { useSafeChainsListValidation: true }, + metamask: { useSafeChainsListValidation: true, orderedNetworkList: {} }, }); const { queryByText } = renderWithProvider( @@ -50,7 +50,7 @@ describe('Add Network Modal', () => { await waitFor(() => { expect(queryByText('Cancel')).not.toBeInTheDocument(); - expect(queryByText('Save')).toBeInTheDocument(); + expect(queryByText('Next')).toBeInTheDocument(); }); }); }); diff --git a/ui/pages/onboarding-flow/add-network-modal/index.js b/ui/pages/onboarding-flow/add-network-modal/index.js index b35785e4f2ac..911bb89eee04 100644 --- a/ui/pages/onboarding-flow/add-network-modal/index.js +++ b/ui/pages/onboarding-flow/add-network-modal/index.js @@ -16,10 +16,13 @@ import NetworksForm from '../../settings/networks-tab/networks-form/networks-for export default function AddNetworkModal({ showHeader = false, - isNewNetworkFlow = false, + onEditNetwork = null, addNewNetwork = true, networkToEdit = null, onRpcUrlAdd, + prevActionMode = null, + networkFormInformation = {}, + setNetworkFormInformation = () => null, }) { const dispatch = useDispatch(); const t = useI18nContext(); @@ -52,7 +55,10 @@ export default function AddNetworkModal({ cancelCallback={closeCallback} submitCallback={closeCallback} onRpcUrlAdd={onRpcUrlAdd} - isNewNetworkFlow={isNewNetworkFlow} + onEditNetwork={onEditNetwork} + prevActionMode={prevActionMode} + networkFormInformation={networkFormInformation} + setNetworkFormInformation={setNetworkFormInformation} {...additionalProps} /> @@ -63,13 +69,21 @@ AddNetworkModal.propTypes = { showHeader: PropTypes.bool, isNewNetworkFlow: PropTypes.bool, addNewNetwork: PropTypes.bool, + onEditNetwork: PropTypes.func, networkToEdit: PropTypes.object, onRpcUrlAdd: PropTypes.func, + prevActionMode: PropTypes.string, + networkFormInformation: PropTypes.object, + setNetworkFormInformation: PropTypes.func, }; AddNetworkModal.defaultProps = { showHeader: false, isNewNetworkFlow: false, addNewNetwork: true, + onEditNetwork: null, networkToEdit: null, + prevActionMode: null, + networkFormInformation: {}, + setNetworkFormInformation: () => null, }; diff --git a/ui/pages/settings/networks-tab/index.scss b/ui/pages/settings/networks-tab/index.scss index 86e792d988c4..69f1cbb9d073 100644 --- a/ui/pages/settings/networks-tab/index.scss +++ b/ui/pages/settings/networks-tab/index.scss @@ -113,8 +113,13 @@ color: var(--color-text-muted); } + &__scrollable { + overflow-y: auto; + } + &__network-form { - padding: 16px 24px; + padding: 16px 16px; + width: 100%; @include design-system.screen-sm-min { padding: 16px; @@ -262,23 +267,25 @@ } &__add-network-form { - margin-left: 16px; - margin-right: 16px; grid-column: span 2; // spread both columns of grid layout @include design-system.screen-sm-min { max-width: 400px; // but only expand to 400px - padding: 16px; + padding-left: 16px; + padding-right: 16px; + padding-bottom: 16px; } &__alert { - margin-top: 0; + margin-top: 4; + padding: 4; } } &__restrict-height { max-height: 578px; overflow-y: auto; + width: 100%; } &__add-network-form-footer { diff --git a/ui/pages/settings/networks-tab/networks-form/__snapshots__/rpc-url-editor.test.tsx.snap b/ui/pages/settings/networks-tab/networks-form/__snapshots__/rpc-url-editor.test.tsx.snap new file mode 100644 index 000000000000..d79e1349e4a4 --- /dev/null +++ b/ui/pages/settings/networks-tab/networks-form/__snapshots__/rpc-url-editor.test.tsx.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RpcUrlEditor should render correctly 1`] = ` +
+

+ defaultRpcUrl +

+
+

+ https://current-rpc-url.com +

+ +
+
+`; 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 c5bdaeeb1fc5..8549b4597924 100644 --- a/ui/pages/settings/networks-tab/networks-form/networks-form.js +++ b/ui/pages/settings/networks-tab/networks-form/networks-form.js @@ -21,10 +21,12 @@ import { import { BUILT_IN_NETWORKS, CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP, + CHAIN_ID_TO_RPC_URL_MAP, CHAIN_IDS, CHAINLIST_CURRENCY_SYMBOLS_MAP_NETWORK_COLLISION, FEATURED_RPCS, infuraProjectId, + NETWORK_TO_NAME_MAP, } from '../../../../../shared/constants/network'; import fetchWithCache from '../../../../../shared/lib/fetch-with-cache'; import { decimalToHex } from '../../../../../shared/modules/conversion.utils'; @@ -40,7 +42,11 @@ import { MetaMetricsContext } from '../../../../contexts/metametrics'; import { getNetworkLabelKey } from '../../../../helpers/utils/i18n-helper'; import { useI18nContext } from '../../../../hooks/useI18nContext'; import { usePrevious } from '../../../../hooks/usePrevious'; -import { useSafeChainsListValidationSelector } from '../../../../selectors'; +import { + getNonTestNetworks, + getOrderedNetworksList, + useSafeChainsListValidationSelector, +} from '../../../../selectors'; import { editAndSetNetworkConfiguration, requestUserApproval, @@ -59,6 +65,8 @@ import { ButtonPrimarySize, HelpText, HelpTextSeverity, + IconName, + IconSize, Text, } from '../../../../components/component-library'; import { FormTextField } from '../../../../components/component-library/form-text-field/deprecated'; @@ -66,6 +74,8 @@ import { AlignItems, BackgroundColor, BlockSize, + Display, + FlexDirection, FontWeight, TextAlign, TextColor, @@ -77,7 +87,8 @@ import { getMatchedSymbols, } from '../../../../helpers/utils/network-helper'; import { getLocalNetworkMenuRedesignFeatureFlag } from '../../../../helpers/utils/feature-flags'; -import { RpcUrlEditor } from './rpc-url-editor'; +import { ACTION_MODES } from '../../../../components/multichain/network-list-menu/network-list-menu'; +import InfoTooltip from '../../../../components/ui/info-tooltip'; /** * Attempts to convert the given chainId to a decimal string, for display @@ -120,25 +131,53 @@ const NetworksForm = ({ selectedNetwork, cancelCallback, submitCallback, - onRpcUrlAdd, + onEditNetwork, + prevActionMode, + networkFormInformation = {}, + setNetworkFormInformation = () => null, }) => { const t = useI18nContext(); const dispatch = useDispatch(); const DEFAULT_SUGGESTED_TICKER = []; const DEFAULT_SUGGESTED_NAME = []; + const CHAIN_LIST_URL = 'https://chainid.network/'; + const BASE_HEX = 16; + const BASE_DECIMAL = 10; + const MAX_CHAIN_ID_LENGTH = 12; + const { label, labelKey, viewOnly, rpcPrefs } = selectedNetwork; const selectedNetworkName = label || (labelKey && t(getNetworkLabelKey(labelKey))); - const [networkName, setNetworkName] = useState(selectedNetworkName || ''); + const networkNameForm = + prevActionMode === ACTION_MODES.ADD + ? networkFormInformation.networkNameForm + : ''; + const networkChainIdForm = + prevActionMode === ACTION_MODES.ADD + ? networkFormInformation.networkChainIdForm + : ''; + const networkTickerForm = + prevActionMode === ACTION_MODES.ADD + ? networkFormInformation.networkTickerForm + : ''; + + const [networkName, setNetworkName] = useState( + selectedNetworkName || networkNameForm, + ); const [rpcUrl, setRpcUrl] = useState(selectedNetwork?.rpcUrl || ''); - const [chainId, setChainId] = useState(selectedNetwork?.chainId || ''); - const [ticker, setTicker] = useState(selectedNetwork?.ticker || ''); + const [chainId, setChainId] = useState( + selectedNetwork?.chainId || networkChainIdForm, + ); + const [ticker, setTicker] = useState( + selectedNetwork?.ticker || networkTickerForm, + ); const [suggestedTicker, setSuggestedTicker] = useState( DEFAULT_SUGGESTED_TICKER, ); const [blockExplorerUrl, setBlockExplorerUrl] = useState( selectedNetwork?.blockExplorerUrl || '', ); + const [errors, setErrors] = useState({}); const [warnings, setWarnings] = useState({}); const [isSubmitting, setIsSubmitting] = useState(false); @@ -148,12 +187,16 @@ const NetworksForm = ({ const [isEditing, setIsEditing] = useState(Boolean(addNewNetwork)); const [previousNetwork, setPreviousNetwork] = useState(selectedNetwork); const [suggestedNames, setSuggestedNames] = useState(DEFAULT_SUGGESTED_NAME); + const nonTestNetworks = useSelector(getNonTestNetworks); const trackEvent = useContext(MetaMetricsContext); const useSafeChainsListValidation = useSelector( useSafeChainsListValidationSelector, ); + + const orderedNetworksList = useSelector(getOrderedNetworksList); + const networkMenuRedesign = useSelector( getLocalNetworkMenuRedesignFeatureFlag, ); @@ -240,7 +283,11 @@ const NetworksForm = ({ const prevBlockExplorerUrl = useRef(); // This effect is used to reset the form when the user switches between networks useEffect(() => { - if (!prevAddNewNetwork.current && addNewNetwork) { + if ( + !prevAddNewNetwork.current && + addNewNetwork && + prevActionMode !== ACTION_MODES.ADD + ) { setNetworkName(''); setRpcUrl(''); setChainId(''); @@ -282,13 +329,52 @@ const NetworksForm = ({ previousNetwork, resetForm, isEditing, + prevActionMode, ]); + const newOrderNetworks = () => { + if (!orderedNetworksList || orderedNetworksList.length === 0) { + return nonTestNetworks; + } + + // Create a mapping of chainId to index in orderedNetworksList + const orderedIndexMap = {}; + orderedNetworksList.forEach((network, index) => { + orderedIndexMap[`${network.networkId}_${network.networkRpcUrl}`] = index; + }); + + // Sort nonTestNetworks based on the order in orderedNetworksList + const sortedNonTestNetworks = nonTestNetworks.sort((a, b) => { + const keyA = `${a.chainId}_${a.rpcUrl}`; + const keyB = `${b.chainId}_${b.rpcUrl}`; + return orderedIndexMap[keyA] - orderedIndexMap[keyB]; + }); + + return sortedNonTestNetworks; + }; + + const handleEditNetworkClick = () => { + const networksList = networkMenuRedesign + ? nonTestNetworks + : newOrderNetworks(); + + const networkToEdit = Object.values(networksList).find( + (network) => + getDisplayChainId(chainId) === getDisplayChainId(network.chainId), + ); + + if (networkToEdit) { + onEditNetwork(networkToEdit); + } + }; + useEffect(() => { return () => { - setNetworkName(''); - setRpcUrl(''); - setChainId(''); + if (prevActionMode !== ACTION_MODES.ADD) { + setNetworkName(''); + setRpcUrl(''); + setChainId(''); + } setTicker(''); setBlockExplorerUrl(''); setErrors({}); @@ -302,6 +388,7 @@ const NetworksForm = ({ setBlockExplorerUrl, setErrors, dispatch, + prevActionMode, ]); const autoSuggestTicker = useCallback((formChainId) => { @@ -369,6 +456,7 @@ const NetworksForm = ({ }); }; + const networksList = Object.values(orderedNetworksList); const validateBlockExplorerURL = useCallback( (url) => { if (url?.length > 0 && !isWebUrl(url)) { @@ -413,6 +501,22 @@ const NetworksForm = ({ } } + if ( + addNewNetwork && + networksList.some( + (network) => + getDisplayChainId(chainArg) === + parseInt(network.networkId, BASE_HEX).toString(BASE_DECIMAL) && + rpcUrl === network.networkRpcUrl, + ) + ) { + return { + error: { + key: 'existingChainId', + }, + }; + } + const [matchingChainId] = networksToRender.filter( (e) => e.chainId === hexChainId && e.rpcUrl !== rpcUrl, ); @@ -474,13 +578,18 @@ const NetworksForm = ({ } errorKey = 'endpointReturnedDifferentChainId'; - errorMessage = t('endpointReturnedDifferentChainId', [ - endpointChainId.length <= 12 - ? endpointChainId - : `${endpointChainId.slice(0, 9)}...`, - ]); + if (networkMenuRedesign) { + errorMessage = t('wrongChainId'); + } else { + errorMessage = t('endpointReturnedDifferentChainId', [ + endpointChainId.length <= MAX_CHAIN_ID_LENGTH + ? endpointChainId + : `${endpointChainId.slice(0, 9)}...`, + ]); + } } } + if (errorKey) { return { error: { @@ -501,7 +610,15 @@ const NetworksForm = ({ autoSuggestName(formChainId); return null; }, - [rpcUrl, networksToRender, t], + [ + rpcUrl, + networksToRender, + t, + addNewNetwork, + autoSuggestName, + autoSuggestTicker, + orderedNetworksList, + ], ); /** @@ -567,7 +684,23 @@ const NetworksForm = ({ let warningMessage; const decimalChainId = getDisplayChainId(formChainId); - if (!decimalChainId || !formName) { + let hexChainId = formChainId; + if (!formChainId.startsWith('0x')) { + try { + hexChainId = `0x${decimalToHex(formChainId)}`; + } catch (err) { + return { + error: { + key: 'invalidHexNumber', + msg: t('invalidHexNumber'), + }, + }; + } + } + + const isMatchedName = NETWORK_TO_NAME_MAP[hexChainId] === formName; + + if (!decimalChainId || !formName || isMatchedName) { setSuggestedNames([]); return null; } @@ -613,7 +746,9 @@ const NetworksForm = ({ ); const validateRPCUrl = useCallback( - (url) => { + async (url, formChainId) => { + const decimalChainId = getDisplayChainId(formChainId); + const [ { rpcUrl: matchingRPCUrl = null, @@ -623,6 +758,21 @@ const NetworksForm = ({ ] = networksToRender.filter((e) => e.rpcUrl === url); const { rpcUrl: selectedNetworkRpcUrl } = selectedNetwork; + if ( + networksList.some((network) => url === network.networkRpcUrl) && + addNewNetwork && + networkMenuRedesign + ) { + return { + key: 'existingRpcUrl', + msg: t('existingRpcUrl'), + }; + } + + if (!url || (!decimalChainId && networkMenuRedesign)) { + return null; + } + if (url?.length > 0 && !isWebUrl(url)) { if (isWebUrl(`https://${url}`)) { return { @@ -642,9 +792,29 @@ const NetworksForm = ({ ]), }; } + + if (networkMenuRedesign) { + let endpointChainId; + let providerError; + + try { + endpointChainId = await jsonRpcRequest(rpcUrl, 'eth_chainId'); + } catch (err) { + log.warn('Failed to fetch the chainId from the endpoint.', err); + providerError = err; + } + + if (providerError || typeof endpointChainId !== 'string') { + return { + key: 'failedToFetchChainId', + msg: t('unMatchedChain'), + }; + } + } + return null; }, - [selectedNetwork, networksToRender, t], + [selectedNetwork, networksToRender, t, rpcUrl], ); // validation effect @@ -652,6 +822,8 @@ const NetworksForm = ({ const previousChainId = usePrevious(chainId); const previousTicker = usePrevious(ticker); const previousBlockExplorerUrl = usePrevious(blockExplorerUrl); + const previousNetworkName = usePrevious(networkName); + useEffect(() => { if (viewOnly) { return; @@ -661,7 +833,8 @@ const NetworksForm = ({ previousRpcUrl === rpcUrl && previousChainId === chainId && previousTicker === ticker && - previousBlockExplorerUrl === blockExplorerUrl + previousBlockExplorerUrl === blockExplorerUrl && + previousNetworkName === networkName ) { return; } @@ -671,7 +844,7 @@ const NetworksForm = ({ const tickerWarning = await validateTickerSymbol(chainId, ticker); const nameWarning = await validateNetworkName(chainId, networkName); const blockExplorerError = validateBlockExplorerURL(blockExplorerUrl); - const rpcUrlError = validateRPCUrl(rpcUrl); + const rpcUrlError = await validateRPCUrl(rpcUrl, chainId); setErrors({ ...errors, @@ -703,6 +876,7 @@ const NetworksForm = ({ previousChainId, previousTicker, previousBlockExplorerUrl, + previousNetworkName, validateBlockExplorerURL, validateChainId, validateTickerSymbol, @@ -846,6 +1020,34 @@ const NetworksForm = ({ }), ); }; + + const isPopularNetwork = Object.values(FEATURED_RPCS).some( + (network) => + getDisplayChainId(chainId) === getDisplayChainId(network.chainId) && + rpcUrl === network.rpcUrl, + ); + + const isDefaultNetwork = (networkId, rpcUrlLink, targetChainId) => + getDisplayChainId(networkId) === parseInt(targetChainId, 16).toString(10) && + rpcUrlLink === CHAIN_ID_TO_RPC_URL_MAP[targetChainId]; + + const isDefaultMainnet = isDefaultNetwork(chainId, rpcUrl, CHAIN_IDS.MAINNET); + const isDefaultLineaMainnet = isDefaultNetwork( + chainId, + rpcUrl, + CHAIN_IDS.LINEA_MAINNET, + ); + const isDefaultSepoliaTestnet = isDefaultNetwork( + chainId, + rpcUrl, + CHAIN_IDS.SEPOLIA, + ); + const isDefaultLineaSepoliaTestnet = isDefaultNetwork( + chainId, + rpcUrl, + CHAIN_IDS.LINEA_SEPOLIA, + ); + const deletable = !isCurrentRpcTarget && !viewOnly && !addNewNetwork; const stateUnchanged = stateIsUnchanged(); const chainIdErrorOnFeaturedRpcDuringEdit = @@ -858,6 +1060,7 @@ const NetworksForm = ({ !rpcUrl || !chainId || !ticker; + let displayRpcUrl = rpcUrl?.includes(`/v3/${infuraProjectId}`) ? rpcUrl.replace(`/v3/${infuraProjectId}`, '') : rpcUrl; @@ -865,189 +1068,380 @@ const NetworksForm = ({ displayRpcUrl = displayRpcUrl?.toLowerCase(); } + const disableEdit = + viewOnly || + isDefaultMainnet || + isDefaultLineaMainnet || + isDefaultLineaSepoliaTestnet || + isDefaultSepoliaTestnet; + return ( -
- {addNewNetwork ? ( - - ) : null}
- { - setIsEditing(true); - setNetworkName(value); - }} - titleText={t('networkName')} - value={networkName} - disabled={viewOnly} - dataTestId="network-form-network-name" - /> - {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} - - ))} - + {addNewNetwork ? ( + ) : null} - - {networkMenuRedesign ? ( - + 0 && + !suggestedNames.some( + (nameSuggested) => nameSuggested === networkName, + ) ? ( + + {t('suggestedTokenName')} + {suggestedNames.map((suggestedName, i) => ( + { + setNetworkName(suggestedName); + setNetworkFormInformation((prevState) => ({ + ...prevState, + networkNameForm: suggestedName, + })); + }} + paddingLeft={1} + paddingRight={1} + style={{ verticalAlign: 'baseline' }} + key={i} + > + {suggestedName} + + ))} + + ) : null + } + onChange={(e) => { + setIsEditing(true); + setNetworkName(e.target?.value); + setNetworkFormInformation((prevState) => ({ + ...prevState, + networkNameForm: e.target?.value ?? '', + })); + }} + label={t('networkName')} + labelProps={{ + variant: TextVariant.bodySm, + fontWeight: FontWeight.Bold, + paddingBottom: 1, + paddingTop: 1, + }} + inputProps={{ + paddingLeft: 2, + variant: TextVariant.bodySm, + 'data-testid': 'network-form-network-name', + }} + value={networkName} + disabled={disableEdit && !addNewNetwork} /> - ) : ( + {errors.networkName?.msg ? ( + + {errors.networkName.msg} + + ) : null} + {warnings.networkName?.msg ? ( + + {warnings.networkName.msg} + + ) : null} + { setIsEditing(true); setRpcUrl(value); }} titleText={t('rpcUrl')} value={displayRpcUrl} - disabled={viewOnly} + disabled={disableEdit && !addNewNetwork} dataTestId="network-form-rpc-url" /> - )} - { - setIsEditing(true); - setChainId(value); - autoSuggestTicker(value); - }} - titleText={t('chainId')} - value={chainId} - disabled={viewOnly} - tooltipText={viewOnly ? null : t('networkSettingsChainIdDescription')} - dataTestId="network-form-chain-id" - /> - 0 && - !suggestedTicker.some( - (symbolSuggested) => symbolSuggested === ticker, - ) ? ( - + {errors.rpcUrl.msg} + + ) : null} + { + setIsEditing(true); + setChainId(e.target?.value); + autoSuggestTicker(e.target?.value); + autoSuggestName(e.target?.value); + setNetworkFormInformation((prevState) => ({ + ...prevState, + networkChainIdForm: e.target?.value ?? '', + })); + }} + label={ + viewOnly || networkMenuRedesign ? ( + t('chainId') + ) : ( + <> + {t('chainId')} + + + + + ) + } + labelProps={{ + variant: TextVariant.bodySm, + fontWeight: FontWeight.Bold, + paddingBottom: 1, + paddingTop: 1, + }} + inputProps={{ + paddingLeft: 2, + variant: TextVariant.bodySm, + 'data-testid': 'network-form-chain-id', + }} + value={chainId} + disabled={(disableEdit || isPopularNetwork) && !addNewNetwork} + /> + + {warnings.chainId?.msg ? ( + + {warnings.chainId?.msg} + + ) : null} + {errors.chainId?.msg ? ( + + {errors.chainId.msg} + + ) : null} + {errors.chainId?.key === 'endpointReturnedDifferentChainId' && + networkMenuRedesign ? ( + + - {t('suggestedTokenSymbol')} - {suggestedTicker.map((suggestedSymbol, i) => ( - { - setTicker(suggestedSymbol); - }} - paddingLeft={1} - paddingRight={1} - style={{ verticalAlign: 'baseline' }} - key={i} - > - {suggestedSymbol} - - ))} - - ) : null - } - onChange={(e) => { - setIsEditing(true); - setTicker(e.target.value); - }} - label={t('currencySymbol')} - labelProps={{ - variant: TextVariant.bodySm, - fontWeight: FontWeight.Bold, - paddingBottom: 1, - paddingTop: 1, - }} - inputProps={{ - paddingLeft: 2, - variant: TextVariant.bodySm, - 'data-testid': 'network-form-ticker-input', - }} - value={ticker} - disabled={viewOnly} - /> - {warnings.ticker?.msg ? ( - - {warnings.ticker.msg} - - ) : null} - { - setIsEditing(true); - setBlockExplorerUrl(value); - }} - titleText={t('blockExplorerUrl')} - titleUnit={t('optionalWithParanthesis')} - value={blockExplorerUrl} - disabled={viewOnly} - autoFocus={window.location.hash.split('#')[2] === 'blockExplorerUrl'} - dataTestId="network-form-block-explorer-url" - /> + {t('findTheRightChainId')}{' '} + { + global.platform.openTab({ + url: CHAIN_LIST_URL, + }); + }} + endIconName={IconName.Export} + endIconProps={{ + size: IconSize.Xs, + }} + > + chainid.network + + + + ) : null} + {errors.chainId?.key === 'existingChainId' ? ( + + + {t('existingChainId')} + + + {t('updateOrEditNetworkInformations')}{' '} + + {t('editNetworkLink')} + + + + ) : null} + 0 && + !suggestedTicker.some( + (symbolSuggested) => symbolSuggested === ticker, + ) ? ( + + {t('suggestedTokenSymbol')} + {suggestedTicker.map((suggestedSymbol, i) => ( + { + setTicker(suggestedSymbol); + setNetworkFormInformation((prevState) => ({ + ...prevState, + networkTickerForm: suggestedSymbol, + })); + }} + paddingLeft={1} + paddingRight={1} + style={{ verticalAlign: 'baseline' }} + key={i} + > + {suggestedSymbol} + + ))} + + ) : null + } + onChange={(e) => { + setIsEditing(true); + setTicker(e.target?.value); + setNetworkFormInformation((prevState) => ({ + ...prevState, + networkTickerForm: e.target?.value ?? '', + })); + }} + label={t('currencySymbol')} + labelProps={{ + variant: TextVariant.bodySm, + fontWeight: FontWeight.Bold, + paddingBottom: 1, + paddingTop: 1, + }} + inputProps={{ + paddingLeft: 2, + variant: TextVariant.bodySm, + 'data-testid': 'network-form-ticker-input', + }} + value={ticker} + disabled={disableEdit && !addNewNetwork} + /> + {warnings.ticker?.msg ? ( + + {warnings.ticker.msg} + + ) : null} + { + setIsEditing(true); + setBlockExplorerUrl(e.target?.value); + }} + label={`${t('blockExplorerUrl')} ${t('optionalWithParanthesis')}`} + labelProps={{ + variant: TextVariant.bodySm, + fontWeight: FontWeight.Bold, + paddingBottom: 1, + paddingTop: 1, + }} + inputProps={{ + paddingLeft: 2, + variant: TextVariant.bodySm, + 'data-testid': 'network-form-block-explorer-url', + }} + value={blockExplorerUrl ?? ''} + disabled={disableEdit && !addNewNetwork} + autoFocus={ + window.location.hash.split('#')[2] === 'blockExplorerUrl' + } + /> + {errors.blockExplorerUrl?.msg ? ( + + {errors.blockExplorerUrl.msg} + + ) : null} +
- {networkMenuRedesign ? ( - {t('save')} + {addNewNetwork ? t('next') : t('save')} ) : ( @@ -1091,6 +1485,7 @@ const NetworksForm = ({ type="primary" disabled={isSubmitDisabled} onClick={onSubmit} + dataTestId="network-form-network-save-button" > {t('save')} @@ -1098,7 +1493,7 @@ const NetworksForm = ({ )} )} - + ); }; @@ -1111,7 +1506,10 @@ NetworksForm.propTypes = { submitCallback: PropTypes.func, restrictHeight: PropTypes.bool, setActiveOnSubmit: PropTypes.bool, - onRpcUrlAdd: PropTypes.func, + onEditNetwork: PropTypes.func, + prevActionMode: PropTypes.string, + networkFormInformation: PropTypes.object, + setNetworkFormInformation: PropTypes.func, }; NetworksForm.defaultProps = { diff --git a/ui/pages/settings/networks-tab/networks-form/networks-form.test.js b/ui/pages/settings/networks-tab/networks-form/networks-form.test.js index e4b338a8a580..adaba2dce9af 100644 --- a/ui/pages/settings/networks-tab/networks-form/networks-form.test.js +++ b/ui/pages/settings/networks-tab/networks-form/networks-form.test.js @@ -13,11 +13,21 @@ import NetworksForm from '.'; const renderComponent = (props) => { const store = configureMockStore([])({ - metamask: { useSafeChainsListValidation: true }, + metamask: { + useSafeChainsListValidation: true, + orderedNetworkList: { + networkId: '0x1', + networkRpcUrl: 'https://mainnet.infura.io/v3/', + }, + }, }); return renderWithProvider(, store); }; +jest.mock('../../../../helpers/utils/feature-flags', () => ({ + getLocalNetworkMenuRedesignFeatureFlag: jest.fn(() => false), +})); + const defaultNetworks = defaultNetworksData.map((network) => ({ ...network, viewOnly: true, @@ -43,11 +53,6 @@ const propNetworkDisplay = { addNewNetwork: false, }; -jest.mock('../../../../helpers/utils/feature-flags', () => ({ - ...jest.requireActual('../../../../helpers/utils/feature-flags'), - getLocalNetworkMenuRedesignFeatureFlag: () => false, -})); - describe('NetworkForm Component', () => { beforeAll(() => { nock.disableNetConnect(); @@ -100,7 +105,7 @@ describe('NetworkForm Component', () => { }); it('should render add new network form correctly', async () => { - const { queryByText, queryAllByText } = renderComponent(propNewNetwork); + const { queryByText, getByTestId } = renderComponent(propNewNetwork); expect( queryByText( 'A malicious network provider can lie about the state of the blockchain and record your network activity. Only add custom networks you trust.', @@ -110,14 +115,16 @@ describe('NetworkForm Component', () => { expect(queryByText('New RPC URL')).toBeInTheDocument(); expect(queryByText('Chain ID')).toBeInTheDocument(); expect(queryByText('Currency symbol')).toBeInTheDocument(); - expect(queryByText('Block explorer URL')).toBeInTheDocument(); - expect(queryAllByText('(Optional)')).toHaveLength(1); + expect(queryByText('Block explorer URL (Optional)')).toBeInTheDocument(); expect(queryByText('Cancel')).toBeInTheDocument(); expect(queryByText('Save')).toBeInTheDocument(); - await fireEvent.change(screen.getByRole('textbox', { name: 'Chain ID' }), { + const chainIdField = getByTestId('network-form-chain-id'); + + fireEvent.change(chainIdField, { target: { value: '1' }, }); + expect( await screen.findByText( 'This Chain ID is currently used by the mainnet network.', @@ -144,7 +151,7 @@ describe('NetworkForm Component', () => { expect(queryByText('New RPC URL')).toBeInTheDocument(); expect(queryByText('Chain ID')).toBeInTheDocument(); expect(queryByText('Currency symbol')).toBeInTheDocument(); - expect(queryByText('Block explorer URL')).toBeInTheDocument(); + expect(queryByText('Block explorer URL (Optional)')).toBeInTheDocument(); expect(queryByText('Delete')).toBeInTheDocument(); expect(queryByText('Cancel')).toBeInTheDocument(); expect(queryByText('Save')).toBeInTheDocument(); @@ -167,12 +174,18 @@ describe('NetworkForm Component', () => { }); it('should validate RPC URL field correctly', async () => { - renderComponent(propNewNetwork); + const { getByTestId } = renderComponent(propNewNetwork); const rpcUrlField = screen.getByRole('textbox', { name: 'New RPC URL' }); await fireEvent.change(rpcUrlField, { target: { value: 'test' }, }); + + const chainIdField = getByTestId('network-form-chain-id'); + + fireEvent.change(chainIdField, { + target: { value: '1' }, + }); expect( await screen.findByText( 'URLs require the appropriate HTTP/HTTPS prefix.', @@ -219,10 +232,11 @@ describe('NetworkForm Component', () => { }); it('should validate chain id field correctly', async () => { - renderComponent(propNewNetwork); - const chainIdField = screen.getByRole('textbox', { name: 'Chain ID' }); + const { getByTestId } = renderComponent(propNewNetwork); + const chainIdField = getByTestId('network-form-chain-id'); + const rpcUrlField = screen.getByRole('textbox', { name: 'New RPC URL' }); - const currencySymbolField = screen.getByTestId('network-form-ticker-input'); + const currencySymbolField = getByTestId('network-form-ticker-input'); fireEvent.change(chainIdField, { target: { value: '1' }, @@ -277,10 +291,10 @@ describe('NetworkForm Component', () => { }); it('should validate currency symbol field correctly', async () => { - renderComponent(propNewNetwork); + const { getByTestId } = renderComponent(propNewNetwork); - const chainIdField = screen.getByRole('textbox', { name: 'Chain ID' }); - const currencySymbolField = screen.getByTestId('network-form-ticker-input'); + const chainIdField = getByTestId('network-form-chain-id'); + const currencySymbolField = getByTestId('network-form-ticker-input'); fireEvent.change(chainIdField, { target: { value: '1234' }, @@ -303,10 +317,12 @@ describe('NetworkForm Component', () => { }); it('should validate block explorer URL field correctly', async () => { - renderComponent(propNewNetwork); - const blockExplorerUrlField = screen.getByRole('textbox', { - name: 'Block explorer URL (Optional)', - }); + const { getByTestId } = renderComponent(propNewNetwork); + + const blockExplorerUrlField = getByTestId( + 'network-form-block-explorer-url', + ); + fireEvent.change(blockExplorerUrlField, { target: { value: '1234' }, }); @@ -332,10 +348,10 @@ describe('NetworkForm Component', () => { .spyOn(fetchWithCacheModule, 'default') .mockResolvedValue(safeChainsList); - renderComponent(propNewNetwork); + const { getByTestId } = renderComponent(propNewNetwork); - const chainIdField = screen.getByRole('textbox', { name: 'Chain ID' }); - const currencySymbolField = screen.getByTestId('network-form-ticker-input'); + const chainIdField = getByTestId('network-form-chain-id'); + const currencySymbolField = getByTestId('network-form-ticker-input'); fireEvent.change(chainIdField, { target: { value: '42161' }, @@ -376,9 +392,9 @@ describe('NetworkForm Component', () => { .spyOn(fetchWithCacheModule, 'default') .mockResolvedValue(safeChainsList); - renderComponent(propNewNetwork); + const { getByTestId } = renderComponent(propNewNetwork); - const chainIdField = screen.getByRole('textbox', { name: 'Chain ID' }); + const chainIdField = getByTestId('network-form-chain-id'); const currencySymbolField = screen.getByTestId('network-form-ticker-input'); fireEvent.change(chainIdField, { diff --git a/ui/pages/settings/networks-tab/networks-form/rpc-url-editor.test.tsx b/ui/pages/settings/networks-tab/networks-form/rpc-url-editor.test.tsx new file mode 100644 index 000000000000..8adf0fcbc985 --- /dev/null +++ b/ui/pages/settings/networks-tab/networks-form/rpc-url-editor.test.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { useDispatch } from 'react-redux'; +import { showModal, toggleNetworkMenu } from '../../../../store/actions'; +import { useI18nContext } from '../../../../hooks/useI18nContext'; +import { RpcUrlEditor } from './rpc-url-editor'; + +// Mock useDispatch +jest.mock('react-redux', () => ({ + useDispatch: jest.fn(), +})); + +jest.mock('../../../../hooks/useI18nContext', () => ({ + useI18nContext: jest.fn(), +})); + +describe('RpcUrlEditor', () => { + const useDispatchMock = useDispatch as jest.Mock; + const mockOnRpcUrlAdd = jest.fn(); + const mockOnRpcSelected = jest.fn(); + const useI18nContextMock = useI18nContext as jest.Mock; + const mockDispatch = jest.fn(); + + const defaultProps = { + currentRpcUrl: 'https://current-rpc-url.com', + onRpcUrlAdd: mockOnRpcUrlAdd, + onRpcSelected: mockOnRpcSelected, + dummyRpcUrls: [ + { url: 'https://rpc-url-1.com', selected: false }, + { url: 'https://rpc-url-2.com', selected: true }, + ], + }; + + beforeEach(() => { + useDispatchMock.mockReturnValue(mockDispatch); + useI18nContextMock.mockReturnValue((key: string) => key); + jest.clearAllMocks(); + }); + + it('should render correctly', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it('should toggle the dropdown when clicked', () => { + render(); + + const dropdown = screen.getByText('https://current-rpc-url.com'); + fireEvent.click(dropdown); + expect(screen.getByText('https://rpc-url-1.com')).toBeVisible(); + + fireEvent.click(dropdown); + expect(screen.queryByText('https://rpc-url-1.com')).not.toBeInTheDocument(); + }); + + it('should call onRpcUrlAdd when "Add RPC URL" button is clicked', () => { + render(); + + const dropdown = screen.getByText('https://current-rpc-url.com'); + fireEvent.click(dropdown); + + const addButton = screen.getByText('addRpcUrl'); + fireEvent.click(addButton); + + expect(mockOnRpcUrlAdd).toHaveBeenCalled(); + }); + + it('should dispatch actions when delete button is clicked', () => { + render(); + + const dropdown = screen.getByText('https://current-rpc-url.com'); + fireEvent.click(dropdown); + + const deleteButton = screen.getAllByLabelText('delete')[0]; + fireEvent.click(deleteButton); + + expect(mockDispatch).toHaveBeenCalledWith(toggleNetworkMenu()); + expect(mockDispatch).toHaveBeenCalledWith( + showModal({ name: 'CONFIRM_DELETE_RPC_URL' }), + ); + }); +}); diff --git a/ui/pages/settings/networks-tab/networks-form/rpc-url-editor.tsx b/ui/pages/settings/networks-tab/networks-form/rpc-url-editor.tsx index 41ece0b7388d..093656855512 100644 --- a/ui/pages/settings/networks-tab/networks-form/rpc-url-editor.tsx +++ b/ui/pages/settings/networks-tab/networks-form/rpc-url-editor.tsx @@ -30,23 +30,25 @@ import { showModal, toggleNetworkMenu } from '../../../../store/actions'; export const RpcUrlEditor = ({ currentRpcUrl, onRpcUrlAdd, + onRpcSelected, + dummyRpcUrls = [], }: { currentRpcUrl: string; onRpcUrlAdd: () => void; + onRpcSelected: (url: string) => void; + dummyRpcUrls: { url: string; selected: boolean }[]; }) => { - // TODO: real endpoints - const dummyRpcUrls = [ - currentRpcUrl, - 'https://mainnet.public.blastapi.io', - 'https://infura.foo.bar.baz/123456789', - ]; - const t = useI18nContext(); const dispatch = useDispatch(); const rpcDropdown = useRef(null); const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [currentRpcEndpoint, setCurrentRpcEndpoint] = useState(currentRpcUrl); + const handleSelectRpc = (rpcEndpoint: string) => { + onRpcSelected(rpcEndpoint); + setCurrentRpcEndpoint(rpcEndpoint); + }; + return ( <> setIsDropdownOpen(!isDropdownOpen)} > - {dummyRpcUrls.map((rpcEndpoint) => ( + {dummyRpcUrls.map(({ url }) => ( { - setCurrentRpcEndpoint(rpcEndpoint); + handleSelectRpc(url); setIsDropdownOpen(false); }} className={classnames('networks-tab__rpc-item', { - 'networks-tab__rpc-item--selected': - rpcEndpoint === currentRpcEndpoint, + 'networks-tab__rpc-item--selected': url === currentRpcEndpoint, })} > - {rpcEndpoint === currentRpcEndpoint && ( + {url === currentRpcEndpoint && ( - {rpcEndpoint} + {url} ({ + getLocalNetworkMenuRedesignFeatureFlag: jest.fn(() => false), +})); + const renderComponent = (props) => { const store = configureMockStore([])(mockState); return renderWithProvider(, store); @@ -58,7 +66,7 @@ describe('NetworksTabContent Component', () => { expect(queryByText('New RPC URL')).toBeInTheDocument(); expect(queryByText('Chain ID')).toBeInTheDocument(); expect(queryByText('Currency symbol')).toBeInTheDocument(); - expect(queryByText('Block explorer URL')).toBeInTheDocument(); + expect(queryByText('Block explorer URL (Optional)')).toBeInTheDocument(); expect(queryByText('Cancel')).toBeInTheDocument(); expect(queryByText('Save')).toBeInTheDocument(); diff --git a/ui/pages/settings/networks-tab/networks-tab.test.js b/ui/pages/settings/networks-tab/networks-tab.test.js index cead29634a70..e30430f53b2a 100644 --- a/ui/pages/settings/networks-tab/networks-tab.test.js +++ b/ui/pages/settings/networks-tab/networks-tab.test.js @@ -14,6 +14,10 @@ const mockState = { type: 'localhost', }, networkConfigurations: {}, + orderedNetworkList: { + chainId: '0x539', + rpcUrl: 'http://localhost:8545', + }, }, appState: { networksTabSelectedRpcUrl: 'http://localhost:8545', @@ -48,7 +52,7 @@ describe('NetworksTab Component', () => { expect(queryByText('New RPC URL')).toBeInTheDocument(); expect(queryByText('Chain ID')).toBeInTheDocument(); expect(queryByText('Currency symbol')).toBeInTheDocument(); - expect(queryByText('Block explorer URL')).toBeInTheDocument(); + expect(queryByText('Block explorer URL (Optional)')).toBeInTheDocument(); expect(queryByText('Cancel')).toBeInTheDocument(); expect(queryByText('Save')).toBeInTheDocument(); }); diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index 9e8221aaf7ed..af6779509830 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -44,6 +44,7 @@ import { POLYGON_ZKEVM_DISPLAY_NAME, MOONBEAM_DISPLAY_NAME, MOONRIVER_DISPLAY_NAME, + BUILT_IN_NETWORKS, } from '../../shared/constants/network'; import { WebHIDConnectedStatuses, @@ -650,6 +651,8 @@ export const getNonTestNetworks = createDeepEqualSelector( ticker: CURRENCY_SYMBOLS.ETH, id: NETWORK_TYPES.MAINNET, removable: false, + blockExplorerUrl: + BUILT_IN_NETWORKS[NETWORK_TYPES.MAINNET].blockExplorerUrl, }, { chainId: CHAIN_IDS.LINEA_MAINNET, @@ -662,12 +665,15 @@ export const getNonTestNetworks = createDeepEqualSelector( ticker: CURRENCY_SYMBOLS.ETH, id: NETWORK_TYPES.LINEA_MAINNET, removable: false, + blockExplorerUrl: + BUILT_IN_NETWORKS[NETWORK_TYPES.LINEA_MAINNET].blockExplorerUrl, }, // Custom networks added by the user ...Object.values(networkConfigurations) .filter(({ chainId }) => ![CHAIN_IDS.LOCALHOST].includes(chainId)) .map((network) => ({ ...network, + blockExplorerUrl: network.rpcPrefs?.blockExplorerUrl, rpcPrefs: { ...network.rpcPrefs, // Provide an image based on chainID if a network