Skip to content
7 changes: 0 additions & 7 deletions app/components/UI/Bridge/Bridge.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,6 @@

import { Transaction } from '@metamask/keyring-api';
import { TransactionMeta } from '@metamask/transaction-controller';
import { CaipChainId, Hex } from '@metamask/utils';

/** Custom slippage modal parameters */
export interface CustomSlippageModalParams {
sourceChainId?: CaipChainId | Hex;
destChainId?: CaipChainId | Hex;
}

/** Transaction details block explorer parameters */
export interface TransactionDetailsBlockExplorerParams {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';
import { fireEvent, render } from '@testing-library/react-native';
import { Hex } from '@metamask/utils';
import { CaipAssetType, Hex } from '@metamask/utils';

import { BridgeToken } from '../../types';
import { BatchSellReview } from './BatchSellReview';
Expand All @@ -9,7 +9,7 @@ import Routes from '../../../../../constants/navigation/Routes';

const mockNavigate = jest.fn();
const mockDispatch = jest.fn();
const mockSelectedTokens: BridgeToken[] = [
const defaultSelectedTokens: BridgeToken[] = [
{
address: '0x1111111111111111111111111111111111111111',
chainId: '0x1' as Hex,
Expand All @@ -25,6 +25,13 @@ const mockSelectedTokens: BridgeToken[] = [
balance: '154.297',
},
];
const thirdSelectedToken: BridgeToken = {
address: '0x3333333333333333333333333333333333333333',
chainId: '0x1' as Hex,
decimals: 18,
symbol: 'LINK',
balance: '42.123',
};
const usdcToken: BridgeToken = {
address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
chainId: '0x1' as Hex,
Expand All @@ -39,8 +46,11 @@ const musdToken: BridgeToken = {
symbol: 'MUSD',
image: 'musd-image-url',
};
let mockSelectedTokens: BridgeToken[] = defaultSelectedTokens;
let mockSelectedDestinationToken: BridgeToken | undefined = usdcToken;
let mockDestinationTokens: BridgeToken[] = [usdcToken];
let mockBatchSellSlippages: Partial<Record<CaipAssetType, string | undefined>> =
{};

jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({
Expand All @@ -51,13 +61,27 @@ jest.mock('@react-navigation/native', () => ({
}));

jest.mock('../../../../../core/redux/slices/bridge', () => ({
resetBridgeState: jest.fn(() => ({
type: 'bridge/resetBridgeState',
})),
selectBatchSellSourceTokens: jest.fn(() => mockSelectedTokens),
selectBatchSellDestStablecoins: jest.fn(() => mockDestinationTokens),
selectBatchSellDestToken: jest.fn(() => mockSelectedDestinationToken),
selectBatchSellSlippages: jest.fn(() => mockBatchSellSlippages),
setBatchSellDestToken: jest.fn((token: BridgeToken) => ({
type: 'bridge/setBatchSellDestToken',
payload: token,
})),
setBatchSellSourceTokens: jest.fn((tokens: BridgeToken[]) => ({
type: 'bridge/setBatchSellSourceTokens',
payload: tokens,
})),
setBatchSellTokenSlippages: jest.fn(
(slippage: Partial<Record<CaipAssetType, string | undefined>>) => ({
type: 'bridge/setBatchSellTokenSlippages',
payload: slippage,
}),
),
}));

jest.mock('react-redux', () => ({
Expand All @@ -67,32 +91,52 @@ jest.mock('react-redux', () => ({

jest.mock('./BatchSellReviewTokenRow', () => {
const ReactActual = jest.requireActual('react');
const { Text, View } = jest.requireActual('react-native');
const { Pressable, Text, View } = jest.requireActual('react-native');

return {
BatchSellReviewTokenRow: ({
isRemoveTokenDisabled,
onRemovePress,
onSlippagePress,
percent,
token,
tokenKey,
}: {
isRemoveTokenDisabled?: boolean;
onRemovePress: (token: BridgeToken) => void;
onSlippagePress: (token: BridgeToken) => void;
percent: number;
token: { symbol: string };
token: BridgeToken;
tokenKey: string;
}) =>
ReactActual.createElement(
View,
{ testID: `batch-sell-review-token-row-${tokenKey}` },
ReactActual.createElement(Text, null, token.symbol),
ReactActual.createElement(Text, null, `${percent}%`),
ReactActual.createElement(Pressable, {
onPress: () => onSlippagePress(token),
testID: `batch-sell-review-customize-button-${tokenKey}`,
}),
ReactActual.createElement(Pressable, {
accessibilityState: { disabled: Boolean(isRemoveTokenDisabled) },
disabled: isRemoveTokenDisabled,
onPress: isRemoveTokenDisabled
? undefined
: () => onRemovePress(token),
testID: `batch-sell-review-remove-button-${tokenKey}`,
}),
),
};
});

describe('BatchSellReview', () => {
beforeEach(() => {
jest.clearAllMocks();
mockSelectedTokens = defaultSelectedTokens;
mockSelectedDestinationToken = usdcToken;
mockDestinationTokens = [usdcToken];
mockBatchSellSlippages = {};
});

it('renders the quote loading screen', () => {
Expand Down Expand Up @@ -151,6 +195,26 @@ describe('BatchSellReview', () => {
});
});

it('opens the slippage modal for a selected token row', () => {
const { getByTestId } = render(<BatchSellReview />);

fireEvent.press(
getByTestId(
`${BatchSellReviewSelectorsIDs.CUSTOMIZE_BUTTON}-0x1:0x1111111111111111111111111111111111111111`,
),
);

expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, {
screen: Routes.BRIDGE.MODALS.BATCH_SELL_DEFAULT_SLIPPAGE_MODAL,
params: {
sourceChainId: '0x1',
destChainId: '0x1',
batchSellAssetId:
'eip155:1/erc20:0x1111111111111111111111111111111111111111',
},
});
});

it('renders the selected destination token from Redux', () => {
mockSelectedDestinationToken = musdToken;
mockDestinationTokens = [usdcToken, musdToken];
Expand All @@ -172,4 +236,48 @@ describe('BatchSellReview', () => {
payload: usdcToken,
});
});

it('resets bridge state on unmount', () => {
const { unmount } = render(<BatchSellReview />);

mockDispatch.mockClear();
unmount();

expect(mockDispatch).toHaveBeenCalledWith({
type: 'bridge/resetBridgeState',
});
});

it('removes a token when more than two source tokens are selected', () => {
mockSelectedTokens = [...defaultSelectedTokens, thirdSelectedToken];
const { getByTestId } = render(<BatchSellReview />);

mockDispatch.mockClear();
fireEvent.press(
getByTestId(
`${BatchSellReviewSelectorsIDs.REMOVE_BUTTON}-0x1:0x2222222222222222222222222222222222222222`,
),
);

expect(mockDispatch).toHaveBeenCalledWith({
type: 'bridge/setBatchSellSourceTokens',
payload: [defaultSelectedTokens[0], thirdSelectedToken],
});
});

it('disables token removal when two source tokens are selected', () => {
const { getByTestId } = render(<BatchSellReview />);
const removeButton = getByTestId(
`${BatchSellReviewSelectorsIDs.REMOVE_BUTTON}-0x1:0x2222222222222222222222222222222222222222`,
);

mockDispatch.mockClear();
fireEvent.press(removeButton);

expect(removeButton.props.accessibilityState.disabled).toBe(true);
expect(mockDispatch).not.toHaveBeenCalledWith({
type: 'bridge/setBatchSellSourceTokens',
payload: expect.any(Array),
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -29,22 +29,47 @@
import Routes from '../../../../../constants/navigation/Routes';
import { Skeleton } from '../../../../../component-library/components-temp/Skeleton';
import {
resetBridgeState,
selectBatchSellSlippages,
selectBatchSellDestToken,
selectBatchSellDestStablecoins,
selectBatchSellSourceTokens,
setBatchSellDestToken,
setBatchSellSourceTokens,
setBatchSellTokenSlippages,
} from '../../../../../core/redux/slices/bridge';
import { RootState } from '../../../../../reducers';
import { BridgeToken } from '../../types';
import { getBridgeTokenAssetId } from '../../utils/tokenUtils';
import { getBatchSellInitialSlippage } from '../../components/SlippageModal/utils';
import { BatchSellReviewSelectorsIDs } from './BatchSellReview.testIds';
import { BatchSellReviewTokenRow } from './BatchSellReviewTokenRow';

const DEFAULT_PERCENT = 100;
const UNKNOWN_DESTINATION_TOKEN_SYMBOL = 'UNKNOWN';
// TODO(SWAPS-4439): When Batch Sell quote fetching is wired, pass

Check warning on line 50 in app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=metamask-mobile&issues=AZ4dA1rO8A84ZkZ-qt4r&open=AZ4dA1rO8A84ZkZ-qt4r&pullRequest=30040
// batchSellSlippages[assetId] into each token's BridgeController quote request.
const HAS_QUOTES = false;

const getTokenKey = (token: BridgeToken) => `${token.chainId}:${token.address}`;

function areBatchSellSlippageMapsEqual(
first: Record<string, string | undefined>,
second: Record<string, string | undefined>,
) {
const firstKeys = Object.keys(first);
const secondKeys = Object.keys(second);

return (
firstKeys.length === secondKeys.length &&
firstKeys.every(
(assetId) =>
Object.prototype.hasOwnProperty.call(second, assetId) &&
first[assetId] === second[assetId],
)
);
}

export function BatchSellReview() {
const navigation = useNavigation();
const dispatch = useDispatch();
Expand All @@ -55,6 +80,8 @@
selectBatchSellDestStablecoins(state, sourceChainId),
);
const selectedDestinationToken = useSelector(selectBatchSellDestToken);
const batchSellSlippages = useSelector(selectBatchSellSlippages);
const isRemoveTokenDisabled = selectedTokens.length <= 2;
const [percentsByTokenKey, setPercentsByTokenKey] = useState<
Record<string, number>
>({});
Expand All @@ -77,6 +104,35 @@
);
}, [selectedTokens]);

// Reset bridge state when component unmounts.
useEffect(
() => () => {
dispatch(resetBridgeState());
},
[dispatch],
);

useEffect(() => {
// Keep Redux slippages aligned with selected tokens when the user removes tokens.
const nextSlippage = selectedTokens.reduce<
Record<string, string | undefined>
>((slippageByAssetId, token) => {
const assetId = getBridgeTokenAssetId(token);

if (!assetId) return slippageByAssetId;

slippageByAssetId[assetId] = getBatchSellInitialSlippage(
batchSellSlippages,
assetId,
);
return slippageByAssetId;
}, {});

if (!areBatchSellSlippageMapsEqual(batchSellSlippages, nextSlippage)) {
dispatch(setBatchSellTokenSlippages(nextSlippage));
}
}, [batchSellSlippages, dispatch, selectedTokens]);
Comment thread
infiniteflower marked this conversation as resolved.

const handlePercentChange = useCallback(
(tokenKey: string, percent: number) => {
setPercentsByTokenKey((currentPercents) => ({
Expand All @@ -97,6 +153,38 @@
});
}, [navigation]);

const handleSlippagePress = useCallback(
(token: BridgeToken) => {
const assetId = getBridgeTokenAssetId(token);

if (!assetId) return;

navigation.navigate(Routes.BRIDGE.MODALS.ROOT, {
screen: Routes.BRIDGE.MODALS.BATCH_SELL_DEFAULT_SLIPPAGE_MODAL,
params: {
sourceChainId: token.chainId,
destChainId: selectedDestinationToken?.chainId,
batchSellAssetId: assetId,
},
});
},
[navigation, selectedDestinationToken?.chainId],
);

const handleRemoveToken = useCallback(
(tokenToRemove: BridgeToken) => {
if (isRemoveTokenDisabled) return;

const tokenKeyToRemove = getTokenKey(tokenToRemove);
const remainingTokens = selectedTokens.filter(
(token) => getTokenKey(token) !== tokenKeyToRemove,
);

dispatch(setBatchSellSourceTokens(remainingTokens));
},
[dispatch, isRemoveTokenDisabled, selectedTokens],
);

return (
<SafeAreaView
style={tw.style('flex-1 bg-default')}
Expand Down Expand Up @@ -188,6 +276,9 @@
tokenKey={tokenKey}
percent={percentsByTokenKey[tokenKey] ?? DEFAULT_PERCENT}
onPercentChange={handlePercentChange}
onSlippagePress={handleSlippagePress}
onRemovePress={handleRemoveToken}
isRemoveTokenDisabled={isRemoveTokenDisabled}
/>
);
})}
Expand Down
Loading
Loading