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(); + }); });