Skip to content

Commit cd36d7b

Browse files
feat: add cycling sort button to trader profile positions (#30027)
--- <!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until this PR meets the canonical Definition of Ready For Review in `docs/readme/ready-for-review.md`. In short: the template must be materially complete (not just section titles present), all status checks must be currently passing, and the only expected follow-up commits must be reviewer-driven. --> ## **Description** https://github.com/user-attachments/assets/6ba73ed6-8a0f-4825-bcfc-27549290963e <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> Adds an inline cycling sort control to the Top Traders trader profile screen on both the Open and Closed tabs. The button visually mirrors the Perps Markets sort dropdown (label + swap-vertical icon), but tapping cycles through the available options inline instead of opening a bottom sheet. Each tap fires a light selection haptic through the project's `app/util/haptics` utility (Redux-gated, kill-switch-aware). Sort state is independent per tab and preserved when switching back and forth: - Open tab: `Value`, `P&L %` — default `Value` - Closed tab: `Value`, `P&L %`, `Recent` — default `Recent` The button is hidden while positions are loading or when the list is empty, so the control is only shown when there is something meaningful to sort. Sorting logic lives in a pure `sortPositions` utility (no React, no Redux) so it is trivial to test and reuse, and keeps domain logic out of the view. ## **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: Added a sort control to a trader's Open and Closed positions on the Top Traders profile screen. ## **Related issues** Fixes: N/A — product request from design/PM, no linked issue. ## **Manual testing steps** ```gherkin Feature: Sort trader positions Scenario: Open tab cycles between Value and P&L % Given I am on a trader profile with Open positions Then the sort button shows "Value" When I tap the sort button Then the sort button shows "P&L %" and the list is sorted by P&L % descending When I tap the sort button again Then the sort button shows "Value" and the list is sorted by Value descending Scenario: Closed tab defaults to Recent and cycles through three options Given I am on a trader profile with Closed positions When I tap the "Closed" tab Then the sort button shows "Recent" and positions are sorted by most recent When I tap the sort button Then the sort button shows "Value" When I tap the sort button Then the sort button shows "P&L %" When I tap the sort button Then the sort button shows "Recent" again Scenario: Sort state is independent and preserved per tab Given I am on the Open tab and have selected "P&L %" When I switch to the Closed tab and select "Value" And I switch back to the Open tab Then the sort button still shows "P&L %" When I switch back to the Closed tab Then the sort button still shows "Value" Scenario: Sort button is hidden when there is nothing to sort Given I am on a trader profile whose Open positions list is loading or empty Then the sort button is not visible Scenario: Tapping the sort button triggers a haptic Given haptics are enabled on the device and in app settings When I tap the sort button Then a light selection haptic is felt ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** N/A — no sort control existed on the trader profile positions list. ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** <!-- Every checklist item must be consciously assessed before marking this PR as "Ready for review". A checked box means you deliberately considered that responsibility, not that you literally performed every action listed. Unchecked boxes are ambiguous: they are not an implicit "N/A" and they are not a silent "skip". See `docs/readme/ready-for-review.md` for the full checklist semantics. --> - [x] 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). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] 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. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** <!-- Reviewer checklist items follow the same semantics as the author checklist: an unchecked box is ambiguous, a checked box means the reviewer consciously assessed that responsibility. See `docs/readme/ready-for-review.md`. --> - [ ] 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. <!-- Generated with the help of the pr-description AI skill --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Low risk UI change that only affects client-side ordering and a non-blocking haptic call; main risk is incorrect sort criteria or state handling across tabs. > > **Overview** > Adds an inline, cycling **sort button** to `TraderProfileView` that appears when positions are loaded/non-empty and reorders the displayed list via a new pure `sortPositions` utility. > > Introduces independent per-tab sort state (Open: `Value`/`P&L %`; Closed: `Recent`/`Value`/`P&L %`), triggers `playSelection()` haptics on each tap, adds i18n labels, and expands test coverage for sort cycling, visibility rules, and sorting logic. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 1fb7f43. 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: Cursor <cursoragent@cursor.com>
1 parent bf7ad8e commit cd36d7b

7 files changed

Lines changed: 530 additions & 17 deletions

File tree

app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.test.tsx

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ const mockGoBack = jest.fn();
1515
const mockNavigate = jest.fn();
1616
const mockToggleFollow = jest.fn();
1717
const mockRefresh = jest.fn();
18+
const mockPlaySelection = jest.fn().mockResolvedValue(undefined);
19+
20+
jest.mock('../../../../util/haptics', () => ({
21+
...jest.requireActual('../../../../util/haptics'),
22+
playSelection: (...args: unknown[]) => mockPlaySelection(...args),
23+
}));
1824

1925
jest.mock('../../../UI/Bridge/hooks/useAssetMetadata/utils', () => ({
2026
getAssetImageUrl: () => 'https://example.com/token.png',
@@ -181,6 +187,60 @@ const fixtureOpenPositions: Position[] = [
181187
},
182188
];
183189

190+
const fixtureClosedPositions: Position[] = [
191+
{
192+
positionId: 'cult-eth',
193+
tokenSymbol: 'CULT',
194+
tokenName: 'Cult',
195+
tokenAddress: '0xc01t',
196+
chain: 'ethereum',
197+
positionAmount: 0,
198+
boughtUsd: 100,
199+
soldUsd: 300,
200+
realizedPnl: 200,
201+
costBasis: 100,
202+
trades: [],
203+
lastTradeAt: 1000,
204+
currentValueUSD: 0,
205+
pnlValueUsd: 200,
206+
pnlPercent: null,
207+
},
208+
{
209+
positionId: 'moonkin-sol',
210+
tokenSymbol: 'MOONKIN',
211+
tokenName: 'Moonkin',
212+
tokenAddress: '0xm00n',
213+
chain: 'solana',
214+
positionAmount: 0,
215+
boughtUsd: 100,
216+
soldUsd: 1000,
217+
realizedPnl: 900,
218+
costBasis: 100,
219+
trades: [],
220+
lastTradeAt: 3000,
221+
currentValueUSD: 0,
222+
pnlValueUsd: 900,
223+
pnlPercent: null,
224+
},
225+
{
226+
positionId: 'dope-base',
227+
tokenSymbol: 'DOPE',
228+
tokenName: 'Dope',
229+
tokenAddress: '0xd0pe',
230+
chain: 'base',
231+
positionAmount: 0,
232+
boughtUsd: 500,
233+
soldUsd: 500,
234+
realizedPnl: 0,
235+
costBasis: 500,
236+
trades: [],
237+
lastTradeAt: 2000,
238+
currentValueUSD: 0,
239+
pnlValueUsd: 0,
240+
pnlPercent: null,
241+
},
242+
];
243+
184244
let mockProfileResult: UseTraderProfileResult = {
185245
profile: fixtureProfile,
186246
isLoading: false,
@@ -436,4 +496,115 @@ describe('TraderProfileView', () => {
436496
expect(screen.getByText('45 followers')).toBeOnTheScreen();
437497
});
438498
});
499+
500+
describe('sort button', () => {
501+
it('renders with default Value label on the Open tab', () => {
502+
renderWithProvider(<TraderProfileView />);
503+
504+
expect(
505+
screen.getByTestId(TraderProfileViewSelectorsIDs.SORT_BUTTON),
506+
).toBeOnTheScreen();
507+
expect(screen.getByText('Value')).toBeOnTheScreen();
508+
});
509+
510+
it('cycles Open tab sort Value -> P&L % -> Value on consecutive taps', () => {
511+
renderWithProvider(<TraderProfileView />);
512+
513+
fireEvent.press(
514+
screen.getByTestId(TraderProfileViewSelectorsIDs.SORT_BUTTON),
515+
);
516+
expect(screen.getByText('P&L %')).toBeOnTheScreen();
517+
518+
fireEvent.press(
519+
screen.getByTestId(TraderProfileViewSelectorsIDs.SORT_BUTTON),
520+
);
521+
expect(screen.getByText('Value')).toBeOnTheScreen();
522+
});
523+
524+
it('defaults Closed tab sort to Recent and cycles Recent -> Value -> P&L % -> Recent', () => {
525+
mockPositionsResult.closedPositions = fixtureClosedPositions;
526+
renderWithProvider(<TraderProfileView />);
527+
fireEvent.press(
528+
screen.getByTestId(TraderProfileViewSelectorsIDs.TAB_CLOSED),
529+
);
530+
531+
expect(screen.getByText('Recent')).toBeOnTheScreen();
532+
533+
fireEvent.press(
534+
screen.getByTestId(TraderProfileViewSelectorsIDs.SORT_BUTTON),
535+
);
536+
expect(screen.getByText('Value')).toBeOnTheScreen();
537+
538+
fireEvent.press(
539+
screen.getByTestId(TraderProfileViewSelectorsIDs.SORT_BUTTON),
540+
);
541+
expect(screen.getByText('P&L %')).toBeOnTheScreen();
542+
543+
fireEvent.press(
544+
screen.getByTestId(TraderProfileViewSelectorsIDs.SORT_BUTTON),
545+
);
546+
expect(screen.getByText('Recent')).toBeOnTheScreen();
547+
});
548+
549+
it('preserves independent sort state when switching between tabs', () => {
550+
mockPositionsResult.closedPositions = fixtureClosedPositions;
551+
renderWithProvider(<TraderProfileView />);
552+
553+
fireEvent.press(
554+
screen.getByTestId(TraderProfileViewSelectorsIDs.SORT_BUTTON),
555+
);
556+
expect(screen.getByText('P&L %')).toBeOnTheScreen();
557+
558+
fireEvent.press(
559+
screen.getByTestId(TraderProfileViewSelectorsIDs.TAB_CLOSED),
560+
);
561+
expect(screen.getByText('Recent')).toBeOnTheScreen();
562+
563+
fireEvent.press(
564+
screen.getByTestId(TraderProfileViewSelectorsIDs.SORT_BUTTON),
565+
);
566+
expect(screen.getByText('Value')).toBeOnTheScreen();
567+
568+
fireEvent.press(
569+
screen.getByTestId(TraderProfileViewSelectorsIDs.TAB_OPEN),
570+
);
571+
expect(screen.getByText('P&L %')).toBeOnTheScreen();
572+
573+
fireEvent.press(
574+
screen.getByTestId(TraderProfileViewSelectorsIDs.TAB_CLOSED),
575+
);
576+
expect(screen.getByText('Value')).toBeOnTheScreen();
577+
});
578+
579+
it('triggers a haptic on each sort tap', () => {
580+
renderWithProvider(<TraderProfileView />);
581+
582+
fireEvent.press(
583+
screen.getByTestId(TraderProfileViewSelectorsIDs.SORT_BUTTON),
584+
);
585+
fireEvent.press(
586+
screen.getByTestId(TraderProfileViewSelectorsIDs.SORT_BUTTON),
587+
);
588+
589+
expect(mockPlaySelection).toHaveBeenCalledTimes(2);
590+
});
591+
592+
it('hides the sort button when positions list is empty', () => {
593+
mockPositionsResult.openPositions = [];
594+
renderWithProvider(<TraderProfileView />);
595+
596+
expect(
597+
screen.queryByTestId(TraderProfileViewSelectorsIDs.SORT_BUTTON),
598+
).not.toBeOnTheScreen();
599+
});
600+
601+
it('hides the sort button while positions are loading', () => {
602+
mockPositionsResult.isLoadingOpen = true;
603+
renderWithProvider(<TraderProfileView />);
604+
605+
expect(
606+
screen.queryByTestId(TraderProfileViewSelectorsIDs.SORT_BUTTON),
607+
).not.toBeOnTheScreen();
608+
});
609+
});
439610
});

