1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-12-23 09:52:26 +01:00

UX: Multichain: Network Menu (#18229)

This commit is contained in:
David Walsh 2023-03-31 12:58:25 -05:00 committed by GitHub
parent 0bbfd38cc6
commit a71a06965c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 714 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@ -0,0 +1,41 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`NetworkListItem renders properly 1`] = `
<div>
<div
class="box multichain-network-list-item box--padding-4 box--gap-2 box--flex-direction-row box--justify-content-space-between box--align-items-center box--width-full box--background-color-transparent box--display-flex"
>
<div
class="box mm-text mm-avatar-base mm-avatar-base--size-md mm-avatar-network mm-text--body-sm mm-text--text-transform-uppercase box--display-flex box--flex-direction-row box--justify-content-center box--align-items-center box--color-text-default box--background-color-background-alternative box--rounded-full box--border-color-transparent box--border-style-solid box--border-width-1"
>
<img
alt="Polygon logo"
class="mm-avatar-network__network-image"
src="./images/matic-token.png"
/>
</div>
<div
class="box multichain-network-list-item__network-name box--flex-direction-row"
>
<button
class="box mm-text mm-button-base mm-button-base--ellipsis mm-button-link mm-button-link--size-auto mm-text--body-md mm-text--ellipsis box--display-inline-flex box--flex-direction-row box--justify-content-center box--align-items-center box--color-text-default box--background-color-transparent"
>
<span
class="box mm-text mm-text--inherit mm-text--ellipsis box--flex-direction-row box--color-text-default"
>
Polygon
</span>
</button>
</div>
<button
aria-label="[deleteNetwork]"
class="box mm-button-icon mm-button-icon--size-sm multichain-network-list-item__delete box--display-inline-flex box--flex-direction-row box--justify-content-center box--align-items-center box--color-error-default box--background-color-transparent box--rounded-lg"
>
<span
class="box mm-icon mm-icon--size-sm box--display-inline-block box--flex-direction-row box--color-inherit"
style="mask-image: url('./images/icons/trash.svg');"
/>
</button>
</div>
</div>
`;

View File

@ -0,0 +1 @@
export { NetworkListItem } from './network-list-item';

View File

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

View File

@ -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 (
<Box
onClick={onClick}
padding={4}
gap={2}
backgroundColor={selected ? Color.primaryMuted : Color.transparent}
className={classnames('multichain-network-list-item', {
'multichain-network-list-item--selected': selected,
})}
alignItems={AlignItems.center}
justifyContent={JustifyContent.spaceBetween}
width={BLOCK_SIZES.FULL}
>
{selected && (
<Box
className="multichain-network-list-item__selected-indicator"
borderRadius={BorderRadius.pill}
backgroundColor={Color.primaryDefault}
/>
)}
<AvatarNetwork name={name} src={iconSrc} />
<Box className="multichain-network-list-item__network-name">
<ButtonLink onClick={onClick} color={TextColor.textDefault} ellipsis>
{name.length > MAXIMUM_CHARACTERS_WITHOUT_TOOLTIP ? (
<Tooltip
title={name}
position="bottom"
wrapperClassName="multichain-network-list-item__tooltip"
>
{name}
</Tooltip>
) : (
name
)}
</ButtonLink>
</Box>
{onDeleteClick ? (
<ButtonIcon
className="multichain-network-list-item__delete"
color={IconColor.errorDefault}
iconName={ICON_NAMES.TRASH}
ariaLabel={t('deleteNetwork')}
size={Size.SM}
onClick={(e) => {
e.stopPropagation();
onDeleteClick();
}}
/>
) : null}
</Box>
);
};
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,
};

View File

