feat(deal-ticket): update deal ticket submit buttons (#4635)

This commit is contained in:
Bartłomiej Głownia 2023-08-31 11:54:52 +02:00 committed by GitHub
parent 2cea73c567
commit a8c2f4e025
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 914 additions and 312 deletions

View File

@ -33,9 +33,7 @@ describe('deal ticker order validation', { tags: '@smoke' }, () => {
it('must see the price unit', function () { it('must see the price unit', function () {
// 7002-SORD-018 // 7002-SORD-018
cy.getByTestId(orderPriceField) cy.getByTestId(orderPriceField).next().should('have.text', 'DAI');
.siblings('label')
.should('have.text', 'Price (DAI)');
}); });
it('must see warning when placing an order with expiry date in past', () => { it('must see warning when placing an order with expiry date in past', () => {

View File

@ -1,25 +0,0 @@
import { t } from '@vegaprotocol/i18n';
import { Side } from '@vegaprotocol/types';
import classNames from 'classnames';
interface Props {
side: Side;
label?: string;
}
export const DealTicketButton = ({ side, label }: Props) => {
const buttonClasses = classNames(
'px-10 py-2 uppercase rounded-md text-white w-full',
{
'bg-market-red': side === Side.SIDE_SELL,
'bg-market-green-550': side === Side.SIDE_BUY,
}
);
return (
<div className="mb-2">
<button type="submit" data-testid="place-order" className={buttonClasses}>
{label || t('Place order')}
</button>
</div>
);
};

View File

@ -1,7 +1,4 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { Tooltip } from '@vegaprotocol/ui-toolkit';
import classnames from 'classnames';
import type { ReactNode } from 'react';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { FeesBreakdown } from '@vegaprotocol/markets'; import { FeesBreakdown } from '@vegaprotocol/markets';
import type { OrderSubmissionBody } from '@vegaprotocol/wallet'; import type { OrderSubmissionBody } from '@vegaprotocol/wallet';
@ -16,7 +13,6 @@ import { marketMarginDataProvider } from '@vegaprotocol/accounts';
import { useDataProvider } from '@vegaprotocol/data-provider'; import { useDataProvider } from '@vegaprotocol/data-provider';
import { import {
NOTIONAL_SIZE_TOOLTIP_TEXT,
MARGIN_DIFF_TOOLTIP_TEXT, MARGIN_DIFF_TOOLTIP_TEXT,
DEDUCTION_FROM_COLLATERAL_TOOLTIP_TEXT, DEDUCTION_FROM_COLLATERAL_TOOLTIP_TEXT,
TOTAL_MARGIN_AVAILABLE, TOTAL_MARGIN_AVAILABLE,
@ -25,87 +21,28 @@ import {
MARGIN_ACCOUNT_TOOLTIP_TEXT, MARGIN_ACCOUNT_TOOLTIP_TEXT,
} from '../../constants'; } from '../../constants';
import { useEstimateFees } from '../../hooks'; import { useEstimateFees } from '../../hooks';
import { KeyValue } from './key-value';
const emptyValue = '-'; const emptyValue = '-';
export interface DealTicketFeeDetailPros {
label: string;
value?: string | null | undefined;
symbol: string;
indent?: boolean | undefined;
labelDescription?: ReactNode;
formattedValue?: string;
onClick?: () => void;
}
export const DealTicketFeeDetail = ({
label,
value,
labelDescription,
symbol,
indent,
onClick,
formattedValue,
}: DealTicketFeeDetailPros) => {
const displayValue = `${formattedValue ?? '-'} ${symbol || ''}`;
const valueElement = onClick ? (
<button onClick={onClick} className="text-muted">
{displayValue}
</button>
) : (
<div className="text-muted">{displayValue}</div>
);
return (
<div
data-testid={
'deal-ticket-fee-' + label.toLocaleLowerCase().replace(/\s/g, '-')
}
key={typeof label === 'string' ? label : 'value-dropdown'}
className={classnames(
'text-xs mt-2 flex justify-between items-center gap-4 flex-wrap',
{ 'ml-2': indent }
)}
>
<Tooltip description={labelDescription}>
<div>{label}</div>
</Tooltip>
<Tooltip description={`${value ?? '-'} ${symbol || ''}`}>
{valueElement}
</Tooltip>
</div>
);
};
export interface DealTicketFeeDetailsProps { export interface DealTicketFeeDetailsProps {
assetSymbol: string; assetSymbol: string;
order: OrderSubmissionBody['orderSubmission']; order: OrderSubmissionBody['orderSubmission'];
market: Market; market: Market;
notionalSize: string | null;
} }
export const DealTicketFeeDetails = ({ export const DealTicketFeeDetails = ({
assetSymbol, assetSymbol,
order, order,
market, market,
notionalSize,
}: DealTicketFeeDetailsProps) => { }: DealTicketFeeDetailsProps) => {
const feeEstimate = useEstimateFees(order); const feeEstimate = useEstimateFees(order);
const { settlementAsset: asset } = const { settlementAsset: asset } =
market.tradableInstrument.instrument.product; market.tradableInstrument.instrument.product;
const { decimals: assetDecimals, quantum } = asset; const { decimals: assetDecimals, quantum } = asset;
const marketDecimals = market.decimalPlaces;
const quoteName = market.tradableInstrument.instrument.product.quoteName;
return ( return (
<> <KeyValue
<DealTicketFeeDetail
label={t('Notional')}
value={formatValue(notionalSize, marketDecimals)}
formattedValue={formatValue(notionalSize, marketDecimals)}
symbol={quoteName}
labelDescription={NOTIONAL_SIZE_TOOLTIP_TEXT(quoteName)}
/>
<DealTicketFeeDetail
label={t('Fees')} label={t('Fees')}
value={ value={
feeEstimate?.totalFeeAmount && feeEstimate?.totalFeeAmount &&
@ -132,7 +69,6 @@ export const DealTicketFeeDetails = ({
} }
symbol={assetSymbol} symbol={assetSymbol}
/> />
</>
); );
}; };
@ -209,7 +145,7 @@ export const DealTicketMarginDetails = ({
BigInt(marginAccountBalance); BigInt(marginAccountBalance);
deductionFromCollateral = ( deductionFromCollateral = (
<DealTicketFeeDetail <KeyValue
indent indent
label={t('Deduction from collateral')} label={t('Deduction from collateral')}
value={formatRange( value={formatRange(
@ -236,7 +172,7 @@ export const DealTicketMarginDetails = ({
/> />
); );
projectedMargin = ( projectedMargin = (
<DealTicketFeeDetail <KeyValue
label={t('Projected margin')} label={t('Projected margin')}
value={formatRange( value={formatRange(
marginEstimate?.bestCase.initialLevel, marginEstimate?.bestCase.initialLevel,
@ -308,7 +244,7 @@ export const DealTicketMarginDetails = ({
return ( return (
<> <>
<DealTicketFeeDetail <KeyValue
label={t('Margin required')} label={t('Margin required')}
value={formatRange( value={formatRange(
marginRequiredBestCase, marginRequiredBestCase,
@ -324,7 +260,7 @@ export const DealTicketMarginDetails = ({
labelDescription={MARGIN_DIFF_TOOLTIP_TEXT(assetSymbol)} labelDescription={MARGIN_DIFF_TOOLTIP_TEXT(assetSymbol)}
symbol={assetSymbol} symbol={assetSymbol}
/> />
<DealTicketFeeDetail <KeyValue
label={t('Total margin available')} label={t('Total margin available')}
indent indent
value={formatValue(totalMarginAvailable, assetDecimals)} value={formatValue(totalMarginAvailable, assetDecimals)}
@ -342,7 +278,7 @@ export const DealTicketMarginDetails = ({
)} )}
/> />
{deductionFromCollateral} {deductionFromCollateral}
<DealTicketFeeDetail <KeyValue
label={t('Current margin allocation')} label={t('Current margin allocation')}
indent indent
onClick={ onClick={
@ -358,7 +294,7 @@ export const DealTicketMarginDetails = ({
)} )}
/> />
{projectedMargin} {projectedMargin}
<DealTicketFeeDetail <KeyValue
label={t('Liquidation price estimate')} label={t('Liquidation price estimate')}
value={liquidationPriceEstimate} value={liquidationPriceEstimate}
formattedValue={liquidationPriceEstimate} formattedValue={liquidationPriceEstimate}

View File

@ -32,7 +32,7 @@ export const DealTicketSizeIceberg = ({
const renderPeakSizeError = () => { const renderPeakSizeError = () => {
if (peakSizeError) { if (peakSizeError) {
return ( return (
<TradingInputError testId="deal-ticket-peak-error-message-size-limit"> <TradingInputError testId="deal-ticket-peak-error-message">
{peakSizeError} {peakSizeError}
</TradingInputError> </TradingInputError>
); );
@ -44,7 +44,7 @@ export const DealTicketSizeIceberg = ({
const renderMinimumSizeError = () => { const renderMinimumSizeError = () => {
if (minimumVisibleSizeError) { if (minimumVisibleSizeError) {
return ( return (
<TradingInputError testId="deal-ticket-minimum-error-message-size-limit"> <TradingInputError testId="deal-ticket-minimum-error-message">
{minimumVisibleSizeError} {minimumVisibleSizeError}
</TradingInputError> </TradingInputError>
); );

View File

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { VegaWalletContext } from '@vegaprotocol/wallet'; import { VegaWalletContext } from '@vegaprotocol/wallet';
import { render, screen, waitFor } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { generateMarket } from '../../test-helpers'; import { generateMarket } from '../../test-helpers';
import { StopOrder } from './deal-ticket-stop-order'; import { StopOrder } from './deal-ticket-stop-order';
@ -12,6 +12,7 @@ import {
useDealTicketFormValues, useDealTicketFormValues,
} from '../../hooks/use-form-values'; } from '../../hooks/use-form-values';
import type { FeatureFlags } from '@vegaprotocol/environment'; import type { FeatureFlags } from '@vegaprotocol/environment';
import { formatForInput } from '@vegaprotocol/utils';
jest.mock('zustand'); jest.mock('zustand');
jest.mock('./deal-ticket-fee-details', () => ({ jest.mock('./deal-ticket-fee-details', () => ({
@ -57,7 +58,7 @@ const orderSideBuy = 'order-side-SIDE_BUY';
const orderSideSell = 'order-side-SIDE_SELL'; const orderSideSell = 'order-side-SIDE_SELL';
const triggerDirectionRisesAbove = 'triggerDirection-risesAbove'; const triggerDirectionRisesAbove = 'triggerDirection-risesAbove';
// const triggerDirectionFallsBelow = 'triggerDirection-fallsBelow'; const triggerDirectionFallsBelow = 'triggerDirection-fallsBelow';
const expiryStrategySubmit = 'expiryStrategy-submit'; const expiryStrategySubmit = 'expiryStrategy-submit';
const expiryStrategyCancel = 'expiryStrategy-cancel'; const expiryStrategyCancel = 'expiryStrategy-cancel';
@ -65,6 +66,7 @@ const expiryStrategyCancel = 'expiryStrategy-cancel';
const triggerTypePrice = 'triggerType-price'; const triggerTypePrice = 'triggerType-price';
const triggerTypeTrailingPercentOffset = 'triggerType-trailingPercentOffset'; const triggerTypeTrailingPercentOffset = 'triggerType-trailingPercentOffset';
const oco = 'oco';
const expire = 'expire'; const expire = 'expire';
const datePicker = 'date-picker-field'; const datePicker = 'date-picker-field';
const timeInForce = 'order-tif'; const timeInForce = 'order-tif';
@ -76,6 +78,8 @@ const triggerPriceWarningMessage = 'stop-order-warning-message-trigger-price';
const triggerTrailingPercentOffsetErrorMessage = const triggerTrailingPercentOffsetErrorMessage =
'stop-order-error-message-trigger-trailing-percent-offset'; 'stop-order-error-message-trigger-trailing-percent-offset';
const ocoPostfix = (id: string, postfix = true) => (postfix ? `${id}-oco` : id);
describe('StopOrder', () => { describe('StopOrder', () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
@ -107,6 +111,7 @@ describe('StopOrder', () => {
'checked' 'checked'
); );
expect(screen.getByTestId(expire).dataset.state).toEqual('unchecked'); expect(screen.getByTestId(expire).dataset.state).toEqual('unchecked');
expect(screen.getByTestId(oco).dataset.state).toEqual('unchecked');
await userEvent.click(screen.getByTestId(expire)); await userEvent.click(screen.getByTestId(expire));
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId(expiryStrategySubmit).dataset.state).toEqual( expect(screen.getByTestId(expiryStrategySubmit).dataset.state).toEqual(
@ -115,6 +120,32 @@ describe('StopOrder', () => {
}); });
}); });
it('calculate notional for market limit', async () => {
render(generateJsx());
await userEvent.type(screen.getByTestId(sizeInput), '10');
await userEvent.type(screen.getByTestId(priceInput), '10');
expect(screen.getByTestId('deal-ticket-fee-notional')).toHaveTextContent(
'Notional100.00 BTC'
);
});
it('calculates notional for limit order', async () => {
render(generateJsx());
await userEvent.click(screen.getByTestId(orderTypeTrigger));
await userEvent.click(screen.getByTestId(orderTypeMarket));
await userEvent.type(screen.getByTestId(sizeInput), '10');
// price trigger is selected but it's empty, calculate base on size and marketPrice prop
expect(screen.getByTestId('deal-ticket-fee-notional')).toHaveTextContent(
'Notional20.00 BTC'
);
await userEvent.type(screen.getByTestId(triggerPriceInput), '3');
// calculate base on size and price trigger
expect(screen.getByTestId('deal-ticket-fee-notional')).toHaveTextContent(
'Notional30.00 BTC'
);
});
it('should use local storage state for initial values', async () => { it('should use local storage state for initial values', async () => {
const values: Partial<StopOrderFormValues> = { const values: Partial<StopOrderFormValues> = {
type: Schema.OrderType.TYPE_LIMIT, type: Schema.OrderType.TYPE_LIMIT,
@ -125,6 +156,11 @@ describe('StopOrder', () => {
expire: true, expire: true,
expiryStrategy: Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_CANCELS, expiryStrategy: Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_CANCELS,
expiresAt: '2023-07-27T16:43:27.000', expiresAt: '2023-07-27T16:43:27.000',
oco: true,
ocoType: Schema.OrderType.TYPE_LIMIT,
ocoSize: '0.2',
ocoPrice: '300.23',
ocoTimeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_FOK,
}; };
useDealTicketFormValues.setState({ useDealTicketFormValues.setState({
@ -143,10 +179,22 @@ describe('StopOrder', () => {
expect(screen.getByTestId(sizeInput)).toHaveDisplayValue( expect(screen.getByTestId(sizeInput)).toHaveDisplayValue(
values.size as string values.size as string
); );
expect(screen.getByTestId('order-tif')).toHaveValue(values.timeInForce); expect(screen.getByTestId(timeInForce)).toHaveValue(values.timeInForce);
expect(screen.getByTestId(priceInput)).toHaveDisplayValue( expect(screen.getByTestId(priceInput)).toHaveDisplayValue(
values.price as string values.price as string
); );
expect(screen.getByTestId(ocoPostfix(sizeInput))).toHaveDisplayValue(
values.ocoSize as string
);
expect(screen.getByTestId(ocoPostfix(timeInForce))).toHaveValue(
values.ocoTimeInForce
);
expect(screen.getByTestId(ocoPostfix(priceInput))).toHaveDisplayValue(
values.ocoPrice as string
);
expect(screen.getByTestId('ocoTypeLimit').dataset.state).toEqual('checked');
expect(screen.getByTestId(expire).dataset.state).toEqual('checked'); expect(screen.getByTestId(expire).dataset.state).toEqual('checked');
expect(screen.getByTestId(expiryStrategyCancel).dataset.state).toEqual( expect(screen.getByTestId(expiryStrategyCancel).dataset.state).toEqual(
'checked' 'checked'
@ -154,6 +202,9 @@ describe('StopOrder', () => {
expect(screen.getByTestId(datePicker)).toHaveDisplayValue( expect(screen.getByTestId(datePicker)).toHaveDisplayValue(
values.expiresAt as string values.expiresAt as string
); );
await userEvent.click(screen.getByTestId(orderTypeMarket));
expect(screen.getByTestId(oco).dataset.state).toEqual('unchecked');
}); });
it('does not submit if no wallet connected', async () => { it('does not submit if no wallet connected', async () => {
@ -174,145 +225,239 @@ describe('StopOrder', () => {
expect(submit).toBeCalled(); expect(submit).toBeCalled();
}); });
it('validates size field', async () => { it.each([
{ fieldName: 'size', ocoValue: false },
{ fieldName: 'ocoSize', ocoValue: true },
])('validates $fieldName field', async ({ ocoValue }) => {
render(generateJsx()); render(generateJsx());
if (ocoValue) {
await userEvent.click(screen.getByTestId(oco));
}
await userEvent.click(screen.getByTestId(submitButton)); await userEvent.click(screen.getByTestId(submitButton));
const getByTestId = (id: string) =>
screen.getByTestId(ocoPostfix(id, ocoValue));
const queryByTestId = (id: string) =>
screen.queryByTestId(ocoPostfix(id, ocoValue));
// default value should be invalid // default value should be invalid
expect(screen.getByTestId(sizeErrorMessage)).toBeInTheDocument(); expect(getByTestId(sizeErrorMessage)).toBeInTheDocument();
// to small value should be invalid // to small value should be invalid
await userEvent.type(screen.getByTestId(sizeInput), '0.01'); await userEvent.type(getByTestId(sizeInput), '0.01');
expect(screen.getByTestId(sizeErrorMessage)).toBeInTheDocument(); expect(getByTestId(sizeErrorMessage)).toBeInTheDocument();
// clear and fill using valid value // clear and fill using valid value
await userEvent.clear(screen.getByTestId(sizeInput)); await userEvent.clear(getByTestId(sizeInput));
await userEvent.type(screen.getByTestId(sizeInput), '0.1'); await userEvent.type(getByTestId(sizeInput), '0.1');
expect(screen.queryByTestId(sizeErrorMessage)).toBeNull(); expect(queryByTestId(sizeErrorMessage)).toBeNull();
}); });
it('validates price field', async () => { it.each([
{ fieldName: 'price', ocoValue: false },
{ fieldName: 'ocoPrice', ocoValue: true },
])('validates $fieldName field', async ({ ocoValue }) => {
render(generateJsx()); render(generateJsx());
if (ocoValue) {
await userEvent.click(screen.getByTestId(oco));
}
await userEvent.click(screen.getByTestId(submitButton)); await userEvent.click(screen.getByTestId(submitButton));
// price error message should not show if size has error
// expect(screen.queryByTestId(priceErrorMessage)).toBeNull(); const getByTestId = (id: string) =>
// await userEvent.type(screen.getByTestId(sizeInput), '0.1'); screen.getByTestId(ocoPostfix(id, ocoValue));
expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument(); const queryByTestId = (id: string) =>
await userEvent.type(screen.getByTestId(priceInput), '0.001'); screen.queryByTestId(ocoPostfix(id, ocoValue));
expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument();
expect(getByTestId(priceErrorMessage)).toBeInTheDocument();
await userEvent.type(getByTestId(priceInput), '0.001');
expect(getByTestId(priceErrorMessage)).toBeInTheDocument();
// switch to market order type error should disappear // switch to market order type error should disappear
await userEvent.click(screen.getByTestId(orderTypeTrigger)); await userEvent.click(screen.getByTestId(orderTypeTrigger));
await userEvent.click(screen.getByTestId(orderTypeMarket)); await userEvent.click(screen.getByTestId(orderTypeMarket));
await userEvent.click(screen.getByTestId(submitButton)); await userEvent.click(screen.getByTestId(submitButton));
expect(screen.queryByTestId(priceErrorMessage)).toBeNull(); expect(queryByTestId(priceErrorMessage)).toBeNull();
// switch back to limit type // switch back to limit type
await userEvent.click(screen.getByTestId(orderTypeTrigger)); await userEvent.click(screen.getByTestId(orderTypeTrigger));
await userEvent.click(screen.getByTestId(orderTypeLimit)); await userEvent.click(screen.getByTestId(orderTypeLimit));
await userEvent.click(screen.getByTestId(submitButton)); await userEvent.click(screen.getByTestId(submitButton));
expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument(); expect(getByTestId(priceErrorMessage)).toBeInTheDocument();
// to small value should be invalid // to small value should be invalid
await userEvent.type(screen.getByTestId(priceInput), '0.001'); await userEvent.type(getByTestId(priceInput), '0.001');
expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument(); expect(getByTestId(priceErrorMessage)).toBeInTheDocument();
// clear and fill using valid value // clear and fill using valid value
await userEvent.clear(screen.getByTestId(priceInput)); await userEvent.clear(getByTestId(priceInput));
await userEvent.type(screen.getByTestId(priceInput), '0.01'); await userEvent.type(getByTestId(priceInput), '0.01');
expect(screen.queryByTestId(priceErrorMessage)).toBeNull(); expect(queryByTestId(priceErrorMessage)).toBeNull();
}); });
it('validates trigger price field', async () => { it.each([
{ fieldName: 'triggerPrice', ocoValue: false },
{ fieldName: 'ocoTriggerPrice', ocoValue: true },
])('validates $fieldName field', async ({ ocoValue }) => {
render(generateJsx()); render(generateJsx());
if (ocoValue) {
await userEvent.click(screen.getByTestId(oco));
await userEvent.click(screen.getByTestId(triggerDirectionFallsBelow));
}
await userEvent.click(screen.getByTestId(submitButton)); await userEvent.click(screen.getByTestId(submitButton));
expect(screen.getByTestId(triggerPriceErrorMessage)).toBeInTheDocument(); const getByTestId = (id: string) =>
screen.getByTestId(ocoPostfix(id, ocoValue));
const queryByTestId = (id: string) =>
screen.queryByTestId(ocoPostfix(id, ocoValue));
expect(getByTestId(triggerPriceErrorMessage)).toBeInTheDocument();
// switch to trailing percentage offset trigger type // switch to trailing percentage offset trigger type
await userEvent.click(screen.getByTestId(triggerTypeTrailingPercentOffset)); await userEvent.click(getByTestId(triggerTypeTrailingPercentOffset));
expect(screen.queryByTestId(triggerPriceErrorMessage)).toBeNull(); expect(queryByTestId(triggerPriceErrorMessage)).toBeNull();
// switch back to price trigger type // switch back to price trigger type
await userEvent.click(screen.getByTestId(triggerTypePrice)); await userEvent.click(getByTestId(triggerTypePrice));
expect(screen.getByTestId(triggerPriceErrorMessage)).toBeInTheDocument(); expect(getByTestId(triggerPriceErrorMessage)).toBeInTheDocument();
// to small value should be invalid // to small value should be invalid
await userEvent.type(screen.getByTestId(triggerPriceInput), '0.001'); await userEvent.type(getByTestId(triggerPriceInput), '0.001');
expect(screen.getByTestId(triggerPriceErrorMessage)).toBeInTheDocument(); expect(getByTestId(triggerPriceErrorMessage)).toBeInTheDocument();
// clear and fill using value causing immediate trigger // clear and fill using value causing immediate trigger
await userEvent.clear(screen.getByTestId(triggerPriceInput)); await userEvent.clear(getByTestId(triggerPriceInput));
await userEvent.type(screen.getByTestId(triggerPriceInput), '0.01'); await userEvent.type(getByTestId(triggerPriceInput), '0.01');
expect(screen.queryByTestId(triggerPriceErrorMessage)).toBeNull(); expect(queryByTestId(triggerPriceErrorMessage)).toBeNull();
expect( expect(queryByTestId(triggerPriceWarningMessage)).toBeInTheDocument();
screen.queryByTestId(triggerPriceWarningMessage)
).toBeInTheDocument();
// change to correct value // change to correct value
await userEvent.type(screen.getByTestId(triggerPriceInput), '2'); await userEvent.type(getByTestId(triggerPriceInput), '2');
expect(screen.queryByTestId(triggerPriceWarningMessage)).toBeNull(); expect(queryByTestId(triggerPriceWarningMessage)).toBeNull();
}); });
it('validates trigger trailing percentage offset field', async () => { it.each([
{ fieldName: 'trailingPercentageOffset', ocoValue: false },
{ fieldName: 'ocoTrailingPercentageOffset', ocoValue: true },
])('validates $fieldName field', async ({ ocoValue }) => {
render(generateJsx()); render(generateJsx());
if (ocoValue) {
await userEvent.click(screen.getByTestId(oco));
}
await userEvent.click(screen.getByTestId(submitButton));
const getByTestId = (id: string) =>
screen.getByTestId(ocoPostfix(id, ocoValue));
const queryByTestId = (id: string) =>
screen.queryByTestId(ocoPostfix(id, ocoValue));
// should not show error with default form values // should not show error with default form values
await userEvent.click(screen.getByTestId(submitButton)); expect(queryByTestId(triggerTrailingPercentOffsetErrorMessage)).toBeNull();
expect(
screen.queryByTestId(triggerTrailingPercentOffsetErrorMessage)
).toBeNull();
// switch to trailing percentage offset trigger type // switch to trailing percentage offset trigger type
await userEvent.click(screen.getByTestId(triggerTypeTrailingPercentOffset)); await userEvent.click(getByTestId(triggerTypeTrailingPercentOffset));
expect( expect(
screen.getByTestId(triggerTrailingPercentOffsetErrorMessage) getByTestId(triggerTrailingPercentOffsetErrorMessage)
).toBeInTheDocument(); ).toBeInTheDocument();
// to small value should be invalid // to small value should be invalid
await userEvent.type( await userEvent.type(
screen.getByTestId(triggerTrailingPercentOffsetInput), getByTestId(triggerTrailingPercentOffsetInput),
'0.09' '0.09'
); );
expect( expect(
screen.getByTestId(triggerTrailingPercentOffsetErrorMessage) getByTestId(triggerTrailingPercentOffsetErrorMessage)
).toBeInTheDocument(); ).toBeInTheDocument();
// clear and fill using valid value // clear and fill using valid value
await userEvent.clear( await userEvent.clear(getByTestId(triggerTrailingPercentOffsetInput));
screen.getByTestId(triggerTrailingPercentOffsetInput) await userEvent.type(getByTestId(triggerTrailingPercentOffsetInput), '0.1');
); expect(queryByTestId(triggerTrailingPercentOffsetErrorMessage)).toBeNull();
await userEvent.type(
screen.getByTestId(triggerTrailingPercentOffsetInput),
'0.1'
);
expect(
screen.queryByTestId(triggerTrailingPercentOffsetErrorMessage)
).toBeNull();
// to big value should be invalid // to big value should be invalid
await userEvent.clear( await userEvent.clear(getByTestId(triggerTrailingPercentOffsetInput));
screen.getByTestId(triggerTrailingPercentOffsetInput)
);
await userEvent.type( await userEvent.type(
screen.getByTestId(triggerTrailingPercentOffsetInput), getByTestId(triggerTrailingPercentOffsetInput),
'99.91' '99.91'
); );
expect( expect(
screen.getByTestId(triggerTrailingPercentOffsetErrorMessage) getByTestId(triggerTrailingPercentOffsetErrorMessage)
).toBeInTheDocument(); ).toBeInTheDocument();
// clear and fill using valid value // clear and fill using valid value
await userEvent.clear( await userEvent.clear(getByTestId(triggerTrailingPercentOffsetInput));
screen.getByTestId(triggerTrailingPercentOffsetInput)
);
await userEvent.type( await userEvent.type(
screen.getByTestId(triggerTrailingPercentOffsetInput), getByTestId(triggerTrailingPercentOffsetInput),
'99.9' '99.9'
); );
expect(queryByTestId(triggerTrailingPercentOffsetErrorMessage)).toBeNull();
});
it('sync oco trigger', async () => {
render(generateJsx());
await userEvent.click(screen.getByTestId(oco));
expect( expect(
screen.queryByTestId(triggerTrailingPercentOffsetErrorMessage) screen.getByTestId(triggerDirectionRisesAbove).dataset.state
).toBeNull(); ).toEqual('checked');
expect(
screen.getByTestId(ocoPostfix(triggerDirectionFallsBelow)).dataset.state
).toEqual('checked');
await userEvent.click(screen.getByTestId(triggerDirectionFallsBelow));
expect(
screen.getByTestId(triggerDirectionRisesAbove).dataset.state
).toEqual('unchecked');
expect(
screen.getByTestId(ocoPostfix(triggerDirectionFallsBelow)).dataset.state
).toEqual('unchecked');
await userEvent.click(
screen.getByTestId(ocoPostfix(triggerDirectionFallsBelow))
);
expect(
screen.getByTestId(triggerDirectionRisesAbove).dataset.state
).toEqual('checked');
expect(
screen.getByTestId(ocoPostfix(triggerDirectionFallsBelow)).dataset.state
).toEqual('checked');
});
it('disables submit expiry strategy when OCO selected', async () => {
render(generateJsx());
await userEvent.click(screen.getByTestId(expire));
await userEvent.click(screen.getByTestId(expiryStrategySubmit));
await userEvent.click(screen.getByTestId(oco));
expect(screen.getByTestId(expiryStrategySubmit).dataset.state).toEqual(
'unchecked'
);
expect(screen.getByTestId(expiryStrategySubmit)).toBeDisabled();
await userEvent.click(screen.getByTestId(oco));
await userEvent.click(screen.getByTestId(expiryStrategySubmit));
expect(screen.getByTestId(expiryStrategySubmit).dataset.state).toEqual(
'checked'
);
expect(screen.getByTestId(expiryStrategySubmit)).not.toBeDisabled();
});
it('sets expiry time/date to now if expiry is changed to checked', async () => {
const now = Math.round(Date.now() / 1000) * 1000;
render(generateJsx());
jest.spyOn(global.Date, 'now').mockImplementationOnce(() => now);
await userEvent.click(screen.getByTestId(expire));
// expiry time/date was empty it should be set to now
expect(
new Date(screen.getByTestId<HTMLInputElement>(datePicker).value).getTime()
).toEqual(now);
// set to the value in the past (now - 1s)
fireEvent.change(screen.getByTestId<HTMLInputElement>(datePicker), {
target: { value: formatForInput(new Date(now - 1000)) },
});
expect(
new Date(
screen.getByTestId<HTMLInputElement>(datePicker).value
).getTime() + 1000
).toEqual(now);
// switch expiry off and on
await userEvent.click(screen.getByTestId(expire));
await userEvent.click(screen.getByTestId(expire));
// expiry time/date was in the past it should be set to now
expect(
new Date(screen.getByTestId<HTMLInputElement>(datePicker).value).getTime()
).toEqual(now);
}); });
}); });

View File

@ -1,8 +1,12 @@
import { useRef, useCallback, useEffect } from 'react'; import { useRef, useCallback, useEffect } from 'react';
import { useVegaWallet } from '@vegaprotocol/wallet'; import { useVegaWallet } from '@vegaprotocol/wallet';
import type { StopOrdersSubmission } from '@vegaprotocol/wallet'; import type {
OrderSubmissionBody,
StopOrdersSubmission,
} from '@vegaprotocol/wallet';
import { import {
formatForInput, formatForInput,
formatValue,
removeDecimal, removeDecimal,
toDecimal, toDecimal,
validateAmount, validateAmount,
@ -19,6 +23,9 @@ import {
TradingInputError as InputError, TradingInputError as InputError,
TradingSelect as Select, TradingSelect as Select,
Tooltip, Tooltip,
TradingButton as Button,
Pill,
Intent,
} from '@vegaprotocol/ui-toolkit'; } from '@vegaprotocol/ui-toolkit';
import { getDerivedPrice } from '@vegaprotocol/markets'; import { getDerivedPrice } from '@vegaprotocol/markets';
import type { Market } from '@vegaprotocol/markets'; import type { Market } from '@vegaprotocol/markets';
@ -31,6 +38,7 @@ import {
REDUCE_ONLY_TOOLTIP, REDUCE_ONLY_TOOLTIP,
stopSubmit, stopSubmit,
getNotionalSize, getNotionalSize,
getAssetUnit,
} from './deal-ticket'; } from './deal-ticket';
import { TypeToggle } from './type-selector'; import { TypeToggle } from './type-selector';
import { import {
@ -41,9 +49,10 @@ import {
} from '../../hooks/use-form-values'; } from '../../hooks/use-form-values';
import type { StopOrderFormValues } from '../../hooks/use-form-values'; import type { StopOrderFormValues } from '../../hooks/use-form-values';
import { mapFormValuesToStopOrdersSubmission } from '../../utils/map-form-values-to-submission'; import { mapFormValuesToStopOrdersSubmission } from '../../utils/map-form-values-to-submission';
import { DealTicketButton } from './deal-ticket-button';
import { DealTicketFeeDetails } from './deal-ticket-fee-details'; import { DealTicketFeeDetails } from './deal-ticket-fee-details';
import { validateExpiration } from '../../utils'; import { validateExpiration } from '../../utils';
import { NOTIONAL_SIZE_TOOLTIP_TEXT } from '../../constants';
import { KeyValue } from './key-value';
export interface StopOrderProps { export interface StopOrderProps {
market: Market; market: Market;
@ -78,7 +87,7 @@ const Trigger = ({
control, control,
watch, watch,
priceStep, priceStep,
assetSymbol, quoteName,
oco, oco,
marketPrice, marketPrice,
decimalPlaces, decimalPlaces,
@ -86,7 +95,7 @@ const Trigger = ({
control: Control<StopOrderFormValues>; control: Control<StopOrderFormValues>;
watch: UseFormWatch<StopOrderFormValues>; watch: UseFormWatch<StopOrderFormValues>;
priceStep: string; priceStep: string;
assetSymbol: string; quoteName: string;
oco?: boolean; oco?: boolean;
marketPrice?: string | null; marketPrice?: string | null;
decimalPlaces: number; decimalPlaces: number;
@ -181,7 +190,7 @@ const Trigger = ({
data-testid={`triggerPrice${oco ? '-oco' : ''}`} data-testid={`triggerPrice${oco ? '-oco' : ''}`}
type="number" type="number"
step={priceStep} step={priceStep}
appendElement={assetSymbol} appendElement={<Pill size="xs">{quoteName}</Pill>}
value={value || ''} value={value || ''}
hasError={!!fieldState.error} hasError={!!fieldState.error}
{...props} {...props}
@ -249,7 +258,7 @@ const Trigger = ({
<Input <Input
type="number" type="number"
step={trailingPercentOffsetStep} step={trailingPercentOffsetStep}
appendElement="%" appendElement={<Pill size="xs">%</Pill>}
data-testid={`triggerTrailingPercentOffset${ data-testid={`triggerTrailingPercentOffset${
oco ? '-oco' : '' oco ? '-oco' : ''
}`} }`}
@ -311,10 +320,14 @@ const Size = ({
control, control,
sizeStep, sizeStep,
oco, oco,
isLimitType,
assetUnit,
}: { }: {
control: Control<StopOrderFormValues>; control: Control<StopOrderFormValues>;
sizeStep: string; sizeStep: string;
oco?: boolean; oco?: boolean;
isLimitType: boolean;
assetUnit?: string;
}) => { }) => {
return ( return (
<Controller <Controller
@ -332,7 +345,7 @@ const Size = ({
const { value, ...props } = field; const { value, ...props } = field;
const id = `order-size${oco ? '-oco' : ''}`; const id = `order-size${oco ? '-oco' : ''}`;
return ( return (
<div className="mb-4"> <div className={isLimitType ? 'mb-4' : 'mb-2'}>
<FormGroup labelFor={id} label={t(`Size`)} compact> <FormGroup labelFor={id} label={t(`Size`)} compact>
<Input <Input
id={id} id={id}
@ -341,6 +354,7 @@ const Size = ({
step={sizeStep} step={sizeStep}
min={sizeStep} min={sizeStep}
onWheel={(e) => e.currentTarget.blur()} onWheel={(e) => e.currentTarget.blur()}
appendElement={assetUnit && <Pill size="xs">{assetUnit}</Pill>}
data-testid={id} data-testid={id}
value={value || ''} value={value || ''}
hasError={!!fieldState.error} hasError={!!fieldState.error}
@ -394,12 +408,8 @@ const Price = ({
const { value, ...props } = field; const { value, ...props } = field;
const id = `order-price${oco ? '-oco' : ''}`; const id = `order-price${oco ? '-oco' : ''}`;
return ( return (
<div className="mb-4"> <div className="mb-2">
<FormGroup <FormGroup labelFor={id} label={t('Price')} compact={true}>
labelFor={id}
label={t(`Price (${quoteName})`)}
compact={true}
>
<Input <Input
id={id} id={id}
className="w-full" className="w-full"
@ -409,6 +419,7 @@ const Price = ({
onWheel={(e) => e.currentTarget.blur()} onWheel={(e) => e.currentTarget.blur()}
value={value || ''} value={value || ''}
hasError={!!fieldState.error} hasError={!!fieldState.error}
appendElement={<Pill size="xs">{quoteName}</Pill>}
{...props} {...props}
/> />
</FormGroup> </FormGroup>
@ -434,17 +445,17 @@ const TimeInForce = ({
oco?: boolean; oco?: boolean;
}) => ( }) => (
<Controller <Controller
name="timeInForce" name={oco ? 'ocoTimeInForce' : 'timeInForce'}
control={control} control={control}
render={({ field, fieldState }) => { render={({ field, fieldState }) => {
const id = `select-time-in-force${oco ? '-oco' : ''}`; const id = `order-tif${oco ? '-oco' : ''}`;
return ( return (
<div className="mb-2"> <div className="mb-2">
<FormGroup label={t('Time in force')} labelFor={id} compact={true}> <FormGroup label={t('Time in force')} labelFor={id} compact={true}>
<Select <Select
id={id} id={id}
className="w-full" className="w-full"
data-testid="order-tif" data-testid={id}
hasError={!!fieldState.error} hasError={!!fieldState.error}
{...field} {...field}
> >
@ -486,6 +497,255 @@ const ReduceOnly = () => (
/> />
); );
const NotionalAndFees = ({
market,
marketPrice,
side,
size,
price,
timeInForce,
triggerPrice,
triggerType,
type,
}: Pick<
OrderSubmissionBody['orderSubmission'],
'side' | 'size' | 'timeInForce' | 'type' | 'price'
> &
Pick<StopOrderProps, 'market' | 'marketPrice'> &
Pick<StopOrderFormValues, 'triggerType' | 'triggerPrice'>) => {
const { quoteName, settlementAsset: asset } =
market.tradableInstrument.instrument.product;
const isPriceTrigger = triggerType === 'price';
const derivedPrice = getDerivedPrice(
{
type,
price,
},
type === Schema.OrderType.TYPE_MARKET && isPriceTrigger && triggerPrice
? removeDecimal(triggerPrice, market.decimalPlaces)
: marketPrice || '0'
);
const notionalSize = getNotionalSize(
derivedPrice,
size,
market.decimalPlaces,
market.positionDecimalPlaces
);
return (
<div className="mb-4">
<KeyValue
label={t('Notional')}
value={formatValue(notionalSize, market.decimalPlaces)}
formattedValue={formatValue(notionalSize, market.decimalPlaces)}
symbol={quoteName}
labelDescription={NOTIONAL_SIZE_TOOLTIP_TEXT(quoteName)}
/>
<DealTicketFeeDetails
order={{
marketId: market.id,
price: derivedPrice,
side,
size,
timeInForce,
type,
}}
assetSymbol={asset.symbol}
market={market}
/>
</div>
);
};
const formatSizeAtPrice = ({
assetUnit,
decimalPlaces,
positionDecimalPlaces,
price,
quoteName,
side,
size,
type,
}: Pick<StopOrderFormValues, 'price' | 'side' | 'size' | 'type'> & {
assetUnit?: string;
decimalPlaces: number;
positionDecimalPlaces: number;
quoteName: string;
}) =>
`${formatValue(
removeDecimal(size, positionDecimalPlaces),
positionDecimalPlaces
)} ${assetUnit} @ ${
type === Schema.OrderType.TYPE_MARKET
? 'market'
: `${formatValue(
removeDecimal(price || '0', decimalPlaces),
decimalPlaces
)} ${quoteName}`
}`;
const formatTrigger = ({
decimalPlaces,
triggerDirection,
triggerPrice,
triggerTrailingPercentOffset,
triggerType,
quoteName,
}: Pick<
StopOrderFormValues,
| 'triggerDirection'
| 'triggerType'
| 'triggerPrice'
| 'triggerTrailingPercentOffset'
> & {
decimalPlaces: number;
quoteName: string;
}) =>
`${
triggerDirection ===
Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_RISES_ABOVE
? t('above')
: t('below')
} ${
triggerType === 'price'
? `${formatValue(
removeDecimal(triggerPrice || '', decimalPlaces),
decimalPlaces
)} ${quoteName}`
: `${(Number(triggerTrailingPercentOffset) || 0).toFixed(1)}% ${t(
'trailing'
)}`
}`;
const SubmitButton = ({
assetUnit,
market,
oco,
ocoPrice,
ocoSize,
ocoTriggerPrice,
ocoTriggerTrailingPercentOffset,
ocoTriggerType,
ocoType,
price,
side,
size,
triggerDirection,
triggerPrice,
triggerTrailingPercentOffset,
triggerType,
type,
}: Pick<
StopOrderFormValues,
| 'oco'
| 'ocoPrice'
| 'ocoSize'
| 'ocoTriggerPrice'
| 'ocoTriggerTrailingPercentOffset'
| 'ocoTriggerType'
| 'ocoType'
| 'price'
| 'side'
| 'size'
| 'triggerDirection'
| 'triggerPrice'
| 'triggerTrailingPercentOffset'
| 'triggerType'
| 'type'
> &
Pick<StopOrderProps, 'market'> & { assetUnit?: string }) => {
const { quoteName } = market.tradableInstrument.instrument.product;
const risesAbove =
triggerDirection ===
Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_RISES_ABOVE;
const subLabel = oco ? (
<>
{formatSizeAtPrice({
assetUnit,
decimalPlaces: market.decimalPlaces,
positionDecimalPlaces: market.positionDecimalPlaces,
price: risesAbove ? price : ocoPrice,
quoteName,
side,
size: risesAbove ? size : ocoSize,
type,
})}{' '}
{formatTrigger({
decimalPlaces: market.decimalPlaces,
quoteName,
triggerDirection:
Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_RISES_ABOVE,
triggerPrice: risesAbove ? triggerPrice : ocoTriggerPrice,
triggerTrailingPercentOffset: risesAbove
? triggerTrailingPercentOffset
: ocoTriggerTrailingPercentOffset,
triggerType: risesAbove ? triggerType : ocoTriggerType,
})}
<br />
{formatSizeAtPrice({
assetUnit,
decimalPlaces: market.decimalPlaces,
positionDecimalPlaces: market.positionDecimalPlaces,
price: !risesAbove ? price : ocoPrice,
quoteName,
side,
size: !risesAbove ? size : ocoSize,
type: ocoType,
})}{' '}
{formatTrigger({
decimalPlaces: market.decimalPlaces,
quoteName,
triggerDirection:
Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_FALLS_BELOW,
triggerPrice: !risesAbove ? triggerPrice : ocoTriggerPrice,
triggerTrailingPercentOffset: !risesAbove
? triggerTrailingPercentOffset
: ocoTriggerTrailingPercentOffset,
triggerType: !risesAbove ? triggerType : ocoTriggerType,
})}
</>
) : (
<>
{formatSizeAtPrice({
assetUnit,
decimalPlaces: market.decimalPlaces,
positionDecimalPlaces: market.positionDecimalPlaces,
price,
quoteName,
side,
size,
type,
})}
<br />
{t('Trigger')}{' '}
{formatTrigger({
decimalPlaces: market.decimalPlaces,
quoteName,
triggerDirection,
triggerPrice,
triggerTrailingPercentOffset,
triggerType,
})}
</>
);
return (
<Button
intent={side === Schema.Side.SIDE_BUY ? Intent.Success : Intent.Danger}
data-testid="place-order"
type="submit"
className="w-full"
subLabel={subLabel}
>
{t(
oco
? 'Place OCO stop order'
: type === Schema.OrderType.TYPE_MARKET
? 'Place market stop order'
: 'Place limit stop order'
)}
</Button>
);
};
export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => { export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
const { pubKey, isReadOnly } = useVegaWallet(); const { pubKey, isReadOnly } = useVegaWallet();
const setType = useDealTicketFormValues((state) => state.setType); const setType = useDealTicketFormValues((state) => state.setType);
@ -521,50 +781,40 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
}, },
[market.id, market.decimalPlaces, market.positionDecimalPlaces, submit] [market.id, market.decimalPlaces, market.positionDecimalPlaces, submit]
); );
const side = watch('side');
const expire = watch('expire'); const expire = watch('expire');
const triggerType = watch('triggerType');
const triggerPrice = watch('triggerPrice');
const timeInForce = watch('timeInForce');
const rawPrice = watch('price');
const rawSize = watch('size');
const oco = watch('oco');
const expiresAt = watch('expiresAt'); const expiresAt = watch('expiresAt');
const oco = watch('oco');
const ocoPrice = watch('ocoPrice');
const ocoSize = watch('ocoSize');
const ocoTimeInForce = watch('ocoTimeInForce');
const ocoTriggerPrice = watch('ocoTriggerPrice');
const ocoTriggerTrailingPercentOffset = watch(
'ocoTriggerTrailingPercentOffset'
);
const ocoTriggerType = watch('ocoTriggerType');
const ocoType = watch('ocoType');
const price = watch('price');
const side = watch('side');
const size = watch('size');
const timeInForce = watch('timeInForce');
const triggerDirection = watch('triggerDirection');
const triggerPrice = watch('triggerPrice');
const triggerTrailingPercentOffset = watch('triggerTrailingPercentOffset');
const triggerType = watch('triggerType');
useEffect(() => { useEffect(() => {
const size = storedFormValues?.[dealTicketType]?.size; const storedSize = storedFormValues?.[dealTicketType]?.size;
if (size && rawSize !== size) { if (storedSize && size !== storedSize) {
setValue('size', size); setValue('size', storedSize);
} }
}, [storedFormValues, dealTicketType, rawSize, setValue]); }, [storedFormValues, dealTicketType, size, setValue]);
useEffect(() => { useEffect(() => {
const price = storedFormValues?.[dealTicketType]?.price; const storedPrice = storedFormValues?.[dealTicketType]?.price;
if (price && rawPrice !== price) { if (storedPrice && price !== storedPrice) {
setValue('price', price); setValue('price', storedPrice);
} }
}, [storedFormValues, dealTicketType, rawPrice, setValue]); }, [storedFormValues, dealTicketType, price, setValue]);
const isPriceTrigger = triggerType === 'price';
const size = removeDecimal(rawSize, market.positionDecimalPlaces);
const price =
marketPrice &&
getDerivedPrice(
{
type,
price: rawPrice && removeDecimal(rawPrice, market.decimalPlaces),
},
type === Schema.OrderType.TYPE_MARKET && isPriceTrigger && triggerPrice
? removeDecimal(triggerPrice, market.decimalPlaces)
: marketPrice
);
const notionalSize = getNotionalSize(
price,
size,
market.decimalPlaces,
market.positionDecimalPlaces
);
useEffect(() => { useEffect(() => {
const subscription = watch((value, { name, type }) => { const subscription = watch((value, { name, type }) => {
@ -573,8 +823,10 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
return () => subscription.unsubscribe(); return () => subscription.unsubscribe();
}, [watch, market.id, updateStoredFormValues]); }, [watch, market.id, updateStoredFormValues]);
const { quoteName, settlementAsset: asset } = const { quoteName } = market.tradableInstrument.instrument.product;
market.tradableInstrument.instrument.product; const assetUnit = getAssetUnit(
market.tradableInstrument.instrument.metadata.tags
);
const sizeStep = toDecimal(market?.positionDecimalPlaces); const sizeStep = toDecimal(market?.positionDecimalPlaces);
const priceStep = toDecimal(market?.decimalPlaces); const priceStep = toDecimal(market?.decimalPlaces);
@ -584,6 +836,10 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
control, control,
}); });
const normalizedPrice = price && removeDecimal(price, market.decimalPlaces);
const normalizedSize =
size && removeDecimal(size, market.positionDecimalPlaces);
return ( return (
<form <form
onSubmit={isReadOnly || !pubKey ? stopSubmit : handleSubmit(onSubmit)} onSubmit={isReadOnly || !pubKey ? stopSubmit : handleSubmit(onSubmit)}
@ -620,18 +876,34 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
control={control} control={control}
watch={watch} watch={watch}
priceStep={priceStep} priceStep={priceStep}
assetSymbol={asset.symbol} quoteName={quoteName}
marketPrice={marketPrice} marketPrice={marketPrice}
decimalPlaces={market.decimalPlaces} decimalPlaces={market.decimalPlaces}
/> />
<hr className="mb-4 border-vega-clight-500 dark:border-vega-cdark-500" /> <hr className="mb-4 border-vega-clight-500 dark:border-vega-cdark-500" />
<Size
control={control}
sizeStep={sizeStep}
isLimitType={type === Schema.OrderType.TYPE_LIMIT}
assetUnit={assetUnit}
/>
<Price <Price
control={control} control={control}
watch={watch} watch={watch}
priceStep={priceStep} priceStep={priceStep}
quoteName={quoteName} quoteName={quoteName}
/> />
<Size control={control} sizeStep={sizeStep} /> <NotionalAndFees
market={market}
marketPrice={marketPrice}
price={normalizedPrice}
side={side}
size={normalizedSize}
timeInForce={timeInForce}
triggerPrice={triggerPrice}
triggerType={triggerType}
type={type}
/>
<TimeInForce control={control} /> <TimeInForce control={control} />
<div className="flex justify-end pb-3 gap-2"> <div className="flex justify-end pb-3 gap-2">
<ReduceOnly /> <ReduceOnly />
@ -682,12 +954,12 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
> >
<Radio <Radio
value={Schema.OrderType.TYPE_MARKET} value={Schema.OrderType.TYPE_MARKET}
id={`ocoTypeMarket`} id="ocoTypeMarket"
label={'Market'} label={'Market'}
/> />
<Radio <Radio
value={Schema.OrderType.TYPE_LIMIT} value={Schema.OrderType.TYPE_LIMIT}
id={`ocoTypeLimit`} id="ocoTypeLimit"
label={'Limit'} label={'Limit'}
/> />
</RadioGroup> </RadioGroup>
@ -699,12 +971,19 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
control={control} control={control}
watch={watch} watch={watch}
priceStep={priceStep} priceStep={priceStep}
assetSymbol={asset.symbol} quoteName={quoteName}
marketPrice={marketPrice} marketPrice={marketPrice}
decimalPlaces={market.decimalPlaces} decimalPlaces={market.decimalPlaces}
oco oco
/> />
<hr className="mb-2 border-vega-clight-500 dark:border-vega-cdark-500" /> <hr className="mb-2 border-vega-clight-500 dark:border-vega-cdark-500" />
<Size
control={control}
sizeStep={sizeStep}
assetUnit={assetUnit}
oco
isLimitType={ocoType === Schema.OrderType.TYPE_LIMIT}
/>
<Price <Price
control={control} control={control}
watch={watch} watch={watch}
@ -712,7 +991,19 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
quoteName={quoteName} quoteName={quoteName}
oco oco
/> />
<Size control={control} sizeStep={sizeStep} oco /> <NotionalAndFees
market={market}
marketPrice={marketPrice}
price={ocoPrice && removeDecimal(ocoPrice, market.decimalPlaces)}
side={side}
size={
ocoSize && removeDecimal(ocoSize, market.positionDecimalPlaces)
}
timeInForce={ocoTimeInForce}
triggerPrice={ocoTriggerPrice}
triggerType={ocoTriggerType}
type={ocoType}
/>
<TimeInForce control={control} oco /> <TimeInForce control={control} oco />
<div className="flex justify-end mb-2 gap-2"> <div className="flex justify-end mb-2 gap-2">
<ReduceOnly /> <ReduceOnly />
@ -728,11 +1019,12 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
return ( return (
<Checkbox <Checkbox
onCheckedChange={(value) => { onCheckedChange={(value) => {
const now = Date.now();
if ( if (
value && value &&
(!expiresAt || new Date(expiresAt).getTime() < Date.now()) (!expiresAt || new Date(expiresAt).getTime() < now)
) { ) {
setValue('expiresAt', formatForInput(new Date()), { setValue('expiresAt', formatForInput(new Date(now)), {
shouldValidate: true, shouldValidate: true,
}); });
} }
@ -803,19 +1095,24 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
</> </>
)} )}
<NoWalletWarning isReadOnly={isReadOnly} /> <NoWalletWarning isReadOnly={isReadOnly} />
<DealTicketButton side={side} label={t('Submit Stop Order')} /> <SubmitButton
<DealTicketFeeDetails assetUnit={assetUnit}
order={{
marketId: market.id,
price: price || undefined,
side,
size,
timeInForce,
type,
}}
notionalSize={notionalSize}
assetSymbol={asset.symbol}
market={market} market={market}
oco={oco}
ocoPrice={ocoPrice}
ocoSize={ocoSize}
ocoTriggerPrice={ocoTriggerPrice}
ocoTriggerTrailingPercentOffset={ocoTriggerTrailingPercentOffset}
ocoTriggerType={ocoTriggerType}
ocoType={ocoType}
price={price}
side={side}
size={size}
triggerDirection={triggerDirection}
triggerPrice={triggerPrice}
triggerTrailingPercentOffset={triggerTrailingPercentOffset}
triggerType={triggerType}
type={type}
/> />
</form> </form>
); );

View File

@ -1,6 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { VegaWalletContext } from '@vegaprotocol/wallet'; import { VegaWalletContext } from '@vegaprotocol/wallet';
import { act, render, screen, waitFor } from '@testing-library/react'; import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { generateMarket, generateMarketData } from '../../test-helpers'; import { generateMarket, generateMarketData } from '../../test-helpers';
import { DealTicket } from './deal-ticket'; import { DealTicket } from './deal-ticket';
@ -14,6 +20,7 @@ import {
} from '../../hooks/use-form-values'; } from '../../hooks/use-form-values';
import * as positionsTools from '@vegaprotocol/positions'; import * as positionsTools from '@vegaprotocol/positions';
import { OrdersDocument } from '@vegaprotocol/orders'; import { OrdersDocument } from '@vegaprotocol/orders';
import { formatForInput } from '@vegaprotocol/utils';
jest.mock('zustand'); jest.mock('zustand');
jest.mock('./deal-ticket-fee-details', () => ({ jest.mock('./deal-ticket-fee-details', () => ({
@ -314,7 +321,7 @@ describe('DealTicket', () => {
expect(screen.getByTestId('iceberg')).toBeChecked(); expect(screen.getByTestId('iceberg')).toBeChecked();
}); });
it('should set values for a non-persistent iceberg order and disable post only checkbox', () => { it('should set values for a non-persistent order and disable post only checkbox', () => {
const expectedOrder = { const expectedOrder = {
marketId: market.id, marketId: market.id,
type: Schema.OrderType.TYPE_LIMIT, type: Schema.OrderType.TYPE_LIMIT,
@ -357,6 +364,7 @@ describe('DealTicket', () => {
expect(screen.getByTestId('reduce-only')).not.toBeChecked(); expect(screen.getByTestId('reduce-only')).not.toBeChecked();
expect(screen.getByTestId('post-only')).not.toBeChecked(); expect(screen.getByTestId('post-only')).not.toBeChecked();
expect(screen.getByTestId('iceberg')).not.toBeChecked(); expect(screen.getByTestId('iceberg')).not.toBeChecked();
expect(screen.getByTestId('iceberg')).toBeDisabled();
}); });
// eslint-disable-next-line jest/no-disabled-tests // eslint-disable-next-line jest/no-disabled-tests
@ -473,4 +481,150 @@ describe('DealTicket', () => {
Object.keys(Schema.OrderTimeInForce).length Object.keys(Schema.OrderTimeInForce).length
); );
}); });
it('validates size field', async () => {
render(generateJsx());
const sizeErrorMessage = 'deal-ticket-error-message-size';
const sizeInput = 'order-size';
await userEvent.click(screen.getByTestId('place-order'));
// default value should be invalid
expect(screen.getByTestId(sizeErrorMessage)).toBeInTheDocument();
// to small value should be invalid
await userEvent.type(screen.getByTestId(sizeInput), '0.01');
expect(screen.getByTestId(sizeErrorMessage)).toBeInTheDocument();
// clear and fill using valid value
await userEvent.clear(screen.getByTestId(sizeInput));
await userEvent.type(screen.getByTestId(sizeInput), '0.1');
expect(screen.queryByTestId(sizeErrorMessage)).toBeNull();
});
it('validates price field', async () => {
const priceErrorMessage = 'deal-ticket-error-message-price';
const priceInput = 'order-price';
const submitButton = 'place-order';
const orderTypeMarket = 'order-type-Market';
const orderTypeLimit = 'order-type-Limit';
render(generateJsx());
await userEvent.click(screen.getByTestId(submitButton));
expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument();
await userEvent.type(screen.getByTestId(priceInput), '0.001');
expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument();
// switch to market order type error should disappear
await userEvent.click(screen.getByTestId(orderTypeMarket));
await userEvent.click(screen.getByTestId(submitButton));
expect(screen.queryByTestId(priceErrorMessage)).toBeNull();
// switch back to limit type
await userEvent.click(screen.getByTestId(orderTypeLimit));
await userEvent.click(screen.getByTestId(submitButton));
expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument();
// to small value should be invalid
await userEvent.type(screen.getByTestId(priceInput), '0.001');
expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument();
// clear and fill using valid value
await userEvent.clear(screen.getByTestId(priceInput));
await userEvent.type(screen.getByTestId(priceInput), '0.01');
expect(screen.queryByTestId(priceErrorMessage)).toBeNull();
});
it('validates iceberg field', async () => {
const peakSizeErrorMessage = 'deal-ticket-peak-error-message';
const minimumSizeErrorMessage = 'deal-ticket-minimum-error-message';
const sizeInput = 'order-size';
const peakSizeInput = 'order-peak-size';
const minimumSizeInput = 'order-minimum-size';
const submitButton = 'place-order';
render(generateJsx());
await userEvent.selectOptions(
screen.getByTestId('order-tif'),
Schema.OrderTimeInForce.TIME_IN_FORCE_GFA
);
await userEvent.click(screen.getByTestId('iceberg'));
await userEvent.click(screen.getByTestId(submitButton));
// validate empty fields
expect(screen.getByTestId(peakSizeErrorMessage)).toBeInTheDocument();
expect(screen.getByTestId(minimumSizeErrorMessage)).toBeInTheDocument();
await userEvent.type(screen.getByTestId(peakSizeInput), '0.01');
await userEvent.type(screen.getByTestId(minimumSizeInput), '0.01');
// validate value smaller than step
expect(screen.getByTestId(peakSizeErrorMessage)).toBeInTheDocument();
expect(screen.getByTestId(minimumSizeErrorMessage)).toBeInTheDocument();
await userEvent.clear(screen.getByTestId(peakSizeInput));
await userEvent.type(screen.getByTestId(peakSizeInput), '0.5');
await userEvent.clear(screen.getByTestId(minimumSizeInput));
await userEvent.type(screen.getByTestId(minimumSizeInput), '0.7');
await userEvent.clear(screen.getByTestId(sizeInput));
await userEvent.type(screen.getByTestId(sizeInput), '0.1');
// validate value higher than size
expect(screen.getByTestId(peakSizeErrorMessage)).toBeInTheDocument();
expect(screen.getByTestId(minimumSizeErrorMessage)).toBeInTheDocument();
await userEvent.clear(screen.getByTestId(sizeInput));
await userEvent.type(screen.getByTestId(sizeInput), '1');
// validate peak higher than minimum
expect(screen.queryByTestId(peakSizeErrorMessage)).toBeNull();
expect(screen.getByTestId(minimumSizeErrorMessage)).toBeInTheDocument();
await userEvent.clear(screen.getByTestId(peakSizeInput));
await userEvent.type(screen.getByTestId(peakSizeInput), '1');
await userEvent.clear(screen.getByTestId(minimumSizeInput));
await userEvent.type(screen.getByTestId(minimumSizeInput), '1');
// validate correct values
expect(screen.queryByTestId(peakSizeErrorMessage)).toBeNull();
expect(screen.queryByTestId(minimumSizeErrorMessage)).toBeNull();
});
it('sets expiry time/date to now if expiry is changed to checked', async () => {
const datePicker = 'date-picker-field';
const now = Math.round(Date.now() / 1000) * 1000;
render(generateJsx());
jest.spyOn(global.Date, 'now').mockImplementationOnce(() => now);
await userEvent.selectOptions(
screen.getByTestId('order-tif'),
Schema.OrderTimeInForce.TIME_IN_FORCE_GTT
);
// expiry time/date was empty it should be set to now
expect(
new Date(screen.getByTestId<HTMLInputElement>(datePicker).value).getTime()
).toEqual(now);
// set to the value in the past (now - 1s)
fireEvent.change(screen.getByTestId<HTMLInputElement>(datePicker), {
target: { value: formatForInput(new Date(now - 1000)) },
});
expect(
new Date(
screen.getByTestId<HTMLInputElement>(datePicker).value
).getTime() + 1000
).toEqual(now);
// switch expiry off and on
await userEvent.selectOptions(
screen.getByTestId('order-tif'),
Schema.OrderTimeInForce.TIME_IN_FORCE_GFA
);
await userEvent.selectOptions(
screen.getByTestId('order-tif'),
Schema.OrderTimeInForce.TIME_IN_FORCE_GTT
);
// expiry time/date was in the past it should be set to now
expect(
new Date(screen.getByTestId<HTMLInputElement>(datePicker).value).getTime()
).toEqual(now);
});
}); });

View File

@ -3,7 +3,6 @@ import * as Schema from '@vegaprotocol/types';
import type { FormEventHandler } from 'react'; import type { FormEventHandler } from 'react';
import { memo, useCallback, useEffect, useRef, useMemo } from 'react'; import { memo, useCallback, useEffect, useRef, useMemo } from 'react';
import { Controller, useController, useForm } from 'react-hook-form'; import { Controller, useController, useForm } from 'react-hook-form';
import { DealTicketButton } from './deal-ticket-button';
import { import {
DealTicketFeeDetails, DealTicketFeeDetails,
DealTicketMarginDetails, DealTicketMarginDetails,
@ -23,6 +22,8 @@ import {
Intent, Intent,
Notification, Notification,
Tooltip, Tooltip,
TradingButton as Button,
Pill,
} from '@vegaprotocol/ui-toolkit'; } from '@vegaprotocol/ui-toolkit';
import { import {
@ -35,6 +36,7 @@ import {
validateAmount, validateAmount,
toDecimal, toDecimal,
formatForInput, formatForInput,
formatValue,
} from '@vegaprotocol/utils'; } from '@vegaprotocol/utils';
import { activeOrdersProvider } from '@vegaprotocol/orders'; import { activeOrdersProvider } from '@vegaprotocol/orders';
import { getDerivedPrice } from '@vegaprotocol/markets'; import { getDerivedPrice } from '@vegaprotocol/markets';
@ -46,7 +48,10 @@ import {
validateType, validateType,
} from '../../utils'; } from '../../utils';
import { ZeroBalanceError } from '../deal-ticket-validation/zero-balance-error'; import { ZeroBalanceError } from '../deal-ticket-validation/zero-balance-error';
import { SummaryValidationType } from '../../constants'; import {
NOTIONAL_SIZE_TOOLTIP_TEXT,
SummaryValidationType,
} from '../../constants';
import type { import type {
Market, Market,
MarketData, MarketData,
@ -68,6 +73,7 @@ import { useDealTicketFormValues } from '../../hooks/use-form-values';
import { DealTicketSizeIceberg } from './deal-ticket-size-iceberg'; import { DealTicketSizeIceberg } from './deal-ticket-size-iceberg';
import noop from 'lodash/noop'; import noop from 'lodash/noop';
import { isNonPersistentOrder } from '../../utils/time-in-force-persistance'; import { isNonPersistentOrder } from '../../utils/time-in-force-persistance';
import { KeyValue } from './key-value';
export const REDUCE_ONLY_TOOLTIP = export const REDUCE_ONLY_TOOLTIP =
'"Reduce only" will ensure that this order will not increase the size of an open position. When the order is matched, it will only trade enough volume to bring your open volume towards 0 but never change the direction of your position. If applied to a limit order that is not instantly filled, the order will be stopped.'; '"Reduce only" will ensure that this order will not increase the size of an open position. When the order is matched, it will only trade enough volume to bring your open volume towards 0 but never change the direction of your position. If applied to a limit order that is not instantly filled, the order will be stopped.';
@ -118,6 +124,11 @@ const getDefaultValues = (
...storedValues, ...storedValues,
}); });
export const getAssetUnit = (tags?: string[] | null) =>
tags
?.find((tag) => tag.startsWith('base:') || tag.startsWith('ticker:'))
?.replace(/^[^:]*:/, '');
export const DealTicket = ({ export const DealTicket = ({
market, market,
onMarketClick, onMarketClick,
@ -257,6 +268,10 @@ export const DealTicket = ({
const assetSymbol = const assetSymbol =
market.tradableInstrument.instrument.product.settlementAsset.symbol; market.tradableInstrument.instrument.product.settlementAsset.symbol;
const assetUnit = getAssetUnit(
market.tradableInstrument.instrument.metadata.tags
);
const summaryError = useMemo(() => { const summaryError = useMemo(() => {
if (!pubKey) { if (!pubKey) {
return { return {
@ -338,6 +353,7 @@ export const DealTicket = ({
const priceStep = toDecimal(market?.decimalPlaces); const priceStep = toDecimal(market?.decimalPlaces);
const sizeStep = toDecimal(market?.positionDecimalPlaces); const sizeStep = toDecimal(market?.positionDecimalPlaces);
const quoteName = market.tradableInstrument.instrument.product.quoteName; const quoteName = market.tradableInstrument.instrument.product.quoteName;
const isLimitType = type === Schema.OrderType.TYPE_LIMIT;
return ( return (
<form <form
@ -384,18 +400,16 @@ export const DealTicket = ({
message: t('Size cannot be lower than ' + sizeStep), message: t('Size cannot be lower than ' + sizeStep),
}, },
validate: validateAmount(sizeStep, 'Size'), validate: validateAmount(sizeStep, 'Size'),
deps: ['peakSize', 'minimumVisibleSize'],
}} }}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<div className="mb-4"> <div className={isLimitType ? 'mb-4' : 'mb-2'}>
<FormGroup <FormGroup label={t('Size')} labelFor="order-size" compact>
label={t('Size')}
labelFor="input-order-size-limit"
compact
>
<Input <Input
id="input-order-size-limit" id="order-size"
className="w-full" className="w-full"
type="number" type="number"
appendElement={assetUnit && <Pill size="xs">{assetUnit}</Pill>}
step={sizeStep} step={sizeStep}
min={sizeStep} min={sizeStep}
data-testid="order-size" data-testid="order-size"
@ -411,7 +425,7 @@ export const DealTicket = ({
</div> </div>
)} )}
/> />
{type === Schema.OrderType.TYPE_LIMIT && ( {isLimitType && (
<Controller <Controller
name="price" name="price"
control={control} control={control}
@ -424,14 +438,15 @@ export const DealTicket = ({
validate: validateAmount(priceStep, 'Price'), validate: validateAmount(priceStep, 'Price'),
}} }}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<div className="mb-4"> <div className="mb-2">
<FormGroup <FormGroup
labelFor="input-price-quote" labelFor="input-price-quote"
label={t(`Price (${quoteName})`)} label={t('Price')}
compact compact
> >
<Input <Input
id="input-price-quote" id="input-price-quote"
appendElement={<Pill size="xs">{quoteName}</Pill>}
className="w-full" className="w-full"
type="number" type="number"
step={priceStep} step={priceStep}
@ -449,6 +464,22 @@ export const DealTicket = ({
)} )}
/> />
)} )}
<div className="mb-4">
<KeyValue
label={t('Notional')}
value={formatValue(notionalSize, market.decimalPlaces)}
formattedValue={formatValue(notionalSize, market.decimalPlaces)}
symbol={quoteName}
labelDescription={NOTIONAL_SIZE_TOOLTIP_TEXT(quoteName)}
/>
<DealTicketFeeDetails
order={
normalizedOrder && { ...normalizedOrder, price: price || undefined }
}
assetSymbol={assetSymbol}
market={market}
/>
</div>
<Controller <Controller
name="timeInForce" name="timeInForce"
control={control} control={control}
@ -475,7 +506,7 @@ export const DealTicket = ({
} }
// iceberg orders must be persistent orders, so if user // iceberg orders must be persistent orders, so if user
// switches to to a non persisten tif value, remove iceberg selection // switches to a non persistent tif value, remove iceberg selection
if (iceberg && isNonPersistentOrder(value)) { if (iceberg && isNonPersistentOrder(value)) {
setValue('iceberg', false); setValue('iceberg', false);
} }
@ -487,7 +518,7 @@ export const DealTicket = ({
/> />
)} )}
/> />
{type === Schema.OrderType.TYPE_LIMIT && {isLimitType &&
timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_GTT && ( timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_GTT && (
<Controller <Controller
name="expiresAt" name="expiresAt"
@ -569,7 +600,7 @@ export const DealTicket = ({
)} )}
/> />
</div> </div>
{type === Schema.OrderType.TYPE_LIMIT && ( {isLimitType && (
<> <>
<div className="flex justify-between pb-2 gap-2"> <div className="flex justify-between pb-2 gap-2">
<Controller <Controller
@ -624,15 +655,29 @@ export const DealTicket = ({
pubKey={pubKey} pubKey={pubKey}
onDeposit={onDeposit} onDeposit={onDeposit}
/> />
<DealTicketButton side={side} /> <Button
<DealTicketFeeDetails data-testid="place-order"
order={ type="submit"
normalizedOrder && { ...normalizedOrder, price: price || undefined } className="w-full"
} intent={side === Schema.Side.SIDE_BUY ? Intent.Success : Intent.Danger}
notionalSize={notionalSize} subLabel={`${formatValue(
assetSymbol={assetSymbol} normalizedOrder.size,
market={market} market.positionDecimalPlaces
/> )} ${assetUnit} @ ${
type === Schema.OrderType.TYPE_MARKET
? 'market'
: `${formatValue(
normalizedOrder.price,
market.decimalPlaces
)} ${quoteName}`
}`}
>
{t(
type === Schema.OrderType.TYPE_MARKET
? 'Place market order'
: 'Place limit order'
)}
</Button>
<DealTicketMarginDetails <DealTicketMarginDetails
onMarketClick={onMarketClick} onMarketClick={onMarketClick}
assetSymbol={assetSymbol} assetSymbol={assetSymbol}

View File

@ -0,0 +1,51 @@
import { Tooltip } from '@vegaprotocol/ui-toolkit';
import classnames from 'classnames';
import type { ReactNode } from 'react';
export interface KeyValuePros {
label: string;
value?: string | null | undefined;
symbol: string;
indent?: boolean | undefined;
labelDescription?: ReactNode;
formattedValue?: string;
onClick?: () => void;
}
export const KeyValue = ({
label,
value,
labelDescription,
symbol,
indent,
onClick,
formattedValue,
}: KeyValuePros) => {
const displayValue = `${formattedValue ?? '-'} ${symbol || ''}`;
const valueElement = onClick ? (
<button onClick={onClick} className="text-muted">
{displayValue}
</button>
) : (
<div className="text-muted">{displayValue}</div>
);
return (
<div
data-testid={
'deal-ticket-fee-' + label.toLocaleLowerCase().replace(/\s/g, '-')
}
key={typeof label === 'string' ? label : 'value-dropdown'}
className={classnames(
'text-xs mt-2 flex justify-between items-center gap-4 flex-wrap',
{ 'ml-2': indent }
)}
>
<Tooltip description={labelDescription}>
<div>{label}</div>
</Tooltip>
<Tooltip description={`${value ?? '-'} ${symbol || ''}`}>
{valueElement}
</Tooltip>
</div>
);
};

View File

@ -80,13 +80,14 @@ const getAffixElement = ({
appendIconName, appendIconName,
appendIconDescription, appendIconDescription,
}: Pick<TradingInputProps, keyof AffixProps>) => { }: Pick<TradingInputProps, keyof AffixProps>) => {
const position = prependIconName || prependElement ? 'pre' : 'post';
const className = classNames( const className = classNames(
['fill-black dark:fill-white', 'absolute', 'z-10'], 'absolute z-10 top-0 bottom-0 flex items-center',
{ {
'left-3': position === 'pre', 'fill-black dark:fill-white': prependIconName || appendIconName,
'right-3': position === 'post', 'left-3': prependIconName,
'right-3': appendIconName,
'left-1': prependElement,
'right-1': appendElement,
} }
); );
@ -161,7 +162,7 @@ export const TradingInput = forwardRef<HTMLInputElement, TradingInputProps>(
if (element) { if (element) {
return ( return (
<div className="flex items-center relative"> <div className="relative">
{hasPrepended && element} {hasPrepended && element}
{input} {input}
{hasAppended && element} {hasAppended && element}