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:
parent
7dab4b53a4
commit
c079c4320e
@ -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=
|
||||
|
@ -1179,9 +1179,14 @@ const state = {
|
||||
accounts: [
|
||||
'0x64a845a5b02460acf8a3d84503b0d68d028b4bb4',
|
||||
'0xb19ac54efa18cc3a14a5b821bfec73d284bf0c5e',
|
||||
'0x9d0ba4ddac06032527b140912ec808ab9451b788',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: HardwareKeyringTypes.ledger,
|
||||
accounts: [
|
||||
'0x9d0ba4ddac06032527b140912ec808ab9451b788'
|
||||
],
|
||||
}
|
||||
],
|
||||
networkConfigurations: {
|
||||
'test-networkConfigurationId-1': {
|
||||
|
9
app/_locales/en/messages.json
generated
9
app/_locales/en/messages.json
generated
@ -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"
|
||||
},
|
||||
|
@ -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',
|
||||
|
@ -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({
|
||||
|
@ -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,
|
||||
};
|
@ -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';
|
@ -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();
|
||||
});
|
||||
});
|
1
ui/components/multichain/account-list-item-menu/index.js
Normal file
1
ui/components/multichain/account-list-item-menu/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { AccountListItemMenu } from './account-list-item-menu';
|
@ -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>
|
||||
`;
|
255
ui/components/multichain/account-list-item/account-list-item.js
Normal file
255
ui/components/multichain/account-list-item/account-list-item.js
Normal 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';
|
@ -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';
|
@ -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();
|
||||
});
|
||||
});
|
1
ui/components/multichain/account-list-item/index.js
Normal file
1
ui/components/multichain/account-list-item/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { AccountListItem } from './account-list-item';
|
32
ui/components/multichain/account-list-item/index.scss
Normal file
32
ui/components/multichain/account-list-item/index.scss
Normal 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;
|
||||
}
|
||||
}
|
196
ui/components/multichain/account-list-menu/account-list-menu.js
Normal file
196
ui/components/multichain/account-list-menu/account-list-menu.js
Normal 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,
|
||||
};
|
@ -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} />;
|
@ -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();
|
||||
});
|
||||
});
|
1
ui/components/multichain/account-list-menu/index.js
Normal file
1
ui/components/multichain/account-list-menu/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { AccountListMenu } from './account-list-menu';
|
6
ui/components/multichain/account-list-menu/index.scss
Normal file
6
ui/components/multichain/account-list-menu/index.scss
Normal file
@ -0,0 +1,6 @@
|
||||
.multichain-account-menu {
|
||||
&__list {
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
3
ui/components/multichain/index.js
Normal file
3
ui/components/multichain/index.js
Normal file
@ -0,0 +1,3 @@
|
||||
export { AccountListItem } from './account-list-item';
|
||||
export { AccountListItemMenu } from './account-list-item-menu';
|
||||
export { AccountListMenu } from './account-list-menu';
|
2
ui/components/multichain/index.scss
Normal file
2
ui/components/multichain/index.scss
Normal file
@ -0,0 +1,2 @@
|
||||
@import 'account-list-item/index';
|
||||
@import 'account-list-menu/index';
|
@ -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>
|
||||
);
|
||||
|
@ -22,6 +22,10 @@
|
||||
|
||||
&-header {
|
||||
position: relative;
|
||||
|
||||
&__title--center {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&-bg {
|
||||
|
@ -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 ? (
|
||||
|
@ -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';
|
||||
|
@ -303,6 +303,7 @@ export const TEXT_ALIGN = {
|
||||
RIGHT: 'right',
|
||||
JUSTIFY: 'justify',
|
||||
END: 'end',
|
||||
START: 'start',
|
||||
};
|
||||
|
||||
export const TEXT_TRANSFORM = {
|
||||
|
@ -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}
|
||||
|
@ -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()),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user