@ -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) => (
<div
style={{ width: '328px', border: '1px solid var(--color-border-muted)' }}
>
<NetworkListItem {...args} />
</div>
);
export const IconStory = (args) => (
<div
style={{ width: '328px', border: '1px solid var(--color-border-muted)' }}
>
<NetworkListItem {...args} />
</div>
);
IconStory.args = { iconSrc: './images/matic-token.png', name: 'Polygon' };
export const SelectedStory = (args) => (
<div
style={{ width: '328px', border: '1px solid var(--color-border-muted)' }}
>
<NetworkListItem {...args} />
</div>
);
SelectedStory.args = { selected: true };
export const ChaosStory = (args) => (
<div
style={{ width: '328px', border: '1px solid var(--color-border-muted)' }}
>
<NetworkListItem {...args} />
</div>
);
ChaosStory.args = {
name: 'This is a super long network name that should be ellipsized',
selected: true,
};

View File

@ -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(<NetworkListItem {...DEFAULT_PROPS} />);
expect(container).toMatchSnapshot();
});
it('does not render the delete icon when no onDeleteClick is clicked', () => {
const { container } = render(
<NetworkListItem {...DEFAULT_PROPS} onDeleteClick={null} />,
);
expect(
container.querySelector('.multichain-network-list-item__delete'),
).toBeNull();
});
it('shows as selected when selected', () => {
const { container } = render(
<NetworkListItem {...DEFAULT_PROPS} selected />,
);
expect(
container.querySelector(
'.multichain-network-list-item__selected-indicator',
),
).toBeInTheDocument();
});
it('renders a tooltip when the network name is very long', () => {
const { container } = render(
<NetworkListItem
{...DEFAULT_PROPS}
name="This is a very long network name that will be truncated"
/>,
);
expect(
container.querySelector('.multichain-network-list-item__tooltip'),
).toBeInTheDocument();
});
it('executes onClick when the item is clicked', () => {
const onClick = jest.fn();
const { container } = render(
<NetworkListItem {...DEFAULT_PROPS} onClick={onClick} />,
);
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(
<NetworkListItem
{...DEFAULT_PROPS}
onDeleteClick={onDeleteClick}
onClick={onClick}
/>,
);
fireEvent.click(
container.querySelector('.multichain-network-list-item__delete'),
);
expect(onDeleteClick).toHaveBeenCalledTimes(1);
expect(onClick).toHaveBeenCalledTimes(0);
});
});

View File

@ -0,0 +1 @@
export { NetworkListMenu } from './network-list-menu';

View File

@ -0,0 +1,4 @@
.multichain-network-list-menu {
max-height: 200px;
overflow: auto;
}

View File

@ -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 (
<Popover onClose={closeMenu} centerTitle title={t('networkMenuHeading')}>
<>
<Box className="multichain-network-list-menu">
{networks.map((network) => {
const isCurrentNetwork = currentChainId === network.chainId;
const canDeleteNetwork =
!isCurrentNetwork &&
!UNREMOVABLE_CHAIN_IDS.includes(network.chainId);
return (
<NetworkListItem
name={network.nickname}
iconSrc={network?.rpcPrefs?.imageUrl}
key={network.id || network.chainId}
selected={isCurrentNetwork}
onClick={() => {
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
}
/>
);
})}
</Box>
<Box
padding={4}
display={DISPLAY.FLEX}
justifyContent={JustifyContent.spaceBetween}
>
<Text>{t('showTestnetNetworks')}</Text>
<ToggleButton
value={showTestNetworks}
onToggle={(value) => dispatch(setShowTestNetworks(!value))}
/>
</Box>
<Box padding={4}>
<Button
type={BUTTON_TYPES.SECONDARY}
block
onClick={() => {
isFullScreen
? history.push(ADD_POPULAR_CUSTOM_NETWORK)
: global.platform.openExtensionInBrowser(
ADD_POPULAR_CUSTOM_NETWORK,
);
}}
>
{t('addNetwork')}
</Button>
</Box>
</>
</Popover>
);
};
NetworkListMenu.propTypes = {
/**
* Executes when the menu should be closed
*/
closeMenu: PropTypes.func.isRequired,
};

View File

@ -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) => <NetworkListMenu {...args} />;
DefaultStory.decorators = [
(Story) => (
<Provider store={customNetworkStore}>
<Story />
</Provider>
),
];

View File

@ -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(<NetworkListMenu closeMenu={jest.fn()} />, 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();
});
});

View File

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

View File

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