Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions app/components/UI/Perps/Perps.testIds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,29 @@ export const PerpsMarketDetailsViewSelectorsIDs = {
'perps-market-details-service-interruption-banner',
};

// ========================================
// PERPS PRO MARKET VIEW SELECTORS
// ========================================

export const PerpsProMarketViewSelectorsIDs = {
CONTAINER: 'perps-pro-market-view',
ERROR: 'perps-pro-market-error',
SCROLL_VIEW: 'perps-pro-market-scroll-view',
HEADER: 'perps-pro-market-header',
HEADER_SYMBOL: 'perps-pro-market-header-symbol',
MARKET_SUMMARY: 'perps-pro-market-summary',
CHART_PANEL: 'perps-pro-market-chart-panel',
CHART_CONTENT: 'perps-pro-market-chart-content',
STATS_BAR: 'perps-pro-market-stats-bar',
LAYOUT: 'perps-pro-market-layout',
LEFT_COLUMN: 'perps-pro-market-left-column',
RIGHT_COLUMN: 'perps-pro-market-right-column',
VERTICAL_DIVIDER: 'perps-pro-market-vertical-divider',
ORDER_FORM_PANEL: 'perps-pro-market-order-form-panel',
ORDER_BOOK_PANEL: 'perps-pro-market-order-book-panel',
POSITIONS_PANEL: 'perps-pro-market-positions-panel',
};

// ========================================
// PERPS MARKET HEADER SELECTORS
// ========================================
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React from 'react';
import { render } from '@testing-library/react-native';
import PerpsMarketDetailsRouter from './PerpsMarketDetailsRouter';
import { usePerpsProModeEnabled } from './usePerpsProModeEnabled';

jest.mock('./usePerpsProModeEnabled');

jest.mock('../PerpsProMarketView', () => {
const { View } = jest.requireActual('react-native');
return {
__esModule: true,
default: () => <View testID="mock-pro-market-view" />,
};
});

jest.mock('../PerpsMarketDetailsView', () => {
const { View } = jest.requireActual('react-native');
return {
__esModule: true,
default: () => <View testID="mock-lite-market-details-view" />,
};
});

const mockUsePerpsProModeEnabled = jest.mocked(usePerpsProModeEnabled);

