Skip to content

Commit 9898e1c

Browse files
authored
feat: add quote details card component to Bridge UI (#14264)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** This PR introduces a new QuoteDetailsCard component to enhance the Bridge UI by displaying detailed quote information along with features such as expandable content for price impact and slippage settings, as well as adding relevant tests and updating redux state management. ### Key Components Added #### 1. QuoteDetailsCard - Displays essential quote information including: - Network fee and estimated processing time - Source and destination network details - Exchange rate and price impact - Slippage settings - Features an expandable accordion view for additional details #### 2. SlippageModal (Updated) - Refactored to use Redux for state management ## **Related issues** Fixes: [MMS-1985](https://consensyssoftware.atlassian.net/browse/MMS-1985) ## **Manual testing steps** 1. Navigate to the Bridge screen 2. Verify the QuoteDetailsCard displays: 3. Source and destination network badges 4. Network fee amount 5. Estimated transaction time 6. Quote rate 7. Price impact (when expanded) 8. Test the expand/collapse functionality 9. Verify the slippage edit button opens the SlippageModal 10. Check that the quote info button opens the QuoteInfoModal ## **Screenshots/Recordings** https://github.com/user-attachments/assets/9b964521-5886-461b-abf5-466f1b609e96 ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. [MMS-1985]: https://consensyssoftware.atlassian.net/browse/MMS-1985?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
1 parent 0216f88 commit 9898e1c

14 files changed

+3287
-70
lines changed
+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Hex } from '@metamask/utils';
2+
3+
export const mockChainId = '0x1' as Hex;
4+
5+
export const defaultBridgeControllerState = {
6+
bridgeFeatureFlags: {
7+
extensionConfig: {
8+
refreshRate: 30000,
9+
maxRefreshCount: 2,
10+
support: true,
11+
chains: {
12+
'0x1': { isActiveSrc: true, isActiveDst: true },
13+
},
14+
},
15+
mobileConfig: {
16+
refreshRate: 30000,
17+
maxRefreshCount: 2,
18+
support: true,
19+
chains: {
20+
'0x1': { isActiveSrc: true, isActiveDst: true },
21+
},
22+
},
23+
},
24+
quoteRequest: {},
25+
quotes: [],
26+
quotesInitialLoadTime: null,
27+
quotesLastFetched: null,
28+
quotesLoadingStatus: null,
29+
quoteFetchError: null,
30+
quotesRefreshCount: 0,
31+
};
+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { ChainId } from '@metamask/controller-utils';
2+
import type { BridgeState } from '../../../../core/redux/slices/bridge';
3+
4+
export const mockBridgeReducerState: BridgeState = {
5+
sourceAmount: undefined,
6+
destAmount: undefined,
7+
sourceToken: {
8+
address: '0x0000000000000000000000000000000000000000',
9+
aggregators: [],
10+
decimals: 18,
11+
image: 'https://example.com/image.png',
12+
name: 'Ethereum',
13+
symbol: 'ETH',
14+
balance: '1.0',
15+
balanceFiat: '$2000',
16+
logo: undefined,
17+
isETH: true,
18+
chainId: '0x1',
19+
},
20+
destToken: {
21+
address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359',
22+
aggregators: [],
23+
decimals: 6,
24+
image: 'https://example.com/image.png',
25+
name: 'USD Coin',
26+
symbol: 'USDC',
27+
balance: '0',
28+
balanceFiat: '$0',
29+
logo: undefined,
30+
isETH: false,
31+
chainId: ChainId['linea-mainnet'],
32+
},
33+
selectedSourceChainIds: ['0x1'],
34+
selectedDestChainId: '0x89',
35+
slippage: '0.5',
36+
};

Diff for: app/components/UI/Bridge/_mocks_/mock-quotes-native-erc20.json

