Skip to content

Commit

Permalink
chore: display bridge quotes (#28031)
Browse files Browse the repository at this point in the history
<!--
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**
Changes:
   - display recommended quote in BridgeQuoteCard
   - add a placeholder modal for displaying alternative quotes

[![Open in GitHub
Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28031?quickstart=1)

## **Related issues**

Fixes: https://consensyssoftware.atlassian.net/browse/MMS-1448

## **Manual testing steps**

1. Request bridge quotes
2. Click "Switch" button and verify that new quotes with updated params
are requested/shown
3. Verify that bridge parameters are reset when extension is reopened

## **Screenshots/Recordings**

<!-- If applicable, add screenshots and/or recordings to visualize the
before and after of your change. -->

### **Before**

N/A

### **After**



https://github.com/user-attachments/assets/acb927af-b04e-46cb-9b93-e2cdeb219722



## **Pre-merge author checklist**

- [X] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask
Extension Coding
Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.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-extension/blob/develop/.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.
  • Loading branch information
micaelae authored Nov 5, 2024
1 parent 9fb69f4 commit 7b8d2c6
Show file tree
Hide file tree
Showing 25 changed files with 1,291 additions and 42 deletions.
28 changes: 28 additions & 0 deletions app/_locales/en/messages.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions test/jest/mock-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { CHAIN_IDS, CURRENCY_SYMBOLS } from '../../shared/constants/network';
import { KeyringType } from '../../shared/constants/keyring';
import { ETH_EOA_METHODS } from '../../shared/constants/eth-methods';
import { mockNetworkState } from '../stub/networks';
import { DEFAULT_BRIDGE_CONTROLLER_STATE } from '../../app/scripts/controllers/bridge/constants';

export const createGetSmartTransactionFeesApiResponse = () => {
return {
Expand Down Expand Up @@ -726,6 +727,8 @@ export const createBridgeMockStore = (
destNetworkAllowlist: [],
...featureFlagOverrides,
},
quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes,
quoteRequest: DEFAULT_BRIDGE_CONTROLLER_STATE.quoteRequest,
...bridgeStateOverrides,
},
},
Expand Down
2 changes: 1 addition & 1 deletion ui/ducks/bridge/selectors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ describe('Bridge selectors', () => {
const state = createBridgeMockStore();
const result = getToAmount(state as never);

expect(result).toStrictEqual('0');
expect(result).toStrictEqual(undefined);
});
});

