Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ const PerpsMarketListView = ({
route.params?.showWatchlistOnly ?? propShowWatchlistOnly ?? false;
const defaultMarketTypeFilter =
route.params?.defaultMarketTypeFilter ?? 'all';
const defaultSortOptionId = route.params?.defaultSortOptionId;

const fadeAnimation = useRef(new Animated.Value(0)).current;
const [isSortFieldSheetVisible, setIsSortFieldSheetVisible] = useState(false);
Expand All @@ -84,6 +85,7 @@ const PerpsMarketListView = ({
enablePolling: false,
showWatchlistOnly,
defaultMarketTypeFilter,
defaultSortOptionId,
showZeroVolume: __DEV__,
});

Expand Down
39 changes: 39 additions & 0 deletions app/components/UI/Perps/hooks/usePerpsMarketListView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,45 @@ describe('usePerpsMarketListView', () => {
});
});

it('defaultSortOptionId overrides the saved sort preference', () => {
let selectorCallCount = 0;
mockUseSelector.mockImplementation(() => {
selectorCallCount++;
if (selectorCallCount % 2 === 1) {
return ['BTC'];
}
// Saved preference is volume/desc
return { optionId: 'volume', direction: 'desc' };
});

renderHook(() =>
usePerpsMarketListView({ defaultSortOptionId: 'priceChange' }),
);

expect(mockUsePerpsSorting).toHaveBeenCalledWith({
initialOptionId: 'priceChange',
initialDirection: 'desc',
});
});

it('falls back to saved sort preference when defaultSortOptionId is not provided', () => {
let selectorCallCount = 0;
mockUseSelector.mockImplementation(() => {
selectorCallCount++;
if (selectorCallCount % 2 === 1) {
return ['BTC'];
}
return { optionId: 'fundingRate', direction: 'asc' };
});

renderHook(() => usePerpsMarketListView());

expect(mockUsePerpsSorting).toHaveBeenCalledWith({
initialOptionId: 'fundingRate',
initialDirection: 'asc',
});
});