describe('PerpsMarketDetailsRouter', () => {
afterEach(() => {
jest.clearAllMocks();
});

it('renders PerpsProMarketView when Pro mode is enabled', () => {
mockUsePerpsProModeEnabled.mockReturnValue(true);

const { getByTestId, queryByTestId } = render(<PerpsMarketDetailsRouter />);

expect(getByTestId('mock-pro-market-view')).toBeOnTheScreen();
expect(
queryByTestId('mock-lite-market-details-view'),
).not.toBeOnTheScreen();
});

it('renders PerpsMarketDetailsView when Pro mode is disabled', () => {
mockUsePerpsProModeEnabled.mockReturnValue(false);

const { getByTestId, queryByTestId } = render(<PerpsMarketDetailsRouter />);

expect(getByTestId('mock-lite-market-details-view')).toBeOnTheScreen();
expect(queryByTestId('mock-pro-market-view')).not.toBeOnTheScreen();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React from 'react';
import PerpsMarketDetailsView from '../PerpsMarketDetailsView';
import PerpsProMarketView from '../PerpsProMarketView';
import { usePerpsProModeEnabled } from './usePerpsProModeEnabled';

/**
* Route component registered for `Routes.PERPS.MARKET_DETAILS`.
*
* Renders the Pro-mode market layout (`PerpsProMarketView`) when Pro mode is
* enabled, otherwise the lite `PerpsMarketDetailsView`. The route name and
* navigation params are identical for both modes; only the rendered component
* differs, so market-list navigation is unaffected.
*/
const PerpsMarketDetailsRouter: React.FC = () => {
const isProModeEnabled = usePerpsProModeEnabled();

return isProModeEnabled ? <PerpsProMarketView /> : <PerpsMarketDetailsView />;
Comment on lines +15 to +17

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we change the hook to send the mode and...

Suggested change
const isProModeEnabled = usePerpsProModeEnabled();
return isProModeEnabled ? <PerpsProMarketView /> : <PerpsMarketDetailsView />;
const mode = usePerpsProMode();
const modeToUI = {
[PerpsModes.Lite]: <PerpsMarketDetailsView />,
[PerpsModes.Pro]: <PerpsProMarketView />
}
return modeToUI[mode];

wdyt?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer to keep the boolean hook because the router has a binary, fail safe decision and 3558 needs to merge before the mode API lands. Once TAT-3551 merges, the hook will resolve featureFlag && mode === Pro. I think a mode to element map adds indirection without improving the two case routing currently.

};

export default PerpsMarketDetailsRouter;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default } from './PerpsMarketDetailsRouter';
export { usePerpsProModeEnabled } from './usePerpsProModeEnabled';
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { renderHook } from '@testing-library/react-native';
import { usePerpsProModeEnabled } from './usePerpsProModeEnabled';

describe('usePerpsProModeEnabled', () => {
it('returns false by default while Pro mode is not yet reachable (TAT-3551)', () => {
const { result } = renderHook(() => usePerpsProModeEnabled());

expect(result.current).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Temporary gate for the Perps Pro-mode layout.
*
* TODO(TAT-3551): replace this placeholder once the mobile feature-flag and

Check warning on line 4 in app/components/UI/Perps/Views/PerpsMarketDetailsRouter/usePerpsProModeEnabled.ts

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_metamask-mobile&issues=AZ-Ci3TZU8iXqbKVeuyj&open=AZ-Ci3TZU8iXqbKVeuyj&pullRequest=33560
* mode selectors land. The Pro view should render only when the feature flag
* is enabled and the persisted controller mode is `PerpsMode.Pro`. Until then
* this always returns `false`, so every user keeps the lite
* `PerpsMarketDetailsView` and the Pro layout is not user-reachable.
*
* @returns Whether the Perps Pro-mode layout is enabled for the current user.
*/
export const usePerpsProModeEnabled = (): boolean => false;
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { StyleSheet } from 'react-native';
import type { Theme } from '../../../../../util/theme/models';

export const createStyles = ({ theme }: { theme: Theme }) =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: theme.colors.background.default,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingBottom: 16,
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import React from 'react';
import { within } from '@testing-library/react-native';
import PerpsProMarketView from './';
import renderWithProvider from '../../../../../util/test/renderWithProvider';
import { backgroundState } from '../../../../../util/test/initial-root-state';
import { PerpsProMarketViewSelectorsIDs } from '../../Perps.testIds';

interface MockRouteParams {
market?: { symbol: string };
}

let mockRouteParams: MockRouteParams | undefined = {
market: { symbol: 'BTC' },
};

jest.mock('@react-navigation/native', () => {
const actualNav = jest.requireActual('@react-navigation/native');
return {
...actualNav,
useRoute: () => ({ params: mockRouteParams }),
};
});

const renderView = () =>
renderWithProvider(<PerpsProMarketView />, {
state: { engine: { backgroundState } },
});

describe('PerpsProMarketView', () => {
beforeEach(() => {
mockRouteParams = { market: { symbol: 'BTC' } };
});

it.each([
['params', undefined],
['market', {}],
['symbol', { market: { symbol: '' } }],
] as const)(
'renders the error state when route %s are invalid',
(_missingField, params) => {
mockRouteParams = params;

const { getByTestId, queryByTestId, getByText } = renderView();

expect(
getByTestId(PerpsProMarketViewSelectorsIDs.ERROR),
).toBeOnTheScreen();
expect(
getByText('Market data not found. Please go back and try again.'),
).toBeOnTheScreen();
expect(
queryByTestId(PerpsProMarketViewSelectorsIDs.CONTAINER),
).not.toBeOnTheScreen();
},
);

it('renders the screen inside every safe-area edge', () => {
const { getByTestId } = renderView();

expect(getByTestId(PerpsProMarketViewSelectorsIDs.CONTAINER)).toHaveProp(
'edges',
['top', 'bottom', 'left', 'right'],
);
});

it('renders every top-level scaffold slot', () => {
const { getByTestId } = renderView();

expect(
getByTestId(PerpsProMarketViewSelectorsIDs.HEADER),
).toBeOnTheScreen();
expect(
getByTestId(PerpsProMarketViewSelectorsIDs.MARKET_SUMMARY),
).toBeOnTheScreen();
expect(
getByTestId(PerpsProMarketViewSelectorsIDs.CHART_PANEL),
).toBeOnTheScreen();
expect(
getByTestId(PerpsProMarketViewSelectorsIDs.STATS_BAR),
).toBeOnTheScreen();
expect(
getByTestId(PerpsProMarketViewSelectorsIDs.LAYOUT),
).toBeOnTheScreen();
expect(
getByTestId(PerpsProMarketViewSelectorsIDs.ORDER_FORM_PANEL),
).toBeOnTheScreen();
expect(
getByTestId(PerpsProMarketViewSelectorsIDs.ORDER_BOOK_PANEL),
).toBeOnTheScreen();
expect(
getByTestId(PerpsProMarketViewSelectorsIDs.POSITIONS_PANEL),
).toBeOnTheScreen();
});

it('keeps the header fixed while the market summary scrolls', () => {
const { getByTestId } = renderView();

const scrollView = getByTestId(PerpsProMarketViewSelectorsIDs.SCROLL_VIEW);

expect(
within(scrollView).queryByTestId(PerpsProMarketViewSelectorsIDs.HEADER),
).not.toBeOnTheScreen();
expect(
within(scrollView).getByTestId(
PerpsProMarketViewSelectorsIDs.MARKET_SUMMARY,
),
).toBeOnTheScreen();
});

it('shows the asset symbol from route params in the header', () => {
const { getByTestId } = renderView();

expect(
getByTestId(PerpsProMarketViewSelectorsIDs.HEADER_SYMBOL),
).toHaveTextContent(/^BTC$/);
});

it('removes the HIP-3 dex prefix from the header symbol', () => {
mockRouteParams = { market: { symbol: 'xyz:TSLA' } };

const { getByTestId } = renderView();

expect(
getByTestId(PerpsProMarketViewSelectorsIDs.HEADER_SYMBOL),
).toHaveTextContent(/^TSLA$/);
});

it('uses the Figma shell heights', () => {
const { getByTestId } = renderView();

expect(getByTestId(PerpsProMarketViewSelectorsIDs.HEADER)).toHaveStyle({
height: 64,
});
expect(
getByTestId(PerpsProMarketViewSelectorsIDs.MARKET_SUMMARY),
).toHaveStyle({ height: 76 });
expect(
getByTestId(PerpsProMarketViewSelectorsIDs.CHART_CONTENT),
).toHaveStyle({ height: 344 });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import {
Box,
SectionDivider,
Text,
TextColor,
TextVariant,
} from '@metamask/design-system-react-native';
import { getPerpsDisplaySymbol } from '@metamask/perps-controller';
import { useRoute, type RouteProp } from '@react-navigation/native';
import React from 'react';
import { ScrollView } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { strings } from '../../../../../../locales/i18n';
import { useStyles } from '../../../../../component-library/hooks';
import { PerpsProMarketViewSelectorsIDs } from '../../Perps.testIds';
import type { PerpsStackParamList } from '../../types/navigation';
import PerpsProChartPanel from './components/PerpsProChartPanel';
import PerpsProMarketHeader from './components/PerpsProMarketHeader';
import PerpsProMarketLayout from './components/PerpsProMarketLayout';
import PerpsProMarketSummary from './components/PerpsProMarketSummary';
import PerpsProOrderBookPanel from './components/PerpsProOrderBookPanel';
import PerpsProOrderFormPanel from './components/PerpsProOrderFormPanel';
import PerpsProPositionsPanel from './components/PerpsProPositionsPanel';
import PerpsProStatsBar from './components/PerpsProStatsBar';
import { createStyles } from './PerpsProMarketView.styles';

/**
* Pro-mode replacement for `PerpsMarketDetailsView`.
*
* Scaffold only: lays out the full Pro trading screen (header, chart, stats
* bar, two-column order form / order book, and positions/orders section) as
* placeholder containers matching Figma node 10041:12979. Each panel can be
* populated by its owning capability without changing the top-level layout.
*/
const PerpsProMarketView = () => {
const { styles } = useStyles(createStyles, {});
const route =
useRoute<RouteProp<PerpsStackParamList, 'PerpsMarketDetails'>>();
const market = route.params?.market;

if (!market?.symbol) {
return (
<SafeAreaView
style={styles.container}
edges={['top', 'bottom', 'left', 'right']}
>
<Box
twClassName="flex-1 items-center justify-center px-4"
testID={PerpsProMarketViewSelectorsIDs.ERROR}
>
<Text variant={TextVariant.BodySm} color={TextColor.ErrorDefault}>
{strings('perps.market.details.error_message')}
</Text>
</Box>
</SafeAreaView>
);
}

const symbol = getPerpsDisplaySymbol(market.symbol);

return (
<SafeAreaView
style={styles.container}
edges={['top', 'bottom', 'left', 'right']}
testID={PerpsProMarketViewSelectorsIDs.CONTAINER}
>
<PerpsProMarketHeader symbol={symbol} />
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
testID={PerpsProMarketViewSelectorsIDs.SCROLL_VIEW}
showsVerticalScrollIndicator={false}
>
<PerpsProMarketSummary />
<PerpsProChartPanel />
<PerpsProStatsBar />
<PerpsProMarketLayout
orderForm={<PerpsProOrderFormPanel />}
orderBook={<PerpsProOrderBookPanel />}
/>
<SectionDivider />
<PerpsProPositionsPanel />
</ScrollView>
</SafeAreaView>
);
};

export default PerpsProMarketView;
Loading
Loading