diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json
index 56a2ba405aef..a59a48a21afe 100644
--- a/app/_locales/en/messages.json
+++ b/app/_locales/en/messages.json
@@ -1734,6 +1734,12 @@
"dropped": {
"message": "Dropped"
},
+ "duplicateContactTooltip": {
+ "message": "This contact name collides with an existing account or contact"
+ },
+ "duplicateContactWarning": {
+ "message": "You have duplicate contacts"
+ },
"edit": {
"message": "Edit"
},
@@ -3028,6 +3034,9 @@
"message": "Address",
"description": "Label above address field in name component modal."
},
+ "nameAlreadyInUse": {
+ "message": "Name is already in use"
+ },
"nameInstructionsNew": {
"message": "If you know this address, give it a nickname to recognize it in the future.",
"description": "Instruction text in name component modal when value is not recognised."
diff --git a/test/data/mock-data.js b/test/data/mock-data.js
index 4775f1dbb25e..c68d70a1fc59 100644
--- a/test/data/mock-data.js
+++ b/test/data/mock-data.js
@@ -1049,6 +1049,31 @@ const NETWORKS_2_API_MOCK_RESULT = {
},
};
+const MOCK_ADDRESS_BOOK = [
+ {
+ address: '0x2f318C334780961FB129D2a6c30D0763d9a5C970',
+ chainId: '0x1',
+ isEns: false,
+ memo: '',
+ name: 'Contact 1',
+ },
+ {
+ address: '0x43c9159B6251f3E205B9113A023C8256cDD40D91',
+ chainId: '0x1',
+ isEns: true,
+ memo: '',
+ name: 'example.eth',
+ },
+];
+
+const MOCK_DOMAIN_RESOLUTION = {
+ addressBookEntryName: 'example.eth',
+ domainName: 'example.eth',
+ protocol: 'Ethereum Name Service',
+ resolvedAddress: '0x2f318C334780961FB129D2a6c30D0763d9a5C970',
+ resolvingSnap: 'Ethereum Name Service resolver',
+};
+
module.exports = {
TOKENS_API_MOCK_RESULT,
TOP_ASSETS_API_MOCK_RESULT,
@@ -1059,4 +1084,6 @@ module.exports = {
SWAP_TEST_ETH_DAI_TRADES_MOCK,
SWAP_TEST_ETH_USDC_TRADES_MOCK,
NETWORKS_2_API_MOCK_RESULT,
+ MOCK_ADDRESS_BOOK,
+ MOCK_DOMAIN_RESOLUTION,
};
diff --git a/ui/components/app/contact-list/contact-list.component.js b/ui/components/app/contact-list/contact-list.component.js
index 548bbf68a90c..b7438f9fe195 100644
--- a/ui/components/app/contact-list/contact-list.component.js
+++ b/ui/components/app/contact-list/contact-list.component.js
@@ -2,10 +2,14 @@ import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { sortBy } from 'lodash';
import Button from '../../ui/button';
+import { BannerAlert, BannerAlertSeverity } from '../../component-library';
import RecipientGroup from './recipient-group/recipient-group.component';
+import { hasDuplicateContacts, buildDuplicateContactMap } from './utils';
export default class ContactList extends PureComponent {
static propTypes = {
+ addressBook: PropTypes.array,
+ internalAccounts: PropTypes.array,
searchForContacts: PropTypes.func,
searchForRecents: PropTypes.func,
searchForMyAccounts: PropTypes.func,
@@ -22,6 +26,19 @@ export default class ContactList extends PureComponent {
isShowingAllRecent: false,
};
+ renderDuplicateContactWarning() {
+ const { t } = this.context;
+
+ return (
+
+
+
+ );
+ }
+
renderRecents() {
const { t } = this.context;
const { isShowingAllRecent } = this.state;
@@ -45,15 +62,40 @@ export default class ContactList extends PureComponent {
}
renderAddressBook() {
- const unsortedContactsByLetter = this.props
- .searchForContacts()
- .reduce((obj, contact) => {
+ const {
+ addressBook,
+ internalAccounts,
+ searchForContacts,
+ selectRecipient,
+ selectedAddress,
+ } = this.props;
+
+ const duplicateContactMap = buildDuplicateContactMap(
+ addressBook,
+ internalAccounts,
+ );
+
+ const unsortedContactsByLetter = searchForContacts().reduce(
+ (obj, contact) => {
const firstLetter = contact.name[0].toUpperCase();
+
+ const isDuplicate =
+ (duplicateContactMap.get(contact.name.trim().toLowerCase()) ?? [])
+ .length > 1;
+
return {
...obj,
- [firstLetter]: [...(obj[firstLetter] || []), contact],
+ [firstLetter]: [
+ ...(obj[firstLetter] || []),
+ {
+ ...contact,
+ isDuplicate,
+ },
+ ],
};
- }, {});
+ },
+ {},
+ );
const letters = Object.keys(unsortedContactsByLetter).sort();
@@ -71,8 +113,8 @@ export default class ContactList extends PureComponent {
key={`${letter}-contact-group`}
label={letter}
items={groupItems}
- onSelect={this.props.selectRecipient}
- selectedAddress={this.props.selectedAddress}
+ onSelect={selectRecipient}
+ selectedAddress={selectedAddress}
/>
));
}
@@ -95,11 +137,16 @@ export default class ContactList extends PureComponent {
searchForRecents,
searchForContacts,
searchForMyAccounts,
+ addressBook,
+ internalAccounts,
} = this.props;
return (
{children || null}
+ {hasDuplicateContacts(addressBook, internalAccounts)
+ ? this.renderDuplicateContactWarning()
+ : null}
{searchForRecents ? this.renderRecents() : null}
{searchForContacts ? this.renderAddressBook() : null}
{searchForMyAccounts ? this.renderMyAccounts() : null}
diff --git a/ui/components/app/contact-list/contact-list.test.js b/ui/components/app/contact-list/contact-list.test.js
index 6d0a990cb08c..1beecc17e0fa 100644
--- a/ui/components/app/contact-list/contact-list.test.js
+++ b/ui/components/app/contact-list/contact-list.test.js
@@ -1,6 +1,8 @@
import React from 'react';
import configureMockStore from 'redux-mock-store';
import { renderWithProvider } from '../../../../test/jest/rendering';
+import { MOCK_ADDRESS_BOOK } from '../../../../test/data/mock-data';
+import { createMockInternalAccount } from '../../../../test/jest/mocks';
import ContactList from '.';
describe('Contact List', () => {
@@ -8,6 +10,48 @@ describe('Contact List', () => {
metamask: {},
});
+ const mockInternalAccounts = [createMockInternalAccount()];
+
+ it('displays the warning banner when multiple contacts have the same name', () => {
+ const mockAddressBook = [...MOCK_ADDRESS_BOOK, MOCK_ADDRESS_BOOK[0]]; // Adding duplicate contact
+
+ const { getByText } = renderWithProvider(
+
,
+ store,
+ );
+
+ const duplicateContactBanner = getByText('You have duplicate contacts');
+
+ expect(duplicateContactBanner).toBeVisible();
+ });
+
+ it('displays the warning banner when contact has same name as an existing account', () => {
+ const mockContactWithAccountName = {
+ address: '0x2f318C334780961FB129D2a6c30D0763d9a5C970',
+ chainId: '0x1',
+ isEns: false,
+ memo: '',
+ name: mockInternalAccounts[0].metadata.name,
+ };
+
+ const mockAddressBook = [...MOCK_ADDRESS_BOOK, mockContactWithAccountName];
+
+ const { getByText } = renderWithProvider(
+
,
+ store,
+ );
+
+ const duplicateContactBanner = getByText('You have duplicate contacts');
+
+ expect(duplicateContactBanner).toBeVisible();
+ });
+
describe('given searchForContacts', () => {
const selectRecipient = () => null;
const selectedAddress = null;
@@ -37,6 +81,8 @@ describe('Contact List', () => {
searchForContacts={() => contacts}
selectRecipient={selectRecipient}
selectedAddress={selectedAddress}
+ addressBook={MOCK_ADDRESS_BOOK}
+ internalAccounts={mockInternalAccounts}
/>,
store,
);
diff --git a/ui/components/app/contact-list/recipient-group/recipient-group.component.js b/ui/components/app/contact-list/recipient-group/recipient-group.component.js
index 6bb0b4c30dd6..0788d29aaecd 100644
--- a/ui/components/app/contact-list/recipient-group/recipient-group.component.js
+++ b/ui/components/app/contact-list/recipient-group/recipient-group.component.js
@@ -7,12 +7,13 @@ export default function RecipientGroup({ items, onSelect }) {
return null;
}
- return items.map(({ address, name }) => (
+ return items.map(({ address, name, isDuplicate }) => (
onSelect(address, name)}
key={address}
+ isDuplicate={isDuplicate}
/>
));
}
diff --git a/ui/components/app/contact-list/utils.ts b/ui/components/app/contact-list/utils.ts
new file mode 100644
index 000000000000..4254988e4af6
--- /dev/null
+++ b/ui/components/app/contact-list/utils.ts
@@ -0,0 +1,61 @@
+import { AddressBookEntry } from '@metamask/address-book-controller';
+import { InternalAccount } from '@metamask/keyring-api';
+
+export const buildDuplicateContactMap = (
+ addressBook: AddressBookEntry[],
+ internalAccounts: InternalAccount[],
+) => {
+ const contactMap = new Map(
+ internalAccounts.map((account) => [
+ account.metadata.name.trim().toLowerCase(),
+ [`account-id-${account.id}`],
+ ]),
+ );
+
+ addressBook.forEach((entry) => {
+ const { name, address } = entry;
+
+ const sanitizedName = name.trim().toLowerCase();
+
+ const currentArray = contactMap.get(sanitizedName) ?? [];
+ currentArray.push(address);
+
+ contactMap.set(sanitizedName, currentArray);
+ });
+
+ return contactMap;
+};
+
+export const hasDuplicateContacts = (
+ addressBook: AddressBookEntry[],
+ internalAccounts: InternalAccount[],
+) => {
+ const uniqueContactNames = Array.from(
+ new Set(addressBook.map(({ name }) => name.toLowerCase().trim())),
+ );
+
+ const hasAccountNameCollision = internalAccounts.some((account) =>
+ uniqueContactNames.includes(account.metadata.name.toLowerCase().trim()),
+ );
+
+ return (
+ uniqueContactNames.length !== addressBook.length || hasAccountNameCollision
+ );
+};
+
+export const isDuplicateContact = (
+ addressBook: AddressBookEntry[],
+ internalAccounts: InternalAccount[],
+ newName: string,
+) => {
+ const nameExistsInAddressBook = addressBook.some(
+ ({ name }) => name.toLowerCase().trim() === newName.toLowerCase().trim(),
+ );
+
+ const nameExistsInAccountList = internalAccounts.some(
+ ({ metadata }) =>
+ metadata.name.toLowerCase().trim() === newName.toLowerCase().trim(),
+ );
+
+ return nameExistsInAddressBook || nameExistsInAccountList;
+};
diff --git a/ui/components/multichain/address-list-item/__snapshots__/address-list-item.test.tsx.snap b/ui/components/multichain/address-list-item/__snapshots__/address-list-item.test.tsx.snap
index 8d840ba595ce..c3895c50d76a 100644
--- a/ui/components/multichain/address-list-item/__snapshots__/address-list-item.test.tsx.snap
+++ b/ui/components/multichain/address-list-item/__snapshots__/address-list-item.test.tsx.snap
@@ -1,6 +1,106 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`AddressListItem renders the address and label 1`] = `
+exports[`AddressListItem displays duplicate contact warning icon 1`] = `
+
+`;
+
+exports[`AddressListItem renders the address and label without duplicate contact warning icon 1`] = `
@@ -152,9 +173,9 @@ export default class AddContact extends PureComponent {
address={resolvedAddress}
domainName={addressBookEntryName ?? domainName}
onClick={() => {
+ this.handleNameChange(domainName);
this.setState({
input: resolvedAddress,
- newName: this.state.newName || domainName,
});
this.props.resetDomainResolution();
}}
@@ -164,9 +185,9 @@ export default class AddContact extends PureComponent {
);
})}
- {errorToRender && (
+ {addressError && (
- {t(errorToRender)}
+ {t(addressError)}
)}
@@ -174,7 +195,10 @@ export default class AddContact extends PureComponent {
{
await addToAddressBook(newAddress, this.state.newName);
diff --git a/ui/pages/settings/contact-list-tab/add-contact/add-contact.container.js b/ui/pages/settings/contact-list-tab/add-contact/add-contact.container.js
index 3b18ebdde8e0..db7b87c48ea1 100644
--- a/ui/pages/settings/contact-list-tab/add-contact/add-contact.container.js
+++ b/ui/pages/settings/contact-list-tab/add-contact/add-contact.container.js
@@ -12,10 +12,13 @@ import {
getDomainResolutions,
resetDomainResolution,
} from '../../../../ducks/domains';
+import { getAddressBook, getInternalAccounts } from '../../../../selectors';
import AddContact from './add-contact.component';
const mapStateToProps = (state) => {
return {
+ addressBook: getAddressBook(state),
+ internalAccounts: getInternalAccounts(state),
qrCodeData: getQrCodeData(state),
domainError: getDomainError(state),
domainResolutions: getDomainResolutions(state),
diff --git a/ui/pages/settings/contact-list-tab/add-contact/add-contact.test.js b/ui/pages/settings/contact-list-tab/add-contact/add-contact.test.js
index af130617ae19..09a0e0d96692 100644
--- a/ui/pages/settings/contact-list-tab/add-contact/add-contact.test.js
+++ b/ui/pages/settings/contact-list-tab/add-contact/add-contact.test.js
@@ -7,6 +7,12 @@ import '@testing-library/jest-dom/extend-expect';
import { mockNetworkState } from '../../../../../test/stub/networks';
import { CHAIN_IDS } from '../../../../../shared/constants/network';
import { domainInitialState } from '../../../../ducks/domains';
+import { createMockInternalAccount } from '../../../../../test/jest/mocks';
+import {
+ MOCK_ADDRESS_BOOK,
+ MOCK_DOMAIN_RESOLUTION,
+} from '../../../../../test/data/mock-data';
+import * as domainDucks from '../../../../ducks/domains';
import AddContact from './add-contact.component';
describe('AddContact component', () => {
@@ -17,16 +23,29 @@ describe('AddContact component', () => {
},
};
const props = {
+ addressBook: MOCK_ADDRESS_BOOK,
+ internalAccounts: [createMockInternalAccount()],
history: { push: jest.fn() },
addToAddressBook: jest.fn(),
scanQrCode: jest.fn(),
qrCodeData: { type: 'address', values: { address: '0x123456789abcdef' } },
qrCodeDetected: jest.fn(),
- domainResolution: '',
+ domainResolutions: [MOCK_DOMAIN_RESOLUTION],
domainError: '',
resetDomainResolution: jest.fn(),
};
+ beforeEach(() => {
+ jest.resetAllMocks();
+ jest
+ .spyOn(domainDucks, 'lookupDomainName')
+ .mockImplementation(() => jest.fn());
+ });
+
+ afterAll(() => {
+ jest.restoreAllMocks();
+ });
+
it('should render the component with correct properties', () => {
const store = configureMockStore(middleware)(state);
@@ -113,4 +132,105 @@ describe('AddContact component', () => {
});
expect(getByText('Save')).toBeDisabled();
});
+
+ it('should disable the submit button when the name is an existing account name', () => {
+ const duplicateName = 'Account 1';
+
+ const store = configureMockStore(middleware)(state);
+ const { getByText, getByTestId } = renderWithProvider(
+ ,
+ store,
+ );
+
+ const nameInput = document.getElementById('nickname');
+ fireEvent.change(nameInput, { target: { value: duplicateName } });
+
+ const addressInput = getByTestId('ens-input');
+
+ fireEvent.change(addressInput, {
+ target: { value: '0x43c9159B6251f3E205B9113A023C8256cDD40D91' },
+ });
+
+ const saveButton = getByText('Save');
+ expect(saveButton).toBeDisabled();
+ });
+
+ it('should disable the submit button when the name is an existing contact name', () => {
+ const duplicateName = MOCK_ADDRESS_BOOK[0].name;
+
+ const store = configureMockStore(middleware)(state);
+ const { getByText, getByTestId } = renderWithProvider(
+ ,
+ store,
+ );
+
+ const nameInput = document.getElementById('nickname');
+ fireEvent.change(nameInput, { target: { value: duplicateName } });
+
+ const addressInput = getByTestId('ens-input');
+
+ fireEvent.change(addressInput, {
+ target: { value: '0x43c9159B6251f3E205B9113A023C8256cDD40D91' },
+ });
+
+ const saveButton = getByText('Save');
+ expect(saveButton).toBeDisabled();
+ });
+
+ it('should display error message when name entered is an existing account name', () => {
+ const duplicateName = 'Account 1';
+
+ const store = configureMockStore(middleware)(state);
+
+ const { getByText } = renderWithProvider(, store);
+
+ const nameInput = document.getElementById('nickname');
+
+ fireEvent.change(nameInput, { target: { value: duplicateName } });
+
+ const saveButton = getByText('Save');
+
+ expect(getByText('Name is already in use')).toBeDefined();
+ expect(saveButton).toBeDisabled();
+ });
+
+ it('should display error message when name entered is an existing contact name', () => {
+ const duplicateName = MOCK_ADDRESS_BOOK[0].name;
+
+ const store = configureMockStore(middleware)(state);
+
+ const { getByText } = renderWithProvider(, store);
+
+ const nameInput = document.getElementById('nickname');
+
+ fireEvent.change(nameInput, { target: { value: duplicateName } });
+
+ const saveButton = getByText('Save');
+
+ expect(getByText('Name is already in use')).toBeDefined();
+ expect(saveButton).toBeDisabled();
+ });
+
+ it('should display error when ENS inserts a name that is already in use', () => {
+ const store = configureMockStore(middleware)(state);
+
+ const { getByTestId, getByText } = renderWithProvider(
+ ,
+ store,
+ );
+
+ const ensInput = getByTestId('ens-input');
+ fireEvent.change(ensInput, { target: { value: 'example.eth' } });
+
+ const domainResolutionCell = getByTestId(
+ 'multichain-send-page__recipient__item',
+ );
+
+ fireEvent.click(domainResolutionCell);
+
+ const saveButton = getByText('Save');
+
+ expect(getByText('Name is already in use')).toBeDefined();
+ expect(saveButton).toBeDisabled();
+ });
});
diff --git a/ui/pages/settings/contact-list-tab/contact-list-tab.component.js b/ui/pages/settings/contact-list-tab/contact-list-tab.component.js
index 6da9bbf4d14f..83cbaccd99d8 100644
--- a/ui/pages/settings/contact-list-tab/contact-list-tab.component.js
+++ b/ui/pages/settings/contact-list-tab/contact-list-tab.component.js
@@ -29,6 +29,7 @@ export default class ContactListTab extends Component {
static propTypes = {
addressBook: PropTypes.array,
+ internalAccounts: PropTypes.array,
history: PropTypes.object,
selectedAddress: PropTypes.string,
viewingContact: PropTypes.bool,
@@ -57,7 +58,8 @@ export default class ContactListTab extends Component {
}
renderAddresses() {
- const { addressBook, history, selectedAddress } = this.props;
+ const { addressBook, internalAccounts, history, selectedAddress } =
+ this.props;
const contacts = addressBook.filter(({ name }) => Boolean(name));
const nonContacts = addressBook.filter(({ name }) => !name);
const { t } = this.context;
@@ -66,6 +68,8 @@ export default class ContactListTab extends Component {
return (
contacts}
searchForRecents={() => nonContacts}
selectRecipient={(address) => {
diff --git a/ui/pages/settings/contact-list-tab/contact-list-tab.container.js b/ui/pages/settings/contact-list-tab/contact-list-tab.container.js
index 98991e7e744d..b1715715b4f7 100644
--- a/ui/pages/settings/contact-list-tab/contact-list-tab.container.js
+++ b/ui/pages/settings/contact-list-tab/contact-list-tab.container.js
@@ -1,7 +1,7 @@
import { compose } from 'redux';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
-import { getAddressBook } from '../../../selectors';
+import { getAddressBook, getInternalAccounts } from '../../../selectors';
import {
CONTACT_ADD_ROUTE,
@@ -28,6 +28,7 @@ const mapStateToProps = (state, ownProps) => {
editingContact,
addingContact,
addressBook: getAddressBook(state),
+ internalAccounts: getInternalAccounts(state),
selectedAddress: pathNameTailIsAddress ? pathNameTail : '',
hideAddressBook,
currentPath: pathname,
diff --git a/ui/pages/settings/contact-list-tab/contact-list-tab.stories.js b/ui/pages/settings/contact-list-tab/contact-list-tab.stories.js
index 06cb6a153c5f..0cfe7be2dd5b 100644
--- a/ui/pages/settings/contact-list-tab/contact-list-tab.stories.js
+++ b/ui/pages/settings/contact-list-tab/contact-list-tab.stories.js
@@ -3,6 +3,7 @@ import { Provider } from 'react-redux';
import configureStore from '../../../store/store';
import testData from '../../../../.storybook/test-data';
+import { getInternalAccounts } from '../../../selectors';
import ContactListTab from './contact-list-tab.component';
// Using Test Data For Redux
@@ -14,6 +15,7 @@ export default {
decorators: [(story) => {story()}],
argsTypes: {
addressBook: { control: 'object' },
+ internalAccounts: { control: 'object' },
hideAddressBook: { control: 'boolean' },
selectedAddress: { control: 'select' },
history: { action: 'history' },
@@ -23,6 +25,8 @@ export default {
const { metamask } = store.getState();
const { addresses } = metamask;
+const internalAccounts = getInternalAccounts(store.getState());
+
export const DefaultStory = (args) => {
return (
@@ -34,6 +38,7 @@ export const DefaultStory = (args) => {
DefaultStory.storyName = 'Default';
DefaultStory.args = {
addressBook: addresses,
+ internalAccounts,
hideAddressBook: false,
selectedAddress: addresses.map(({ address }) => address),
};
diff --git a/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.component.js b/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.component.js
index 335c5166da05..afb7efb1cc01 100644
--- a/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.component.js
+++ b/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.component.js
@@ -21,6 +21,7 @@ import {
Display,
TextVariant,
} from '../../../../helpers/constants/design-system';
+import { isDuplicateContact } from '../../../../components/app/contact-list/utils';
export default class EditContact extends PureComponent {
static contextTypes = {
@@ -28,6 +29,8 @@ export default class EditContact extends PureComponent {
};
static propTypes = {
+ addressBook: PropTypes.array,
+ internalAccounts: PropTypes.array,
addToAddressBook: PropTypes.func,
removeFromAddressBook: PropTypes.func,
history: PropTypes.object,
@@ -48,7 +51,30 @@ export default class EditContact extends PureComponent {
newName: this.props.name,
newAddress: this.props.address,
newMemo: this.props.memo,
- error: '',
+ nameError: '',
+ addressError: '',
+ };
+
+ validateName = (newName) => {
+ if (newName === this.props.name) {
+ return true;
+ }
+
+ const { addressBook, internalAccounts } = this.props;
+
+ return !isDuplicateContact(addressBook, internalAccounts, newName);
+ };
+
+ handleNameChange = (e) => {
+ const newName = e.target.value;
+
+ const isValidName = this.validateName(newName);
+
+ this.setState({
+ nameError: isValidName ? null : this.context.t('nameAlreadyInUse'),
+ });
+
+ this.setState({ newName });
};
render() {
@@ -118,9 +144,10 @@ export default class EditContact extends PureComponent {
id="nickname"
placeholder={this.context.t('addAlias')}
value={this.state.newName}
- onChange={(e) => this.setState({ newName: e.target.value })}
+ onChange={this.handleNameChange}
fullWidth
margin="dense"
+ error={this.state.nameError}
/>
@@ -132,7 +159,7 @@ export default class EditContact extends PureComponent {
type="text"
id="address"
value={this.state.newAddress}
- error={this.state.error}
+ error={this.state.addressError}
onChange={(e) => this.setState({ newAddress: e.target.value })}
fullWidth
multiline
@@ -189,7 +216,9 @@ export default class EditContact extends PureComponent {
);
history.push(listRoute);
} else {
- this.setState({ error: this.context.t('invalidAddress') });
+ this.setState({
+ addressError: this.context.t('invalidAddress'),
+ });
}
} else {
// update name
@@ -205,12 +234,13 @@ export default class EditContact extends PureComponent {
history.push(`${viewRoute}/${address}`);
}}
submitText={this.context.t('save')}
- disabled={
+ disabled={Boolean(
(this.state.newName === name &&
this.state.newAddress === address &&
this.state.newMemo === memo) ||
- !this.state.newName.trim()
- }
+ !this.state.newName.trim() ||
+ this.state.nameError,
+ )}
/>
);
diff --git a/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.container.js b/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.container.js
index af248b04d330..ff10d850345e 100644
--- a/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.container.js
+++ b/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.container.js
@@ -2,8 +2,10 @@ import { compose } from 'redux';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import {
+ getAddressBook,
getAddressBookEntry,
getInternalAccountByAddress,
+ getInternalAccounts,
} from '../../../../selectors';
import { getProviderConfig } from '../../../../ducks/metamask/metamask';
import {
@@ -34,6 +36,8 @@ const mapStateToProps = (state, ownProps) => {
return {
address: contact ? address : null,
+ addressBook: getAddressBook(state),
+ internalAccounts: getInternalAccounts(state),
chainId,
name,
memo,
diff --git a/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.test.js b/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.test.js
index 779416140e10..958385d2c79a 100644
--- a/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.test.js
+++ b/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.test.js
@@ -4,6 +4,8 @@ import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { renderWithProvider } from '../../../../../test/lib/render-helpers';
import '@testing-library/jest-dom/extend-expect';
+import { MOCK_ADDRESS_BOOK } from '../../../../../test/data/mock-data';
+import { createMockInternalAccount } from '../../../../../test/jest/mocks';
import EditContact from './edit-contact.component';
describe('AddContact component', () => {
@@ -11,11 +13,17 @@ describe('AddContact component', () => {
const state = {
metamask: {},
};
+
+ const mockAccount1 = createMockInternalAccount();
+ const mockAccount2 = createMockInternalAccount({ name: 'Test Contact' });
+
const props = {
+ addressBook: MOCK_ADDRESS_BOOK,
+ internalAccounts: [mockAccount1, mockAccount2],
addToAddressBook: jest.fn(),
removeFromAddressBook: jest.fn(),
history: { push: jest.fn() },
- name: '',
+ name: mockAccount1.metadata.name,
address: '0x0000000000000000001',
chainId: '',
memo: '',
@@ -36,11 +44,14 @@ describe('AddContact component', () => {
const store = configureMockStore(middleware)(state);
const { getByText } = renderWithProvider(, store);
- const input = document.getElementById('address');
- fireEvent.change(input, { target: { value: 'invalid address' } });
- setTimeout(() => {
- expect(getByText('Invalid address')).toBeInTheDocument();
- }, 100);
+ const addressInput = document.getElementById('address');
+ fireEvent.change(addressInput, { target: { value: 'invalid address' } });
+
+ const submitButton = getByText('Save');
+
+ fireEvent.click(submitButton);
+
+ expect(getByText('Invalid address')).toBeInTheDocument();
});
it('should get disabled submit button when username field is empty', () => {
@@ -53,4 +64,46 @@ describe('AddContact component', () => {
const saveButton = getByText('Save');
expect(saveButton).toBeDisabled();
});
+
+ it('should display error when entering a name that is in use by an existing contact', () => {
+ const store = configureMockStore(middleware)(state);
+ const { getByText } = renderWithProvider(, store);
+
+ const input = document.getElementById('nickname');
+ fireEvent.change(input, { target: { value: MOCK_ADDRESS_BOOK[0].name } });
+
+ const saveButton = getByText('Save');
+
+ expect(saveButton).toBeDisabled();
+ expect(getByText('Name is already in use')).toBeDefined();
+ });
+
+ it('should display error when entering a name that is in use by an existing account', () => {
+ const store = configureMockStore(middleware)(state);
+ const { getByText } = renderWithProvider(, store);
+
+ const input = document.getElementById('nickname');
+ fireEvent.change(input, { target: { value: mockAccount2.metadata.name } });
+
+ const saveButton = getByText('Save');
+
+ expect(saveButton).toBeDisabled();
+ expect(getByText('Name is already in use')).toBeDefined();
+ });
+
+ it('should not display error when entering the current contact name', () => {
+ const store = configureMockStore(middleware)(state);
+ const { getByText, queryByText } = renderWithProvider(
+ ,
+ store,
+ );
+
+ const input = document.getElementById('nickname');
+ fireEvent.change(input, { target: { value: mockAccount1.metadata.name } });
+
+ const saveButton = getByText('Save');
+
+ expect(saveButton).toBeDisabled();
+ expect(queryByText('Name is already in use')).toBeNull();
+ });
});