Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,8 @@ jest.mock('./components/PerpsMarketFiltersBar', () => {
showStocksCommoditiesDropdown,
stocksCommoditiesFilter,
onStocksCommoditiesPress,
showWatchlistBadge,
onWatchlistToggle,
testID,
}: {
selectedOptionId: string;
Expand All @@ -228,6 +230,9 @@ jest.mock('./components/PerpsMarketFiltersBar', () => {
showStocksCommoditiesDropdown?: boolean;
stocksCommoditiesFilter?: 'all' | 'stock' | 'commodity';
onStocksCommoditiesPress?: () => void;
showWatchlistBadge?: boolean;
isWatchlistSelected?: boolean;
onWatchlistToggle?: () => void;
testID?: string;
}) {
// Map sort option IDs to display labels
Expand Down Expand Up @@ -297,6 +302,15 @@ jest.mock('./components/PerpsMarketFiltersBar', () => {
`Stocks/Commodities: ${stocksCommoditiesFilter || 'all'}`,
),
),
showWatchlistBadge &&
MockReact.createElement(
RNTouchableOpacity,
{
testID: testID ? `${testID}-categories-watchlist` : undefined,
onPress: onWatchlistToggle,
},
MockReact.createElement(Text, null, 'Watchlist'),
),
);
};
});
Expand All @@ -316,6 +330,11 @@ jest.mock('../../selectors/perpsController', () => ({
})),
}));

let mockWatchlistFlagEnabled = false;
jest.mock('../../selectors/featureFlags', () => ({
selectPerpsWatchlistEnabledFlag: jest.fn(() => mockWatchlistFlagEnabled),
}));

