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

This commit is contained in:
Bartłomiej Głownia
2023-08-31 08:50:11 +02:00
parent 2cea73c567
commit a9fc09cff4
6 changed files with 217 additions and 152 deletions
@@ -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>
);
};
@@ -1,7 +1,4 @@
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 { FeesBreakdown } from '@vegaprotocol/markets';
import type { OrderSubmissionBody } from '@vegaprotocol/wallet';
@@ -16,7 +13,6 @@ import { marketMarginDataProvider } from '@vegaprotocol/accounts';
import { useDataProvider } from '@vegaprotocol/data-provider';
import {
NOTIONAL_SIZE_TOOLTIP_TEXT,
MARGIN_DIFF_TOOLTIP_TEXT,
DEDUCTION_FROM_COLLATERAL_TOOLTIP_TEXT,
TOTAL_MARGIN_AVAILABLE,
@@ -25,114 +21,54 @@ import {
MARGIN_ACCOUNT_TOOLTIP_TEXT,
} from '../../constants';
import { useEstimateFees } from '../../hooks';
import { KeyValue } from './key-value';
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 {
assetSymbol: string;
order: OrderSubmissionBody['orderSubmission'];
market: Market;
notionalSize: string | null;
}
export const DealTicketFeeDetails = ({
assetSymbol,
order,
market,
notionalSize,
}: DealTicketFeeDetailsProps) => {
const feeEstimate = useEstimateFees(order);
const { settlementAsset: asset } =
market.tradableInstrument.instrument.product;
const { decimals: assetDecimals, quantum } = asset;
const marketDecimals = market.decimalPlaces;
const quoteName = market.tradableInstrument.instrument.product.quoteName;
return (
<>
<DealTicketFeeDetail
label={t('Notional')}
value={formatValue(notionalSize, marketDecimals)}
formattedValue={formatValue(notionalSize, marketDecimals)}
symbol={quoteName}
labelDescription={NOTIONAL_SIZE_TOOLTIP_TEXT(quoteName)}
/>
<DealTicketFeeDetail
label={t('Fees')}
value={
feeEstimate?.totalFeeAmount &&
`~${formatValue(feeEstimate?.totalFeeAmount, assetDecimals)}`
}
formattedValue={
feeEstimate?.totalFeeAmount &&
`~${formatValue(feeEstimate?.totalFeeAmount, assetDecimals, quantum)}`
}
labelDescription={
<>
<span>
{t(
`An estimate of the most you would be expected to pay in fees, in the market's settlement asset ${assetSymbol}.`
)}
</span>
<FeesBreakdown
fees={feeEstimate?.fees}
feeFactors={market.fees.factors}
symbol={assetSymbol}
decimals={assetDecimals}
/>
</>
}
symbol={assetSymbol}
/>
</>
<KeyValue
label={t('Fees')}
value={
feeEstimate?.totalFeeAmount &&
`~${formatValue(feeEstimate?.totalFeeAmount, assetDecimals)}`
}
formattedValue={
feeEstimate?.totalFeeAmount &&
`~${formatValue(feeEstimate?.totalFeeAmount, assetDecimals, quantum)}`
}
labelDescription={
<>
<span>
{t(
`An estimate of the most you would be expected to pay in fees, in the market's settlement asset ${assetSymbol}.`
)}
</span>
<FeesBreakdown
fees={feeEstimate?.fees}
feeFactors={market.fees.factors}
symbol={assetSymbol}
decimals={assetDecimals}
/>
</>
}
symbol={assetSymbol}
/>
);
};
@@ -209,7 +145,7 @@ export const DealTicketMarginDetails = ({
BigInt(marginAccountBalance);
deductionFromCollateral = (
<DealTicketFeeDetail
<KeyValue
indent
label={t('Deduction from collateral')}
value={formatRange(
@@ -236,7 +172,7 @@ export const DealTicketMarginDetails = ({
/>
);
projectedMargin = (
<DealTicketFeeDetail
<KeyValue
label={t('Projected margin')}
value={formatRange(
marginEstimate?.bestCase.initialLevel,
@@ -308,7 +244,7 @@ export const DealTicketMarginDetails = ({
return (
<>
<DealTicketFeeDetail
<KeyValue
label={t('Margin required')}
value={formatRange(
marginRequiredBestCase,
@@ -324,7 +260,7 @@ export const DealTicketMarginDetails = ({
labelDescription={MARGIN_DIFF_TOOLTIP_TEXT(assetSymbol)}
symbol={assetSymbol}
/>
<DealTicketFeeDetail
<KeyValue
label={t('Total margin available')}
indent
value={formatValue(totalMarginAvailable, assetDecimals)}
@@ -342,7 +278,7 @@ export const DealTicketMarginDetails = ({
)}
/>
{deductionFromCollateral}
<DealTicketFeeDetail
<KeyValue
label={t('Current margin allocation')}
indent
onClick={
@@ -358,7 +294,7 @@ export const DealTicketMarginDetails = ({
)}
/>
{projectedMargin}
<DealTicketFeeDetail
<KeyValue
label={t('Liquidation price estimate')}
value={liquidationPriceEstimate}
formattedValue={liquidationPriceEstimate}
@@ -115,6 +115,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 () => {
const values: Partial<StopOrderFormValues> = {
type: Schema.OrderType.TYPE_LIMIT,
@@ -3,6 +3,7 @@ import { useVegaWallet } from '@vegaprotocol/wallet';
import type { StopOrdersSubmission } from '@vegaprotocol/wallet';
import {
formatForInput,
formatValue,
removeDecimal,
toDecimal,
validateAmount,
@@ -19,6 +20,9 @@ import {
TradingInputError as InputError,
TradingSelect as Select,
Tooltip,
TradingButton,
Intent,
Pill,
} from '@vegaprotocol/ui-toolkit';
import { getDerivedPrice } from '@vegaprotocol/markets';
import type { Market } from '@vegaprotocol/markets';
@@ -41,9 +45,10 @@ import {
} from '../../hooks/use-form-values';
import type { StopOrderFormValues } from '../../hooks/use-form-values';
import { mapFormValuesToStopOrdersSubmission } from '../../utils/map-form-values-to-submission';
import { DealTicketButton } from './deal-ticket-button';
import { DealTicketFeeDetails } from './deal-ticket-fee-details';
import { validateExpiration } from '../../utils';
import { NOTIONAL_SIZE_TOOLTIP_TEXT } from '../../constants';
import { KeyValue } from './key-value';
export interface StopOrderProps {
market: Market;
@@ -78,7 +83,7 @@ const Trigger = ({
control,
watch,
priceStep,
assetSymbol,
quoteName,
oco,
marketPrice,
decimalPlaces,
@@ -86,7 +91,7 @@ const Trigger = ({
control: Control<StopOrderFormValues>;
watch: UseFormWatch<StopOrderFormValues>;
priceStep: string;
assetSymbol: string;
quoteName: string;
oco?: boolean;
marketPrice?: string | null;
decimalPlaces: number;
@@ -181,7 +186,7 @@ const Trigger = ({
data-testid={`triggerPrice${oco ? '-oco' : ''}`}
type="number"
step={priceStep}
appendElement={assetSymbol}
appendElement={<Pill size="xs">{quoteName}</Pill>}
value={value || ''}
hasError={!!fieldState.error}
{...props}
@@ -249,7 +254,7 @@ const Trigger = ({
<Input
type="number"
step={trailingPercentOffsetStep}
appendElement="%"
appendElement={<Pill size="xs">%</Pill>}
data-testid={`triggerTrailingPercentOffset${
oco ? '-oco' : ''
}`}
@@ -311,10 +316,12 @@ const Size = ({
control,
sizeStep,
oco,
isLimitType,
}: {
control: Control<StopOrderFormValues>;
sizeStep: string;
oco?: boolean;
isLimitType: boolean;
}) => {
return (
<Controller
@@ -332,7 +339,7 @@ const Size = ({
const { value, ...props } = field;
const id = `order-size${oco ? '-oco' : ''}`;
return (
<div className="mb-4">
<div className={`mb-${isLimitType ? '4' : '2'}`}>
<FormGroup labelFor={id} label={t(`Size`)} compact>
<Input
id={id}
@@ -394,12 +401,8 @@ const Price = ({
const { value, ...props } = field;
const id = `order-price${oco ? '-oco' : ''}`;
return (
<div className="mb-4">
<FormGroup
labelFor={id}
label={t(`Price (${quoteName})`)}
compact={true}
>
<div className="mb-2">
<FormGroup labelFor={id} label={t('Price')} compact={true}>
<Input
id={id}
className="w-full"
@@ -409,6 +412,7 @@ const Price = ({
onWheel={(e) => e.currentTarget.blur()}
value={value || ''}
hasError={!!fieldState.error}
appendElement={<Pill size="xs">{quoteName}</Pill>}
{...props}
/>
</FormGroup>
@@ -530,6 +534,9 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
const rawSize = watch('size');
const oco = watch('oco');
const expiresAt = watch('expiresAt');
const ocoPrice = watch('ocoPrice');
const ocoSize = watch('ocoSize');
const ocoType = watch('ocoType');
useEffect(() => {
const size = storedFormValues?.[dealTicketType]?.size;
@@ -566,6 +573,13 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
market.positionDecimalPlaces
);
const notionalSizeOco = getNotionalSize(
ocoPrice,
ocoSize,
market.decimalPlaces,
market.positionDecimalPlaces
);
useEffect(() => {
const subscription = watch((value, { name, type }) => {
updateStoredFormValues(market.id, value);
@@ -620,18 +634,31 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
control={control}
watch={watch}
priceStep={priceStep}
assetSymbol={asset.symbol}
quoteName={quoteName}
marketPrice={marketPrice}
decimalPlaces={market.decimalPlaces}
/>
<hr className="mb-4 border-vega-clight-500 dark:border-vega-cdark-500" />
<Size
control={control}
sizeStep={sizeStep}
isLimitType={type === Schema.OrderType.TYPE_LIMIT}
/>
<Price
control={control}
watch={watch}
priceStep={priceStep}
quoteName={quoteName}
/>
<Size control={control} sizeStep={sizeStep} />
<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)}
/>
</div>
<TimeInForce control={control} />
<div className="flex justify-end pb-3 gap-2">
<ReduceOnly />
@@ -699,12 +726,18 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
control={control}
watch={watch}
priceStep={priceStep}
assetSymbol={asset.symbol}
quoteName={quoteName}
marketPrice={marketPrice}
decimalPlaces={market.decimalPlaces}
oco
/>
<hr className="mb-2 border-vega-clight-500 dark:border-vega-cdark-500" />
<Size
control={control}
sizeStep={sizeStep}
oco
isLimitType={ocoType === Schema.OrderType.TYPE_LIMIT}
/>
<Price
control={control}
watch={watch}
@@ -712,7 +745,18 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
quoteName={quoteName}
oco
/>
<Size control={control} sizeStep={sizeStep} oco />
<div className="mb-4">
<KeyValue
label={t('Notional')}
value={formatValue(notionalSizeOco, market.decimalPlaces)}
formattedValue={formatValue(
notionalSizeOco,
market.decimalPlaces
)}
symbol={quoteName}
labelDescription={NOTIONAL_SIZE_TOOLTIP_TEXT(quoteName)}
/>
</div>
<TimeInForce control={control} oco />
<div className="flex justify-end mb-2 gap-2">
<ReduceOnly />
@@ -803,7 +847,16 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
</>
)}
<NoWalletWarning isReadOnly={isReadOnly} />
<DealTicketButton side={side} label={t('Submit Stop Order')} />
<TradingButton
data-testid="place-order"
className="w-full"
intent={side === Schema.Side.SIDE_BUY ? Intent.Success : Intent.Danger}
subLabel={`${formatValue(notionalSize, market.decimalPlaces)} ${
market.tradableInstrument.instrument.product.quoteName
}`}
>
{t('Place order')}
</TradingButton>
<DealTicketFeeDetails
order={{
marketId: market.id,
@@ -813,7 +866,6 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
timeInForce,
type,
}}
notionalSize={notionalSize}
assetSymbol={asset.symbol}
market={market}
/>
@@ -3,7 +3,6 @@ import * as Schema from '@vegaprotocol/types';
import type { FormEventHandler } from 'react';
import { memo, useCallback, useEffect, useRef, useMemo } from 'react';
import { Controller, useController, useForm } from 'react-hook-form';
import { DealTicketButton } from './deal-ticket-button';
import {
DealTicketFeeDetails,
DealTicketMarginDetails,
@@ -23,6 +22,8 @@ import {
Intent,
Notification,
Tooltip,
TradingButton,
Pill,
} from '@vegaprotocol/ui-toolkit';
import {
@@ -35,6 +36,7 @@ import {
validateAmount,
toDecimal,
formatForInput,
formatValue,
} from '@vegaprotocol/utils';
import { activeOrdersProvider } from '@vegaprotocol/orders';
import { getDerivedPrice } from '@vegaprotocol/markets';
@@ -46,7 +48,10 @@ import {
validateType,
} from '../../utils';
import { ZeroBalanceError } from '../deal-ticket-validation/zero-balance-error';
import { SummaryValidationType } from '../../constants';
import {
NOTIONAL_SIZE_TOOLTIP_TEXT,
SummaryValidationType,
} from '../../constants';
import type {
Market,
MarketData,
@@ -68,6 +73,7 @@ import { useDealTicketFormValues } from '../../hooks/use-form-values';
import { DealTicketSizeIceberg } from './deal-ticket-size-iceberg';
import noop from 'lodash/noop';
import { isNonPersistentOrder } from '../../utils/time-in-force-persistance';
import { KeyValue } from './key-value';
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.';
@@ -338,6 +344,7 @@ export const DealTicket = ({
const priceStep = toDecimal(market?.decimalPlaces);
const sizeStep = toDecimal(market?.positionDecimalPlaces);
const quoteName = market.tradableInstrument.instrument.product.quoteName;
const isLimitType = type === Schema.OrderType.TYPE_LIMIT;
return (
<form
@@ -386,7 +393,7 @@ export const DealTicket = ({
validate: validateAmount(sizeStep, 'Size'),
}}
render={({ field, fieldState }) => (
<div className="mb-4">
<div className={`mb-${isLimitType ? '4' : '2'}`}>
<FormGroup
label={t('Size')}
labelFor="input-order-size-limit"
@@ -411,7 +418,7 @@ export const DealTicket = ({
</div>
)}
/>
{type === Schema.OrderType.TYPE_LIMIT && (
{isLimitType && (
<Controller
name="price"
control={control}
@@ -424,14 +431,15 @@ export const DealTicket = ({
validate: validateAmount(priceStep, 'Price'),
}}
render={({ field, fieldState }) => (
<div className="mb-4">
<div className="mb-2">
<FormGroup
labelFor="input-price-quote"
label={t(`Price (${quoteName})`)}
label={t('Price')}
compact
>
<Input
id="input-price-quote"
appendElement={<Pill size="xs">{quoteName}</Pill>}
className="w-full"
type="number"
step={priceStep}
@@ -449,6 +457,15 @@ 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)}
/>
</div>
<Controller
name="timeInForce"
control={control}
@@ -475,7 +492,7 @@ export const DealTicket = ({
}
// 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)) {
setValue('iceberg', false);
}
@@ -487,7 +504,7 @@ export const DealTicket = ({
/>
)}
/>
{type === Schema.OrderType.TYPE_LIMIT &&
{isLimitType &&
timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_GTT && (
<Controller
name="expiresAt"
@@ -569,7 +586,7 @@ export const DealTicket = ({
)}
/>
</div>
{type === Schema.OrderType.TYPE_LIMIT && (
{isLimitType && (
<>
<div className="flex justify-between pb-2 gap-2">
<Controller
@@ -624,12 +641,20 @@ export const DealTicket = ({
pubKey={pubKey}
onDeposit={onDeposit}
/>
<DealTicketButton side={side} />
<TradingButton
data-testid="place-order"
className="w-full"
intent={side === Schema.Side.SIDE_BUY ? Intent.Success : Intent.Danger}
subLabel={`${formatValue(notionalSize, market.decimalPlaces)} ${
market.tradableInstrument.instrument.product.quoteName
}`}
>
{t('Place order')}
</TradingButton>
<DealTicketFeeDetails
order={
normalizedOrder && { ...normalizedOrder, price: price || undefined }
}
notionalSize={notionalSize}
assetSymbol={assetSymbol}
market={market}
/>
@@ -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>
);
};