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

UX: Multichain: Account Menu List (#17947)

* UX: Multichain: Account Menu List

* Move to using stylesheet

* Add hover state

* Implement George's suggestions

* Add connected site avatar

* Add hardware tag

* Create story for selected hardware item

* Progress on the AccountListItemMenu

* Add story for AccountListItemMenu

* Better position the account menu

* Fix AvatarFavicon missing name prop

* Update menu options label to be account specific

* Update text of 'View on Explorer'

* Add AccountListMenu component

* Move all items to multichain directory

* Fix paths

* Fix linting, use AvatarIcon

* Add title and close button to account menu

* Center the popover title

* Add search functionality

* Implementation WIP

* Add MULTICHAIN feature flag

* Add MULTICHAIN feature flag, add actions for menu items

* Properly dispatch events

* Fix search box padding

* Fix sizing of menu item text

* Fix isRequired

* Fix alignment of the popover

* Update label for hardware wallet items, add text for no search results

* Update keyring retreival to remove account and add label

* Fix storybook

* Fix double link click issue, prevent wrapping of values

* Use labelProps for tag variant

* Restructure item menu story

* Empower storybooks for all new components

* Allow only 3 decimals for currencies

* Avoid inline styles

* Prefix classes with multichain, fix account-list-menu storybook

* Close the accounts menu when account details is clicked

* Restore tag.js

* Create global file for multichain css

* Add index file for multichain js

* Update file paths

* Ensure the block domain is present in menu

* Add AccountListItem test

* Add AccountListItemMenu tests

* Show account connect to current dapp

* Improve tests

* Make avatar smaller

* Add tooltip for account menu

* Align icon better

* Update snapshot

* Rename files to DS standard

* Add index files for export

* Export all multichain components

* Update snapshot

* Remove embedded style in popover

* Add comments for props, cleanup storybook

* Improve test coverage

* Improve test code quality

* Remove border form avatar

* Switch to using the ButtonLink iconName prop

* Only show tooltip if character limit is reached

* Restore prior search settings

* Add test for tooltip
This commit is contained in:
David Walsh 2023-03-22 05:00:08 -05:00 committed by GitHub
parent 7dab4b53a4
commit c079c4320e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1298 additions and 8 deletions

View File

@ -7,6 +7,7 @@ PUBNUB_PUB_KEY=
PUBNUB_SUB_KEY=
PORTFOLIO_URL=
TRANSACTION_SECURITY_PROVIDER=
MULTICHAIN=
; Set this to test changes to the phishing warning page.
PHISHING_WARNING_PAGE_URL=

View File

@ -1179,9 +1179,14 @@ const state = {
accounts: [
'0x64a845a5b02460acf8a3d84503b0d68d028b4bb4',
'0xb19ac54efa18cc3a14a5b821bfec73d284bf0c5e',
'0x9d0ba4ddac06032527b140912ec808ab9451b788',
],
},
{
type: HardwareKeyringTypes.ledger,
accounts: [
'0x9d0ba4ddac06032527b140912ec808ab9451b788'
],
}
],
networkConfigurations: {
'test-networkConfigurationId-1': {

View File

@ -171,6 +171,9 @@
"addANickname": {
"message": "Add a nickname"
},
"addAccount": {
"message": "Add account"
},
"addAcquiredTokens": {
"message": "Add the tokens you've acquired using MetaMask"
},
@ -1573,6 +1576,9 @@
"hardware": {
"message": "Hardware"
},
"hardwareWallet": {
"message": "Hardware wallet"
},
"hardwareWalletConnected": {
"message": "Hardware wallet connected"
},
@ -4611,6 +4617,9 @@
"message": "View $1 on Etherscan",
"description": "$1 is the action type. e.g (Account, Transaction, Swap)"
},
"viewOnExplorer": {
"message": "View on explorer"
},
"viewOnOpensea": {
"message": "View on Opensea"
},

View File

@ -7,6 +7,7 @@ const commonConfigurationPropertyNames = ['PUBNUB_PUB_KEY', 'PUBNUB_SUB_KEY'];
const configurationPropertyNames = [
...commonConfigurationPropertyNames,
'MULTICHAIN',
'INFURA_PROJECT_ID',
'PHISHING_WARNING_PAGE_URL',
'PORTFOLIO_URL',

View File

@ -1109,6 +1109,7 @@ async function getEnvironmentVariables({ buildTarget, buildType, version }) {
const iconNames = await generateIconNames();
return {
ICON_NAMES: iconNames,
MULTICHAIN: config.MULTICHAIN === '1',
CONF: devMode ? config : {},
IN_TEST: testing,
INFURA_PROJECT_ID: getInfuraProjectId({

View File

@ -0,0 +1,147 @@
import React, { useContext } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import PropTypes from 'prop-types';
import { getAccountLink } from '@metamask/etherscan-link';
import { MetaMetricsContext } from '../../../contexts/metametrics';
import { useI18nContext } from '../../../hooks/useI18nContext';
import {
getRpcPrefsForCurrentProvider,
getBlockExplorerLinkText,
getCurrentChainId,
} from '../../../selectors';
import { NETWORKS_ROUTE } from '../../../helpers/constants/routes';
import { Menu, MenuItem } from '../../ui/menu';
import { ICON_NAMES, Text } from '../../component-library';
import { EVENT_NAMES, EVENT } from '../../../../shared/constants/metametrics';
import { getURLHostName } from '../../../helpers/utils/util';
import { showModal } from '../../../store/actions';
import { TextVariant } from '../../../helpers/constants/design-system';
export const AccountListItemMenu = ({
anchorElement,
blockExplorerUrlSubTitle,
onClose,
closeMenu,
isRemovable,
identity,
}) => {
const t = useI18nContext();
const trackEvent = useContext(MetaMetricsContext);
const dispatch = useDispatch();
const history = useHistory();
const chainId = useSelector(getCurrentChainId);
const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider);
const addressLink = getAccountLink(identity.address, chainId, rpcPrefs);
const blockExplorerLinkText = useSelector(getBlockExplorerLinkText);
const openBlockExplorer = () => {
trackEvent({
event: EVENT_NAMES.EXTERNAL_LINK_CLICKED,
category: EVENT.CATEGORIES.NAVIGATION,
properties: {
link_type: EVENT.EXTERNAL_LINK_TYPES.ACCOUNT_TRACKER,
location: 'Account Options',
url_domain: getURLHostName(addressLink),
},
});
global.platform.openTab({
url: addressLink,
});
onClose();
};
const routeToAddBlockExplorerUrl = () => {
history.push(`${NETWORKS_ROUTE}#blockExplorerUrl`);
};
return (
<Menu
anchorElement={anchorElement}
className="account-list-item-menu"
onHide={onClose}
>
<MenuItem
onClick={
blockExplorerLinkText.firstPart === 'addBlockExplorer'
? routeToAddBlockExplorerUrl
: openBlockExplorer
}
subtitle={blockExplorerUrlSubTitle || null}
iconName={ICON_NAMES.EXPORT}
data-testid="account-list-menu-open-explorer"
>
<Text variant={TextVariant.bodySm}>{t('viewOnExplorer')}</Text>
</MenuItem>
<MenuItem
onClick={() => {
dispatch(showModal({ name: 'ACCOUNT_DETAILS' }));
trackEvent({
event: EVENT_NAMES.NAV_ACCOUNT_DETAILS_OPENED,
category: EVENT.CATEGORIES.NAVIGATION,
properties: {
location: 'Account Options',
},
});
onClose();
closeMenu?.();
}}
iconName={ICON_NAMES.SCAN_BARCODE}
>
<Text variant={TextVariant.bodySm}>{t('accountDetails')}</Text>
</MenuItem>
{isRemovable ? (
<MenuItem
data-testid="account-list-menu-remove"
onClick={() => {
dispatch(
showModal({
name: 'CONFIRM_REMOVE_ACCOUNT',
identity,
}),
);
onClose();
}}
iconName={ICON_NAMES.TRASH}
>
<Text variant={TextVariant.bodySm}>{t('removeAccount')}</Text>
</MenuItem>
) : null}
</Menu>
);
};
AccountListItemMenu.propTypes = {
/**
* Element that the menu should display next to
*/
anchorElement: PropTypes.instanceOf(window.Element),
/**
* Function that executes when the menu is closed
*/
onClose: PropTypes.func.isRequired,
/**
* Function that closes the menu
*/
closeMenu: PropTypes.func,
/**
* Domain of the block explorer
*/
blockExplorerUrlSubTitle: PropTypes.string,
/**
* Represents if the account should be removable
*/
isRemovable: PropTypes.bool.isRequired,
/**
* Identity of the account
*/
/**
* Identity of the account
*/
identity: PropTypes.shape({
name: PropTypes.string.isRequired,
address: PropTypes.string.isRequired,
balance: PropTypes.string.isRequired,
}).isRequired,
};

View File

@ -0,0 +1,41 @@
import React from 'react';
import { AccountListItemMenu } from '.';
export default {
title: 'Components/Multichain/AccountListItemMenu',
component: AccountListItemMenu,
argTypes: {
anchorElement: {
control: 'window.Element',
},
onClose: {
action: 'onClose',
},
closeMenu: {
action: 'closeMenu',
},
blockExplorerUrlSubTitle: {
control: 'text',
},
isRemovable: {
control: 'boolean',
},
identity: {
control: 'object',
},
},
args: {
anchorElement: null,
identity: {
address: '"0xb19ac54efa18cc3a14a5b821bfec73d284bf0c5e"',
name: 'Account 1',
balance: '0x152387ad22c3f0',
tokenBalance: '32.09 ETH',
},
isRemovable: true,
blockExplorerUrlSubTitle: 'etherscan.io',
},
};
export const DefaultStory = (args) => <AccountListItemMenu {...args} />;
DefaultStory.storyName = 'Default';

View File

@ -0,0 +1,54 @@
/* eslint-disable jest/require-top-level-describe */
import React from 'react';
import { renderWithProvider, fireEvent } from '../../../../test/jest';
import configureStore from '../../../store/store';
import mockState from '../../../../test/data/mock-state.json';
import { AccountListItemMenu } from '.';
const identity = {
...mockState.metamask.identities[
'0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'
],
balance: '0x152387ad22c3f0',
};
const DEFAULT_PROPS = {
identity,
onClose: jest.fn(),
onHide: jest.fn(),
isRemovable: false,
};
const render = (props = {}) => {
const store = configureStore({
metamask: {
...mockState.metamask,
},
});
const allProps = { ...DEFAULT_PROPS, ...props };
return renderWithProvider(<AccountListItemMenu {...allProps} />, store);
};
describe('AccountListItem', () => {
it('renders the URL for explorer', () => {
const blockExplorerDomain = 'etherscan.io';
const { getByText, getByTestId } = render({
blockExplorerUrlSubTitle: blockExplorerDomain,
});
expect(getByText(blockExplorerDomain)).toBeInTheDocument();
Object.defineProperty(global, 'platform', {
value: {
openTab: jest.fn(),
},
});
const openExplorerTabSpy = jest.spyOn(global.platform, 'openTab');
fireEvent.click(getByTestId('account-list-menu-open-explorer'));
expect(openExplorerTabSpy).toHaveBeenCalled();
});
it('renders remove icon with isRemovable', () => {
const { getByTestId } = render({ isRemovable: true });
expect(getByTestId('account-list-menu-remove')).toBeInTheDocument();
});
});

View File

@ -0,0 +1 @@
export { AccountListItemMenu } from './account-list-item-menu';

View File

@ -0,0 +1,140 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AccountListItem renders AccountListItem component and shows account name, address, and balance 1`] = `
<div>
<button
class="box multichain-account-list-item box--padding-4 box--display-flex box--gap-2 box--flex-direction-row box--background-color-transparent"
>
<div
class="box mm-text mm-avatar-base mm-avatar-base--size-sm mm-avatar-account mm-text--body-sm mm-text--text-transform-uppercase mm-text--color-text-default box--display-flex box--flex-direction-row box--justify-content-center box--align-items-center box--background-color-background-alternative box--rounded-full box--border-color-transparent box--border-style-solid box--border-width-1"
>
<div
class="mm-avatar-account__jazzicon"
>
<div
style="border-radius: 50px; overflow: hidden; padding: 0px; margin: 0px; width: 24px; height: 24px; display: inline-block; background: rgb(250, 58, 0);"
>
<svg
height="24"
width="24"
x="0"
y="0"
>
<rect
fill="#18CDF2"
height="24"
transform="translate(-0.786295127845455 -2.478213052095374) rotate(328.9 12 12)"
width="24"
x="0"
y="0"
/>
<rect
fill="#035E56"
height="24"
transform="translate(-13.723846281624033 7.94434640381145) rotate(176.2 12 12)"
width="24"
x="0"
y="0"
/>
<rect
fill="#F26602"
height="24"
transform="translate(12.500881513667943 -10.653854792247811) rotate(468.9 12 12)"
width="24"
x="0"
y="0"
/>
</svg>
</div>
</div>
</div>
<div
class="box multichain-account-list-item__content box--display-flex box--flex-direction-column"
>
<div
class="box box--display-flex box--flex-direction-column"
>
<div
class="box box--display-flex box--gap-2 box--flex-direction-row box--justify-content-space-between"
>
<div
class="box mm-text mm-text--body-md mm-text--ellipsis mm-text--color-text-default box--flex-direction-row"
>
Test Account
</div>
<div
class="box box--display-flex box--flex-direction-row box--align-items-center"
>
<div
class="box mm-text mm-text--body-md mm-text--text-align-end mm-text--color-text-default box--flex-direction-row"
>
<div
class="currency-display-component"
title="0.006 ETH"
>
<span
class="currency-display-component__prefix"
/>
<span
class="currency-display-component__text"
>
0.006
</span>
<span
class="currency-display-component__suffix"
>
ETH
</span>
</div>
</div>
</div>
</div>
</div>
<div
class="box box--display-flex box--flex-direction-row box--justify-content-space-between"
>
<p
class="box mm-text mm-text--body-sm mm-text--color-text-alternative box--flex-direction-row"
>
0x0dc...e7bc
</p>
<div
class="box mm-text mm-text--body-sm mm-text--text-align-end mm-text--color-text-alternative box--flex-direction-row"
>
<div
class="currency-display-component"
title="0.006 ETH"
>
<span
class="currency-display-component__prefix"
/>
<span
class="currency-display-component__text"
>
0.006
</span>
<span
class="currency-display-component__suffix"
>
ETH
</span>
</div>
</div>
</div>
</div>
<div>
<div
aria-label="Test Account Options"
class="box mm-button-icon mm-button-icon--size-sm box--display-inline-flex box--flex-direction-row box--justify-content-center box--align-items-center box--color-icon-default box--background-color-transparent box--rounded-lg"
data-testid="account-list-item-menu-button"
tabindex="0"
>
<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/more-vertical.svg');"
/>
</div>
</div>
</button>
</div>
`;

View File

@ -0,0 +1,255 @@
import React, { useState, useRef } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { useSelector } from 'react-redux';
import { useI18nContext } from '../../../hooks/useI18nContext';
import { getRpcPrefsForCurrentProvider } from '../../../selectors';
import { getURLHostName, shortenAddress } from '../../../helpers/utils/util';
import { AccountListItemMenu } from '..';
import Box from '../../ui/box/box';
import {
AvatarAccount,
ButtonIcon,
Text,
ICON_NAMES,
ICON_SIZES,
AvatarFavicon,
Tag,
} from '../../component-library';
import {
Color,
TEXT_ALIGN,
AlignItems,
DISPLAY,
TextVariant,
FLEX_DIRECTION,
BorderRadius,
JustifyContent,
Size,
BorderColor,
} from '../../../helpers/constants/design-system';
import {
HardwareKeyringTypes,
HardwareKeyringNames,
} from '../../../../shared/constants/hardware-wallets';
import UserPreferencedCurrencyDisplay from '../../app/user-preferenced-currency-display/user-preferenced-currency-display.component';
import { SECONDARY, PRIMARY } from '../../../helpers/constants/common';
import { findKeyringForAddress } from '../../../ducks/metamask/metamask';
import Tooltip from '../../ui/tooltip/tooltip';
const MAXIMUM_CURRENCY_DECIMALS = 3;
const MAXIMUM_CHARACTERS_WITHOUT_TOOLTIP = 17;
function getLabel(keyring = {}, t) {
const { type } = keyring;
switch (type) {
case HardwareKeyringTypes.qr:
return HardwareKeyringNames.qr;
case HardwareKeyringTypes.imported:
return t('imported');
case HardwareKeyringTypes.trezor:
return HardwareKeyringNames.trezor;
case HardwareKeyringTypes.ledger:
return HardwareKeyringNames.ledger;
case HardwareKeyringTypes.lattice:
return HardwareKeyringNames.lattice;
default:
return null;
}
}
export const AccountListItem = ({
identity,
selected = false,
onClick,
closeMenu,
connectedAvatar,
connectedAvatarName,
}) => {
const t = useI18nContext();
const [accountOptionsMenuOpen, setAccountOptionsMenuOpen] = useState(false);
const ref = useRef(false);
const keyring = useSelector((state) =>
findKeyringForAddress(state, identity.address),
);
const label = getLabel(keyring, t);
const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider);
const { blockExplorerUrl } = rpcPrefs;
const blockExplorerUrlSubTitle = getURLHostName(blockExplorerUrl);
return (
<Box
display={DISPLAY.FLEX}
padding={4}
gap={2}
backgroundColor={selected ? Color.primaryMuted : Color.transparent}
className={classnames('multichain-account-list-item', {
'multichain-account-list-item--selected': selected,
})}
as="button"
onClick={(e) => {
e.preventDefault();
// Without this check, the account will be selected after
// the account options menu closes
if (!accountOptionsMenuOpen) {
onClick();
}
}}
>
{selected && (
<Box
className="multichain-account-list-item__selected-indicator"
borderRadius={BorderRadius.pill}
backgroundColor={Color.primaryDefault}
/>
)}
<AvatarAccount
borderColor={BorderColor.transparent}
size={Size.SM}
address={identity.address}
></AvatarAccount>
<Box
display={DISPLAY.FLEX}
flexDirection={FLEX_DIRECTION.COLUMN}
className="multichain-account-list-item__content"
>
<Box display={DISPLAY.FLEX} flexDirection={FLEX_DIRECTION.COLUMN}>
<Box
display={DISPLAY.FLEX}
justifyContent={JustifyContent.spaceBetween}
gap={2}
>
<Text ellipsis as="div">
{identity.name.length > MAXIMUM_CHARACTERS_WITHOUT_TOOLTIP ? (
<Tooltip
title={identity.name}
position="bottom"
wrapperClassName="multichain-account-list-item__tooltip"
>
{identity.name}
</Tooltip>
) : (
identity.name
)}
</Text>
<Box
display={DISPLAY.FLEX}
flexDirection={FLEX_DIRECTION.ROW}
alignItems={AlignItems.center}
>
{connectedAvatar ? (
<AvatarFavicon
size={Size.XS}
src={connectedAvatar}
name={connectedAvatarName}
marginInlineEnd={2}
/>
) : null}
<Text textAlign={TEXT_ALIGN.END} as="div">
<UserPreferencedCurrencyDisplay
ethNumberOfDecimals={MAXIMUM_CURRENCY_DECIMALS}
value={identity.balance}
type={SECONDARY}
/>
</Text>
</Box>
</Box>
</Box>
<Box
display={DISPLAY.FLEX}
justifyContent={JustifyContent.spaceBetween}
>
<Text variant={TextVariant.bodySm} color={Color.textAlternative}>
{shortenAddress(identity.address)}
</Text>
<Text
variant={TextVariant.bodySm}
color={Color.textAlternative}
textAlign={TEXT_ALIGN.END}
as="div"
>
<UserPreferencedCurrencyDisplay
ethNumberOfDecimals={MAXIMUM_CURRENCY_DECIMALS}
value={identity.balance}
type={PRIMARY}
/>
</Text>
</Box>
{label ? (
<Tag
label={label}
labelProps={{
variant: TextVariant.bodyXs,
color: Color.textAlternative,
}}
/>
) : null}
</Box>
<div ref={ref}>
<ButtonIcon
ariaLabel={`${identity.name} ${t('options')}`}
iconName={ICON_NAMES.MORE_VERTICAL}
size={ICON_SIZES.SM}
onClick={(e) => {
e.stopPropagation();
setAccountOptionsMenuOpen(true);
}}
as="div"
tabIndex={0}
onKeyPress={(e) => {
if (e.key === 'Enter') {
setAccountOptionsMenuOpen(true);
}
}}
data-testid="account-list-item-menu-button"
/>
{accountOptionsMenuOpen ? (
<AccountListItemMenu
anchorElement={ref.current}
blockExplorerUrlSubTitle={blockExplorerUrlSubTitle}
identity={identity}
onClose={() => setAccountOptionsMenuOpen(false)}
isRemovable={keyring?.type !== HardwareKeyringTypes.hdKeyTree}
closeMenu={closeMenu}
/>
) : null}
</div>
</Box>
);
};
AccountListItem.propTypes = {
/**
* Identity of the account
*/
identity: PropTypes.shape({
name: PropTypes.string.isRequired,
address: PropTypes.string.isRequired,
balance: PropTypes.string.isRequired,
}).isRequired,
/**
* Represents if this account is currently selected
*/
selected: PropTypes.bool,
/**
* Function to execute when the item is clicked
*/
onClick: PropTypes.func.isRequired,
/**
* Function that closes the menu
*/
closeMenu: PropTypes.func,
/**
* File location of the avatar icon
*/
connectedAvatar: PropTypes.string,
/**
* Text used as the avatar alt text
*/
connectedAvatarName: PropTypes.string,
};
AccountListItem.displayName = 'AccountListItem';

View File

@ -0,0 +1,129 @@
import React from 'react';
import { Provider } from 'react-redux';
import testData from '../../../../.storybook/test-data';
import configureStore from '../../../store/store';
import { AccountListItem } from '.';
const store = configureStore(testData);
const [chaosAddress, simpleAddress, hardwareAddress] = Object.keys(
testData.metamask.identities,
);
const SIMPLE_IDENTITY = {
...testData.metamask.identities[simpleAddress],
balance: '0x152387ad22c3f0',
};
const HARDWARE_IDENTITY = {
...testData.metamask.identities[hardwareAddress],
balance: '0x152387ad22c3f0',
};
const CHAOS_IDENTITY = {
...testData.metamask.identities[chaosAddress],
balance: '0x152387ad22c3f0',
};
const CONTAINER_STYLES = {
style: {
width: '328px',
border: '1px solid var(--color-border-muted)',
},
};
const onClick = () => console.log('Clicked account!');
export default {
title: 'Components/Multichain/AccountListItem',
component: AccountListItem,
argTypes: {
identity: {
control: 'object',
},
selected: {
control: 'boolean',
},
onClick: {
action: 'onClick',
},
closeMenu: {
action: 'closeMenu',
},
connectedAvatar: {
control: 'text',
},
connectedAvatarName: {
control: 'text',
},
},
args: {
identity: SIMPLE_IDENTITY,
onClick,
},
};
export const DefaultStory = (args) => (
<div {...CONTAINER_STYLES}>
<AccountListItem {...args} />
</div>
);
export const SelectedItem = (args) => (
<div {...CONTAINER_STYLES}>
<AccountListItem {...args} />
</div>
);
SelectedItem.args = { selected: true };
export const HardwareItem = (args) => (
<div {...CONTAINER_STYLES}>
<AccountListItem {...args} />
</div>
);
HardwareItem.args = { identity: HARDWARE_IDENTITY };
HardwareItem.decorators = [
(story) => <Provider store={store}>{story()}</Provider>,
];
export const SelectedHardwareItem = (args) => (
<div {...CONTAINER_STYLES}>
<AccountListItem {...args} />
</div>
);
SelectedHardwareItem.args = { identity: HARDWARE_IDENTITY, selected: true };
SelectedHardwareItem.decorators = [
(story) => <Provider store={store}>{story()}</Provider>,
];
export const ChaosDataItem = (args) => (
<div {...CONTAINER_STYLES}>
<AccountListItem {...args} />
</div>
);
ChaosDataItem.args = { identity: CHAOS_IDENTITY };
export const ConnectedSiteItem = (args) => (
<div {...CONTAINER_STYLES}>
<AccountListItem {...args} />
</div>
);
ConnectedSiteItem.args = {
connectedAvatar: 'https://uniswap.org/favicon.ico',
connectedAvatarName: 'Uniswap',
};
export const ConnectedSiteChaosItem = (args) => (
<div {...CONTAINER_STYLES}>
<AccountListItem {...args} />
</div>
);
ConnectedSiteChaosItem.args = {
identity: CHAOS_IDENTITY,
connectedAvatar: 'https://uniswap.org/favicon.ico',
connectedAvatarName: 'Uniswap',
};
DefaultStory.storyName = 'Default';

View File

@ -0,0 +1,103 @@
/* eslint-disable jest/require-top-level-describe */
import React from 'react';
import { screen, fireEvent } from '@testing-library/react';
import { renderWithProvider } from '../../../../test/jest';
import configureStore from '../../../store/store';
import mockState from '../../../../test/data/mock-state.json';
import { shortenAddress } from '../../../helpers/utils/util';
import { AccountListItem } from '.';
const identity = {
...mockState.metamask.identities[
'0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'
],
balance: '0x152387ad22c3f0',
};
const DEFAULT_PROPS = {
identity,
onClick: jest.fn(),
};
const render = (props = {}) => {
const store = configureStore({
metamask: {
...mockState.metamask,
},
});
const allProps = { ...DEFAULT_PROPS, ...props };
return renderWithProvider(<AccountListItem {...allProps} />, store);
};
describe('AccountListItem', () => {
it('renders AccountListItem component and shows account name, address, and balance', () => {
const { container } = render();
expect(screen.getByText(identity.name)).toBeInTheDocument();
expect(
screen.getByText(shortenAddress(identity.address)),
).toBeInTheDocument();
expect(document.querySelector('[title="0.006 ETH"]')).toBeInTheDocument();
expect(container).toMatchSnapshot();
});
it('renders selected block when account is selected', () => {
render({ selected: true });
expect(
document.querySelector('.multichain-account-list-item--selected'),
).toBeInTheDocument();
});
it('renders the account name tooltip for long names', () => {
render({
selected: true,
identity: {
...identity,
name: 'This is a super long name that requires tooltip',
},
});
expect(
document.querySelector('.multichain-account-list-item__tooltip'),
).toBeInTheDocument();
});
it('renders the tree-dot menu to lauch the details menu', () => {
render();
const optionsButton = document.querySelector(
'[aria-label="Test Account Options"]',
);
expect(optionsButton).toBeInTheDocument();
fireEvent.click(optionsButton);
expect(document.querySelector('.menu__background')).toBeInTheDocument();
});
it('executes the action when the item is clicked', () => {
const onClick = jest.fn();
render({ onClick });
const item = document.querySelector('.multichain-account-list-item');
fireEvent.click(item);
expect(onClick).toHaveBeenCalled();
});
it('clicking the three-dot menu opens up options', () => {
const onClick = jest.fn();
render({ onClick });
const item = document.querySelector(
'[data-testid="account-list-item-menu-button"]',
);
fireEvent.click(item);
expect(
document.querySelector('[data-testid="account-list-menu-open-explorer"]'),
).toBeInTheDocument();
});
it('renders connected site icon', () => {
const connectedAvatarName = 'Uniswap';
const { getByAltText } = render({
connectedAvatar: 'https://uniswap.org/favicon.ico',
connectedAvatarName,
});
expect(getByAltText(`${connectedAvatarName} logo`)).toBeInTheDocument();
});
});

View File

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

View File

@ -0,0 +1,32 @@
.multichain-account-list-item {
position: relative;
width: 100%;
&:not(.account-list-item--selected) {
&:hover,
&:focus-within {
background: var(--color-background-default-hover);
}
}
&__selected-indicator {
width: 4px;
height: calc(100% - 8px);
position: absolute;
top: 4px;
left: 4px;
}
&__content {
overflow: hidden;
flex: 1;
}
.currency-display-component {
flex-wrap: nowrap;
}
&__tooltip {
display: inline;
}
}

View File

@ -0,0 +1,196 @@
import React, { useState, useContext } from 'react';
import PropTypes from 'prop-types';
import { useHistory } from 'react-router-dom';
import Fuse from 'fuse.js';
import { useDispatch, useSelector } from 'react-redux';
import Box from '../../ui/box/box';
import {
ButtonLink,
ICON_NAMES,
TextFieldSearch,
Text,
} from '../../component-library';
import { AccountListItem } from '..';
import {
BLOCK_SIZES,
Size,
TextColor,
} from '../../../helpers/constants/design-system';
import { useI18nContext } from '../../../hooks/useI18nContext';
import { MetaMetricsContext } from '../../../contexts/metametrics';
import Popover from '../../ui/popover';
import {
getSelectedAccount,
getMetaMaskAccountsOrdered,
getConnectedSubjectsForAllAddresses,
getOriginOfCurrentTab,
} from '../../../selectors';
import { toggleAccountMenu, setSelectedAccount } from '../../../store/actions';
import { EVENT_NAMES, EVENT } from '../../../../shared/constants/metametrics';
import {
IMPORT_ACCOUNT_ROUTE,
NEW_ACCOUNT_ROUTE,
CONNECT_HARDWARE_ROUTE,
} from '../../../helpers/constants/routes';
import { getEnvironmentType } from '../../../../app/scripts/lib/util';
import { ENVIRONMENT_TYPE_POPUP } from '../../../../shared/constants/app';
export const AccountListMenu = ({ onClose }) => {
const t = useI18nContext();
const trackEvent = useContext(MetaMetricsContext);
const accounts = useSelector(getMetaMaskAccountsOrdered);
const selectedAccount = useSelector(getSelectedAccount);
const connectedSites = useSelector(getConnectedSubjectsForAllAddresses);
const currentTabOrigin = useSelector(getOriginOfCurrentTab);
const history = useHistory();
const dispatch = useDispatch();
const [searchQuery, setSearchQuery] = useState('');
let searchResults = accounts;
if (searchQuery) {
const fuse = new Fuse(accounts, {
threshold: 0.2,
location: 0,
distance: 100,
maxPatternLength: 32,
minMatchCharLength: 1,
keys: ['name', 'address'],
});
fuse.setCollection(accounts);
searchResults = fuse.search(searchQuery);
}
return (
<Popover title={t('selectAnAccount')} centerTitle onClose={onClose}>
<Box className="multichain-account-menu">
{/* Search box */}
<Box paddingLeft={4} paddingRight={4} paddingBottom={4} paddingTop={0}>
<TextFieldSearch
size={Size.SM}
width={BLOCK_SIZES.FULL}
placeholder={t('searchAccounts')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</Box>
{/* Account list block */}
<Box className="multichain-account-menu__list">
{searchResults.length === 0 && searchQuery !== '' ? (
<Text
paddingLeft={4}
paddingRight={4}
color={TextColor.textMuted}
data-testid="multichain-account-menu-no-results"
>
{t('noAccountsFound')}
</Text>
) : null}
{searchResults.map((account) => {
const connectedSite = connectedSites[account.address]?.find(
({ origin }) => origin === currentTabOrigin,
);
return (
<AccountListItem
onClick={() => {
dispatch(toggleAccountMenu());
trackEvent({
category: EVENT.CATEGORIES.NAVIGATION,
event: EVENT_NAMES.NAV_ACCOUNT_SWITCHED,
properties: {
location: 'Main Menu',
},
});
dispatch(setSelectedAccount(account.address));
}}
identity={account}
key={account.address}
selected={selectedAccount.address === account.address}
closeMenu={onClose}
connectedAvatar={connectedSite?.iconUrl}
connectedAvatarName={connectedSite?.name}
/>
);
})}
</Box>
{/* Add / Import / Hardware */}
<Box padding={4}>
<Box marginBottom={4}>
<ButtonLink
size={Size.SM}
startIconName={ICON_NAMES.ADD}
onClick={() => {
dispatch(toggleAccountMenu());
trackEvent({
category: EVENT.CATEGORIES.NAVIGATION,
event: EVENT_NAMES.ACCOUNT_ADD_SELECTED,
properties: {
account_type: EVENT.ACCOUNT_TYPES.DEFAULT,
location: 'Main Menu',
},
});
history.push(NEW_ACCOUNT_ROUTE);
}}
>
{t('addAccount')}
</ButtonLink>
</Box>
<Box marginBottom={4}>
<ButtonLink
size={Size.SM}
startIconName={ICON_NAMES.IMPORT}
onClick={() => {
dispatch(toggleAccountMenu());
trackEvent({
category: EVENT.CATEGORIES.NAVIGATION,
event: EVENT_NAMES.ACCOUNT_ADD_SELECTED,
properties: {
account_type: EVENT.ACCOUNT_TYPES.IMPORTED,
location: 'Main Menu',
},
});
history.push(IMPORT_ACCOUNT_ROUTE);
}}
>
{t('importAccount')}
</ButtonLink>
</Box>
<Box>
<ButtonLink
size={Size.SM}
startIconName={ICON_NAMES.HARDWARE}
onClick={() => {
dispatch(toggleAccountMenu());
trackEvent({
category: EVENT.CATEGORIES.NAVIGATION,
event: EVENT_NAMES.ACCOUNT_ADD_SELECTED,
properties: {
account_type: EVENT.ACCOUNT_TYPES.HARDWARE,
location: 'Main Menu',
},
});
if (getEnvironmentType() === ENVIRONMENT_TYPE_POPUP) {
global.platform.openExtensionInBrowser(
CONNECT_HARDWARE_ROUTE,
);
} else {
history.push(CONNECT_HARDWARE_ROUTE);
}
}}
>
{t('hardwareWallet')}
</ButtonLink>
</Box>
</Box>
</Box>
</Popover>
);
};
AccountListMenu.propTypes = {
/**
* Function that executes when the menu closes
*/
onClose: PropTypes.func.isRequired,
};

View File

@ -0,0 +1,14 @@
import React from 'react';
import { AccountListMenu } from '.';
export default {
title: 'Components/Multichain/AccountListMenu',
component: AccountListMenu,
argTypes: {
onClose: {
action: 'onClose',
},
},
};
export const DefaultStory = (args) => <AccountListMenu {...args} />;

View File

@ -0,0 +1,103 @@
/* eslint-disable jest/require-top-level-describe */
import React from 'react';
import reactRouterDom from 'react-router-dom';
import { fireEvent, renderWithProvider } from '../../../../test/jest';
import configureStore from '../../../store/store';
import mockState from '../../../../test/data/mock-state.json';
import {
NEW_ACCOUNT_ROUTE,
IMPORT_ACCOUNT_ROUTE,
CONNECT_HARDWARE_ROUTE,
} from '../../../helpers/constants/routes';
import { AccountListMenu } from '.';
const render = (props = { onClose: () => jest.fn() }) => {
const store = configureStore({
activeTab: {
id: 113,
title: 'E2E Test Dapp',
origin: 'https://metamask.github.io',
protocol: 'https:',
url: 'https://metamask.github.io/test-dapp/',
},
metamask: {
...mockState.metamask,
},
});
return renderWithProvider(<AccountListMenu {...props} />, store);
};
describe('AccountListMenu', () => {
const historyPushMock = jest.fn();
jest
.spyOn(reactRouterDom, 'useHistory')
.mockImplementation()
.mockReturnValue({ push: historyPushMock });
afterEach(() => {
jest.clearAllMocks();
});
it('displays important controls', () => {
const { getByPlaceholderText, getByText } = render();
expect(getByPlaceholderText('Search accounts')).toBeInTheDocument();
expect(getByText('Add account')).toBeInTheDocument();
expect(getByText('Import account')).toBeInTheDocument();
expect(getByText('Hardware wallet')).toBeInTheDocument();
});
it('navigates to new account screen when clicked', () => {
const { getByText } = render();
fireEvent.click(getByText('Add account'));
expect(historyPushMock).toHaveBeenCalledWith(NEW_ACCOUNT_ROUTE);
});
it('navigates to import account screen when clicked', () => {
const { getByText } = render();
fireEvent.click(getByText('Import account'));
expect(historyPushMock).toHaveBeenCalledWith(IMPORT_ACCOUNT_ROUTE);
});
it('navigates to hardware wallet connection screen when clicked', () => {
const { getByText } = render();
fireEvent.click(getByText('Hardware wallet'));
expect(historyPushMock).toHaveBeenCalledWith(CONNECT_HARDWARE_ROUTE);
});
it('displays accounts for list and filters by search', () => {
render();
const listItems = document.querySelectorAll(
'.multichain-account-list-item',
);
expect(listItems).toHaveLength(4);
const searchBox = document.querySelector('input[type=search]');
fireEvent.change(searchBox, {
target: { value: 'Le' },
});
const filteredListItems = document.querySelectorAll(
'.multichain-account-list-item',
);
expect(filteredListItems).toHaveLength(1);
});
it('displays the "no accounts" message when search finds nothing', () => {
const { getByTestId } = render();
const searchBox = document.querySelector('input[type=search]');
fireEvent.change(searchBox, {
target: { value: 'adslfkjlx' },
});
const filteredListItems = document.querySelectorAll(
'.multichain-account-list-item',
);
expect(filteredListItems).toHaveLength(0);
expect(
getByTestId('multichain-account-menu-no-results'),
).toBeInTheDocument();
});
});

View File

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

View File

@ -0,0 +1,6 @@
.multichain-account-menu {
&__list {
max-height: 200px;
overflow: auto;
}
}

View File

@ -0,0 +1,3 @@
export { AccountListItem } from './account-list-item';
export { AccountListItemMenu } from './account-list-item-menu';
export { AccountListMenu } from './account-list-menu';

View File

@ -0,0 +1,2 @@
@import 'account-list-item/index';
@import 'account-list-menu/index';

View File

@ -2,7 +2,8 @@ import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { Icon, ICON_SIZES } from '../../component-library';
import { Text, Icon, ICON_SIZES } from '../../component-library';
import { TextVariant } from '../../../helpers/constants/design-system';
const MenuItem = ({
children,
@ -22,7 +23,7 @@ const MenuItem = ({
) : null}
<div>
<div>{children}</div>
{subtitle ? <div>{subtitle}</div> : null}
{subtitle ? <Text variant={TextVariant.bodyXs}>{subtitle}</Text> : null}
</div>
</button>
);

View File

@ -22,6 +22,10 @@
&-header {
position: relative;
&__title--center {
flex: 1;
}
}
&-bg {

View File

@ -15,6 +15,8 @@ import {
Size,
BorderColor,
IconColor,
TEXT_ALIGN,
BLOCK_SIZES,
} from '../../../helpers/constants/design-system';
import {
ButtonIcon,
@ -76,9 +78,10 @@ const Popover = ({
<Box
display={DISPLAY.FLEX}
alignItems={AlignItems.center}
justifyContent={
centerTitle ? JustifyContent.center : JustifyContent.spaceBetween
}
justifyContent={centerTitle ? null : JustifyContent.spaceBetween}
className={classnames('popover-header__title', {
'popover-header__title--center': centerTitle,
})}
marginBottom={2}
>
{onBack ? (
@ -90,7 +93,13 @@ const Popover = ({
size={Size.SM}
/>
) : null}
<Text ellipsis variant={TextVariant.headingSm} as="h2">
<Text
textAlign={centerTitle ? TEXT_ALIGN.CENTER : TEXT_ALIGN.START}
ellipsis
variant={TextVariant.headingSm}
as="h2"
width={BLOCK_SIZES.FULL}
>
{title}
</Text>
{onClose ? (

View File

@ -10,6 +10,7 @@
@import './base-styles.scss';
@import '../components/component-library/component-library-components.scss';
@import '../components/app/app-components';
@import '../components/multichain/index.scss';
@import '../components/ui/ui-components';
@import '../pages/pages';
@import './errors.scss';

View File

@ -303,6 +303,7 @@ export const TEXT_ALIGN = {
RIGHT: 'right',
JUSTIFY: 'justify',
END: 'end',
START: 'start',
};
export const TEXT_TRANSFORM = {

View File

@ -90,6 +90,7 @@ import { SEND_STAGES } from '../../ducks/send';
import DeprecatedTestNetworks from '../../components/ui/deprecated-test-networks/deprecated-test-networks';
import NewNetworkInfo from '../../components/ui/new-network-info/new-network-info';
import { ThemeType } from '../../../shared/constants/preferences';
import { AccountListMenu } from '../../components/multichain';
export default class Routes extends Component {
static propTypes = {
@ -125,6 +126,8 @@ export default class Routes extends Component {
forgottenPassword: PropTypes.bool,
isCurrentProviderCustom: PropTypes.bool,
completedOnboarding: PropTypes.bool,
isAccountMenuOpen: PropTypes.bool,
toggleAccountMenu: PropTypes.func,
};
static contextTypes = {
@ -427,6 +430,8 @@ export default class Routes extends Component {
shouldShowSeedPhraseReminder,
isCurrentProviderCustom,
completedOnboarding,
isAccountMenuOpen,
toggleAccountMenu,
} = this.props;
const loadMessage =
loadingMessage || isNetworkLoading
@ -483,7 +488,10 @@ export default class Routes extends Component {
)}
{this.showOnboardingHeader() && <OnboardingAppHeader />}
{completedOnboarding ? <NetworkDropdown /> : null}
<AccountMenu />
{process.env.MULTICHAIN ? null : <AccountMenu />}
{process.env.MULTICHAIN && isAccountMenuOpen ? (
<AccountListMenu onClose={() => toggleAccountMenu()} />
) : null}
<div className="main-container-wrapper">
{isLoading ? <Loading loadingMessage={loadMessage} /> : null}
{!isLoading && isNetworkLoading ? <LoadingNetwork /> : null}

View File

@ -18,6 +18,7 @@ import {
setCurrentCurrency,
setLastActiveTime,
setMouseUserState,
toggleAccountMenu,
} from '../../store/actions';
import { pageChanged } from '../../ducks/history/history';
import { prepareToLeaveSwaps } from '../../ducks/swaps/swaps';
@ -55,6 +56,7 @@ function mapStateToProps(state) {
forgottenPassword: state.metamask.forgottenPassword,
isCurrentProviderCustom: isCurrentProviderCustom(state),
completedOnboarding,
isAccountMenuOpen: state.metamask.isAccountMenuOpen,
};
}
@ -67,6 +69,7 @@ function mapDispatchToProps(dispatch) {
setLastActiveTime: () => dispatch(setLastActiveTime()),
pageChanged: (path) => dispatch(pageChanged(path)),
prepareToLeaveSwaps: () => dispatch(prepareToLeaveSwaps()),
toggleAccountMenu: () => dispatch(toggleAccountMenu()),
};
}

View File

@ -100,6 +100,24 @@ export function getConnectedSubjectsForSelectedAddress(state) {
return connectedSubjects;
}
export function getConnectedSubjectsForAllAddresses(state) {
const subjects = getPermissionSubjects(state);
const subjectMetadata = getSubjectMetadata(state);
const accountsToConnections = {};
Object.entries(subjects).forEach(([subjectKey, subjectValue]) => {
const exposedAccounts = getAccountsFromSubject(subjectValue);
exposedAccounts.forEach((address) => {
if (!accountsToConnections[address]) {
accountsToConnections[address] = [];
}
accountsToConnections[address].push(subjectMetadata[subjectKey] || {});
});
});
return accountsToConnections;
}
export function getSubjectsWithPermission(state, permissionName) {
const subjects = getPermissionSubjects(state);