jest.mock('../../utils/formatUtils', () => ({
formatPerpsFiat: jest.fn((amount) => `$${amount}`),
}));
Expand Down Expand Up @@ -876,6 +895,36 @@ describe('PerpsMarketListView', () => {

// Note: Stocks/Commodities Dropdown and Market Type Dropdown tests removed - replaced with category badges

describe('Watchlist feature flag gating', () => {
beforeEach(() => {
mockWatchlistFlagEnabled = false;
});

afterEach(() => {
mockWatchlistFlagEnabled = false;
});

it('does not render the watchlist pill when perps-watchlist-v2-enabled is OFF', () => {
mockWatchlistFlagEnabled = false;
renderWithProvider(<PerpsMarketListView />, { state: mockState });
expect(
screen.queryByTestId(
`${PerpsMarketListViewSelectorsIDs.SORT_FILTERS}-categories-watchlist`,
),
).not.toBeOnTheScreen();
});

it('renders the watchlist pill when perps-watchlist-v2-enabled is ON', () => {
mockWatchlistFlagEnabled = true;
renderWithProvider(<PerpsMarketListView />, { state: mockState });
expect(
screen.getByTestId(
`${PerpsMarketListViewSelectorsIDs.SORT_FILTERS}-categories-watchlist`,
),
).toBeOnTheScreen();
});
});

describe('Edge Cases', () => {
it('filters markets with whitespace-only query', async () => {
mockSearchQuery = ' ';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
usePerpsMeasurement,
usePerpsNavigation,
} from '../../hooks';
import { selectPerpsWatchlistEnabledFlag } from '../../selectors/featureFlags';
import { usePerpsLivePositions, usePerpsLiveAccount } from '../../hooks/stream';
import PerpsMarketRowSkeleton from './components/PerpsMarketRowSkeleton';
import styleSheet from './PerpsMarketListView.styles';
Expand All @@ -34,6 +35,7 @@ import {
} from '@metamask/perps-controller';
import { PerpsMarketListViewSelectorsIDs } from '../../Perps.testIds';
import { useRoute, RouteProp } from '@react-navigation/native';
import { useSelector } from 'react-redux';
import { SafeAreaView } from 'react-native-safe-area-context';
import { TraceName } from '../../../../../util/trace';
import { MetaMetricsEvents } from '../../../../../core/Analytics';
Expand Down Expand Up @@ -67,6 +69,8 @@ const PerpsMarketListView = ({
const defaultSortDirection = route.params?.defaultSortDirection;
const transactionActiveAbTests = route.params?.transactionActiveAbTests;

const isWatchlistEnabled = useSelector(selectPerpsWatchlistEnabledFlag);

const fadeAnimation = useRef(new Animated.Value(0)).current;
const [isSortFieldSheetVisible, setIsSortFieldSheetVisible] = useState(false);

Expand Down Expand Up @@ -242,7 +246,8 @@ const PerpsMarketListView = ({
}

// Empty watchlist — show suggested markets with the same default state as PerpsHome
if (showFavoritesOnly && !hasWatchlistMarkets) {
// Only reachable when the watchlist flag is enabled (pill is hidden otherwise)
if (isWatchlistEnabled && showFavoritesOnly && !hasWatchlistMarkets) {
return (
<PerpsWatchlistMarkets
markets={watchlistMarketObjects}
Expand Down Expand Up @@ -338,7 +343,7 @@ const PerpsMarketListView = ({
onSortPress={() => setIsSortFieldSheetVisible(true)}
marketTypeFilter={marketTypeFilter}
onCategorySelect={handleCategorySelect}
showWatchlistBadge
showWatchlistBadge={isWatchlistEnabled}
isWatchlistSelected={showFavoritesOnly}
onWatchlistToggle={handleWatchlistToggle}
testID={PerpsMarketListViewSelectorsIDs.SORT_FILTERS}
Expand Down
13 changes: 10 additions & 3 deletions app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import PerpsBottomSheetTooltip from '../../components/PerpsBottomSheetTooltip';
import PerpsCard from '../../components/PerpsCard';
import { useSelector } from 'react-redux';
import { selectPerpsEligibility } from '../../selectors/perpsController';
import { selectPerpsWatchlistEnabledFlag } from '../../selectors/featureFlags';
import {
PERPS_EVENT_PROPERTY,
PERPS_EVENT_VALUE,
Expand Down Expand Up @@ -101,11 +102,17 @@ const PerpsTabView = () => {
? styles.watchlistHeaderStyleWithBalance // 24px/4px
: styles.watchlistHeaderStyleNoBalance; // 16px/4px

// The watchlist section is visible if the user has watchlist markets OR when suggested
// markets are available (empty state is shown instead of hiding the section entirely).
const isWatchlistEnabled = useSelector(selectPerpsWatchlistEnabledFlag);

// The watchlist section is visible if the user has watchlist markets OR, when the
// redesigned flag is ON, when suggested markets are available (V2 empty state renders).
// When the flag is OFF the V1 path returns null for an empty watchlist, so suggestions
// must not contribute to visibility — otherwise the Explore header gets incorrect spacing.
const isWatchlistVisible =
watchlistMarkets.length > 0 ||
(suggestedWatchlistMarkets.length > 0 && !isExploreLoading);
(isWatchlistEnabled &&
suggestedWatchlistMarkets.length > 0 &&
!isExploreLoading);

// Explore header: depends on position and balance
const exploreSectionHeaderStyle = isWatchlistVisible
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import PerpsWatchlistMarkets from './PerpsWatchlistMarkets';
import { type PerpsMarketData } from '@metamask/perps-controller';
import Routes from '../../../../../constants/navigation/Routes';
import { createActiveABTestAssignment } from '../../../../../util/analytics/activeABTestAssignments';
import { selectPerpsWatchlistEnabledFlag } from '../../selectors/featureFlags';
import { selectPerpsWatchlistMarkets } from '../../selectors/perpsController';

// ---------------------------------------------------------------------------
// Mocks
Expand Down Expand Up @@ -107,7 +109,15 @@ jest.mock('../../../../../component-library/components/Icons/Icon', () => ({
}));

jest.mock('react-redux', () => ({
useSelector: jest.fn(() => []),
useSelector: jest.fn(),
}));

jest.mock('../../selectors/featureFlags', () => ({
selectPerpsWatchlistEnabledFlag: jest.fn(),
}));

jest.mock('../../selectors/perpsController', () => ({
selectPerpsWatchlistMarkets: jest.fn(),
}));

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -145,7 +155,12 @@ describe('PerpsWatchlistMarkets', () => {

beforeEach(() => {
jest.clearAllMocks();
(useSelector as jest.Mock).mockReturnValue([]);
// Default: flag enabled, empty watchlist symbols
(useSelector as jest.Mock).mockImplementation((selector) => {
if (selector === selectPerpsWatchlistEnabledFlag) return true;
if (selector === selectPerpsWatchlistMarkets) return [];
return [];
});
const { useNavigation } = jest.requireMock('@react-navigation/native');
useNavigation.mockReturnValue({ navigate: mockNavigate });
});
Expand Down Expand Up @@ -506,6 +521,114 @@ describe('PerpsWatchlistMarkets', () => {
});
});

// -------------------------------------------------------------------------
// Flag disabled — legacy (pre-redesign) path
// -------------------------------------------------------------------------

describe('when perps-watchlist-v2-enabled flag is OFF (legacy path)', () => {
beforeEach(() => {
(useSelector as jest.Mock).mockImplementation((selector) => {
if (selector === selectPerpsWatchlistEnabledFlag) return false;
if (selector === selectPerpsWatchlistMarkets) return [];
return [];
});
});

it('returns null when markets is empty', () => {
const { toJSON } = render(<PerpsWatchlistMarkets markets={[]} />);
expect(toJSON()).toBeNull();
});

it('returns null even when suggestedMarkets are provided', () => {
const { toJSON } = render(
<PerpsWatchlistMarkets
markets={[]}
suggestedMarkets={mockSuggestedMarkets}
/>,
);
expect(toJSON()).toBeNull();
});

it('renders market rows when markets are present', () => {
render(<PerpsWatchlistMarkets markets={mockMarkets} />);
expect(screen.getByText('BTC')).toBeOnTheScreen();
expect(screen.getByText('ETH')).toBeOnTheScreen();
});

it('renders the Watchlist section header', () => {
render(<PerpsWatchlistMarkets markets={mockMarkets} />);
expect(screen.getByText('Watchlist')).toBeOnTheScreen();
});

it('renders the section testID', () => {
render(<PerpsWatchlistMarkets markets={mockMarkets} />);
expect(screen.getByTestId('perps-watchlist-section')).toBeOnTheScreen();
});

it('does not render suggested markets', () => {
render(
<PerpsWatchlistMarkets
markets={mockMarkets}
suggestedMarkets={mockSuggestedMarkets}
/>,
);
for (const market of mockSuggestedMarkets) {
expect(
screen.queryByTestId(`perps-market-row-${market.symbol}`),
).not.toBeOnTheScreen();
}
});

it('does not render show-more button regardless of market count', () => {
const manyMarkets = [
makeMarket('BTC', 'Bitcoin'),
makeMarket('ETH', 'Ethereum'),
makeMarket('SOL', 'Solana'),
makeMarket('AVAX', 'Avalanche'),
makeMarket('DOT', 'Polkadot'),
];
render(<PerpsWatchlistMarkets markets={manyMarkets} />);
expect(screen.queryByText(/Show \d+ more/)).not.toBeOnTheScreen();
expect(screen.queryByText('Show less')).not.toBeOnTheScreen();
// All markets rendered (no pagination)
expect(screen.getByText('BTC')).toBeOnTheScreen();
expect(screen.getByText('DOT')).toBeOnTheScreen();
});

it('does not render the suggested section or empty-state subtitle', () => {
render(
<PerpsWatchlistMarkets
markets={mockMarkets}
suggestedMarkets={mockSuggestedMarkets}
/>,
);
expect(
screen.queryByTestId('perps-watchlist-suggested-section'),
).not.toBeOnTheScreen();
expect(
screen.queryByText('Tap + to add a market to your watchlist.'),
).not.toBeOnTheScreen();
});

it('shows skeleton during loading', () => {
render(<PerpsWatchlistMarkets markets={mockMarkets} isLoading />);
expect(screen.getByTestId('perps-row-skeleton-3')).toBeOnTheScreen();
});

it('navigates to market details on row press', () => {
render(<PerpsWatchlistMarkets markets={mockMarkets} />);
fireEvent.press(screen.getByTestId('perps-market-row-BTC'));
expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, {
screen: Routes.PERPS.MARKET_DETAILS,
params: {
market: mockMarkets[0],
initialTab: undefined,
source: undefined,
},
});
});
});

// -------------------------------------------------------------------------
// Component lifecycle
// -------------------------------------------------------------------------
Expand Down
Loading
Loading