+294
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { StyleSheet } from 'react-native';
2+
import { Theme } from '../../../../../util/theme/models';
3+
4+
const createStyles = ({ colors }: Theme) =>
5+
StyleSheet.create({
6+
container: {
7+
backgroundColor: colors.background.default,
8+
borderWidth: 1,
9+
borderRadius: 8,
10+
borderColor: colors.border.muted,
11+
overflow: 'hidden',
12+
paddingVertical: 12,
13+
paddingHorizontal: 16,
14+
gap: 12,
15+
},
16+
gradientContainer: {
17+
position: 'absolute',
18+
bottom: 0,
19+
left: 0,
20+
right: 0,
21+
height: 30,
22+
},
23+
networkContainer: {
24+
flexDirection: 'row',
25+
flexWrap: 'wrap',
26+
maxWidth: '80%',
27+
},
28+
});
29+
30+
export default createStyles;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import { fireEvent } from '@testing-library/react-native';
2+
import {
3+
renderScreen,
4+
DeepPartial,
5+
} from '../../../../../util/test/renderWithProvider';
6+
import QuoteDetailsCard from './QuoteDetailsCard';
7+
import { strings } from '../../../../../../locales/i18n';
8+
import Routes from '../../../../../constants/navigation/Routes';
9+
import { defaultBridgeControllerState } from '../../../../../core/Engine/controllers/bridge-controller/constants';
10+
import mockQuotes from '../../_mocks_/mock-quotes-native-erc20.json';
11+
import { QuoteResponse } from '@metamask/bridge-controller';
12+
import { mockNetworkState } from '../../../../../util/test/network';
13+
import { RpcEndpointType } from '@metamask/network-controller';
14+
import { mockBridgeReducerState } from '../../_mocks_/bridgeReducerState';
15+
import initialRootState from '../../../../../util/test/initial-root-state';
16+
import { RootState } from '../../../../../reducers';
17+
import { ChainId } from '@metamask/controller-utils';
18+
import { createMockInternalAccount } from '../../../../../util/test/accountsControllerTestUtils';
19+
20+
const mockNavigate = jest.fn();
21+
jest.mock('@react-navigation/native', () => ({
22+
...jest.requireActual('@react-navigation/native'),
23+
useNavigation: () => ({
24+
navigate: mockNavigate,
25+
}),
26+
}));
27+
28+
describe('QuoteDetailsCard', () => {
29+
const mockAccount = createMockInternalAccount(
30+
'0x1234567890123456789012345678901234567890',
31+
'Test Account',
32+
);
33+
34+
const initialState: DeepPartial<RootState> = {
35+
engine: {
36+
backgroundState: {
37+
...initialRootState,
38+
BridgeController: {
39+
...defaultBridgeControllerState,
40+
quotes: mockQuotes as unknown as QuoteResponse[],
41+
},
42+
NetworkController: {
43+
...mockNetworkState(
44+
{
45+
chainId: ChainId.mainnet,
46+
id: 'mainnet',
47+
nickname: 'Ethereum',
48+
ticker: 'ETH',
49+
type: RpcEndpointType.Infura,
50+
rpcUrl: 'https://eth-mainnet.alchemyapi.io/v2/demo',
51+
},
52+
{
53+
chainId: ChainId['linea-mainnet'],
54+
id: 'linea',
55+
nickname: 'Linea',
56+
ticker: 'LINEA',
57+
type: RpcEndpointType.Custom,
58+
rpcUrl: 'https://linea-rpc.com',
59+
},
60+
),
61+
},
62+
MultichainNetworkController: {
63+
multichainNetworkConfigurationsByChainId: {},
64+
},
65+
AccountsController: {
66+
internalAccounts: {
67+
accounts: {
68+
[mockAccount.id]: mockAccount,
69+
},
70+
selectedAccount: mockAccount.id,
71+
},
72+
},
73+
},
74+
},
75+
bridge: mockBridgeReducerState,
76+
};
77+
78+
beforeEach(() => {
79+
jest.clearAllMocks();
80+
});
81+
82+
it('renders initial state', () => {
83+
const { toJSON } = renderScreen(
84+
QuoteDetailsCard,
85+
{
86+
name: Routes.BRIDGE.ROOT,
87+
},
88+
{ state: initialState },
89+
);
90+
expect(toJSON()).toMatchSnapshot();
91+
});
92+
93+
it('renders expanded state', () => {
94+
const { getByLabelText, toJSON } = renderScreen(
95+
QuoteDetailsCard,
96+
{
97+
name: Routes.BRIDGE.ROOT,
98+
},
99+
{ state: initialState },
100+
);
101+
102+
// Expand the accordion
103+
const expandButton = getByLabelText('Expand quote details');
104+
fireEvent.press(expandButton);
105+
106+
expect(toJSON()).toMatchSnapshot();
107+
});
108+
109+
it('displays fee amount', () => {
110+
const { getByText } = renderScreen(
111+
QuoteDetailsCard,
112+
{
113+
name: Routes.BRIDGE.ROOT,
114+
},
115+
{ state: initialState },
116+
);
117+
118+
expect(getByText('$0.01')).toBeDefined();
119+
});
120+
121+
it('displays processing time', () => {
122+
const { getByText } = renderScreen(
123+
QuoteDetailsCard,
124+
{
125+
name: Routes.BRIDGE.ROOT,
126+
},
127+
{ state: initialState },
128+
);
129+
130+
expect(getByText('1 min')).toBeDefined();
131+
});
132+
133+
it('displays quote rate', () => {
134+
const { getByText } = renderScreen(
135+
QuoteDetailsCard,
136+
{
137+
name: Routes.BRIDGE.ROOT,
138+
},
139+
{ state: initialState },
140+
);
141+
142+
expect(getByText('1 ETH = 0.0 USDC')).toBeDefined();
143+
});
144+
145+
it('toggles content visibility on chevron press', () => {
146+
const { getByLabelText, queryByText } = renderScreen(
147+
QuoteDetailsCard,
148+
{
149+
name: Routes.BRIDGE.ROOT,
150+
},
151+
{ state: initialState },
152+
);
153+
154+
// Initially price impact should not be visible
155+
expect(queryByText(strings('bridge.price_impact'))).toBeNull();
156+
157+
// Press chevron to expand
158+
const expandButton = getByLabelText('Expand quote details');
159+
fireEvent.press(expandButton);
160+
161+
// After expansion, price impact should be visible
162+
expect(queryByText(strings('bridge.price_impact'))).toBeDefined();
163+
expect(queryByText('-0.06%')).toBeDefined();
164+
165+
// Press chevron again to collapse
166+
fireEvent.press(expandButton);
167+
168+
// After collapse, price impact should not be visible
169+
expect(queryByText(strings('bridge.price_impact'))).toBeNull();
170+
});
171+
172+
it('navigates to slippage modal on edit press', () => {
173+
const { getByLabelText, getByTestId } = renderScreen(
174+
QuoteDetailsCard,
175+
{
176+
name: Routes.BRIDGE.ROOT,
177+
},
178+
{ state: initialState },
179+
);
180+
181+
// Expand the accordion first
182+
const expandButton = getByLabelText('Expand quote details');
183+
fireEvent.press(expandButton);
184+
185+
// Find and press the edit button
186+
const editButton = getByTestId('edit-slippage-button');
187+
fireEvent.press(editButton);
188+
189+
// Check if navigation was called with correct params
190+
expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, {
191+
screen: Routes.BRIDGE.MODALS.SLIPPAGE_MODAL,
192+
});
193+
});
194+
195+
it('displays network names', () => {
196+
const { getByText } = renderScreen(
197+
QuoteDetailsCard,
198+
{
199+
name: Routes.BRIDGE.ROOT,
200+
},
201+
{ state: initialState },
202+
);
203+
204+
expect(getByText('Ethereum')).toBeDefined();
205+
expect(getByText('Linea')).toBeDefined();
206+
});
207+
208+
it('displays slippage value', () => {
209+
const { getByLabelText, getByText } = renderScreen(
210+
QuoteDetailsCard,
211+
{
212+
name: Routes.BRIDGE.ROOT,
213+
},
214+
{ state: initialState },
215+
);
216+
217+
// Expand the accordion first
218+
const expandButton = getByLabelText('Expand quote details');
219+
fireEvent.press(expandButton);
220+
221+
// Verify slippage value
222+
expect(getByText('0.5%')).toBeDefined();
223+
});
224+
});

0 commit comments

Comments
 (0)