app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.testIds.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const TraderProfileViewSelectorsIDs = {
77
FOLLOW_BUTTON: 'trader-profile-follow-button',
88
TAB_OPEN: 'trader-profile-tab-open',
99
TAB_CLOSED: 'trader-profile-tab-closed',
10+
SORT_BUTTON: 'trader-profile-sort-button',
1011
ERROR_BANNER: 'trader-profile-error-banner',
1112
TWITTER_LINK: 'trader-profile-twitter-link',
1213
};

app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.tsx

Lines changed: 73 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import React, { useCallback, useRef, useState } from 'react';
1+
import React, { useCallback, useMemo, useRef, useState } from 'react';
22
import { TouchableOpacity } from 'react-native';
33
import { ScrollView } from 'react-native-gesture-handler';
4+
import { playSelection } from '../../../../util/haptics';
45
import {
56
useNavigation,
67
useRoute,
@@ -33,6 +34,15 @@ import type { Position } from '@metamask/social-controllers';
3334
import ProfileHeader from './components/ProfileHeader';
3435
import StatsRow from './components/StatsRow';
3536
import PositionRow from './components/PositionRow';
37+
import SortButton from './components/SortButton';
38+
import {
39+
CLOSED_SORT_CYCLE,
40+
OPEN_SORT_CYCLE,
41+
sortPositions,
42+
type ClosedSortKey,
43+
type OpenSortKey,
44+
type SortKey,
45+
} from './utils/sortPositions';
3646
import {
3747
ProfileHeaderSkeleton,
3848
StatsRowSkeleton,
@@ -53,6 +63,12 @@ const POSITION_SKELETON_KEYS = Array.from(
5363
(_, i) => `position-skeleton-${i}`,
5464
);
5565

66+
const SORT_LABEL_KEYS: Record<SortKey, string> = {
67+
value: 'social_leaderboard.trader_profile.sort.value',
68+
pnl: 'social_leaderboard.trader_profile.sort.pnl_percent',
69+
recent: 'social_leaderboard.trader_profile.sort.recent',
70+
};
71+
5672
interface TabButtonProps {
5773
label: string;
5874
isActive: boolean;
@@ -110,6 +126,8 @@ const TraderProfileView = () => {
110126
} = useNotificationPreferences();
111127

112128
const [activeTab, setActiveTab] = useState<'open' | 'closed'>('open');
129+
const [openSort, setOpenSort] = useState<OpenSortKey>('value');
130+
const [closedSort, setClosedSort] = useState<ClosedSortKey>('recent');
113131

114132
const notificationsSheetRef = useRef<TraderNotificationsBottomSheetRef>(null);
115133
const setupSheetRef =
@@ -147,6 +165,29 @@ const TraderProfileView = () => {
147165
const isLoadingPositions =
148166
activeTab === 'open' ? isLoadingOpen : isLoadingClosed;
149167

168+
const currentSortKey: SortKey = activeTab === 'open' ? openSort : closedSort;
169+
170+
const sortedPositions = useMemo(
171+
() => sortPositions(positions, currentSortKey, activeTab),
172+
[positions, currentSortKey, activeTab],
173+
);
174+
175+
const handleSortPress = useCallback(() => {
176+
// Fire-and-forget haptic; ignore rejection (unsupported platforms).
177+
playSelection().catch(() => undefined);
178+
if (activeTab === 'open') {
179+
setOpenSort((current) => {
180+
const idx = OPEN_SORT_CYCLE.indexOf(current);
181+
return OPEN_SORT_CYCLE[(idx + 1) % OPEN_SORT_CYCLE.length];
182+
});
183+
} else {
184+
setClosedSort((current) => {
185+
const idx = CLOSED_SORT_CYCLE.indexOf(current);
186+
return CLOSED_SORT_CYCLE[(idx + 1) % CLOSED_SORT_CYCLE.length];
187+
});
188+
}
189+
}, [activeTab]);
190+
150191
return (
151192
<SafeAreaView
152193
style={tw.style('flex-1 bg-default')}
@@ -242,28 +283,44 @@ const TraderProfileView = () => {
242283

243284
<Box
244285
flexDirection={BoxFlexDirection.Row}
286+
alignItems={BoxAlignItems.Center}
287+
justifyContent={BoxJustifyContent.Between}
245288
twClassName="px-4 mb-2"
246-
gap={4}
247289
>
248-
<TabButton
249-
label={strings('social_leaderboard.trader_profile.open')}
250-
isActive={activeTab === 'open'}
251-
onPress={() => setActiveTab('open')}
252-
testID={TraderProfileViewSelectorsIDs.TAB_OPEN}
253-
/>
254-
<TabButton
255-
label={strings('social_leaderboard.trader_profile.closed')}
256-
isActive={activeTab === 'closed'}
257-
onPress={() => setActiveTab('closed')}
258-
testID={TraderProfileViewSelectorsIDs.TAB_CLOSED}
259-
/>
290+
<Box
291+
flexDirection={BoxFlexDirection.Row}
292+
alignItems={BoxAlignItems.Center}
293+
gap={4}
294+
>
295+
<TabButton
296+
label={strings('social_leaderboard.trader_profile.open')}
297+
isActive={activeTab === 'open'}
298+
onPress={() => setActiveTab('open')}
299+
testID={TraderProfileViewSelectorsIDs.TAB_OPEN}
300+
/>
301+
<TabButton
302+
label={strings(
303+
'social_leaderboard.trader_profile.closed',
304+
)}
305+
isActive={activeTab === 'closed'}
306+
onPress={() => setActiveTab('closed')}
307+
testID={TraderProfileViewSelectorsIDs.TAB_CLOSED}
308+
/>
309+
</Box>
310+
{!isLoadingPositions && positions.length > 0 && (
311+
<SortButton
312+
label={strings(SORT_LABEL_KEYS[currentSortKey])}
313+
onPress={handleSortPress}
314+
testID={TraderProfileViewSelectorsIDs.SORT_BUTTON}
315+
/>
316+
)}
260317
</Box>
261318

262319
{isLoadingPositions ? (
263320
POSITION_SKELETON_KEYS.map((key) => (
264321
<PositionRowSkeleton key={key} />
265322
))
266-
) : positions.length === 0 ? (
323+
) : sortedPositions.length === 0 ? (
267324
<Box
268325
twClassName="px-4 py-8"
269326
alignItems={BoxAlignItems.Center}
@@ -278,7 +335,7 @@ const TraderProfileView = () => {
278335
</Text>
279336
</Box>
280337
) : (
281-
positions.map((position, index) => (
338+
sortedPositions.map((position, index) => (
282339
<PositionRow
283340
key={`${position.tokenAddress}-${position.chain}-${index}`}
284341
position={position}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import React from 'react';
2+
import { Pressable } from 'react-native';
3+
import {
4+
Box,
5+
BoxAlignItems,
6+
BoxFlexDirection,
7+
Icon,
8+
IconColor,
9+
IconName,
10+
IconSize,
11+
Text,
12+
TextColor,
13+
TextVariant,
14+
} from '@metamask/design-system-react-native';
15+
import { useTailwind } from '@metamask/design-system-twrnc-preset';
16+
17+
export interface SortButtonProps {
18+
label: string;
19+
onPress: () => void;
20+
testID?: string;
21+
}
22+
23+
const SortButton: React.FC<SortButtonProps> = ({ label, onPress, testID }) => {
24+
const tw = useTailwind();
25+
26+
return (
27+
<Pressable
28+
onPress={onPress}
29+
testID={testID}
30+
hitSlop={8}
31+
style={({ pressed }) =>
32+
tw.style('flex-row items-center', pressed && 'opacity-70')
33+
}
34+
>
35+
<Box
36+
flexDirection={BoxFlexDirection.Row}
37+
alignItems={BoxAlignItems.Center}
38+
gap={1}
39+
>
40+
<Text variant={TextVariant.BodySm} color={TextColor.TextAlternative}>
41+
{label}
42+
</Text>
43+
<Icon
44+
name={IconName.SwapVertical}
45+
size={IconSize.Sm}
46+
color={IconColor.IconAlternative}
47+
/>
48+
</Box>
49+
</Pressable>
50+
);
51+
};
52+
53+
export default SortButton;

0 commit comments

Comments
 (0)