diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 30475784e64a..0ef2ec82406f 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -6407,6 +6407,9 @@ "transactionFailed": { "message": "Transaction Failed" }, + "transactionFailedBannerMessage": { + "message": "This transaction would have cost you extra fees, so we stopped it. Your money is still in your wallet." + }, "transactionFee": { "message": "Transaction fee" }, diff --git a/test/e2e/tests/dapp-interactions/failing-contract.spec.js b/test/e2e/tests/dapp-interactions/failing-contract.spec.js index c05938d668e0..c02a279adb3f 100644 --- a/test/e2e/tests/dapp-interactions/failing-contract.spec.js +++ b/test/e2e/tests/dapp-interactions/failing-contract.spec.js @@ -72,6 +72,15 @@ describe('Failing contract interaction ', function () { css: '.activity-list-item .transaction-status-label', text: 'Failed', }); + // inspect transaction details + await driver.clickElement({ + css: '.activity-list-item .transaction-status-label', + text: 'Failed', + }); + await driver.waitForSelector('.transaction-list-item-details'); + await driver.waitForSelector( + '[data-testid="transaction-list-item-details-banner-error-message"]', + ); }, ); }); diff --git a/ui/components/app/transaction-list-item-details/index.scss b/ui/components/app/transaction-list-item-details/index.scss index 13adb780fa4d..34d4af1ea82e 100644 --- a/ui/components/app/transaction-list-item-details/index.scss +++ b/ui/components/app/transaction-list-item-details/index.scss @@ -49,6 +49,8 @@ display: flex; flex-direction: column; align-items: flex-end; + height: 42px; + justify-content: space-between; .btn-link { font-size: 12px; @@ -62,9 +64,10 @@ } &__operations { - margin: 0 0 16px 16px; + margin: 0 16px 16px 16px; display: flex; - justify-content: flex-end; + justify-content: space-between; + align-items: center; } &__header { diff --git a/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.js b/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.js index 226a2a9113c0..f614a4dbc250 100644 --- a/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.js +++ b/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.js @@ -13,9 +13,19 @@ import Tooltip from '../../ui/tooltip'; import CancelButton from '../cancel-button'; import Popover from '../../ui/popover'; import { Box } from '../../component-library/box'; +import { Text } from '../../component-library/text'; +import { + BannerAlert, + BannerAlertSeverity, +} from '../../component-library/banner-alert'; +import { + TextVariant, + ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) + IconColor, + ///: END:ONLY_INCLUDE_IF +} from '../../../helpers/constants/design-system'; ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) -import { Icon, IconName, Text } from '../../component-library'; -import { IconColor } from '../../../helpers/constants/design-system'; +import { Icon, IconName } from '../../component-library'; ///: END:ONLY_INCLUDE_IF import { SECOND } from '../../../../shared/constants/time'; import { MetaMetricsEventCategory } from '../../../../shared/constants/metametrics'; @@ -55,6 +65,7 @@ export default class TransactionListItemDetails extends PureComponent { recipientNickname: PropTypes.string, transactionStatus: PropTypes.func, isCustomNetwork: PropTypes.bool, + showErrorBanner: PropTypes.bool, history: PropTypes.object, blockExplorerLinkText: PropTypes.object, ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) @@ -204,6 +215,7 @@ export default class TransactionListItemDetails extends PureComponent { onClose, recipientNickname, showCancel, + showErrorBanner, transactionStatus: TransactionStatus, blockExplorerLinkText, ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) @@ -220,6 +232,17 @@ export default class TransactionListItemDetails extends PureComponent {
+ {showErrorBanner && ( + + + {t('transactionFailedBannerMessage')} + + + )}
{showSpeedUp && ( )} @@ -258,22 +281,18 @@ export default class TransactionListItemDetails extends PureComponent { data-testid="transaction-list-item-details-tx-status" >
{t('status')}
-
- -
+
-
- -
+
{ +const render = (overrideProps) => { const rpcPrefs = { blockExplorerUrl: 'https://customblockexplorer.com/', }; @@ -71,7 +71,7 @@ const render = async (overrideProps) => { senderAddress: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', tryReverseResolveAddress: jest.fn(), transactionGroup, - transactionStatus: () =>
, + transactionStatus: () =>
, blockExplorerLinkText, rpcPrefs, ...overrideProps, @@ -79,59 +79,57 @@ const render = async (overrideProps) => { const mockStore = configureMockStore([thunk])(mockState); - let result; - - await act( - async () => - (result = renderWithProvider( - , - mockStore, - )), + const result = renderWithProvider( + , + mockStore, ); return result; }; describe('TransactionListItemDetails Component', () => { - it('should render title with title prop', async () => { - const { queryByText } = await render(); + describe('matches snapshot', () => { + it('for non-error details', async () => { + const { queryByText, queryByTestId } = render(); + expect(queryByText('Test Transaction Details')).toBeInTheDocument(); + expect( + queryByTestId('transaction-list-item-details-banner-error-message'), + ).not.toBeInTheDocument(); + }); - await waitFor(() => { + it('for error details', async () => { + const { queryByText, queryByTestId } = render({ showErrorBanner: true }); expect(queryByText('Test Transaction Details')).toBeInTheDocument(); + expect( + queryByTestId('transaction-list-item-details-banner-error-message'), + ).toBeInTheDocument(); }); }); - describe('Retry button', () => { - it('should render retry button with showRetry prop', async () => { - const { queryByTestId } = await render({ showRetry: true }); - + describe('Action buttons', () => { + it('renders retry button with showRetry prop', async () => { + const { queryByTestId } = render({ showRetry: true }); expect(queryByTestId('rety-button')).toBeInTheDocument(); }); - }); - - describe('Cancel button', () => { - it('should render cancel button with showCancel prop', async () => { - const { queryByTestId } = await render({ showCancel: true }); + it('renders cancel button with showCancel prop', async () => { + const { queryByTestId } = render({ showCancel: true }); expect(queryByTestId('cancel-button')).toBeInTheDocument(); }); - }); - - describe('Speedup button', () => { - it('should render speedup button with showSpeedUp prop', async () => { - const { queryByTestId } = await render({ showSpeedUp: true }); + it('renders speedup button with showSpeedUp prop', async () => { + const { queryByTestId } = render({ showSpeedUp: true }); expect(queryByTestId('speedup-button')).toBeInTheDocument(); }); }); describe('Institutional', () => { - it('should render correctly if custodyTransactionDeepLink has a url', async () => { + it('renders correctly if custodyTransactionDeepLink has a url', async () => { mockGetCustodianTransactionDeepLink = jest .fn() .mockReturnValue({ url: 'https://url.com' }); - await render({ showCancel: true }); + render({ showCancel: true }); await waitFor(() => { const custodianViewButton = document.querySelector( @@ -143,7 +141,7 @@ describe('TransactionListItemDetails Component', () => { }); }); - it('should render correctly if transactionNote is provided', async () => { + it('renders correctly if transactionNote is provided', async () => { const newTransaction = { ...transaction, metadata: { @@ -159,13 +157,11 @@ describe('TransactionListItemDetails Component', () => { initialTransaction: newTransaction, }; - const { queryByText } = await render({ + const { queryByText } = render({ transactionGroup: newTransactionGroup, }); - await waitFor(() => { - expect(queryByText('some note')).toBeInTheDocument(); - }); + expect(queryByText('some note')).toBeInTheDocument(); }); }); }); diff --git a/ui/components/app/transaction-list-item/smart-transaction-list-item.component.js b/ui/components/app/transaction-list-item/smart-transaction-list-item.component.js index 5c1658918dbe..547f2e460497 100644 --- a/ui/components/app/transaction-list-item/smart-transaction-list-item.component.js +++ b/ui/components/app/transaction-list-item/smart-transaction-list-item.component.js @@ -125,6 +125,7 @@ export default function SmartTransactionListItem({ date={date} status={displayedStatusKey} statusOnly + shouldShowTooltip={false} /> )} /> diff --git a/ui/components/app/transaction-list-item/transaction-list-item.component.js b/ui/components/app/transaction-list-item/transaction-list-item.component.js index 09e8983b9196..5dc8cf48ef35 100644 --- a/ui/components/app/transaction-list-item/transaction-list-item.component.js +++ b/ui/components/app/transaction-list-item/transaction-list-item.component.js @@ -459,6 +459,7 @@ function TransactionListItemInner({ !hasCancelled && !isBridgeTx } + showErrorBanner={Boolean(error)} transactionStatus={() => (
`; -exports[`TransactionStatusLabel Component should render PENDING properly 1`] = ` +exports[`TransactionStatusLabel Component renders PENDING properly and tooltip 1`] = `
`; -exports[`TransactionStatusLabel Component should render QUEUED properly 1`] = ` +exports[`TransactionStatusLabel Component renders QUEUED properly and tooltip 1`] = `
`; -exports[`TransactionStatusLabel Component should render SIGNING if status is approved 1`] = ` +exports[`TransactionStatusLabel Component renders SIGNING properly and tooltip 1`] = `
`; -exports[`TransactionStatusLabel Component should render UNAPPROVED properly 1`] = ` +exports[`TransactionStatusLabel Component renders UNAPPROVED properly and tooltip 1`] = `
{statusText} + ) : ( +
+ {statusText} +
); } @@ -120,8 +131,13 @@ TransactionStatusLabel.propTypes = { error: PropTypes.object, isEarliestNonce: PropTypes.bool, statusOnly: PropTypes.bool, + shouldShowTooltip: PropTypes.bool, ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) custodyStatus: PropTypes.string, custodyStatusDisplayText: PropTypes.string, ///: END:ONLY_INCLUDE_IF }; + +TransactionStatusLabel.defaultProps = { + shouldShowTooltip: true, +}; diff --git a/ui/components/app/transaction-status-label/transaction-status-label.test.js b/ui/components/app/transaction-status-label/transaction-status-label.test.js index 4fa8e832201f..663cb45e802e 100644 --- a/ui/components/app/transaction-status-label/transaction-status-label.test.js +++ b/ui/components/app/transaction-status-label/transaction-status-label.test.js @@ -7,103 +7,160 @@ import { renderWithProvider } from '../../../../test/lib/render-helpers'; import { ETH_EOA_METHODS } from '../../../../shared/constants/eth-methods'; import TransactionStatusLabel from '.'; -describe('TransactionStatusLabel Component', () => { - const createMockStore = configureMockStore([thunk]); - const mockState = { - metamask: { - custodyStatusMaps: {}, - internalAccounts: { - accounts: { - 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3': { - address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', - id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', - metadata: { - name: 'Test Account', - keyring: { - type: 'HD Key Tree', - }, - }, - options: {}, - methods: ETH_EOA_METHODS, - type: EthAccountType.Eoa, - }, - }, - selectedAccount: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', - }, +const TEST_ACCOUNT_ID = 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3'; +const TEST_ACCOUNT_ADDRESS = '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'; + +const createBasicAccount = (type = 'HD Key Tree') => ({ + address: TEST_ACCOUNT_ADDRESS, + id: TEST_ACCOUNT_ID, + metadata: { + name: 'Test Account', + keyring: { + type, }, - }; + }, + options: {}, + methods: ETH_EOA_METHODS, + type: EthAccountType.Eoa, +}); - let store = createMockStore(mockState); - it('should render CONFIRMED properly', () => { - const confirmedProps = { - status: 'confirmed', - date: 'June 1', - }; +const createCustodyAccount = () => ({ + ...createBasicAccount('Custody - JSONRPC'), + metadata: { + name: 'Account 1', + keyring: { + type: 'Custody - JSONRPC', + }, + }, +}); - const { container } = renderWithProvider( - , - store, - ); +const createMockStateWithAccount = (account) => ({ + metamask: { + custodyStatusMaps: {}, + internalAccounts: { + accounts: { + [TEST_ACCOUNT_ID]: account, + }, + selectedAccount: TEST_ACCOUNT_ID, + }, + }, +}); - expect(container).toMatchSnapshot(); - }); +const createCustodyMockState = (account) => ({ + metamask: { + custodyStatusMaps: { + saturn: { + approved: { + shortText: 'Short Text Test', + longText: 'Long Text Test', + }, + }, + }, + internalAccounts: { + accounts: { + [TEST_ACCOUNT_ID]: account, + }, + selectedAccount: TEST_ACCOUNT_ID, + }, + keyrings: [ + { + type: 'Custody - JSONRPC', + accounts: [TEST_ACCOUNT_ADDRESS], + }, + ], + }, +}); - it('should render PENDING properly', () => { - const props = { +const statusTestCases = [ + { + name: 'CONFIRMED', + props: { status: 'confirmed', date: 'June 1' }, + }, + { + name: 'PENDING', + props: { date: 'June 1', status: TransactionStatus.submitted, isEarliestNonce: true, - }; - - const { container } = renderWithProvider( - , - store, - ); - - expect(container).toMatchSnapshot(); - }); - - it('should render QUEUED properly', () => { - const props = { + }, + }, + { + name: 'QUEUED', + props: { status: TransactionStatus.submitted, isEarliestNonce: false, - }; - - const { container } = renderWithProvider( - , - store, - ); - - expect(container).toMatchSnapshot(); - }); - - it('should render UNAPPROVED properly', () => { - const props = { + }, + }, + { + name: 'UNAPPROVED', + props: { status: TransactionStatus.unapproved, - }; + }, + }, + { + name: 'SIGNING', + props: { + status: TransactionStatus.approved, + }, + }, +]; - const { container } = renderWithProvider( - , - store, - ); +const errorTestCases = [ + { + name: 'error message', + props: { + status: 'approved', + custodyStatus: 'approved', + error: { message: 'An error occurred' }, + }, + expectedText: 'Error', + }, + { + name: 'error with aborted custody status', + props: { + status: 'approved', + custodyStatus: 'aborted', + error: { message: 'An error occurred' }, + custodyStatusDisplayText: 'Test', + shouldShowTooltip: true, + }, + expectedText: 'Test', + }, +]; + +describe('TransactionStatusLabel Component', () => { + const createMockStore = configureMockStore([thunk]); + let store; - expect(container).toMatchSnapshot(); + beforeEach(() => { + const basicAccount = createBasicAccount(); + const mockState = createMockStateWithAccount(basicAccount); + store = createMockStore(mockState); }); - it('should render SIGNING if status is approved', () => { - const props = { - status: TransactionStatus.approved, - }; + statusTestCases.forEach(({ name, props }) => { + it(`renders ${name} properly and tooltip`, () => { + const { container, queryByTestId } = renderWithProvider( + , + store, + ); + expect(container).toMatchSnapshot(); + expect(queryByTestId('transaction-status-label')).not.toBeInTheDocument(); + }); + }); - const { container } = renderWithProvider( - , + it('renders pure text for status when shouldShowTooltip is specified as false', () => { + const { queryByTestId } = renderWithProvider( + , store, ); - - expect(container).toMatchSnapshot(); + expect(queryByTestId('transaction-status-label')).toBeInTheDocument(); }); - it('should render statusText properly when is custodyStatusDisplayText is defined', () => { + it('renders statusText properly when is custodyStatusDisplayText is defined', () => { const props = { custodyStatusDisplayText: 'test', }; @@ -116,51 +173,15 @@ describe('TransactionStatusLabel Component', () => { expect(getByText(props.custodyStatusDisplayText)).toBeVisible(); }); - it('should display the correct status text and tooltip', () => { - const mockShortText = 'Short Text Test'; - const mockLongText = 'Long Text Test'; + it('displays correct text and tooltip', () => { const props = { status: 'approved', custodyStatus: 'approved', custodyStatusDisplayText: 'Test', }; - const customMockStore = { - metamask: { - custodyStatusMaps: { - saturn: { - approved: { - shortText: mockShortText, - longText: mockLongText, - }, - }, - }, - internalAccounts: { - accounts: { - 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3': { - address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', - id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', - metadata: { - name: 'Account 1', - keyring: { - type: 'Custody - JSONRPC', - }, - }, - options: {}, - methods: ETH_EOA_METHODS, - type: EthAccountType.Eoa, - }, - }, - selectedAccount: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', - }, - keyrings: [ - { - type: 'Custody - JSONRPC', - accounts: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'], - }, - ], - }, - }; + const custodyAccount = createCustodyAccount(); + const customMockStore = createCustodyMockState(custodyAccount); store = createMockStore(customMockStore); const { getByText } = renderWithProvider( @@ -170,114 +191,19 @@ describe('TransactionStatusLabel Component', () => { expect(getByText(props.custodyStatusDisplayText)).toBeVisible(); }); - it('should display the error message when there is an error', () => { - const mockShortText = 'Short Text Test'; - const mockLongText = 'Long Text Test'; - const props = { - status: 'approved', - custodyStatus: 'approved', - error: { message: 'An error occurred' }, - }; - const customMockStore = { - metamask: { - custodyStatusMaps: { - saturn: { - approved: { - shortText: mockShortText, - longText: mockLongText, - }, - }, - }, - internalAccounts: { - accounts: { - 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3': { - address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', - id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', - metadata: { - name: 'Account 1', - keyring: { - type: 'Custody - JSONRPC', - }, - }, - options: {}, - methods: ETH_EOA_METHODS, - type: EthAccountType.Eoa, - }, - }, - selectedAccount: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', - }, - keyrings: [ - { - type: 'Custody - JSONRPC', - accounts: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'], - }, - ], - }, - }; - store = createMockStore(customMockStore); - - const { getByText } = renderWithProvider( - , - store, - ); - - expect(getByText('Error')).toBeVisible(); - }); - - it('should display correctly the error message when there is an error and custodyStatus is aborted', () => { - const mockShortText = 'Short Text Test'; - const mockLongText = 'Long Text Test'; - const props = { - status: 'approved', - custodyStatus: 'aborted', - error: { message: 'An error occurred' }, - custodyStatusDisplayText: 'Test', - }; - const customMockStore = { - metamask: { - custodyStatusMaps: { - saturn: { - approved: { - shortText: mockShortText, - longText: mockLongText, - }, - }, - }, - internalAccounts: { - accounts: { - 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3': { - address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', - id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', - metadata: { - name: 'Account 1', - keyring: { - type: 'Custody - JSONRPC', - }, - }, - options: {}, - methods: ETH_EOA_METHODS, - type: EthAccountType.Eoa, - }, - }, - selectedAccount: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', - }, - keyrings: [ - { - type: 'Custody - JSONRPC', - accounts: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'], - }, - ], - }, - }; + errorTestCases.forEach(({ name, props, expectedText }) => { + it(`displays correctly the ${name}`, () => { + const custodyAccount = createCustodyAccount(); + const customMockStore = createCustodyMockState(custodyAccount); + store = createMockStore(customMockStore); - store = createMockStore(customMockStore); + const { getByText } = renderWithProvider( + , + store, + ); - const { getByText } = renderWithProvider( - , - store, - ); - - expect(getByText(props.custodyStatusDisplayText)).toBeVisible(); + expect(getByText(expectedText)).toBeVisible(); + }); }); });