it('exposes sort state correctly', () => {
const { result } = renderHook(() => usePerpsMarketListView());

Expand Down
12 changes: 10 additions & 2 deletions app/components/UI/Perps/hooks/usePerpsMarketListView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ interface UsePerpsMarketListViewParams {
* @default 'all'
*/
defaultMarketTypeFilter?: MarketTypeFilter;
/**
* Initial sort option ID — overrides the persisted user preference when provided.
* @default undefined (falls back to saved user preference)
*/
defaultSortOptionId?: SortOptionId;
/**
* Show markets with $0.00 volume
* @default false
Expand Down Expand Up @@ -133,6 +138,7 @@ export const usePerpsMarketListView = ({
enablePolling = false,
showWatchlistOnly = false,
defaultMarketTypeFilter = 'all',
defaultSortOptionId,
showZeroVolume = false,
}: UsePerpsMarketListViewParams = {}): UsePerpsMarketListViewReturn => {
// Fetch markets data
Expand Down Expand Up @@ -196,9 +202,11 @@ export const usePerpsMarketListView = ({
return searchedMarkets;
}, [searchedMarkets, marketTypeFilter]);

// Use sorting hook for sort state and sorting logic
// Use sorting hook for sort state and sorting logic.
// defaultSortOptionId (from navigation params) takes precedence over the saved user preference.
const sortingHook = usePerpsSorting({
initialOptionId: savedSortPreference.optionId as SortOptionId,
initialOptionId: (defaultSortOptionId ??
savedSortPreference.optionId) as SortOptionId,
Comment thread
cursor[bot] marked this conversation as resolved.
initialDirection: savedSortPreference.direction,
});

Expand Down
2 changes: 2 additions & 0 deletions app/components/UI/Perps/types/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type OrderType,
type PerpsMarketData,
type TPSLTrackingData,
type SortOptionId,
} from '@metamask/perps-controller';
import { PerpsTransaction } from './transactionHistory';
import type { DataMonitorParams } from '../hooks/usePerpsDataMonitor';
Expand Down Expand Up @@ -90,6 +91,7 @@ export interface PerpsNavigationParamList extends ParamListBase {
| 'commodities'
| 'forex'
| 'new';
defaultSortOptionId?: SortOptionId;
fromHome?: boolean;
button_clicked?: string;
button_location?: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
/**
* PerpsToggleBlock — unit tests
*
* Core concerns:
* 1. Renders title and the default pill's items.
* 2. "View All" button calls onViewAll with the active pill key and the
* sortOptionId prop — this is the critical wiring introduced alongside the
* sort-param feature.
* 3. Switching pills updates the key passed to onViewAll.
* 4. Shows skeletons while loading.
* 5. Analytics: trackExploreInteracted is called when a row is pressed.
*/

jest.mock('@shopify/flash-list', () => {
const RN = jest.requireActual<typeof import('react-native')>('react-native');
return { FlashList: RN.FlatList };
});

jest.mock('../../search/analytics', () => ({
trackExploreInteracted: jest.fn(),
}));

jest.mock('@metamask/design-system-twrnc-preset', () => {
const twFn = () => ({});
twFn.style = () => ({});
return { useTailwind: () => twFn };
});

import React from 'react';
import { Text } from 'react-native';
import { render, fireEvent, act } from '@testing-library/react-native';
import type { PerpsMarketData, SortOptionId } from '@metamask/perps-controller';
import { trackExploreInteracted } from '../../search/analytics';
import PerpsToggleBlock, {
type PerpsToggleBlockProps,
} from './PerpsToggleBlock';

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

const makeMarket = (symbol: string): PerpsMarketData =>
({
symbol,
name: `${symbol} Market`,
price: '$1.00',
change24h: '+1%',
change24hPercent: '1',
volume: '$100M',
maxLeverage: '10x',
isHip3: true,
marketType: 'equity',
}) as PerpsMarketData;

// Minimal PerpsRowItem mock — renders the symbol so assertions are simple.
jest.mock('./PerpsRowItem', () => {
const { TouchableOpacity, Text: RNText } = jest.requireActual('react-native');
return function MockPerpsRowItem({
market,
onCardPress,
}: {
market: PerpsMarketData;
onCardPress?: () => void;
}) {
return (
<TouchableOpacity
testID={`perps-row-${market.symbol}`}
onPress={onCardPress}
>
<RNText>{market.symbol}</RNText>
</TouchableOpacity>
);
};
});

jest.mock('../../../../UI/Perps/components/PerpsRowSkeleton', () => {
const { View, Text: RNText } = jest.requireActual('react-native');
return function MockPerpsRowSkeleton() {
return (
<View>
<RNText testID="perps-row-skeleton">skeleton</RNText>
</View>
);
};
});

const Skeleton = () => <Text testID="skeleton-item">sk</Text>;
Comment thread
juanmigdr marked this conversation as resolved.

const STOCKS_MARKETS = [makeMarket('AAPL'), makeMarket('GOOGL')];
const COMMODITY_MARKETS = [makeMarket('GOLD')];

const DEFAULT_TABS = [
{ key: 'stocks', name: 'Stocks', items: STOCKS_MARKETS },
{ key: 'commodities', name: 'Commodities', items: COMMODITY_MARKETS },
];

const DEFAULT_PROPS: PerpsToggleBlockProps = {
title: 'Stocks & Commodities',
tabs: DEFAULT_TABS,
isLoading: false,
defaultPillKey: 'stocks',
onViewAll: jest.fn(),
sortOptionId: 'volume',
tabName: 'Macro',
sectionName: 'perps_stocks_commodities',
headerTestID: 'section-header-view-all-test',
idPrefix: 'test',
testIdPrefix: 'test-toggle',
listTestId: 'test-list',
};

const renderBlock = (props = DEFAULT_PROPS) =>
render(<PerpsToggleBlock {...props} />);

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

describe('rendering', () => {
it('renders the section title', () => {
const { getByText } = renderBlock();
expect(getByText('Stocks & Commodities')).toBeTruthy();
Comment thread
juanmigdr marked this conversation as resolved.
});

it('renders items for the default pill', () => {
const { getByTestId, queryByTestId } = renderBlock();
expect(getByTestId('perps-row-AAPL')).toBeTruthy();
expect(getByTestId('perps-row-GOOGL')).toBeTruthy();
expect(queryByTestId('perps-row-GOLD')).toBeNull();
});

it('renders pill buttons for each tab', () => {
const { getByTestId } = renderBlock();
expect(getByTestId('test-toggle-pill-stocks')).toBeTruthy();
expect(getByTestId('test-toggle-pill-commodities')).toBeTruthy();
});
});

describe('loading state', () => {
it('shows skeletons while isLoading is true', () => {
const { getAllByTestId } = renderBlock({
...DEFAULT_PROPS,
isLoading: true,
});
expect(getAllByTestId('perps-row-skeleton').length).toBeGreaterThan(0);
});
});

describe('pill switching', () => {
it('shows the commodities items after selecting the commodities pill', () => {
const { getByTestId, queryByTestId } = renderBlock();

act(() => {
fireEvent.press(getByTestId('test-toggle-pill-commodities'));
});

expect(getByTestId('perps-row-GOLD')).toBeTruthy();
expect(queryByTestId('perps-row-AAPL')).toBeNull();
});

it('switching back to stocks shows stocks items again', () => {
const { getByTestId } = renderBlock();

act(() => {
fireEvent.press(getByTestId('test-toggle-pill-commodities'));
});
act(() => {
fireEvent.press(getByTestId('test-toggle-pill-stocks'));
});

expect(getByTestId('perps-row-AAPL')).toBeTruthy();
});
});

describe('View All — sort and filter forwarding', () => {
it('calls onViewAll with the defaultPillKey and sortOptionId before any pill change', () => {
const onViewAll = jest.fn();
const { getByTestId } = renderBlock({ ...DEFAULT_PROPS, onViewAll });

fireEvent.press(getByTestId('section-header-view-all-test'));

expect(onViewAll).toHaveBeenCalledTimes(1);
expect(onViewAll).toHaveBeenCalledWith('stocks', 'volume');
});

it('calls onViewAll with the newly active pill key after switching pills', () => {
const onViewAll = jest.fn();
const { getByTestId } = renderBlock({ ...DEFAULT_PROPS, onViewAll });

act(() => {
fireEvent.press(getByTestId('test-toggle-pill-commodities'));
});

fireEvent.press(getByTestId('section-header-view-all-test'));

expect(onViewAll).toHaveBeenCalledWith('commodities', 'volume');
});

it('forwards the sortOptionId prop unchanged regardless of active pill', () => {
const onViewAll = jest.fn();
const { getByTestId } = renderBlock({
...DEFAULT_PROPS,
sortOptionId: 'priceChange',
onViewAll,
});

act(() => {
fireEvent.press(getByTestId('test-toggle-pill-commodities'));
});
fireEvent.press(getByTestId('section-header-view-all-test'));

expect(onViewAll).toHaveBeenCalledWith('commodities', 'priceChange');
});

it('calls onViewAll only once per press', () => {
const onViewAll = jest.fn();
const { getByTestId } = renderBlock({ ...DEFAULT_PROPS, onViewAll });

fireEvent.press(getByTestId('section-header-view-all-test'));
fireEvent.press(getByTestId('section-header-view-all-test'));

expect(onViewAll).toHaveBeenCalledTimes(2);
});
});

describe('analytics', () => {
it('calls trackExploreInteracted with correct context when a row is pressed', () => {
const mockTrack = trackExploreInteracted as jest.Mock;
const { getByTestId } = renderBlock();

fireEvent.press(getByTestId('perps-row-AAPL'));

expect(mockTrack).toHaveBeenCalledWith(
expect.objectContaining({
interaction_type: 'section_item_tapped',
tab_name: 'Macro',
section_name: 'perps_stocks_commodities',
asset_type: 'perp',
item_clicked: 'AAPL',
}),
);
});

it('includes the correct position index in analytics', () => {
const mockTrack = trackExploreInteracted as jest.Mock;
const { getByTestId } = renderBlock();

fireEvent.press(getByTestId('perps-row-GOOGL'));

expect(mockTrack).toHaveBeenCalledWith(
expect.objectContaining({
position: 1,
item_clicked: 'GOOGL',
}),
);
});
});
});
Loading
Loading