Expand Down
37 changes: 35 additions & 2 deletions ui/ducks/bridge/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
NetworkState,
} from '@metamask/network-controller';
import { uniqBy } from 'lodash';
import { createSelector } from 'reselect';
import {
getNetworkConfigurationsByChainId,
getIsBridgeEnabled,
Expand All @@ -19,6 +20,10 @@ import {
import { createDeepEqualSelector } from '../../selectors/util';
import { getProviderConfig } from '../metamask/metamask';
import { SwapsTokenObject } from '../../../shared/constants/swaps';
import { calcTokenAmount } from '../../../shared/lib/transactions-controller-utils';
// TODO: Remove restricted import
// eslint-disable-next-line import/no-restricted-paths
import { RequestStatus } from '../../../app/scripts/controllers/bridge/constants';
import { BridgeState } from './bridge';

type BridgeAppState = {
Expand Down Expand Up @@ -124,10 +129,38 @@ export const getToToken = (

export const getFromAmount = (state: BridgeAppState): string | null =>
state.bridge.fromTokenInputValue;
export const getToAmount = (_state: BridgeAppState) => {
return '0';

export const getBridgeQuotes = (state: BridgeAppState) => {
return {
quotes: state.metamask.bridgeState.quotes,
quotesLastFetchedMs: state.metamask.bridgeState.quotesLastFetched,
isLoading:
state.metamask.bridgeState.quotesLoadingStatus === RequestStatus.LOADING,
};
};

export const getRecommendedQuote = createSelector(
getBridgeQuotes,
({ quotes }) => {
// TODO implement sorting
return quotes[0];
},
);

export const getQuoteRequest = (state: BridgeAppState) => {
const { quoteRequest } = state.metamask.bridgeState;
return quoteRequest;
};

export const getToAmount = createSelector(getRecommendedQuote, (quote) =>
quote
? calcTokenAmount(
quote.quote.destTokenAmount,
quote.quote.destAsset.decimals,
)
: undefined,
);

export const getIsBridgeTx = createDeepEqualSelector(
getFromChain,
getToChain,
Expand Down
34 changes: 34 additions & 0 deletions ui/hooks/bridge/useCountdownTimer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { renderHookWithProvider } from '../../../test/lib/render-helpers';
import { createBridgeMockStore } from '../../../test/jest/mock-store';
import { flushPromises } from '../../../test/lib/timer-helpers';
import { useCountdownTimer } from './useCountdownTimer';

jest.useFakeTimers();
const renderUseCountdownTimer = (mockStoreState: object) =>
renderHookWithProvider(() => useCountdownTimer(), mockStoreState);

describe('useCountdownTimer', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.clearAllTimers();
});

it('returns time remaining', async () => {
const quotesLastFetched = Date.now();
const { result } = renderUseCountdownTimer(
createBridgeMockStore({}, {}, { quotesLastFetched }),
);

let i = 0;
while (i <= 30) {
const secondsLeft = Math.min(30, 30 - i + 1);
expect(result.current).toStrictEqual(
`0:${secondsLeft < 10 ? '0' : ''}${secondsLeft}`,
);
i += 10;
jest.advanceTimersByTime(10000);
await flushPromises();
}
expect(result.current).toStrictEqual('0:00');
});
});
37 changes: 37 additions & 0 deletions ui/hooks/bridge/useCountdownTimer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Duration } from 'luxon';
import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { getBridgeQuotes } from '../../ducks/bridge/selectors';
// TODO: Remove restricted import
// eslint-disable-next-line import/no-restricted-paths
import { REFRESH_INTERVAL_MS } from '../../../app/scripts/controllers/bridge/constants';
import { SECOND } from '../../../shared/constants/time';

/**
* Custom hook that provides a countdown timer based on the last fetched quotes timestamp.
*
* This hook calculates the remaining time until the next refresh interval and updates every second.
*
* @returns The formatted remaining time in 'm:ss' format.
*/
export const useCountdownTimer = () => {
const [timeRemaining, setTimeRemaining] = useState(REFRESH_INTERVAL_MS);
const { quotesLastFetchedMs } = useSelector(getBridgeQuotes);

useEffect(() => {
if (quotesLastFetchedMs) {
setTimeRemaining(
REFRESH_INTERVAL_MS - (Date.now() - quotesLastFetchedMs),
);
}
}, [quotesLastFetchedMs]);

useEffect(() => {
const interval = setInterval(() => {
setTimeRemaining(Math.max(0, timeRemaining - SECOND));
}, SECOND);
return () => clearInterval(interval);
}, [timeRemaining]);

return Duration.fromMillis(timeRemaining).toFormat('m:ss');
};
2 changes: 1 addition & 1 deletion ui/pages/bridge/__snapshots__/index.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ exports[`Bridge renders the component with initial props 1`] = `
data-theme="light"
disabled=""
>
Select token
Select token and amount
</button>
</div>
</div>
Expand Down
2 changes: 2 additions & 0 deletions ui/pages/bridge/index.scss
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
@use "design-system";

@import 'prepare/index';
@import 'quotes/index';


.bridge {
max-height: 100vh;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ exports[`PrepareBridgePage should render the component, with initial state 1`] =
class="mm-box prepare-bridge-page__content"
>
<div
class="mm-box prepare-bridge-page__from"
class="mm-box bridge-box"
>
<div
class="mm-box prepare-bridge-page__input-row"
Expand Down Expand Up @@ -130,7 +130,7 @@ exports[`PrepareBridgePage should render the component, with initial state 1`] =
</button>
</div>
<div
class="mm-box prepare-bridge-page__to"
class="mm-box bridge-box"
>
<div
class="mm-box prepare-bridge-page__input-row"
Expand Down Expand Up @@ -213,7 +213,7 @@ exports[`PrepareBridgePage should render the component, with inputs set 1`] = `
class="mm-box prepare-bridge-page__content"
>
<div
class="mm-box prepare-bridge-page__from"
class="mm-box bridge-box"
>
<div
class="mm-box prepare-bridge-page__input-row"
Expand Down Expand Up @@ -340,7 +340,7 @@ exports[`PrepareBridgePage should render the component, with inputs set 1`] = `
</button>
</div>
<div
class="mm-box prepare-bridge-page__to"
class="mm-box bridge-box"
>
<div
class="mm-box prepare-bridge-page__input-row"
Expand Down
Loading

0 comments on commit 7b8d2c6

Please sign in to comment.