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

UX: Multichain: Move Add Account and Import Account into Account Menu Popover (#19346)

* UX: Multichain: Move Add Account and Import Account into Account Menu Popover

* Create a new CreateAccount component for the Account Menu

* Add actions for import form

* Use separate actions for cancel vs. submit

* Fix jest tests

* Remove commented route navigation

* Accommodate for failing import

* Fix tests

* Remove routes for new account and import

* Remove old create account page

* Move import-account files to multichain directory

* Fix paths on the import files

* Remove deprecated component library variables

* Fix error property of add form

* Fix user-actions-benchmark
This commit is contained in:
David Walsh 2023-06-13 10:07:01 -05:00 committed by GitHub
parent abd2a5559e
commit 28137798b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 562 additions and 575 deletions

View File

@ -45,7 +45,7 @@ describe('Add account', function () {
'[data-testid="multichain-account-menu-add-account"]',
);
await driver.fill('.new-account-create-form input', '2nd account');
await driver.fill('[placeholder="Account 2"]', '2nd account');
await driver.clickElement({ text: 'Create', tag: 'button' });
const accountName = await driver.waitForSelector({
css: '[data-testid="account-menu-icon"]',
@ -86,7 +86,7 @@ describe('Add account', function () {
await driver.clickElement(
'[data-testid="multichain-account-menu-add-account"]',
);
await driver.fill('.new-account-create-form input', '2nd account');
await driver.fill('[placeholder="Account 2"]', '2nd account');
await driver.clickElement({ text: 'Create', tag: 'button' });
await waitForAccountRendered(driver);
@ -190,8 +190,7 @@ describe('Add account', function () {
await driver.clickElement(
'[data-testid="multichain-account-menu-add-account"]',
);
await driver.fill('.new-account-create-form input', '2nd account');
await driver.fill('[placeholder="Account 2"]', '2nd account');
await driver.clickElement({ text: 'Create', tag: 'button' });
// Wait for 2nd account to be created
@ -220,7 +219,10 @@ describe('Add account', function () {
await driver.clickElement('.menu__background');
await driver.clickElement({ text: 'Import account', tag: 'button' });
await driver.fill('#private-key-box', testPrivateKey);
await driver.clickElement({ text: 'Import', tag: 'button' });
await driver.clickElement(
'[data-testid="import-account-confirm-button"]',
);
// Wait for 3rd account to be created
await waitForAccountRendered(driver);

View File

@ -86,7 +86,7 @@ describe('MetaMask Import UI', function () {
await driver.clickElement({ text: 'Add account', tag: 'button' });
// set account name
await driver.fill('.new-account-create-form input', '2nd account');
await driver.fill('[placeholder="Account 2"]', '2nd account');
await driver.delay(regularDelayMs);
await driver.clickElement({ text: 'Create', tag: 'button' });
@ -203,7 +203,9 @@ describe('MetaMask Import UI', function () {
// enter private key
await driver.findClickableElement('#private-key-box');
await driver.fill('#private-key-box', testPrivateKey1);
await driver.clickElement({ text: 'Import', tag: 'button' });
await driver.clickElement(
'[data-testid="import-account-confirm-button"]',
);
// should show the correct account name
const importedAccountName = await driver.findElement(
@ -230,8 +232,9 @@ describe('MetaMask Import UI', function () {
// enter private key
await driver.findClickableElement('#private-key-box');
await driver.fill('#private-key-box', testPrivateKey2);
await driver.findClickableElement({ text: 'Import', tag: 'button' });
await driver.clickElement({ text: 'Import', tag: 'button' });
await driver.clickElement(
'[data-testid="import-account-confirm-button"]',
);
// should see new account in account menu
const importedAccount2Name = await driver.findElement(
@ -303,8 +306,9 @@ describe('MetaMask Import UI', function () {
fileInput.sendKeys(importJsonFile);
await driver.fill('#json-password-box', 'foobarbazqux');
await driver.clickElement({ text: 'Import', tag: 'button' });
await driver.clickElement(
'[data-testid="import-account-confirm-button"]',
);
// should show the correct account name
const importedAccountName = await driver.findElement(
@ -358,8 +362,9 @@ describe('MetaMask Import UI', function () {
// enter private key
await driver.findClickableElement('#private-key-box');
await driver.fill('#private-key-box', testPrivateKey);
await driver.findClickableElement({ text: 'Import', tag: 'button' });
await driver.clickElement({ text: 'Import', tag: 'button' });
await driver.clickElement(
'[data-testid="import-account-confirm-button"]',
);
// error should occur
await driver.waitForSelector({

View File

@ -38,7 +38,7 @@ async function loadNewAccount() {
await driver.clickElement(
'[data-testid="multichain-account-menu-add-account"]',
);
await driver.fill('.new-account-create-form input', '2nd account');
await driver.fill('[placeholder="Account 2"]', '2nd account');
await driver.clickElement({ text: 'Create', tag: 'button' });
await driver.waitForSelector({
css: '.currency-display-component__text',

View File

@ -10,7 +10,7 @@ import {
TextFieldSearch,
Text,
} from '../../component-library';
import { AccountListItem } from '..';
import { AccountListItem, CreateAccount, ImportAccount } from '..';
import {
BLOCK_SIZES,
Size,
@ -38,8 +38,6 @@ import {
MetaMetricsEventName,
} from '../../../../shared/constants/metametrics';
import {
IMPORT_ACCOUNT_ROUTE,
NEW_ACCOUNT_ROUTE,
CONNECT_HARDWARE_ROUTE,
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
CUSTODY_ACCOUNT_ROUTE,
@ -66,6 +64,7 @@ export const AccountListMenu = ({ onClose }) => {
///: END:ONLY_INCLUDE_IN
const [searchQuery, setSearchQuery] = useState('');
const [actionMode, setActionMode] = useState('');
let searchResults = accounts;
if (searchQuery) {
@ -88,209 +87,244 @@ export const AccountListMenu = ({ onClose }) => {
}
}, [inputRef]);
let title = t('selectAnAccount');
if (actionMode === 'add') {
title = t('addAccount');
} else if (actionMode === 'import') {
title = t('importAccount');
}
return (
<Popover
title={t('selectAnAccount')}
title={title}
ref={inputRef}
centerTitle
onClose={onClose}
onBack={actionMode === '' ? null : () => setActionMode('')}
>
<Box className="multichain-account-menu">
{/* Search box */}
{accounts.length > 1 ? (
<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)}
clearButtonOnClick={() => setSearchQuery('')}
clearButtonProps={{
size: Size.SM,
}}
/>
</Box>
) : null}
{/* Account list block */}
<Box className="multichain-account-menu__list">
{searchResults.length === 0 && searchQuery !== '' ? (
<Text
{actionMode === 'add' ? (
<Box paddingLeft={4} paddingRight={4} paddingBottom={4} paddingTop={0}>
<CreateAccount
onActionComplete={(confirmed) => {
if (confirmed) {
dispatch(toggleAccountMenu());
} else {
setActionMode('');
}
}}
/>
</Box>
) : null}
{actionMode === 'import' ? (
<Box paddingLeft={4} paddingRight={4} paddingBottom={4} paddingTop={0}>
<ImportAccount
onActionComplete={(confirmed) => {
if (confirmed) {
dispatch(toggleAccountMenu());
} else {
setActionMode('');
}
}}
/>
</Box>
) : null}
{actionMode === '' ? (
<Box className="multichain-account-menu">
{/* Search box */}
{accounts.length > 1 ? (
<Box
paddingLeft={4}
paddingRight={4}
color={TextColor.textMuted}
data-testid="multichain-account-menu-no-results"
paddingBottom={4}
paddingTop={0}
>
{t('noAccountsFound')}
</Text>
<TextFieldSearch
size={Size.SM}
width={BLOCK_SIZES.FULL}
placeholder={t('searchAccounts')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
clearButtonOnClick={() => setSearchQuery('')}
clearButtonProps={{
size: Size.SM,
}}
/>
</Box>
) : null}
{searchResults.map((account) => {
const connectedSite = connectedSites[account.address]?.find(
({ origin }) => origin === currentTabOrigin,
);
{/* 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
return (
<AccountListItem
onClick={() => {
dispatch(toggleAccountMenu());
trackEvent({
category: MetaMetricsEventCategory.Navigation,
event: MetaMetricsEventName.NavAccountSwitched,
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={IconName.Add}
onClick={() => {
trackEvent({
category: MetaMetricsEventCategory.Navigation,
event: MetaMetricsEventName.AccountAddSelected,
properties: {
account_type: MetaMetricsEventAccountType.Default,
location: 'Main Menu',
},
});
setActionMode('add');
}}
data-testid="multichain-account-menu-add-account"
>
{t('addAccount')}
</ButtonLink>
</Box>
<Box marginBottom={4}>
<ButtonLink
size={Size.SM}
startIconName={IconName.Import}
onClick={() => {
trackEvent({
category: MetaMetricsEventCategory.Navigation,
event: MetaMetricsEventName.AccountAddSelected,
properties: {
account_type: MetaMetricsEventAccountType.Imported,
location: 'Main Menu',
},
});
setActionMode('import');
}}
>
{t('importAccount')}
</ButtonLink>
</Box>
<Box>
<ButtonLink
size={Size.SM}
startIconName={IconName.Hardware}
onClick={() => {
dispatch(toggleAccountMenu());
trackEvent({
category: MetaMetricsEventCategory.Navigation,
event: MetaMetricsEventName.NavAccountSwitched,
event: MetaMetricsEventName.AccountAddSelected,
properties: {
account_type: MetaMetricsEventAccountType.Hardware,
location: 'Main Menu',
},
});
dispatch(setSelectedAccount(account.address));
if (getEnvironmentType() === ENVIRONMENT_TYPE_POPUP) {
global.platform.openExtensionInBrowser(
CONNECT_HARDWARE_ROUTE,
);
} else {
history.push(CONNECT_HARDWARE_ROUTE);
}
}}
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={IconName.Add}
onClick={() => {
dispatch(toggleAccountMenu());
trackEvent({
category: MetaMetricsEventCategory.Navigation,
event: MetaMetricsEventName.AccountAddSelected,
properties: {
account_type: MetaMetricsEventAccountType.Default,
location: 'Main Menu',
},
});
history.push(NEW_ACCOUNT_ROUTE);
}}
data-testid="multichain-account-menu-add-account"
>
{t('addAccount')}
</ButtonLink>
</Box>
<Box marginBottom={4}>
<ButtonLink
size={Size.SM}
startIconName={IconName.Import}
onClick={() => {
dispatch(toggleAccountMenu());
trackEvent({
category: MetaMetricsEventCategory.Navigation,
event: MetaMetricsEventName.AccountAddSelected,
properties: {
account_type: MetaMetricsEventAccountType.Imported,
location: 'Main Menu',
},
});
history.push(IMPORT_ACCOUNT_ROUTE);
}}
>
{t('importAccount')}
</ButtonLink>
</Box>
<Box>
<ButtonLink
size={Size.SM}
startIconName={IconName.Hardware}
onClick={() => {
dispatch(toggleAccountMenu());
trackEvent({
category: MetaMetricsEventCategory.Navigation,
event: MetaMetricsEventName.AccountAddSelected,
properties: {
account_type: MetaMetricsEventAccountType.Hardware,
location: 'Main Menu',
},
});
if (getEnvironmentType() === ENVIRONMENT_TYPE_POPUP) {
global.platform.openExtensionInBrowser(
CONNECT_HARDWARE_ROUTE,
);
} else {
history.push(CONNECT_HARDWARE_ROUTE);
}
}}
>
{t('hardwareWallet')}
</ButtonLink>
{
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
<>
<ButtonLink
size={Size.SM}
startIconName={IconName.Custody}
onClick={() => {
dispatch(toggleAccountMenu());
trackEvent({
category: MetaMetricsEventCategory.Navigation,
event:
MetaMetricsEventName.UserClickedConnectCustodialAccount,
});
if (getEnvironmentType() === ENVIRONMENT_TYPE_POPUP) {
global.platform.openExtensionInBrowser(
CUSTODY_ACCOUNT_ROUTE,
);
} else {
history.push(CUSTODY_ACCOUNT_ROUTE);
}
}}
>
{t('connectCustodialAccountMenu')}
</ButtonLink>
{mmiPortfolioEnabled && (
>
{t('hardwareWallet')}
</ButtonLink>
{
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
<>
<ButtonLink
size={Size.SM}
startIconName={IconName.MmmiPortfolioDashboard}
startIconName={IconName.Custody}
onClick={() => {
dispatch(toggleAccountMenu());
trackEvent({
category: MetaMetricsEventCategory.Navigation,
event: MetaMetricsEventName.UserClickedPortfolioButton,
event:
MetaMetricsEventName.UserClickedConnectCustodialAccount,
});
window.open(mmiPortfolioUrl, '_blank');
if (getEnvironmentType() === ENVIRONMENT_TYPE_POPUP) {
global.platform.openExtensionInBrowser(
CUSTODY_ACCOUNT_ROUTE,
);
} else {
history.push(CUSTODY_ACCOUNT_ROUTE);
}
}}
>
{t('portfolioDashboard')}
{t('connectCustodialAccountMenu')}
</ButtonLink>
)}
<ButtonLink
size={Size.SM}
startIconName={IconName.Compliance}
onClick={() => {
dispatch(toggleAccountMenu());
trackEvent({
category: MetaMetricsEventCategory.Navigation,
event: MetaMetricsEventName.UserClickedCompliance,
});
if (getEnvironmentType() === ENVIRONMENT_TYPE_POPUP) {
global.platform.openExtensionInBrowser(
COMPLIANCE_FEATURE_ROUTE,
);
} else {
history.push(COMPLIANCE_FEATURE_ROUTE);
}
}}
>
{t('compliance')}
</ButtonLink>
</>
///: END:ONLY_INCLUDE_IN
}
{mmiPortfolioEnabled && (
<ButtonLink
size={Size.SM}
startIconName={IconName.MmmiPortfolioDashboard}
onClick={() => {
dispatch(toggleAccountMenu());
trackEvent({
category: MetaMetricsEventCategory.Navigation,
event:
MetaMetricsEventName.UserClickedPortfolioButton,
});
window.open(mmiPortfolioUrl, '_blank');
}}
>
{t('portfolioDashboard')}
</ButtonLink>
)}
<ButtonLink
size={Size.SM}
startIconName={IconName.Compliance}
onClick={() => {
dispatch(toggleAccountMenu());
trackEvent({
category: MetaMetricsEventCategory.Navigation,
event: MetaMetricsEventName.UserClickedCompliance,
});
if (getEnvironmentType() === ENVIRONMENT_TYPE_POPUP) {
global.platform.openExtensionInBrowser(
COMPLIANCE_FEATURE_ROUTE,
);
} else {
history.push(COMPLIANCE_FEATURE_ROUTE);
}
}}
>
{t('compliance')}
</ButtonLink>
</>
///: END:ONLY_INCLUDE_IN
}
</Box>
</Box>
</Box>
</Box>
) : null}
</Popover>
);
};

View File

@ -4,11 +4,7 @@ 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 { CONNECT_HARDWARE_ROUTE } from '../../../helpers/constants/routes';
import { AccountListMenu } from '.';
const render = (props = { onClose: () => jest.fn() }) => {
@ -48,16 +44,24 @@ describe('AccountListMenu', () => {
expect(getByText('Hardware wallet')).toBeInTheDocument();
});
it('navigates to new account screen when clicked', () => {
const { getByText } = render();
it('shows the account creation UI when Add Account is clicked', () => {
const { getByText, getByPlaceholderText } = render();
fireEvent.click(getByText('Add account'));
expect(historyPushMock).toHaveBeenCalledWith(NEW_ACCOUNT_ROUTE);
expect(getByText('Create')).toBeInTheDocument();
expect(getByText('Cancel')).toBeInTheDocument();
fireEvent.click(getByText('Cancel'));
expect(getByPlaceholderText('Search accounts')).toBeInTheDocument();
});
it('navigates to import account screen when clicked', () => {
const { getByText } = render();
it('shows the account import UI when Import Account is clicked', () => {
const { getByText, getByPlaceholderText } = render();
fireEvent.click(getByText('Import account'));
expect(historyPushMock).toHaveBeenCalledWith(IMPORT_ACCOUNT_ROUTE);
expect(getByText('Import')).toBeInTheDocument();
expect(getByText('Cancel')).toBeInTheDocument();
fireEvent.click(getByText('Cancel'));
expect(getByPlaceholderText('Search accounts')).toBeInTheDocument();
});
it('navigates to hardware wallet connection screen when clicked', () => {

View File

@ -0,0 +1,109 @@
import React, { useContext, useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import {
ButtonPrimary,
ButtonSecondary,
FormTextField,
} from '../../component-library';
import { useI18nContext } from '../../../hooks/useI18nContext';
import { getAccountNameErrorMessage } from '../../../helpers/utils/accounts';
import {
getMetaMaskAccountsOrdered,
getMetaMaskIdentities,
} from '../../../selectors';
import { addNewAccount, setAccountLabel } from '../../../store/actions';
import { getMostRecentOverviewPage } from '../../../ducks/history/history';
import Box from '../../ui/box/box';
import {
MetaMetricsEventAccountType,
MetaMetricsEventCategory,
MetaMetricsEventName,
} from '../../../../shared/constants/metametrics';
import { MetaMetricsContext } from '../../../contexts/metametrics';
import { Display } from '../../../helpers/constants/design-system';
export const CreateAccount = ({ onActionComplete }) => {
const t = useI18nContext();
const dispatch = useDispatch();
const history = useHistory();
const trackEvent = useContext(MetaMetricsContext);
const accounts = useSelector(getMetaMaskAccountsOrdered);
const identities = useSelector(getMetaMaskIdentities);
const mostRecentOverviewPage = useSelector(getMostRecentOverviewPage);
const newAccountNumber = Object.keys(identities).length + 1;
const defaultAccountName = t('newAccountNumberName', [newAccountNumber]);
const [newAccountName, setNewAccountName] = useState('');
const { isValidAccountName, errorMessage } = getAccountNameErrorMessage(
accounts,
{ t },
newAccountName,
defaultAccountName,
);
const onCreateAccount = async (name) => {
const newAccountAddress = await dispatch(addNewAccount());
if (name) {
dispatch(setAccountLabel(newAccountAddress, name));
}
};
return (
<Box
as="form"
onSubmit={async (event) => {
event.preventDefault();
try {
await onCreateAccount(newAccountName || defaultAccountName);
onActionComplete(true);
trackEvent({
category: MetaMetricsEventCategory.Accounts,
event: MetaMetricsEventName.AccountAdded,
properties: {
account_type: MetaMetricsEventAccountType.Default,
location: 'Home',
},
});
history.push(mostRecentOverviewPage);
} catch (error) {
trackEvent({
category: MetaMetricsEventCategory.Accounts,
event: MetaMetricsEventName.AccountAddFailed,
properties: {
account_type: MetaMetricsEventAccountType.Default,
error: error.message,
},
});
}
}}
>
<FormTextField
autoFocus
label={t('accountName')}
placeholder={defaultAccountName}
onChange={(event) => setNewAccountName(event.target.value)}
helpText={errorMessage}
error={!isValidAccountName}
/>
<Box display={Display.Flex} marginTop={6} gap={2}>
<ButtonSecondary onClick={() => onActionComplete()} block>
{t('cancel')}
</ButtonSecondary>
<ButtonPrimary type="submit" disabled={!isValidAccountName} block>
{t('create')}
</ButtonPrimary>
</Box>
</Box>
);
};
CreateAccount.propTypes = {
onActionComplete: PropTypes.func.isRequired,
};

View File

@ -0,0 +1,10 @@
import React from 'react';
import { CreateAccount } from '.';
export default {
title: 'Components/Multichain/CreateAccount',
component: CreateAccount,
};
export const DefaultStory = (args) => <CreateAccount {...args} />;
DefaultStory.storyName = 'Default';

View File

@ -0,0 +1,67 @@
/* eslint-disable jest/require-top-level-describe */
import React from 'react';
import { fireEvent, renderWithProvider, waitFor } from '../../../../test/jest';
import configureStore from '../../../store/store';
import mockState from '../../../../test/data/mock-state.json';
import { CreateAccount } from '.';
const render = (props = { onActionComplete: () => jest.fn() }) => {
const store = configureStore(mockState);
return renderWithProvider(<CreateAccount {...props} />, store);
};
const mockAddNewAccount = jest.fn().mockReturnValue({ type: 'TYPE' });
const mockSetAccountLabel = jest.fn().mockReturnValue({ type: 'TYPE' });
jest.mock('../../../store/actions', () => ({
addNewAccount: (...args) => mockAddNewAccount(...args),
setAccountLabel: (...args) => mockSetAccountLabel(...args),
}));
describe('CreateAccount', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('displays account name input and suggests name', () => {
const { getByPlaceholderText } = render();
expect(getByPlaceholderText('Account 5')).toBeInTheDocument();
});
it('fires onActionComplete when clicked', async () => {
const onActionComplete = jest.fn();
const { getByText, getByPlaceholderText } = render({ onActionComplete });
const input = getByPlaceholderText('Account 5');
const newAccountName = 'New Account Name';
fireEvent.change(input, {
target: { value: newAccountName },
});
fireEvent.click(getByText('Create'));
await waitFor(() => expect(mockAddNewAccount).toHaveBeenCalled());
await waitFor(() =>
expect(mockSetAccountLabel).toHaveBeenCalledWith(
{ type: 'TYPE' },
newAccountName,
),
);
await waitFor(() => expect(onActionComplete).toHaveBeenCalled());
});
it(`doesn't allow duplicate account names`, async () => {
const { getByText, getByPlaceholderText } = render();
const input = getByPlaceholderText('Account 5');
const usedAccountName = 'Account 4';
fireEvent.change(input, {
target: { value: usedAccountName },
});
const submitButton = getByText('Create');
expect(submitButton).toHaveAttribute('disabled');
});
});

View File

@ -0,0 +1 @@
export { CreateAccount } from './create-account';

View File

@ -49,6 +49,7 @@ exports[`Json should match snapshot 1`] = `
</button>
<button
class="box mm-text mm-button-base mm-button-base--size-lg mm-button-base--disabled mm-button-base--block mm-button-primary mm-button-primary--disabled mm-text--body-md box--padding-right-4 box--padding-left-4 box--display-inline-flex box--flex-direction-row box--justify-content-center box--align-items-center box--color-primary-inverse box--background-color-primary-default box--rounded-pill"
data-testid="import-account-confirm-button"
disabled=""
>
Import

View File

@ -5,30 +5,32 @@ import {
ButtonPrimary,
ButtonSecondary,
BUTTON_SECONDARY_SIZES,
} from '../../../components/component-library';
import Box from '../../../components/ui/box/box';
import { DISPLAY } from '../../../helpers/constants/design-system';
} from '../../component-library';
import Box from '../../ui/box/box';
import { Display } from '../../../helpers/constants/design-system';
import { useI18nContext } from '../../../hooks/useI18nContext';
import * as actions from '../../../store/actions';
BottomButtons.propTypes = {
importAccountFunc: PropTypes.func.isRequired,
isPrimaryDisabled: PropTypes.bool.isRequired,
onActionComplete: PropTypes.func.isRequired,
};
export default function BottomButtons({
importAccountFunc,
isPrimaryDisabled,
onActionComplete,
}) {
const t = useI18nContext();
const dispatch = useDispatch();
return (
<Box display={DISPLAY.FLEX} gap={4}>
<Box display={Display.Flex} gap={4}>
<ButtonSecondary
onClick={() => {
dispatch(actions.hideWarning());
window.history.back();
onActionComplete();
}}
size={BUTTON_SECONDARY_SIZES.LG}
block
@ -36,9 +38,19 @@ export default function BottomButtons({
{t('cancel')}
</ButtonSecondary>
<ButtonPrimary
onClick={importAccountFunc}
onClick={async () => {
try {
const result = await importAccountFunc();
if (result) {
onActionComplete(true);
}
} catch (e) {
// Take no action
}
}}
disabled={isPrimaryDisabled}
size={BUTTON_SECONDARY_SIZES.LG}
data-testid="import-account-confirm-button"
block
>
{t('import')}

View File

@ -2,7 +2,7 @@ import React from 'react';
import BottomButtons from './bottom-buttons';
export default {
title: 'Pages/CreateAccount/ImportAccount/BottomButtons',
title: 'Components/Multichain/BottomButtons',
component: BottomButtons,
argTypes: {
isPrimaryDisabled: {

View File

@ -1,4 +1,5 @@
import React, { useContext, useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import {
MetaMetricsEventAccountImportType,
@ -6,54 +7,56 @@ import {
MetaMetricsEventCategory,
MetaMetricsEventName,
} from '../../../../shared/constants/metametrics';
import { ButtonLink, Label, Text } from '../../../components/component-library';
import Box from '../../../components/ui/box';
import Dropdown from '../../../components/ui/dropdown';
import { ButtonLink, Label, Text } from '../../component-library';
import Box from '../../ui/box';
import Dropdown from '../../ui/dropdown';
import { MetaMetricsContext } from '../../../contexts/metametrics';
import {
BLOCK_SIZES,
BorderColor,
FONT_WEIGHT,
BlockSize,
FontWeight,
JustifyContent,
Size,
TextVariant,
} from '../../../helpers/constants/design-system';
import ZENDESK_URLS from '../../../helpers/constants/zendesk-url';
import { useI18nContext } from '../../../hooks/useI18nContext';
import { useRouting } from '../../../hooks/useRouting';
import * as actions from '../../../store/actions';
// Subviews
import JsonImportView from './json';
import PrivateKeyImportView from './private-key';
export default function NewAccountImportForm() {
export const ImportAccount = ({ onActionComplete }) => {
const t = useI18nContext();
const dispatch = useDispatch();
const trackEvent = useContext(MetaMetricsContext);
const { navigateToMostRecentOverviewPage } = useRouting();
const menuItems = [t('privateKey'), t('jsonFile')];
const [type, setType] = useState(menuItems[0]);
function importAccount(strategy, importArgs) {
async function importAccount(strategy, importArgs) {
const loadingMessage = getLoadingMessage(strategy);
dispatch(actions.importNewAccount(strategy, importArgs, loadingMessage))
.then(({ selectedAddress }) => {
if (selectedAddress) {
trackImportEvent(strategy, true);
dispatch(actions.hideWarning());
navigateToMostRecentOverviewPage();
} else {
dispatch(actions.displayWarning(t('importAccountError')));
}
})
.catch((error) => {
trackImportEvent(strategy, error.message);
translateWarning(error.message);
});
try {
const { selectedAddress } = await dispatch(
actions.importNewAccount(strategy, importArgs, loadingMessage),
);
if (selectedAddress) {
trackImportEvent(strategy, true);
dispatch(actions.hideWarning());
onActionComplete(true);
} else {
dispatch(actions.displayWarning(t('importAccountError')));
return false;
}
} catch (error) {
trackImportEvent(strategy, error.message);
translateWarning(error.message);
return false;
}
return true;
}
function trackImportEvent(strategy, wasSuccessful) {
@ -79,13 +82,14 @@ export default function NewAccountImportForm() {
function getLoadingMessage(strategy) {
if (strategy === 'JSON File') {
return (
<Text width={BLOCK_SIZES.THREE_FOURTHS} fontWeight={FONT_WEIGHT.BOLD}>
<br />
{t('importAccountJsonLoading1')}
<br />
<br />
{t('importAccountJsonLoading2')}
</Text>
<>
<Text width={BlockSize.ThreeFourths} fontWeight={FontWeight.Bold}>
{t('importAccountJsonLoading1')}
</Text>
<Text width={BlockSize.ThreeFourths} fontWeight={FontWeight.Bold}>
{t('importAccountJsonLoading2')}
</Text>
</>
);
}
@ -112,40 +116,22 @@ export default function NewAccountImportForm() {
}
}
function PrivateKeyOrJson() {
switch (type) {
case menuItems[0]:
return <PrivateKeyImportView importAccountFunc={importAccount} />;
case menuItems[1]:
default:
return <JsonImportView importAccountFunc={importAccount} />;
}
}
return (
<>
<Box
padding={4}
className="bottom-border-1px" // There is no way to do just a bottom border in the Design System
borderColor={BorderColor.borderDefault}
>
<Text variant={TextVariant.headingLg}>{t('importAccount')}</Text>
<Text variant={TextVariant.bodySm} marginTop={2}>
{t('importAccountMsg')}{' '}
<ButtonLink
size={Size.inherit}
href={ZENDESK_URLS.IMPORTED_ACCOUNTS}
target="_blank"
rel="noopener noreferrer"
>
{t('here')}
</ButtonLink>
</Text>
</Box>
<Box padding={4} paddingBottom={8} paddingLeft={4} paddingRight={4}>
<Text variant={TextVariant.bodySm} marginTop={2}>
{t('importAccountMsg')}{' '}
<ButtonLink
size={Size.inherit}
href={ZENDESK_URLS.IMPORTED_ACCOUNTS}
target="_blank"
rel="noopener noreferrer"
>
{t('here')}
</ButtonLink>
</Text>
<Box paddingTop={4} paddingBottom={8}>
<Label
width={BLOCK_SIZES.FULL}
width={BlockSize.Full}
marginBottom={4}
justifyContent={JustifyContent.spaceBetween}
>
@ -159,8 +145,22 @@ export default function NewAccountImportForm() {
}}
/>
</Label>
<PrivateKeyOrJson />
{type === menuItems[0] ? (
<PrivateKeyImportView
importAccountFunc={importAccount}
onActionComplete={onActionComplete}
/>
) : (
<JsonImportView
importAccountFunc={importAccount}
onActionComplete={onActionComplete}
/>
)}
</Box>
</>
);
}
};
ImportAccount.propTypes = {
onActionComplete: PropTypes.func.isRequired,
};

View File

@ -0,0 +1,11 @@
import React from 'react';
import { ImportAccount } from '.';
export default {
title: 'Components/Multichain/ImportAccount',
component: ImportAccount,
};
export const DefaultStory = (args) => <ImportAccount {...args} />;
DefaultStory.storyName = 'Default';

View File

@ -0,0 +1 @@
export { ImportAccount } from './import-account';

View File

@ -8,7 +8,7 @@ import {
Text,
TEXT_FIELD_SIZES,
TEXT_FIELD_TYPES,
} from '../../../components/component-library';
} from '../../component-library';
import {
Size,
TextVariant,
@ -21,9 +21,13 @@ import BottomButtons from './bottom-buttons';
JsonImportSubview.propTypes = {
importAccountFunc: PropTypes.func.isRequired,
onActionComplete: PropTypes.func.isRequired,
};
export default function JsonImportSubview({ importAccountFunc }) {
export default function JsonImportSubview({
importAccountFunc,
onActionComplete,
}) {
const t = useI18nContext();
const warning = useSelector((state) => state.appState.warning);
const [password, setPassword] = useState('');
@ -95,6 +99,7 @@ export default function JsonImportSubview({ importAccountFunc }) {
<BottomButtons
importAccountFunc={_importAccountFunc}
isPrimaryDisabled={isPrimaryDisabled}
onActionComplete={onActionComplete}
/>
</>
);

View File

@ -2,7 +2,7 @@ import React from 'react';
import JsonImportSubview from './json';
export default {
title: 'Pages/CreateAccount/ImportAccount/JsonImportSubview',
title: 'Components/Multichain/JsonImportSubview',
component: JsonImportSubview,
};

View File

@ -7,11 +7,16 @@ import messages from '../../../../app/_locales/en/messages.json';
import Json from './json';
const mockImportFunc = jest.fn();
const mockOnActionComplete = jest.fn();
describe('Json', () => {
const mockStore = configureMockStore()(mockState);
it('should match snapshot', () => {
const { asFragment } = renderWithProvider(
<Json importAccountFunc={mockImportFunc} />,
<Json
importAccountFunc={mockImportFunc}
onActionComplete={mockOnActionComplete}
/>,
mockStore,
);
expect(asFragment()).toMatchSnapshot();
@ -19,7 +24,10 @@ describe('Json', () => {
it('should render', () => {
const { getByText } = renderWithProvider(
<Json importAccountFunc={mockImportFunc} />,
<Json
importAccountFunc={mockImportFunc}
onActionComplete={mockOnActionComplete}
/>,
mockStore,
);
@ -29,7 +37,10 @@ describe('Json', () => {
it('should import file without password', async () => {
const { getByText, getByTestId } = renderWithProvider(
<Json importAccountFunc={mockImportFunc} />,
<Json
importAccountFunc={mockImportFunc}
onActionComplete={mockOnActionComplete}
/>,
mockStore,
);
@ -58,7 +69,10 @@ describe('Json', () => {
it('should import file with password', async () => {
const { getByText, getByTestId, getByPlaceholderText } = renderWithProvider(
<Json importAccountFunc={mockImportFunc} />,
<Json
importAccountFunc={mockImportFunc}
onActionComplete={mockOnActionComplete}
/>,
mockStore,
);

View File

@ -5,15 +5,19 @@ import {
FormTextField,
TEXT_FIELD_SIZES,
TEXT_FIELD_TYPES,
} from '../../../components/component-library';
} from '../../component-library';
import { useI18nContext } from '../../../hooks/useI18nContext';
import BottomButtons from './bottom-buttons';
PrivateKeyImportView.propTypes = {
importAccountFunc: PropTypes.func.isRequired,
onActionComplete: PropTypes.func.isRequired,
};
export default function PrivateKeyImportView({ importAccountFunc }) {
export default function PrivateKeyImportView({
importAccountFunc,
onActionComplete,
}) {
const t = useI18nContext();
const [privateKey, setPrivateKey] = useState('');
@ -51,6 +55,7 @@ export default function PrivateKeyImportView({ importAccountFunc }) {
<BottomButtons
importAccountFunc={_importAccountFunc}
isPrimaryDisabled={privateKey === ''}
onActionComplete={onActionComplete}
/>
</>
);

View File

@ -13,3 +13,5 @@ export { NetworkListItem } from './network-list-item';
export { NetworkListMenu } from './network-list-menu';
export { ProductTour } from './product-tour-popover';
export { AccountDetails } from './account-details';
export { CreateAccount } from './create-account';
export { ImportAccount } from './import-account';

View File

@ -25,8 +25,6 @@ const RESTORE_VAULT_ROUTE = '/restore-vault';
const IMPORT_TOKEN_ROUTE = '/import-token';
const CONFIRM_IMPORT_TOKEN_ROUTE = '/confirm-import-token';
const CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE = '/confirm-add-suggested-token';
const NEW_ACCOUNT_ROUTE = '/new-account';
const IMPORT_ACCOUNT_ROUTE = '/new-account/import';
const CONNECT_HARDWARE_ROUTE = '/new-account/connect';
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
const CUSTODY_ACCOUNT_ROUTE = '/new-account/custody';
@ -129,8 +127,6 @@ const PATH_NAME_MAP = {
[IMPORT_TOKEN_ROUTE]: 'Import Token Page',
[CONFIRM_IMPORT_TOKEN_ROUTE]: 'Confirm Import Token Page',
[CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE]: 'Confirm Add Suggested Token Page',
[NEW_ACCOUNT_ROUTE]: 'New Account Page',
[IMPORT_ACCOUNT_ROUTE]: 'Import Account Page',
[CONNECT_HARDWARE_ROUTE]: 'Connect Hardware Wallet Page',
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
[INSTITUTIONAL_FEATURES_DONE_ROUTE]: 'Institutional Features Done Page',
@ -194,8 +190,6 @@ export {
IMPORT_TOKEN_ROUTE,
CONFIRM_IMPORT_TOKEN_ROUTE,
CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE,
NEW_ACCOUNT_ROUTE,
IMPORT_ACCOUNT_ROUTE,
CONNECT_HARDWARE_ROUTE,
SEND_ROUTE,
TOKEN_DETAILS,

View File

@ -4,8 +4,6 @@ import Box from '../../components/ui/box';
import {
CONNECT_HARDWARE_ROUTE,
IMPORT_ACCOUNT_ROUTE,
NEW_ACCOUNT_ROUTE,
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
CUSTODY_ACCOUNT_ROUTE,
///: END:ONLY_INCLUDE_IN
@ -14,23 +12,11 @@ import {
import CustodyPage from '../institutional/custody';
///: END:ONLY_INCLUDE_IN
import ConnectHardwareForm from './connect-hardware';
import NewAccountImportForm from './import-account';
import NewAccountCreateForm from './new-account.container';
export default function CreateAccountPage() {
return (
<Box className="new-account">
<Switch>
<Route
exact
path={NEW_ACCOUNT_ROUTE}
component={NewAccountCreateForm}
/>
<Route
exact
path={IMPORT_ACCOUNT_ROUTE}
component={NewAccountImportForm}
/>
<Route
exact
path={CONNECT_HARDWARE_ROUTE}

View File

@ -1,11 +0,0 @@
import React from 'react';
import NewAccountImportForm from '.';
export default {
title: 'Pages/CreateAccount/ImportAccount',
component: NewAccountImportForm,
};
export const DefaultStory = (args) => <NewAccountImportForm {...args} />;
DefaultStory.storyName = 'Default';

View File

@ -1 +0,0 @@
export { default } from './import-account';

View File

@ -1,83 +0,0 @@
@import 'connect-hardware/index';
.new-account {
width: 375px;
background-color: var(--color-background-default);
box-shadow: var(--shadow-size-xs) var(--color-shadow-default);
z-index: 25;
height: unset;
overflow: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
@include screen-sm-min {
position: absolute;
}
.bottom-border-1px {
border-width: 0 0 1px 0;
}
}
.new-account-create-form {
display: flex;
flex-flow: column;
align-items: center;
padding: 30px;
&__input-label {
@include Paragraph;
color: var(--color-text-alternative);
align-self: flex-start;
}
&__input {
@include Paragraph;
height: 54px;
width: 315.84px;
border: 1px solid var(--color-border-muted);
border-radius: 4px;
background-color: var(--color-background-default);
color: var(--color-text-default);
margin-top: 15px;
padding: 0 20px;
&__error {
border: 1px solid var(--color-error-alternative);
}
&::placeholder {
color: var(--color-text-muted);
opacity: 1;
}
}
&__error {
@include H7;
left: 8px;
color: var(--color-error-default);
}
&__error-amount {
margin-top: 5px;
}
&__buttons {
margin-top: 22px;
display: flex;
width: 100%;
justify-content: space-between;
}
&__button {
width: 150px;
min-width: initial;
}
}

View File

@ -1,119 +0,0 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import Button from '../../components/ui/button';
import {
MetaMetricsEventAccountType,
MetaMetricsEventCategory,
MetaMetricsEventName,
} from '../../../shared/constants/metametrics';
import { getAccountNameErrorMessage } from '../../helpers/utils/accounts';
export default class NewAccountCreateForm extends Component {
static defaultProps = {
newAccountNumber: 0,
};
state = {
newAccountName: '',
defaultAccountName: this.context.t('newAccountNumberName', [
this.props.newAccountNumber,
]),
};
render() {
const { newAccountName, defaultAccountName } = this.state;
const { history, createAccount, mostRecentOverviewPage, accounts } =
this.props;
const createClick = (event) => {
event.preventDefault();
createAccount(newAccountName || defaultAccountName)
.then(() => {
this.context.trackEvent({
category: MetaMetricsEventCategory.Accounts,
event: MetaMetricsEventName.AccountAdded,
properties: {
account_type: MetaMetricsEventAccountType.Default,
location: 'Home',
},
});
history.push(mostRecentOverviewPage);
})
.catch((e) => {
this.context.trackEvent({
category: MetaMetricsEventCategory.Accounts,
event: MetaMetricsEventName.AccountAddFailed,
properties: {
account_type: MetaMetricsEventAccountType.Default,
error: e.message,
},
});
});
};
const { isValidAccountName, errorMessage } = getAccountNameErrorMessage(
accounts,
this.context,
newAccountName,
defaultAccountName,
);
return (
<div className="new-account-create-form">
<div className="new-account-create-form__input-label">
{this.context.t('accountName')}
</div>
<div>
<input
className={classnames({
'new-account-create-form__input': true,
'new-account-create-form__input__error': !isValidAccountName,
})}
value={newAccountName}
placeholder={defaultAccountName}
onChange={(event) =>
this.setState({ newAccountName: event.target.value })
}
autoFocus
/>
<div className="new-account-create-form__error new-account-create-form__error-amount">
{errorMessage}
</div>
<div className="new-account-create-form__buttons">
<Button
type="secondary"
large
className="new-account-create-form__button"
onClick={() => history.push(mostRecentOverviewPage)}
>
{this.context.t('cancel')}
</Button>
<Button
type="primary"
large
className="new-account-create-form__button"
onClick={createClick}
disabled={!isValidAccountName}
>
{this.context.t('create')}
</Button>
</div>
</div>
</div>
);
}
}
NewAccountCreateForm.propTypes = {
createAccount: PropTypes.func,
newAccountNumber: PropTypes.number,
history: PropTypes.object,
mostRecentOverviewPage: PropTypes.string.isRequired,
accounts: PropTypes.array,
};
NewAccountCreateForm.contextTypes = {
t: PropTypes.func,
trackEvent: PropTypes.func,
};

View File

@ -1,36 +0,0 @@
import { connect } from 'react-redux';
import * as actions from '../../store/actions';
import { getMostRecentOverviewPage } from '../../ducks/history/history';
import { getMetaMaskAccountsOrdered } from '../../selectors';
import NewAccountCreateForm from './new-account.component';
const mapStateToProps = (state) => {
const {
metamask: { identities = {} },
} = state;
const numberOfExistingAccounts = Object.keys(identities).length;
const newAccountNumber = numberOfExistingAccounts + 1;
return {
newAccountNumber,
mostRecentOverviewPage: getMostRecentOverviewPage(state),
accounts: getMetaMaskAccountsOrdered(state),
};
};
const mapDispatchToProps = (dispatch) => {
return {
createAccount: (newAccountName) => {
return dispatch(actions.addNewAccount()).then((newAccountAddress) => {
if (newAccountName) {
dispatch(actions.setAccountLabel(newAccountAddress, newAccountName));
}
});
},
};
};
export default connect(
mapStateToProps,
mapDispatchToProps,
)(NewAccountCreateForm);

View File

@ -1,22 +0,0 @@
import { action } from '@storybook/addon-actions';
import React from 'react';
import NewAccountCreateForm from './new-account.component';
export default {
title: 'Pages/CreateAccount/NewAccount',
component: NewAccountCreateForm,
argTypes: {
accounts: {
control: 'array',
},
},
args: {
accounts: [],
},
};
export const DefaultStory = (args) => (
<NewAccountCreateForm {...args} createAccount={action('Account Created')} />
);
DefaultStory.storyName = 'Default';

View File

@ -11,7 +11,6 @@
@import 'connected-sites/index';
@import 'connected-accounts/index';
@import 'connected-sites/index';
@import 'create-account/index';
@import "institutional/connect-custody/index";
@import "institutional/custody/index";
@import "institutional/institutional-entity-done-page/index";

View File

@ -22,7 +22,6 @@ import ImportTokenPage from '../import-token';
import AddNftPage from '../add-nft';
import ConfirmImportTokenPage from '../confirm-import-token';
import ConfirmAddSuggestedTokenPage from '../confirm-add-suggested-token';
import CreateAccountPage from '../create-account';
import Loading from '../../components/ui/loading-screen';
import LoadingNetwork from '../../components/app/loading-network-screen';
import { Modal } from '../../components/app/modals';
@ -63,7 +62,6 @@ import {
CONNECT_ROUTE,
DEFAULT_ROUTE,
LOCK_ROUTE,
NEW_ACCOUNT_ROUTE,
RESTORE_VAULT_ROUTE,
REVEAL_SEED_ROUTE,
SEND_ROUTE,
@ -326,7 +324,6 @@ export default class Routes extends Component {
{
///: END:ONLY_INCLUDE_IN
}
<Authenticated path={NEW_ACCOUNT_ROUTE} component={CreateAccountPage} />
<Authenticated
path={`${CONNECT_ROUTE}/:id`}
component={PermissionsConnect}