Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix (cherry-pick): navigation between watch asset approvals #29279 #29401

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,18 @@ import {
TransactionStatus,
TransactionType,
} from '@metamask/transaction-controller';
import { useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import { genUnapprovedContractInteractionConfirmation } from '../../../../../../test/data/confirmations/contract-interaction';
import { getMockConfirmState } from '../../../../../../test/data/confirmations/helper';
import { renderHookWithConfirmContextProvider } from '../../../../../../test/lib/confirmations/render-helpers';
import { RowAlertKey } from '../../../../../components/app/confirm/info/row/constants';
import { Severity } from '../../../../../helpers/constants/design-system';
import {
getRedesignedTransactionsEnabled,
submittedPendingTransactionsSelector,
} from '../../../../../selectors';
import { PendingTransactionAlertMessage } from './PendingTransactionAlertMessage';
import { usePendingTransactionAlerts } from './usePendingTransactionAlerts';

jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn(),
}));

jest.mock('./PendingTransactionAlertMessage', () => ({
PendingTransactionAlertMessage: () => 'PendingTransactionAlertMessage',
}));

jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: jest.fn().mockReturnValue({ id: 'mock-transaction-id' }),
}));

const ACCOUNT_ADDRESS = '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc';
const TRANSACTION_ID_MOCK = '123-456';

