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
3 changes: 3 additions & 0 deletions app/_locales/en/messages.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions app/_locales/en_GB/messages.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

142 changes: 125 additions & 17 deletions ui/components/app/perps/order-card/order-card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,25 +50,118 @@ describe('OrderCard', () => {
expect(screen.getByText('TSLA')).toBeInTheDocument();
});

it('displays Long for buy side order', () => {
const order = createMockOrder({ side: 'buy' });
renderWithProvider(<OrderCard order={order} />, mockStore);
describe('order label (formatOrderLabel)', () => {
it('displays "Limit long" for a plain buy limit order', () => {
const order = createMockOrder({ side: 'buy', orderType: 'limit' });
renderWithProvider(<OrderCard order={order} />, mockStore);

expect(screen.getByText(messages.perpsLong.message)).toBeInTheDocument();
});
expect(screen.getByText('Limit long')).toBeInTheDocument();
});

it('displays Short for sell side order', () => {
const order = createMockOrder({ side: 'sell' });
renderWithProvider(<OrderCard order={order} />, mockStore);
it('displays "Limit short" for a plain sell limit order', () => {
const order = createMockOrder({ side: 'sell', orderType: 'limit' });
renderWithProvider(<OrderCard order={order} />, mockStore);

expect(screen.getByText(messages.perpsShort.message)).toBeInTheDocument();
});
expect(screen.getByText('Limit short')).toBeInTheDocument();
});

it('displays the order type', () => {
const order = createMockOrder({ orderType: 'limit' });
renderWithProvider(<OrderCard order={order} />, mockStore);
it('displays "Market long" for a plain buy market order', () => {
const order = createMockOrder({
side: 'buy',
orderType: 'market',
price: '0',
});
renderWithProvider(<OrderCard order={order} />, mockStore);

expect(screen.getByText('Market long')).toBeInTheDocument();
});

it('displays "Limit close long" for a reduce-only sell limit order', () => {
const order = createMockOrder({
side: 'sell',
orderType: 'limit',
reduceOnly: true,
});
renderWithProvider(<OrderCard order={order} />, mockStore);

expect(screen.getByText('Limit close long')).toBeInTheDocument();
});

it('displays "Limit close short" for a reduce-only buy limit order', () => {
const order = createMockOrder({
side: 'buy',
orderType: 'limit',
reduceOnly: true,
});
renderWithProvider(<OrderCard order={order} />, mockStore);

expect(screen.getByText('Limit close short')).toBeInTheDocument();
});

expect(screen.getByText(messages.perpsLimit.message)).toBeInTheDocument();
it('displays "Take profit limit close long" for a TP trigger (sell side)', () => {
const order = createMockOrder({
side: 'sell',
orderType: 'limit',
isTrigger: true,
reduceOnly: true,
detailedOrderType: 'Take Profit Limit',
});
renderWithProvider(<OrderCard order={order} />, mockStore);

expect(
screen.getByText('Take profit limit close long'),
).toBeInTheDocument();
});

it('shows market symbol only after size for TP/SL, not before the label', () => {
const order = createMockOrder({
side: 'sell',
orderType: 'limit',
symbol: 'ETH',
size: '1.5',
isTrigger: true,
reduceOnly: true,
detailedOrderType: 'Take Profit Limit',
triggerPrice: '3200',
price: '3200',
});
renderWithProvider(<OrderCard order={order} />, mockStore);

expect(
screen.getByText('Take profit limit close long'),
).toBeInTheDocument();
expect(screen.getByText('1.5 ETH')).toBeInTheDocument();
expect(screen.queryByText(/^ETH$/u)).not.toBeInTheDocument();
});

it('shows "Trigger price" subtitle for TP/SL orders', () => {
const order = createMockOrder({
side: 'sell',
isTrigger: true,
reduceOnly: true,
detailedOrderType: 'Take Profit Limit',
triggerPrice: '3200',
price: '3200',
});
renderWithProvider(<OrderCard order={order} />, mockStore);

expect(
screen.getByText(messages.perpsTriggerPrice.message),
).toBeInTheDocument();
});

it('displays "Stop market close short" for a SL trigger (buy side)', () => {
const order = createMockOrder({
side: 'buy',
orderType: 'market',
isTrigger: true,
reduceOnly: true,
detailedOrderType: 'Stop Market',
});
renderWithProvider(<OrderCard order={order} />, mockStore);

expect(screen.getByText('Stop market close short')).toBeInTheDocument();
});
});

it('displays the order size with symbol', () => {
Expand All @@ -90,6 +183,23 @@ describe('OrderCard', () => {
expect(screen.getByText('$3,500.00')).toBeInTheDocument();
});

it('displays TP/SL trigger price, not size × price notional', () => {
const order = createMockOrder({
orderType: 'limit',
side: 'sell',
isTrigger: true,
reduceOnly: true,
detailedOrderType: 'Take Profit Limit',
triggerPrice: '3200.00',
price: '3200.00',
size: '2.0',
});
renderWithProvider(<OrderCard order={order} />, mockStore);

expect(screen.getByText('$3,200.00')).toBeInTheDocument();
expect(screen.queryByText('$6,400.00')).not.toBeInTheDocument();
});

it('displays Market label when order value is zero', () => {
const order = createMockOrder({
orderType: 'market',
Expand All @@ -98,9 +208,7 @@ describe('OrderCard', () => {
});
renderWithProvider(<OrderCard order={order} />, mockStore);

// "Market" appears in both the value slot and the order type slot
const marketElements = screen.getAllByText(messages.perpsMarket.message);
expect(marketElements.length).toBeGreaterThanOrEqual(1);
expect(screen.getByText(messages.perpsMarket.message)).toBeInTheDocument();
});

it('renders the token logo', () => {
Expand Down
77 changes: 55 additions & 22 deletions ui/components/app/perps/order-card/order-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
import { useI18nContext } from '../../../../hooks/useI18nContext';
import { useFormatters } from '../../../../hooks/useFormatters';
import { PerpsTokenLogo } from '../perps-token-logo';
import { formatOrderType, getDisplayName } from '../utils';
import { getDisplayName } from '../utils';
import { formatOrderLabel } from '../utils/orderUtils';
import type { Order } from '../types';
import { PERPS_MARKET_DETAIL_ROUTE } from '../../../../helpers/constants/routes';

Expand All @@ -27,7 +28,8 @@

/**
* OrderCard component displays individual order information
* Two rows: symbol/type/side + size on left, USD value + limit price on right
* Two rows on the left: symbol + order label (TP/SL: label only; symbol follows size below),
* trigger or notional value on the right
*
* @param options0 - Component props
* @param options0.order - The order data to display
Expand All @@ -42,8 +44,9 @@
const navigate = useNavigate();
const t = useI18nContext();
const { formatCurrencyWithMinThreshold } = useFormatters();
const isBuy = order.side === 'buy';
const displayName = getDisplayName(order.symbol);
const isTriggerBasedOrder =
order.isTrigger === true || order.isPositionTpsl === true;

const handleClick = useCallback(() => {
if (onClick) {
Expand All @@ -56,17 +59,34 @@
}
}, [navigate, order, onClick]);

// Calculate order value in USD (size * price), formatted like position values
// Limit/market: notional (size × limit price). TP/SL: trigger level (take-profit / stop-loss price).
const orderValueUsd = useMemo(() => {
if (isTriggerBasedOrder) {
const triggerLevel =
parseFloat(order.triggerPrice || order.price || '0') || 0;

Check warning on line 66 in ui/components/app/perps/order-card/order-card.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseFloat` over `parseFloat`.

See more on https://sonarcloud.io/project/issues?id=metamask-extension&issues=AZ2tNe1VSJzgaFLOQHh9&open=AZ2tNe1VSJzgaFLOQHh9&pullRequest=41971
if (triggerLevel > 0) {
return formatCurrencyWithMinThreshold(triggerLevel, 'USD');
}
}

const size = parseFloat(order.size) || 0;
const price = parseFloat(order.price) || 0;
if (size > 0 && price > 0) {
return formatCurrencyWithMinThreshold(size * price, 'USD');
}
return null;
}, [order.size, order.price, formatCurrencyWithMinThreshold]);
}, [
isTriggerBasedOrder,
order.triggerPrice,
order.size,
order.price,
formatCurrencyWithMinThreshold,
]);

const baseStyles = 'cursor-pointer pt-2 pb-2 px-4 h-[62px]';
const baseStyles = 'cursor-pointer pt-2 pb-2 px-4';
// Non-trigger rows keep the fixed 62 px height to match the position/token tabs.
// Trigger-based (TP/SL) rows grow with content; min-h keeps the floor at 62 px.
const heightStyle = isTriggerBasedOrder ? 'h-auto min-h-[62px]' : 'h-[62px]';
const variantStyles =
variant === 'muted'
? 'bg-muted hover:bg-muted-hover active:bg-muted-pressed'
Expand All @@ -77,9 +97,11 @@
className={twMerge(
// Reset ButtonBase defaults for card layout
'justify-start rounded-none min-w-0',
// Card styles (matches tokens tab: 62px height, 8px v-padding, 16px h-padding, 16px gap)
'gap-4 text-left',
// items-center keeps each column's content block centered in the card height,
// whether the label fits on one line or wraps to two.
'gap-4 text-left items-center',
baseStyles,
heightStyle,
variantStyles,
)}
isFullWidth
Expand All @@ -100,22 +122,31 @@
alignItems={BoxAlignItems.Start}
gap={1}
>
<Box
flexDirection={BoxFlexDirection.Row}
alignItems={BoxAlignItems.Center}
gap={1}
>
<Text fontWeight={FontWeight.Medium}>{displayName}</Text>
<Text variant={TextVariant.BodySm} color={TextColor.TextAlternative}>
{isBuy ? t('perpsLong') : t('perpsShort')}
</Text>
</Box>
{isTriggerBasedOrder ? (
// TP/SL: render label directly in the column so it wraps freely.
// The symbol is redundant here — it appears after the size below.
<Text fontWeight={FontWeight.Medium}>{formatOrderLabel(order)}</Text>
) : (
<Box
flexDirection={BoxFlexDirection.Row}
alignItems={BoxAlignItems.Center}
gap={1}
>
<Text fontWeight={FontWeight.Medium}>{displayName}</Text>
<Text
variant={TextVariant.BodySm}
color={TextColor.TextAlternative}
>
{formatOrderLabel(order)}
</Text>
</Box>
)}
<Text variant={TextVariant.BodySm} color={TextColor.TextAlternative}>
{order.size} {displayName}
</Text>
</Box>

{/* Right side: USD value + limit price */}
{/* Right side: USD value */}
<Box
className="shrink-0"
flexDirection={BoxFlexDirection.Column}
Expand All @@ -125,9 +156,11 @@
<Text variant={TextVariant.BodySm} fontWeight={FontWeight.Medium}>
{orderValueUsd ?? t('perpsMarket')}
</Text>
<Text variant={TextVariant.BodySm} color={TextColor.TextAlternative}>
{formatOrderType(order.orderType)}
</Text>
{isTriggerBasedOrder && orderValueUsd && (
<Text variant={TextVariant.BodyXs} color={TextColor.TextAlternative}>
{t('perpsTriggerPrice')}
</Text>
)}
</Box>
</ButtonBase>
);
Expand Down
9 changes: 9 additions & 0 deletions ui/components/app/perps/perps-view.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,15 @@ describe('PerpsView', () => {
expect(screen.getByTestId('order-card-order-001')).toBeInTheDocument();
});

it('filters TP/SL trigger orders from Open orders on the Perps tab', () => {
renderWithProvider(<PerpsView />, mockStore);

expect(screen.getByTestId('order-card-order-001')).toBeInTheDocument();
expect(
screen.queryByTestId('order-card-order-004'),
).not.toBeInTheDocument();
});

it('displays position section header', () => {
renderWithProvider(<PerpsView />, mockStore);

Expand Down
16 changes: 9 additions & 7 deletions ui/components/app/perps/perps-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,9 @@ export const PerpsView: React.FC = () => {
} = usePerpsTransactionHistory();

// Recent Activity shows only trade executions, deposits, and withdrawals.
// Open orders are already surfaced in PerpsPositionsOrders above.
// Funding payments belong in the full activity page.
// Open limit/market orders (excluding TP/SL triggers) are in PerpsPositionsOrders;
// TP/SL trigger rows are listed on the per-asset market detail page only.
// Funding payments belong on the full activity page.
const recentActivityTransactions = useMemo(
() =>
allRecentActivityTransactions.filter(
Expand All @@ -106,14 +107,15 @@ export const PerpsView: React.FC = () => {
[allRecentActivityTransactions],
);

// Show only user-placed limit orders resting on the orderbook.
// Excludes:
// - isTrigger: TP/SL trigger orders
// - isSynthetic: synthetic/virtual orders not placed directly by the user
// Open orders on the Perps tab: user-placed limits/markets on the book only.
// Excludes TP/SL (isTrigger / isPositionTpsl — those list on market detail) and synthetics.
const orders = useMemo(() => {
return allOrders.filter(
(order) =>
order.status === 'open' && !order.isTrigger && !order.isSynthetic,
order.status === 'open' &&
!order.isTrigger &&
order.isPositionTpsl !== true &&
!order.isSynthetic,
);
}, [allOrders]);

Expand Down
1 change: 1 addition & 0 deletions ui/components/app/perps/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export {
shouldDisplayOrderInMarketDetailsOrders,
buildDisplayOrdersWithSyntheticTpsl,
isOrderAssociatedWithFullPosition,
formatOrderLabel,
} from './orderUtils';

export { formatPerpsPrice, type PerpsPriceRange } from './formatPerpsPrice';
Expand Down
Loading
Loading