-
Notifications
You must be signed in to change notification settings - Fork 5.6k
feat: tdp sticky buy swap cta ASSETS-3674 #45593
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
Changes from all commits
6fac1a9
a6b9dd5
840b0a6
f00f9a5
acdd871
05d4325
196e5d1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| import { Driver } from '../../../webdriver/driver'; | ||
|
|
||
| /** | ||
| * Sticky Buy / Swap CTA bar on the Token Detail Page V2. | ||
| * | ||
| * Screen: `#/asset/:chainId/:asset?/:id?` | ||
| * Owns: presence of the sticky footer and its Buy / Swap buttons, plus | ||
| * asserting the bar stays pinned to the bottom of the viewport after scroll. | ||
| * Boundaries: does not own destinations of Buy / Swap clicks. | ||
| * | ||
| * @see ui/pages/asset/components/asset-sticky-actions.tsx | ||
| */ | ||
| class AssetStickyActions { | ||
| private driver: Driver; | ||
|
|
||
| private readonly scrollContainer = | ||
| '[data-testid="asset-page-scroll-container"]'; | ||
|
|
||
| private readonly stickyActions = '[data-testid="asset-sticky-actions"]'; | ||
|
|
||
| private readonly stickyBuy = '[data-testid="asset-sticky-buy"]'; | ||
|
|
||
| private readonly stickySwap = '[data-testid="asset-sticky-swap"]'; | ||
|
|
||
| constructor(driver: Driver) { | ||
| this.driver = driver; | ||
| } | ||
|
|
||
| /** | ||
| * Waits for the sticky Buy and Swap CTAs to be present. | ||
| */ | ||
| async checkPageIsLoaded(): Promise<void> { | ||
| console.log('Check asset sticky actions are loaded'); | ||
| await this.driver.waitForSelector(this.stickyActions); | ||
| await this.driver.waitForSelector(this.stickyBuy); | ||
| await this.driver.waitForSelector(this.stickySwap); | ||
| } | ||
|
|
||
| /** | ||
| * Asserts the sticky bar is pinned near the bottom of the visual viewport. | ||
| * Uses a small tolerance so scrollbar / safe-area padding do not flake. | ||
| */ | ||
| async checkPinnedToViewportBottom(): Promise<void> { | ||
| console.log('Check asset sticky actions are pinned to the viewport bottom'); | ||
| await this.driver.wait(async () => { | ||
| const isPinned = await this.driver.executeScript(` | ||
| const bar = document.querySelector('[data-testid="asset-sticky-actions"]'); | ||
| if (!bar) { | ||
| return false; | ||
| } | ||
| const rect = bar.getBoundingClientRect(); | ||
| return Math.abs(rect.bottom - window.innerHeight) < 8 && rect.top >= 0; | ||
| `); | ||
| return Boolean(isPinned); | ||
| }, this.driver.timeout); | ||
| } | ||
|
|
||
| /** | ||
| * Scrolls the Token Detail Page scrollport to the bottom, far enough that a | ||
| * non-sticky footer would leave the viewport. | ||
| */ | ||
| async scrollToBottom(): Promise<void> { | ||
| console.log('Scroll the token detail page to the bottom'); | ||
| await this.driver.waitForSelector(this.scrollContainer); | ||
| await this.driver.executeScript(` | ||
| const scroller = document.querySelector('[data-testid="asset-page-scroll-container"]'); | ||
| if (scroller) { | ||
| scroller.scrollTo(0, scroller.scrollHeight); | ||
| } | ||
| `); | ||
| } | ||
| } | ||
|
|
||
| export default AssetStickyActions; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| import { Mockttp } from 'mockttp'; | ||
| import { Context } from 'mocha'; | ||
| import { CHAIN_IDS } from '../../../../shared/constants/network'; | ||
| import FixtureBuilderV2 from '../../fixtures/fixture-builder-v2'; | ||
| import { NETWORK_CLIENT_ID } from '../../constants'; | ||
| import { withFixtures } from '../../helpers'; | ||
| import { Driver } from '../../webdriver/driver'; | ||
| import HomePage from '../../page-objects/pages/home/homepage'; | ||
| import TokensTab from '../../page-objects/pages/home/tokens-tab'; | ||
| import AssetStickyActions from '../../page-objects/pages/asset/asset-sticky-actions'; | ||
| import { login } from '../../page-objects/flows/login.flow'; | ||
| import { mockHistoricalPricesV3, mockSpotPrices } from './utils/mocks'; | ||
|
|
||
| describe('Asset sticky actions', function () { | ||
| const chainId = CHAIN_IDS.MAINNET; | ||
|
|
||
| it('keeps Buy and Swap pinned to the bottom while the token detail page scrolls', async function () { | ||
| await withFixtures( | ||
| { | ||
| fixtures: new FixtureBuilderV2() | ||
| .withSelectedNetwork(NETWORK_CLIENT_ID.MAINNET) | ||
| .withEnabledNetworks({ eip155: { [chainId]: true } }) | ||
| .build(), | ||
| title: (this as Context).test?.fullTitle(), | ||
| ethConversionInUsd: 1700, | ||
| // Known SubscriptionsController startup race, unrelated to this page. | ||
| // Tracked in https://github.com/MetaMask/metamask-extension/issues/45612 | ||
| ignoredConsoleErrors: ['getSubscriptions'], | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Filtering the |
||
| localNodeOptions: { | ||
| chainId: parseInt(chainId, 16), | ||
| }, | ||
| testSpecificMock: async (mockServer: Mockttp) => [ | ||
| await mockSpotPrices(mockServer, { | ||
| 'eip155:1/slip44:60': { | ||
| price: 1700, | ||
| marketCap: 382623505141, | ||
| pricePercentChange1d: 0, | ||
| }, | ||
| }), | ||
| await mockHistoricalPricesV3(mockServer, 'eip155:1', 'slip44:60'), | ||
| ], | ||
| }, | ||
| async ({ driver }: { driver: Driver }) => { | ||
| await login(driver); | ||
|
|
||
| const homePage = new HomePage(driver); | ||
| await homePage.checkPageIsLoaded(); | ||
|
|
||
| const tokensTab = new TokensTab(driver); | ||
| await tokensTab.openTokenDetails('Ethereum'); | ||
|
|
||
| const stickyActions = new AssetStickyActions(driver); | ||
| await stickyActions.checkPageIsLoaded(); | ||
| await stickyActions.checkPinnedToViewportBottom(); | ||
|
|
||
| await stickyActions.scrollToBottom(); | ||
|
|
||
| await stickyActions.checkPageIsLoaded(); | ||
| await stickyActions.checkPinnedToViewportBottom(); | ||
| }, | ||
| ); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -96,7 +96,9 @@ const NATIVE_SWAP_TOKEN_OVERRIDE_PER_CHAIN: { [key: string]: BridgeAsset } = { | |
| [ARC_HEX_CHAIN_ID]: ARC_ERC20_USDC_BRIDGE_ASSET, | ||
| }; | ||
|
|
||
| function getSwapNativeTokenWithOverridesForChain(chainId: string): BridgeAsset { | ||
| export function getSwapNativeTokenWithOverridesForChain( | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Only change here: |
||
| chainId: string, | ||
| ): BridgeAsset { | ||
| const override = NATIVE_SWAP_TOKEN_OVERRIDE_PER_CHAIN[chainId]; | ||
| return override ?? getNativeAssetForChainId(chainId); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -47,7 +47,7 @@ | |
| } | ||
|
|
||
| /* Lift the toaster above fixed CTA footers */ | ||
| :root:has(.cta-footer, .multichain-page-footer, .dapp-connection-control-bar, .bottom-nav-bar) { | ||
| :root:has(.cta-footer, .multichain-page-footer, .dapp-connection-control-bar, .bottom-nav-bar, .asset-page__sticky-actions) { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Adds the new sticky bar to the list of fixed footers the toaster lifts above, so toasts don't render underneath it.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @salimtb can we use
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. sure , let me do it now |
||
| --toaster-bottom-offset: 80px; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,9 +3,20 @@ | |
| .asset { | ||
| &__container { | ||
| background-color: var(--color-background-default); | ||
| // `.main-container` owns `overflow-y: auto`, but the app shell's flex chain | ||
| // lets it keep its full content height, so it never actually scrolls — the | ||
| // scrolling happens further up on `.app`. That silently breaks | ||
| // `position: sticky` for the bottom CTA bar, whose nearest scrollport is | ||
| // then this non-scrolling box. Letting it shrink makes it the real | ||
| // scrollport so the CTA bar can pin to the bottom of the viewport. | ||
| min-height: 0; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
| } | ||
| } | ||
|
|
||
| .main-container-wrapper:has(> .asset__container) { | ||
| min-height: 0; | ||
| } | ||
|
|
||
| .asset-navigation { | ||
| display: flex; | ||
| align-items: center; | ||
|
|
@@ -107,3 +118,24 @@ | |
| height: 100%; | ||
| border-radius: 1rem; | ||
| } | ||
|
|
||
| // Persistent bottom CTA bar (Buy / Swap) for the Token Detail Page V2. Mirrors | ||
| // the Mobile sticky footer: pinned to the bottom of the scroll area, elevated | ||
| // above the content, and padded for the device safe-area inset. | ||
| .asset-page__sticky-actions { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you try moving these to tailwind? Trying to avoid adding more Sass |
||
| position: sticky; | ||
| inset-inline: 0; | ||
| bottom: 0; | ||
| // Direct flex child of the scrolling `.main-container`: never let it be | ||
| // compressed, and keep it at the bottom even when the page is short enough | ||
| // that it does not scroll. | ||
| flex-shrink: 0; | ||
| margin-top: auto; | ||
| z-index: 1; | ||
| background-color: var(--color-background-default); | ||
| border-top: 1px solid var(--color-border-muted); | ||
| box-shadow: 0 -4px 12px var(--color-shadow-default); | ||
| padding: 12px 16px; | ||
| // Safe-area handling for devices with a home indicator / rounded corners. | ||
| padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px)); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mirrors the mobile sticky footer: pinned bottom, elevated above content, and |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -106,6 +106,7 @@ import { isMusdToken } from '../../../components/app/musd/constants'; | |
| import { processAssetParams } from '../util'; | ||
| import { AssetInactiveBadge } from '../../../components/app/assets/asset-inactive-badge/asset-inactive-badge'; | ||
| import { AssetMarketDetails } from './asset-market-details'; | ||
| import { AssetStickyActions } from './asset-sticky-actions'; | ||
| import AssetChart from './chart/asset-chart'; | ||
| import { MarketClosedActionButton } from './market-closed-action-button'; | ||
| import TokenButtons from './token-buttons'; | ||
|
|
@@ -732,6 +733,14 @@ const AssetPage = ({ | |
| onClose={() => setIsMarketClosedModalOpen(false)} | ||
| /> | ||
| </Box> | ||
| {/* Sibling of `asset__content` so it is a direct child of the scrolling | ||
| container, which is what lets it stick to the bottom of the viewport. */} | ||
| <AssetStickyActions | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mounted as a sibling of |
||
| asset={updatedAsset} | ||
| buyAssetId={caipAssetId as CaipAssetType} | ||
| isMarketClosed={isMarketClosed} | ||
| isSigningEnabled={isSigningEnabled} | ||
| /> | ||
| </AssetPageSecurityTrustProvider> | ||
| ); | ||
| }; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| import React from 'react'; | ||
| import configureMockStore from 'redux-mock-store'; | ||
| import { fireEvent, waitFor } from '@testing-library/react'; | ||
| import { renderWithProvider } from '../../../../test/lib/render-helpers-navigate'; | ||
| import { CHAIN_IDS } from '../../../../shared/constants/network'; | ||
| import { mockNetworkState } from '../../../../test/stub/networks'; | ||
| import { AssetType } from '../../../../shared/constants/transaction'; | ||
| import { toAssetId } from '../../../../shared/lib/asset-utils'; | ||
| import { MetaMetricsSwapsEventSource } from '../../../../shared/constants/metametrics'; | ||
| import { Asset } from '../types/asset'; | ||
| import { AssetStickyActions } from './asset-sticky-actions'; | ||
|
|
||
| const mockGoToBuy = jest.fn().mockResolvedValue(true); | ||
| jest.mock('../../../hooks/ramps/useRampsNavigation/useRampsNavigation', () => ({ | ||
| // eslint-disable-next-line @typescript-eslint/naming-convention | ||
| __esModule: true, | ||
| default: () => ({ | ||
| goToBuy: mockGoToBuy, | ||
| opensBuyInPortfolioTab: false, | ||
| }), | ||
| })); | ||
|
|
||
| const mockOpenBridgeExperience = jest.fn(); | ||
| jest.mock('../../../hooks/bridge/useBridging', () => ({ | ||
| // eslint-disable-next-line @typescript-eslint/naming-convention | ||
| __esModule: true, | ||
| default: () => ({ openBridgeExperience: mockOpenBridgeExperience }), | ||
| })); | ||
|
|
||
| const mockTrackEvent = jest.fn(); | ||
| jest.mock('../../../hooks/useAnalytics', () => { | ||
| const { createEventBuilder } = jest.requireActual( | ||
| '../../../../shared/lib/analytics/create-event-builder', | ||
| ); | ||
| return { | ||
| useAnalytics: () => ({ trackEvent: mockTrackEvent, createEventBuilder }), | ||
| }; | ||
| }); | ||
|
|
||
| const token = { | ||
| type: AssetType.token, | ||
| address: '0x6b175474e89094c44da98b954eedeac495271d0f', | ||
| chainId: CHAIN_IDS.MAINNET, | ||
| decimals: 18, | ||
| symbol: 'DAI', | ||
| image: '', | ||
| } as Asset & { type: AssetType.token }; | ||
|
|
||
| const store = configureMockStore()({ | ||
| metamask: { | ||
| ...mockNetworkState({ chainId: CHAIN_IDS.MAINNET }), | ||
| useExternalServices: true, | ||
| }, | ||
| }); | ||
|
|
||
| describe('AssetStickyActions', () => { | ||
| beforeEach(() => jest.clearAllMocks()); | ||
|
|
||
| it('routes the Buy button through goToBuy with the token as intent assetId', () => { | ||
| const { getByTestId } = renderWithProvider( | ||
| <AssetStickyActions asset={token} />, | ||
| store, | ||
| ); | ||
|
|
||
| fireEvent.click(getByTestId('asset-sticky-buy')); | ||
| expect(mockGoToBuy).toHaveBeenCalledWith({ | ||
| assetId: toAssetId(token.address, token.chainId), | ||
| chainId: token.chainId, | ||
| }); | ||
| }); | ||
|
|
||
| it('does not track a buy click when the ramps gate blocks the buy', async () => { | ||
| mockGoToBuy.mockResolvedValueOnce(false); | ||
| const { getByTestId } = renderWithProvider( | ||
| <AssetStickyActions asset={token} />, | ||
| store, | ||
| ); | ||
|
|
||
| fireEvent.click(getByTestId('asset-sticky-buy')); | ||
| await waitFor(() => expect(mockGoToBuy).toHaveBeenCalled()); | ||
| expect(mockTrackEvent).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('opens the swap experience with the token as the source asset', () => { | ||
| const { getByTestId } = renderWithProvider( | ||
| <AssetStickyActions asset={token} />, | ||
| store, | ||
| ); | ||
|
|
||
| fireEvent.click(getByTestId('asset-sticky-swap')); | ||
| expect(mockOpenBridgeExperience).toHaveBeenCalledWith( | ||
| MetaMetricsSwapsEventSource.TokenView, | ||
| token, | ||
| ); | ||
| }); | ||
|
|
||
| it('disables swap while the stock market is closed', () => { | ||
| const { getByTestId } = renderWithProvider( | ||
| <AssetStickyActions asset={token} isMarketClosed />, | ||
| store, | ||
| ); | ||
|
|
||
| expect(getByTestId('asset-sticky-swap')).toBeDisabled(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pinning check compares the bar's
rect.bottomtowindow.innerHeightwith an 8px tolerance so scrollbar / safe-area padding don't cause flakes.