Expand Down Expand Up @@ -59,6 +43,7 @@ function runHook({
transactions?: TransactionMeta[];
} = {}) {
let pendingApprovals = {};

if (currentConfirmation) {
pendingApprovals = {
[currentConfirmation.id as string]: {
Expand All @@ -68,12 +53,14 @@ function runHook({
};
transactions.push(currentConfirmation);
}

const state = getMockConfirmState({
metamask: {
pendingApprovals,
transactions,
},
});

const response = renderHookWithConfirmContextProvider(
usePendingTransactionAlerts,
state,
Expand All @@ -83,19 +70,8 @@ function runHook({
}

describe('usePendingTransactionAlerts', () => {
const useSelectorMock = useSelector as jest.Mock;

beforeEach(() => {
jest.resetAllMocks();

(useParams as jest.Mock).mockReturnValue({ id: 'mock-transaction-id' });

useSelectorMock.mockImplementation((selector) => {
if (selector.toString().includes('pendingApprovalsSortedSelector')) {
return [];
}
return undefined;
});
});

it('returns no alerts if no confirmation', () => {
Expand Down Expand Up @@ -152,24 +128,6 @@ describe('usePendingTransactionAlerts', () => {
});

it('returns alert if submitted transaction', () => {
useSelectorMock.mockImplementation((selector) => {
if (selector === submittedPendingTransactionsSelector) {
return [
{ name: 'first transaction', id: '1' },
{ name: 'second transaction', id: '2' },
];
} else if (selector === getRedesignedTransactionsEnabled) {
return true;
} else if (selector.toString().includes('getUnapprovedTransaction')) {
return { type: TransactionType.contractInteraction };
} else if (
selector.toString().includes('pendingApprovalsSortedSelector')
) {
return [];
}
return undefined;
});

const alerts = runHook({
currentConfirmation: CONFIRMATION_MOCK,
transactions: [TRANSACTION_META_MOCK],
Expand Down
104 changes: 86 additions & 18 deletions ui/pages/confirmations/hooks/useConfirmationNavigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,34 +28,40 @@ jest.mock('../confirmation/templates', () => ({
const APPROVAL_ID_MOCK = '123-456';
const APPROVAL_ID_2_MOCK = '456-789';

function renderHook(
approvalType: ApprovalType,
requestData?: Json,
approvalFlows?: ApprovalFlowState[],
) {
function renderHookWithState(state: Record<string, unknown>) {
const { result } = renderHookWithProvider(() => useConfirmationNavigation(), {
...mockState,
metamask: {
...mockState.metamask,
pendingApprovals: {
[APPROVAL_ID_MOCK]: {
id: APPROVAL_ID_MOCK,
type: approvalType,
requestData,
},
[APPROVAL_ID_2_MOCK]: {
id: APPROVAL_ID_2_MOCK,
type: approvalType,
requestData,
},
},
approvalFlows,
...state,
},
});

return result.current;
}

function renderHook(
approvalType: ApprovalType,
requestData?: Json,
approvalFlows?: ApprovalFlowState[],
) {
return renderHookWithState({
pendingApprovals: {
[APPROVAL_ID_MOCK]: {
id: APPROVAL_ID_MOCK,
type: approvalType,
requestData,
},
[APPROVAL_ID_2_MOCK]: {
id: APPROVAL_ID_2_MOCK,
type: approvalType,
requestData,
},
},
approvalFlows,
});
}

describe('useConfirmationNavigation', () => {
const useHistoryMock = jest.mocked(useHistory);
const history = { replace: jest.fn() };
Expand Down Expand Up @@ -202,6 +208,36 @@ describe('useConfirmationNavigation', () => {
const result = renderHook(ApprovalType.Transaction);
expect(result.count).toBe(2);
});

// @ts-expect-error This function is missing from the Mocha type definitions
it.each([
['token', undefined],
['NFT', '123'],
])(
'ignores additional watch %s approvals',
(_title: string, tokenId?: string) => {
const result = renderHookWithState({
pendingApprovals: {
[APPROVAL_ID_MOCK]: {
id: APPROVAL_ID_MOCK,
type: ApprovalType.WatchAsset,
requestData: { asset: { tokenId } },
},
[APPROVAL_ID_2_MOCK]: {
id: APPROVAL_ID_2_MOCK,
type: ApprovalType.Transaction,
},
duplicate: {
id: 'duplicate',
type: ApprovalType.WatchAsset,
requestData: { asset: { tokenId } },
},
},
});

expect(result.count).toBe(2);
},
);
});

describe('getIndex', () => {
Expand All @@ -224,5 +260,37 @@ describe('useConfirmationNavigation', () => {
APPROVAL_ID_2_MOCK,
]);
});

// @ts-expect-error This function is missing from the Mocha type definitions
it.each([
['token', undefined],
['NFT', '123'],
])(
'ignores additional watch %s approvals',
(_title: string, tokenId?: string) => {
const result = renderHookWithState({
pendingApprovals: {
[APPROVAL_ID_MOCK]: {
id: APPROVAL_ID_MOCK,
type: ApprovalType.WatchAsset,
requestData: { asset: { tokenId } },
},
[APPROVAL_ID_2_MOCK]: {
id: APPROVAL_ID_2_MOCK,
type: ApprovalType.Transaction,
},
duplicate: {
id: 'duplicate',
type: ApprovalType.WatchAsset,
requestData: { asset: { tokenId } },
},
},
});

expect(
result.confirmations.map(({ id }: { id: string }) => id),
).toEqual([APPROVAL_ID_MOCK, APPROVAL_ID_2_MOCK]);
},
);
});
});
4 changes: 2 additions & 2 deletions ui/pages/confirmations/hooks/useConfirmationNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
import { isSignatureTransactionType } from '../utils';
import {
getApprovalFlows,
pendingApprovalsSortedSelector,
selectPendingApprovalsForNavigation,
} from '../../../selectors';

const CONNECT_APPROVAL_TYPES = [
Expand All @@ -30,7 +30,7 @@ const CONNECT_APPROVAL_TYPES = [
];

export function useConfirmationNavigation() {
const confirmations = useSelector(pendingApprovalsSortedSelector, isEqual);
const confirmations = useSelector(selectPendingApprovalsForNavigation);
const approvalFlows = useSelector(getApprovalFlows, isEqual);
const history = useHistory();

Expand Down
4 changes: 2 additions & 2 deletions ui/pages/home/home.container.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import {
getSelectedInternalAccount,
getQueuedRequestCount,
getEditedNetwork,
pendingApprovalsSortedSelector,
selectPendingApprovalsForNavigation,
///: BEGIN:ONLY_INCLUDE_IF(build-mmi)
getAccountType,
///: END:ONLY_INCLUDE_IF
Expand Down Expand Up @@ -108,7 +108,7 @@ const mapStateToProps = (state) => {
const totalUnapprovedAndQueuedRequestCount =
totalUnapprovedCount + queuedRequestCount;
const swapsEnabled = getSwapsFeatureIsLive(state);
const pendingApprovals = pendingApprovalsSortedSelector(state);
const pendingApprovals = selectPendingApprovalsForNavigation(state);

///: BEGIN:ONLY_INCLUDE_IF(build-mmi)
const institutionalConnectRequests = getInstitutionalConnectRequests(state);
Expand Down
46 changes: 45 additions & 1 deletion ui/selectors/approvals.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { ApprovalControllerState } from '@metamask/approval-controller';
import {
ApprovalControllerState,
ApprovalRequest,
} from '@metamask/approval-controller';
import { ApprovalType } from '@metamask/controller-utils';
import { createSelector } from 'reselect';
import { Json } from '@metamask/utils';
import { createDeepEqualSelector } from '../../shared/modules/selectors/util';

export type ApprovalsMetaMaskState = {
Expand Down Expand Up @@ -58,6 +62,32 @@ export function pendingApprovalsSortedSelector(state: ApprovalsMetaMaskState) {
return getPendingApprovals(state).sort((a1, a2) => a1.time - a2.time);
}

/**
* Returns pending approvals sorted by time for use in confirmation navigation.
* Excludes duplicate watch asset approvals as they are combined into a single confirmation.
*/
export const selectPendingApprovalsForNavigation = createDeepEqualSelector(
pendingApprovalsSortedSelector,
(sortedPendingApprovals) =>
sortedPendingApprovals.filter((approval, index) => {
if (
isWatchNftApproval(approval) &&
sortedPendingApprovals.findIndex(isWatchNftApproval) !== index
) {
return false;
}

if (
isWatchTokenApproval(approval) &&
sortedPendingApprovals.findIndex(isWatchTokenApproval) !== index
) {
return false;
}

return true;
}),
);

const internalSelectPendingApproval = createSelector(
getPendingApprovals,
(_state: ApprovalsMetaMaskState, id: string) => id,
Expand All @@ -68,3 +98,17 @@ export const selectPendingApproval = createDeepEqualSelector(
internalSelectPendingApproval,
(approval) => approval,
);

function isWatchTokenApproval(approval: ApprovalRequest<Record<string, Json>>) {
const tokenId = (approval.requestData?.asset as Record<string, string>)
?.tokenId;

return approval.type === ApprovalType.WatchAsset && !tokenId;
}

function isWatchNftApproval(approval: ApprovalRequest<Record<string, Json>>) {
const tokenId = (approval.requestData?.asset as Record<string, string>)
?.tokenId;

return approval.type === ApprovalType.WatchAsset && Boolean(tokenId);
}
Loading