1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-11-22 01:47:00 +01:00

[MMI] Added code fencing in transaction list (#18071)

* Added code fencing in transaction list

* Fixed import

* Fixed tests

* Fixed indentation

* Fixed code fences

* Removed custody icon in favor of svg

* Fix prettier

* lint

* Fixed prettier issue

* adds check before set state with variable _mounted

* lint

* check for address in selectedIdentity

* review fix

* lint

* updates test

* lint

* clean up

* prettier

* adds missing locale

* Added tests and improved code

* Fixed code

---------

Co-authored-by: Antonio Regadas <antonio.regadas@consensys.net>
This commit is contained in:
Albert Olivé 2023-05-24 13:40:58 +02:00 committed by GitHub
parent 832ce634fd
commit 00bad7b8a8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 402 additions and 27 deletions

View File

@ -4933,6 +4933,9 @@
"viewOnOpensea": {
"message": "View on Opensea"
},
"viewinCustodianApp": {
"message": "View in custodian app"
},
"viewinExplorer": {
"message": "View $1 in explorer",
"description": "$1 is the action type. e.g (Account, Transaction, Swap)"

View File

@ -11,6 +11,11 @@ import Button from '../../ui/button';
import Tooltip from '../../ui/tooltip';
import CancelButton from '../cancel-button';
import Popover from '../../ui/popover';
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
import Box from '../../ui/box/box';
import { Icon, IconName, Text } from '../../component-library';
import { IconColor } from '../../../helpers/constants/design-system';
///: END:ONLY_INCLUDE_IN
import { SECOND } from '../../../../shared/constants/time';
import { MetaMetricsEventCategory } from '../../../../shared/constants/metametrics';
import { TransactionType } from '../../../../shared/constants/transaction';
@ -52,10 +57,18 @@ export default class TransactionListItemDetails extends PureComponent {
isCustomNetwork: PropTypes.bool,
history: PropTypes.object,
blockExplorerLinkText: PropTypes.object,
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
getCustodianTransactionDeepLink: PropTypes.func,
selectedIdentity: PropTypes.object,
transactionNote: PropTypes.string,
///: END:ONLY_INCLUDE_IN
};
state = {
justCopied: false,
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
custodyTransactionDeepLink: null,
///: END:ONLY_INCLUDE_IN
};
handleBlockExplorerClick = () => {
@ -124,16 +137,57 @@ export default class TransactionListItemDetails extends PureComponent {
};
componentDidMount() {
const { recipientAddress, tryReverseResolveAddress } = this.props;
const {
recipientAddress,
tryReverseResolveAddress,
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
selectedIdentity,
transactionGroup,
///: END:ONLY_INCLUDE_IN
} = this.props;
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
this._mounted = true;
const address = selectedIdentity?.address;
const custodyId = transactionGroup?.primaryTransaction?.custodyId;
if (this._mounted && address && custodyId) {
this.getCustodianTransactionDeepLink(address, custodyId);
}
///: END:ONLY_INCLUDE_IN
if (recipientAddress) {
tryReverseResolveAddress(recipientAddress);
}
}
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
getCustodianTransactionDeepLink = async (address, custodyId) => {
const { getCustodianTransactionDeepLink } = this.props;
const custodyTransactionDeepLink = await getCustodianTransactionDeepLink(
address,
custodyId,
);
if (custodyTransactionDeepLink && this._mounted) {
this.setState({ custodyTransactionDeepLink });
}
};
componentWillUnmount() {
this._mounted = false;
}
///: END:ONLY_INCLUDE_IN
render() {
const { t } = this.context;
const { justCopied } = this.state;
const {
justCopied,
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
custodyTransactionDeepLink,
///: END:ONLY_INCLUDE_IN
} = this.state;
const {
transactionGroup,
primaryCurrency,
@ -152,6 +206,9 @@ export default class TransactionListItemDetails extends PureComponent {
showCancel,
transactionStatus: TransactionStatus,
blockExplorerLinkText,
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
transactionNote,
///: END:ONLY_INCLUDE_IN
} = this.props;
const {
primaryTransaction: transaction,
@ -229,6 +286,30 @@ export default class TransactionListItemDetails extends PureComponent {
</Button>
</Tooltip>
</div>
{
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
custodyTransactionDeepLink &&
custodyTransactionDeepLink.url && (
<Tooltip
wrapperClassName="transaction-list-item-details__header-button"
containerClassName="transaction-list-item-details__header-button-tooltip-container"
title={t('viewinCustodianApp')}
>
<Button
type="raised"
onClick={() => {
window.open(custodyTransactionDeepLink.url);
}}
>
<Icon
name={IconName.Custody}
color={IconColor.primaryDefault}
/>
</Button>
</Tooltip>
)
///: END:ONLY_INCLUDE_IN
}
</div>
</div>
<div className="transaction-list-item-details__body">
@ -281,6 +362,20 @@ export default class TransactionListItemDetails extends PureComponent {
primaryCurrency={primaryCurrency}
className="transaction-list-item-details__transaction-breakdown"
/>
{
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
transactionNote && transactionNote.length !== 0 && (
<Box className="transaction-list-item-details__transaction-breakdown">
<Text as="h4" className="transaction-breakdown__title">
{t('transactionNote')}
</Text>
<Text as="p" className="transaction-breakdown__description">
{transactionNote}
</Text>
</Box>
)
///: END:ONLY_INCLUDE_IN
}
{transactionGroup.initialTransaction.type !==
TransactionType.incoming && (
<Disclosure title={t('activityLog')} size="small">

View File

@ -1,6 +1,7 @@
import React from 'react';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { waitFor } from '@testing-library/react';
import { TransactionStatus } from '../../../../shared/constants/transaction';
import { GAS_LIMITS } from '../../../../shared/constants/gas';
import { renderWithProvider } from '../../../../test/lib/render-helpers';
@ -13,6 +14,14 @@ jest.mock('../../../store/actions.ts', () => ({
addPollingTokenToAppState: jest.fn(),
}));
let mockGetCustodianTransactionDeepLink = jest.fn();
jest.mock('../../../store/institutional/institution-background', () => ({
mmiActionsFactory: () => ({
getCustodianTransactionDeepLink: () => mockGetCustodianTransactionDeepLink,
}),
}));
describe('TransactionListItemDetails Component', () => {
const transaction = {
history: [],
@ -26,6 +35,10 @@ describe('TransactionListItemDetails Component', () => {
to: '0x2',
value: '0x2386f26fc10000',
},
metadata: {
note: 'some note',
},
custodyId: '1',
};
const transactionGroup = {
@ -58,7 +71,7 @@ describe('TransactionListItemDetails Component', () => {
rpcPrefs,
};
it('should render title with title prop', () => {
it('should render title with title prop', async () => {
const mockStore = configureMockStore([thunk])(mockState);
const { queryByText } = renderWithProvider(
@ -66,7 +79,9 @@ describe('TransactionListItemDetails Component', () => {
mockStore,
);
expect(queryByText(props.title)).toBeInTheDocument();
await waitFor(() => {
expect(queryByText(props.title)).toBeInTheDocument();
});
});
describe('Retry button', () => {
@ -122,4 +137,55 @@ describe('TransactionListItemDetails Component', () => {
expect(queryByTestId('speedup-button')).toBeInTheDocument();
});
});
describe('Institutional', () => {
it('should render correctly if custodyTransactionDeepLink has a url', async () => {
mockGetCustodianTransactionDeepLink = jest
.fn()
.mockReturnValue({ url: 'https://url.com' });
const mockStore = configureMockStore([thunk])(mockState);
renderWithProvider(<TransactionListItemDetails {...props} />, mockStore);
await waitFor(() => {
const custodianViewButton = document.querySelector(
'[data-original-title="View in custodian app"]',
);
// Assert that the custodian view button is rendered
expect(custodianViewButton).toBeInTheDocument();
});
});
it('should render correctly if transactionNote is provided', async () => {
const newTransaction = {
...transaction,
metadata: {
note: 'some note',
},
custodyId: '1',
};
const newTransactionGroup = {
...transactionGroup,
transactions: [newTransaction],
primaryTransaction: newTransaction,
initialTransaction: newTransaction,
};
const mockStore = configureMockStore([thunk])(mockState);
const { queryByText } = renderWithProvider(
<TransactionListItemDetails
{...props}
transactionGroup={newTransactionGroup}
/>,
mockStore,
);
await waitFor(() => {
expect(queryByText('some note')).toBeInTheDocument();
});
});
});
});

View File

@ -2,6 +2,9 @@ import { connect } from 'react-redux';
import { compose } from 'redux';
import { withRouter } from 'react-router-dom';
import { tryReverseResolveAddress } from '../../../store/actions';
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
import { mmiActionsFactory } from '../../../store/institutional/institution-background';
///: END:ONLY_INCLUDE_IN
import {
getAddressBook,
getBlockExplorerLinkText,
@ -11,6 +14,10 @@ import {
getAccountName,
getMetadataContractName,
getMetaMaskIdentities,
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
getSelectedIdentity,
getKnownMethodData,
///: END:ONLY_INCLUDE_IN
} from '../../../selectors';
import { toChecksumHexAddress } from '../../../../shared/modules/hexstring-utils';
import TransactionListItemDetails from './transaction-list-item-details.component';
@ -40,6 +47,13 @@ const mapStateToProps = (state, ownProps) => {
const isCustomNetwork = getIsCustomNetwork(state);
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
const data = ownProps.transactionGroup?.primaryTransaction?.txParams?.data;
const methodData = getKnownMethodData(state, data) || {};
const transactionNote =
ownProps.transactionGroup?.primaryTransaction?.metadata?.note;
///: END:ONLY_INCLUDE_IN
return {
rpcPrefs,
recipientEns,
@ -49,14 +63,29 @@ const mapStateToProps = (state, ownProps) => {
blockExplorerLinkText: getBlockExplorerLinkText(state),
recipientName,
recipientMetadataName,
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
methodData,
transactionNote,
selectedIdentity: getSelectedIdentity(state),
///: END:ONLY_INCLUDE_IN
};
};
const mapDispatchToProps = (dispatch) => {
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
const mmiActions = mmiActionsFactory();
///: END:ONLY_INCLUDE_IN
return {
tryReverseResolveAddress: (address) => {
return dispatch(tryReverseResolveAddress(address));
},
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
getCustodianTransactionDeepLink: (address, txId) => {
return dispatch(
mmiActions.getCustodianTransactionDeepLink(address, txId),
);
},
///: END:ONLY_INCLUDE_IN
};
};

View File

@ -67,4 +67,13 @@
text-overflow: ellipsis;
white-space: nowrap;
}
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
&__icon-badge {
position: absolute;
top: 18px;
left: 18px;
transform: scale(0.8);
}
///: END:ONLY_INCLUDE_IN
}

View File

@ -13,6 +13,10 @@ import { CONFIRM_TRANSACTION_ROUTE } from '../../../helpers/constants/routes';
import { useShouldShowSpeedUp } from '../../../hooks/useShouldShowSpeedUp';
import TransactionStatusLabel from '../transaction-status-label/transaction-status-label';
import TransactionIcon from '../transaction-icon';
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
import { IconColor } from '../../../helpers/constants/design-system';
import { Icon, IconName, IconSize } from '../../component-library';
///: END:ONLY_INCLUDE_IN
import { MetaMetricsEventCategory } from '../../../../shared/constants/metametrics';
import {
TransactionGroupCategory,
@ -125,6 +129,9 @@ function TransactionListItemInner({
const isApproval = category === TransactionGroupCategory.approval;
const isUnapproved = status === TransactionStatus.unapproved;
const isSwap = category === TransactionGroupCategory.swap;
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
const isCustodian = Boolean(transactionGroup.primaryTransaction.custodyId);
///: END:ONLY_INCLUDE_IN
const className = classnames('transaction-list-item', {
'transaction-list-item--unconfirmed':
@ -144,10 +151,29 @@ function TransactionListItemInner({
setShowDetails((prev) => !prev);
}, [isUnapproved, history, id]);
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
const debugTransactionMeta = {
'data-hash': transactionGroup.primaryTransaction.hash,
...(isCustodian
? {
'data-custodiantransactionid':
transactionGroup.primaryTransaction.custodyId,
}
: {}),
};
///: END:ONLY_INCLUDE_IN
const speedUpButton = useMemo(() => {
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
if (isCustodian) {
return null;
}
///: END:ONLY_INCLUDE_IN
if (!shouldShowSpeedUp || !isPending || isUnapproved) {
return null;
}
return (
<Button
type="primary"
@ -165,10 +191,32 @@ function TransactionListItemInner({
hasCancelled,
retryTransaction,
cancelTransaction,
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
isCustodian,
///: END:ONLY_INCLUDE_IN
]);
const showCancelButton = !hasCancelled && isPending && !isUnapproved;
const showBorder = process.env.MULTICHAIN;
let showCancelButton = !hasCancelled && isPending && !isUnapproved;
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
showCancelButton = showCancelButton && !isCustodian;
const PENDING_COLOR = IconColor.iconAlternative;
const OK_COLOR = IconColor.primaryDefault;
const FAIL_COLOR = IconColor.errorDefault;
const getTransactionColor = (tsStatus) => {
switch (tsStatus) {
case TransactionStatus.signed:
return PENDING_COLOR;
case TransactionStatus.rejected:
case TransactionStatus.failed:
case TransactionStatus.dropped:
return FAIL_COLOR;
default:
return OK_COLOR;
}
};
///: END:ONLY_INCLUDE_IN
return (
<>
@ -177,7 +225,26 @@ function TransactionListItemInner({
className={className}
title={title}
icon={
<TransactionIcon category={category} status={displayedStatusKey} />
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
isCustodian ? (
<div style={{ position: 'relative' }} data-testid="custody-icon">
<TransactionIcon
category={category}
status={displayedStatusKey}
/>
<Icon
name={IconName.Custody}
className="transaction-list-item__icon-badge"
color={getTransactionColor(status)}
size={IconSize.Xs}
/>
</div>
) : (
///: END:ONLY_INCLUDE_IN
<TransactionIcon category={category} status={displayedStatusKey} />
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
)
///: END:ONLY_INCLUDE_IN
}
showBorder={showBorder}
subtitle={
@ -188,6 +255,12 @@ function TransactionListItemInner({
error={err}
date={date}
status={displayedStatusKey}
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
custodyStatus={transactionGroup.primaryTransaction.custodyStatus}
custodyStatusDisplayText={
transactionGroup.primaryTransaction.custodyStatusDisplayText
}
///: END:ONLY_INCLUDE_IN
/>
{subtitleContainsOrigin ? (
<SiteOrigin siteOrigin={subtitle} />
@ -224,6 +297,11 @@ function TransactionListItemInner({
/>
)}
</div>
{
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
<a {...debugTransactionMeta} className="test-transaction-meta" />
///: END:ONLY_INCLUDE_IN
}
</ListItem>
{showDetails && (
<TransactionListItemDetails
@ -234,11 +312,28 @@ function TransactionListItemInner({
senderAddress={senderAddress}
recipientAddress={recipientAddress}
onRetry={retryTransaction}
showRetry={status === TransactionStatus.failed && !isSwap}
showSpeedUp={shouldShowSpeedUp}
showRetry={
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
!isCustodian &&
///: END:ONLY_INCLUDE_IN
status === TransactionStatus.failed &&
!isSwap
}
showSpeedUp={
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
!isCustodian &&
///: END:ONLY_INCLUDE_IN
shouldShowSpeedUp
}
isEarliestNonce={isEarliestNonce}
onCancel={cancelTransaction}
showCancel={isPending && !hasCancelled}
showCancel={
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
!isCustodian &&
///: END:ONLY_INCLUDE_IN
isPending &&
!hasCancelled
}
transactionStatus={() => (
<TransactionStatusLabel
isPending={isPending}

View File

@ -1,6 +1,8 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { fireEvent } from '@testing-library/react';
import { fireEvent, screen } from '@testing-library/react';
import configureStore from 'redux-mock-store';
import mockState from '../../../../test/data/mock-state.json';
import transactionGroup from '../../../../test/data/mock-pending-transaction-data.json';
import {
getConversionRate,
@ -72,6 +74,20 @@ jest.mock('react', () => {
};
});
jest.mock('../../../store/actions.ts', () => ({
tryReverseResolveAddress: jest.fn().mockReturnValue({ type: 'TYPE' }),
}));
jest.mock('../../../store/institutional/institution-background', () => ({
mmiActionsFactory: () => ({
getCustodianTransactionDeepLink: jest
.fn()
.mockReturnValue({ type: 'TYPE' }),
}),
}));
const mockStore = configureStore();
const generateUseSelectorRouter = (opts) => (selector) => {
if (selector === getConversionRate) {
return 1;
@ -146,5 +162,49 @@ describe('TransactionListItem', () => {
fireEvent.click(cancelButton);
expect(getByText('Cancel transaction')).toBeInTheDocument();
});
it('should have a custodian Tx and show the custody icon', () => {
useSelector.mockImplementation(
generateUseSelectorRouter({
balance: '2AA1EFB94E0000',
}),
);
const newTransactionGroup = {
...transactionGroup,
...(transactionGroup.primaryTransaction.custodyId = '1'),
};
const { queryByTestId } = renderWithProvider(
<TransactionListItem transactionGroup={newTransactionGroup} />,
);
expect(queryByTestId('custody-icon')).toBeInTheDocument();
});
it('should click the custody list item and view the send screen', () => {
const store = mockStore(mockState);
useSelector.mockImplementation(
generateUseSelectorRouter({
balance: '2AA1EFB94E0000',
}),
);
const newTransactionGroup = {
...transactionGroup,
...(transactionGroup.primaryTransaction.custodyId = '1'),
};
const { queryByTestId } = renderWithProvider(
<TransactionListItem transactionGroup={newTransactionGroup} />,
store,
);
const custodyListItem = queryByTestId('custody-icon');
fireEvent.click(custodyListItem);
const sendTextExists = screen.queryAllByText('Send');
expect(sendTextExists).toBeTruthy();
});
});
});

View File

@ -123,23 +123,36 @@ export default function TransactionList({
<div className="transaction-list__header">
{`${t('queue')} (${pendingTransactions.length})`}
</div>
{pendingTransactions.map((transactionGroup, index) =>
transactionGroup.initialTransaction.transactionType ===
TransactionType.smart ? (
<SmartTransactionListItem
isEarliestNonce={index === 0}
smartTransaction={transactionGroup.initialTransaction}
transactionGroup={transactionGroup}
key={`${transactionGroup.nonce}:${index}`}
/>
) : (
<TransactionListItem
isEarliestNonce={index === 0}
transactionGroup={transactionGroup}
key={`${transactionGroup.nonce}:${index}`}
/>
),
)}
{pendingTransactions
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
.sort(
(a, b) => b.primaryTransaction.time - a.primaryTransaction.time,
)
///: END:ONLY_INCLUDE_IN
.map((transactionGroup, index) => {
///: BEGIN:ONLY_INCLUDE_IN(build-main,build-beta,build-flask)
if (
transactionGroup.initialTransaction.transactionType ===
TransactionType.smart
) {
return (
<SmartTransactionListItem
isEarliestNonce={index === 0}
smartTransaction={transactionGroup.initialTransaction}
transactionGroup={transactionGroup}
key={`${transactionGroup.nonce}:${index}`}
/>
);
}
///: END:ONLY_INCLUDE_IN
return (
<TransactionListItem
isEarliestNonce={index === 0}
transactionGroup={transactionGroup}
key={`${transactionGroup.nonce}:${index}`}
/>
);
})}
</div>
)}
<div className="transaction-list__completed-transactions">
@ -148,6 +161,11 @@ export default function TransactionList({
) : null}
{completedTransactions.length > 0 ? (
completedTransactions
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
.sort(
(a, b) => b.primaryTransaction.time - a.primaryTransaction.time,
)
///: END:ONLY_INCLUDE_IN
.slice(0, limit)
.map((transactionGroup, index) =>
transactionGroup.initialTransaction?.transactionType ===