Skip to content

Commit 41c8d80

Browse files
sahar-fehrimetamaskbotPrithpal-Sooriya
authored
feat: Advanced charts integration in token details page (#26465)
## **Description** The token overview page's chart experience has been upgraded in two ways: ### 1. Updated legacy chart to visually match the advanced chart's line mode The existing SVG line chart (`PriceChart`) has been restyled so that even without the feature flag, users see a modernized chart: - **Line color now reflects price direction** — green (`success.default`) for positive, red (`error.default`) for negative, replacing the old primary blue color used regardless of direction. - **Removed the area gradient fill** under the line — the chart now renders as a clean line-only chart (`svg.fill` set to `'none'`, `DataGradient` removed). - **Added a TradingView-style end dot** (`EndDot`) — a 16px circle marker at the last data point with a background-colored stroke, matching the chart line color. - **Shared chart height** — new `TOKEN_OVERVIEW_CHART_HEIGHT` constant (24% screen height) replaces the old 44%, aligning both legacy and advanced chart dimensions. - **Responsive width** — chart uses `width: '100%'` / `alignSelf: 'stretch'` instead of `Dimensions.get('screen').width`, preventing overflow in padded containers and enabling proper end-dot visibility via `contentInset.right`. - **Price diff text color** now uses design system `TextColor` enums (`SuccessDefault` / `ErrorDefault` / `TextAlternative`) instead of the old stylesheet-based `priceDiff` style. ### 2. New advanced OHLCV chart behind feature flag A new `tokenDetailsAdvancedCharts` remote feature flag (version-gated, minimum 7.73) enables a TradingView Lightweight Charts-based WebView chart with candlestick and line modes. When the flag is disabled, the updated legacy chart renders. **Architecture:** - **Refactored `Price` component** into a routing layer delegating to `PriceAdvanced` (new) or `PriceLegacy` (extracted from original) based on `selectTokenOverviewAdvancedChartEnabled`. - **`PriceAdvanced`** — renders `AdvancedChart` WebView with time range selector (1H/1D/1W/1M/1Y), chart type toggle (line ↔ candlestick), volume overlay, `OHLCVBar` crosshair data display, and empty/error states with a placeholder SVG. - **`useOHLCVChart` hook** — fetches OHLCV data from the MetaMask Price API (`/v3/ohlcv-chart`) with cursor-based pagination for scroll-back history. - **`OHLCVBar` component** — displays Open, High, Low, Close, Volume values above the chart when crosshair is active in candlestick mode. - **`TimeRangeSelector` updates** — added chart type toggle icon (line/candlestick) and exported `TIME_RANGE_CONFIGS` for OHLCV API mapping. - **`AssetOverviewContent`** — conditionally hides the legacy `ChartNavigationButton` time period tabs when the advanced chart is enabled (the range selector lives inside `PriceAdvanced`). - **Analytics** — new MetaMetrics events: `chart_type_changed`, `chart_timeframe_changed`, `chart_interacted`, `chart_tradingview_clicked`, `chart_empty_displayed`. - **Styles migration** — `Price.styles.tsx` simplified (removed `priceDiff` vars param; added chart container, time range, and empty state styles for the advanced chart). - **Feature flag selector** — `selectTokenOverviewAdvancedChartEnabled` reads `tokenDetailsAdvancedCharts` from remote feature flags with version gating via `validatedVersionGatedFeatureFlag`. - **E2E mocking** — added OHLCV API mock response in the E2E mock defaults. ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: Updated token overview chart visuals (direction-colored line, end dot marker, no area fill) and added an advanced OHLCV candlestick/line chart behind a remote feature flag ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> https://github.com/user-attachments/assets/4d8b5fa0-6a6a-471e-b55f-77a672d77980 ## **Pre-merge author checklist** - [ ] 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). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] 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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Moderate risk: introduces new TradingView WebView chart behavior (data syncing, pagination, external link handling) and refactors the token overview `Price` header/chart rendering behind a feature flag, which could affect chart loading and interaction flows. > > **Overview** > Adds a feature-flagged advanced token overview chart (`PriceAdvanced`) backed by a new `useOHLCVChart` hook that fetches/paginates OHLCV data from the Price API, supports line/candlestick toggling, time-range selection, crosshair OHLCV display, and empty/insufficient-data overlays. > > Refactors `Price` into a flag-based router between new `PriceAdvanced` and extracted `PriceLegacy`, and updates the legacy `PriceChart` visuals/behavior to better match the advanced experience (shared chart height, responsive width, direction-colored line, no area fill, TradingView-style end-dot marker, improved touch mapping). > > Enhances `AdvancedChart`’s RN↔WebView protocol and rendering: adds `ohlcvSeriesKey`-based full data reloads vs realtime updates, layout-settle skeleton gating, volume overlay support, line-chrome configuration, TradingView external link interception (in-app browser + debounce), and additional analytics hooks; updates `TimeRangeSelector` UI/ranges and adds new `OHLCVBar` + volume formatting utilities. Includes comprehensive new/updated unit tests across these components. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 76c6fac. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: metamaskbot <metamaskbot@users.noreply.github.com> Co-authored-by: Prithpal Sooriya <prithpal.sooriya@consensys.net>
1 parent f923f78 commit 41c8d80

42 files changed

Lines changed: 9322 additions & 981 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
import React from 'react';
2+
import { render, fireEvent, act } from '@testing-library/react-native';
3+
import PriceAdvanced, { type PriceAdvancedProps } from './Price.advanced';
4+
import type { TokenI } from '../../Tokens/types';
5+
import { TokenOverviewSelectorsIDs } from '../TokenOverview.testIds';
6+
import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics';
7+
import { createMockUseAnalyticsHook } from '../../../../util/test/analyticsMock';
8+
9+
jest.mock('../../../hooks/useAnalytics/useAnalytics');
10+
11+
jest.mock('react-native-skeleton-placeholder', () => {
12+
const { View } = jest.requireActual('react-native');
13+
const MockSkeleton = ({ children }: { children: React.ReactNode }) => (
14+
<View testID="skeleton-placeholder">{children}</View>
15+
);
16+
MockSkeleton.Item = (props: Record<string, unknown>) => <View {...props} />;
17+
return MockSkeleton;
18+
});
19+
20+
jest.mock('../../Charts/AdvancedChart/AdvancedChart', () => {
21+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
22+
const { View } = require('react-native');
23+
return {
24+
__esModule: true,
25+
default: (props: Record<string, unknown>) => (
26+
<View testID="mock-advanced-chart" {...props} />
27+
),
28+
};
29+
});
30+
31+
jest.mock('../../Charts/AdvancedChart/OHLCVBar/OHLCVBar', () => {
32+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
33+
const { View } = require('react-native');
34+
return {
35+
OHLCVBar: () => <View testID="mock-ohlcv-bar" />,
36+
};
37+
});
38+
39+
const mockFetchMoreHistory = jest.fn();
40+
const mockUseOHLCVChart = jest.fn().mockReturnValue({
41+
ohlcvData: [
42+
{ time: 1000, open: 100, high: 101, low: 99, close: 100, volume: 1 },
43+
{ time: 2000, open: 100, high: 106, low: 100, close: 105, volume: 1 },
44+
],
45+
isLoading: false,
46+
error: undefined,
47+
fetchMoreHistory: mockFetchMoreHistory,
48+
hasMore: false,
49+
});
50+
51+
jest.mock('../../Charts/AdvancedChart/useOHLCVChart', () => ({
52+
useOHLCVChart: (...args: unknown[]) => mockUseOHLCVChart(...args),
53+
}));
54+
55+
jest.mock('../../Charts/AdvancedChart/TimeRangeSelector', () => {
56+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
57+
const { View, Pressable, Text } = require('react-native');
58+
const MockSelector = ({
59+
onSelect,
60+
onChartTypeToggle,
61+
}: {
62+
onSelect: (r: string) => void;
63+
onChartTypeToggle?: () => void;
64+
}) => (
65+
<View testID="mock-time-range-selector">
66+
<Pressable testID="select-1W" onPress={() => onSelect('1W')} />
67+
<Pressable testID="select-1D" onPress={() => onSelect('1D')} />
68+
{onChartTypeToggle && (
69+
<Pressable testID="toggle-chart-type" onPress={onChartTypeToggle}>
70+
<Text>Toggle</Text>
71+
</Pressable>
72+
)}
73+
</View>
74+
);
75+
return {
76+
__esModule: true,
77+
default: MockSelector,
78+
TIME_RANGE_CONFIGS: {
79+
'1H': { timePeriod: '1h' },
80+
'1D': { timePeriod: '1d' },
81+
'1W': { timePeriod: '1w' },
82+
'1M': { timePeriod: '1m' },
83+
'1Y': { timePeriod: '1y' },
84+
},
85+
};
86+
});
87+
88+
const mockAsset: TokenI = {
89+
address: '0x1234567890123456789012345678901234567890',
90+
chainId: '0x1',
91+
name: 'Test Token',
92+
symbol: 'TST',
93+
ticker: 'TST',
94+
decimals: 18,
95+
image: '',
96+
balance: '0',
97+
logo: undefined,
98+
isETH: false,
99+
};
100+
101+
const baseProps: PriceAdvancedProps = {
102+
asset: mockAsset,
103+
priceDiff: 5,
104+
currentPrice: 105,
105+
currentCurrency: 'USD',
106+
comparePrice: 100,
107+
isLoading: false,
108+
};
109+
110+
describe('PriceAdvanced', () => {
111+
let mockTrackEvent: jest.Mock;
112+
113+
beforeEach(() => {
114+
jest.clearAllMocks();
115+
const analyticsHook = createMockUseAnalyticsHook();
116+
mockTrackEvent = analyticsHook.trackEvent as jest.Mock;
117+
jest.mocked(useAnalytics).mockReturnValue(analyticsHook);
118+
});
119+
120+
it('renders the token price when not loading', () => {
121+
const { getByTestId } = render(<PriceAdvanced {...baseProps} />);
122+
expect(
123+
getByTestId(TokenOverviewSelectorsIDs.TOKEN_PRICE),
124+
).toBeOnTheScreen();
125+
});
126+
127+
it('shows loading skeletons when isLoading is true', () => {
128+
const { getByTestId } = render(<PriceAdvanced {...baseProps} isLoading />);
129+
expect(getByTestId('loading-price-diff')).toBeOnTheScreen();
130+
});
131+
132+
it('does not show header skeletons when only chart is loading', () => {
133+
mockUseOHLCVChart.mockReturnValueOnce({
134+
ohlcvData: [],
135+
isLoading: true,
136+
error: undefined,
137+
fetchMoreHistory: jest.fn(),
138+
hasMore: false,
139+
});
140+
const { queryByTestId } = render(<PriceAdvanced {...baseProps} />);
141+
expect(queryByTestId('loading-price-diff')).not.toBeOnTheScreen();
142+
});
143+
144+
it('does not render token price when currentPrice is NaN', () => {
145+
const { queryByTestId } = render(
146+
<PriceAdvanced {...baseProps} currentPrice={NaN} />,
147+
);
148+
expect(
149+
queryByTestId(TokenOverviewSelectorsIDs.TOKEN_PRICE),
150+
).not.toBeOnTheScreen();
151+
});
152+
153+
it('renders the AdvancedChart when data is available', () => {
154+
const { getByTestId } = render(<PriceAdvanced {...baseProps} />);
155+
expect(getByTestId('mock-advanced-chart')).toBeOnTheScreen();
156+
});
157+
158+
it('renders TimeRangeSelector when chart has data', () => {
159+
const { getByTestId } = render(<PriceAdvanced {...baseProps} />);
160+
expect(getByTestId('mock-time-range-selector')).toBeOnTheScreen();
161+
});
162+
163+
it('shows no-data overlay when ohlcvData is empty and chart not loading', () => {
164+
mockUseOHLCVChart.mockReturnValueOnce({
165+
ohlcvData: [],
166+
isLoading: false,
167+
error: undefined,
168+
fetchMoreHistory: jest.fn(),
169+
hasMore: false,
170+
});
171+
const { getByTestId, queryByTestId } = render(
172+
<PriceAdvanced {...baseProps} />,
173+
);
174+
175+
expect(getByTestId('price-chart-no-data')).toBeOnTheScreen();
176+
expect(queryByTestId('mock-advanced-chart')).not.toBeOnTheScreen();
177+
expect(queryByTestId('mock-time-range-selector')).not.toBeOnTheScreen();
178+
});
179+
180+
it('shows insufficient-data overlay when only 1 data point', () => {
181+
mockUseOHLCVChart.mockReturnValueOnce({
182+
ohlcvData: [
183+
{ time: 1000, open: 100, high: 101, low: 99, close: 100, volume: 1 },
184+
],
185+
isLoading: false,
186+
error: undefined,
187+
fetchMoreHistory: jest.fn(),
188+
hasMore: false,
189+
});
190+
const { getByTestId } = render(<PriceAdvanced {...baseProps} />);
191+
192+
expect(getByTestId('price-chart-insufficient-data')).toBeOnTheScreen();
193+
});
194+
195+
it('shows no-data overlay on chart error', () => {
196+
mockUseOHLCVChart.mockReturnValueOnce({
197+
ohlcvData: [
198+
{ time: 1000, open: 100, high: 101, low: 99, close: 100, volume: 1 },
199+
{ time: 2000, open: 100, high: 106, low: 100, close: 105, volume: 1 },
200+
],
201+
isLoading: false,
202+
error: new Error('fetch failed'),
203+
fetchMoreHistory: jest.fn(),
204+
hasMore: false,
205+
});
206+
const { getByTestId } = render(<PriceAdvanced {...baseProps} />);
207+
208+
expect(getByTestId('price-chart-no-data')).toBeOnTheScreen();
209+
});
210+
211+
it('tracks CHART_EMPTY_DISPLAYED when empty state is shown', () => {
212+
mockUseOHLCVChart.mockReturnValueOnce({
213+
ohlcvData: [],
214+
isLoading: false,
215+
error: undefined,
216+
fetchMoreHistory: jest.fn(),
217+
hasMore: false,
218+
});
219+
render(<PriceAdvanced {...baseProps} />);
220+
221+
expect(mockTrackEvent).toHaveBeenCalled();
222+
});
223+
224+
it('tracks CHART_TIMEFRAME_CHANGED when a different time range is selected', () => {
225+
const { getByTestId } = render(<PriceAdvanced {...baseProps} />);
226+
227+
fireEvent.press(getByTestId('select-1W'));
228+
229+
expect(mockTrackEvent).toHaveBeenCalled();
230+
});
231+
232+
it('does not track when selecting the already-active time range', () => {
233+
const { getByTestId } = render(<PriceAdvanced {...baseProps} />);
234+
235+
fireEvent.press(getByTestId('select-1D'));
236+
237+
expect(mockTrackEvent).not.toHaveBeenCalled();
238+
});
239+
240+
it('tracks CHART_TYPE_CHANGED when chart type is toggled', () => {
241+
const { getByTestId } = render(<PriceAdvanced {...baseProps} />);
242+
243+
fireEvent.press(getByTestId('toggle-chart-type'));
244+
245+
expect(mockTrackEvent).toHaveBeenCalled();
246+
});
247+
248+
it('passes correct OHLCV hook params based on selected time range', () => {
249+
render(<PriceAdvanced {...baseProps} />);
250+
251+
expect(mockUseOHLCVChart).toHaveBeenCalledWith(
252+
expect.objectContaining({
253+
timePeriod: '1d',
254+
vsCurrency: 'USD',
255+
}),
256+
);
257+
});
258+
259+
it('re-fetches with new params after time range change', () => {
260+
const { getByTestId } = render(<PriceAdvanced {...baseProps} />);
261+
mockUseOHLCVChart.mockClear();
262+
263+
act(() => {
264+
fireEvent.press(getByTestId('select-1W'));
265+
});
266+
267+
expect(mockUseOHLCVChart).toHaveBeenCalledWith(
268+
expect.objectContaining({
269+
timePeriod: '1w',
270+
}),
271+
);
272+
});
273+
274+
it('renders price-label with the time range date label', () => {
275+
const { getByTestId } = render(<PriceAdvanced {...baseProps} />);
276+
expect(getByTestId('price-label')).toBeOnTheScreen();
277+
});
278+
});

0 commit comments

Comments
 (0)