* feat: add positionDecimalPlaces prop to market query and regenerate types * feat: add stepper to order amount input * feat: convert fractions back to integers when submitting the order * refactor: move order transformations into hooks * fix: formatting * fix: simplify toDecimal calculation * fix: remove redundant function for size calculation * fix: add new prop to e2e test mock generator * feat: add tests for order validation * fix: lint * chore: add validation to the simple trading app order form * fix: lint
This commit is contained in:
parent
707ccc0136
commit
bf07dac445
@ -1,40 +1,54 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import type { FormEvent } from 'react';
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import { Stepper } from '../stepper';
|
import { Stepper } from '../stepper';
|
||||||
import type { Order, DealTicketQuery_market } from '@vegaprotocol/deal-ticket';
|
import type { DealTicketQuery_market, Order } from '@vegaprotocol/deal-ticket';
|
||||||
|
import { Button, InputError } from '@vegaprotocol/ui-toolkit';
|
||||||
import {
|
import {
|
||||||
ExpirySelector,
|
ExpirySelector,
|
||||||
SideSelector,
|
SideSelector,
|
||||||
SubmitButton,
|
|
||||||
TimeInForceSelector,
|
TimeInForceSelector,
|
||||||
TypeSelector,
|
TypeSelector,
|
||||||
useOrderState,
|
getDefaultOrder,
|
||||||
|
useOrderValidation,
|
||||||
useOrderSubmit,
|
useOrderSubmit,
|
||||||
DealTicketLimitForm,
|
DealTicketAmount,
|
||||||
DealTicketMarketForm,
|
|
||||||
} from '@vegaprotocol/deal-ticket';
|
} from '@vegaprotocol/deal-ticket';
|
||||||
import {
|
import {
|
||||||
OrderSide,
|
|
||||||
OrderTimeInForce,
|
OrderTimeInForce,
|
||||||
OrderType,
|
OrderType,
|
||||||
VegaTxStatus,
|
VegaTxStatus,
|
||||||
} from '@vegaprotocol/wallet';
|
} from '@vegaprotocol/wallet';
|
||||||
import { addDecimal } from '@vegaprotocol/react-helpers';
|
import { t, addDecimal, toDecimal } from '@vegaprotocol/react-helpers';
|
||||||
|
|
||||||
interface DealTicketMarketProps {
|
interface DealTicketMarketProps {
|
||||||
market: DealTicketQuery_market;
|
market: DealTicketQuery_market;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_ORDER: Order = {
|
|
||||||
type: OrderType.Market,
|
|
||||||
side: OrderSide.Buy,
|
|
||||||
size: '1',
|
|
||||||
timeInForce: OrderTimeInForce.IOC,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DealTicketSteps = ({ market }: DealTicketMarketProps) => {
|
export const DealTicketSteps = ({ market }: DealTicketMarketProps) => {
|
||||||
const [order, updateOrder] = useOrderState(DEFAULT_ORDER);
|
const {
|
||||||
|
register,
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<Order>({
|
||||||
|
mode: 'onChange',
|
||||||
|
defaultValues: getDefaultOrder(market),
|
||||||
|
});
|
||||||
|
|
||||||
|
const step = toDecimal(market.positionDecimalPlaces);
|
||||||
|
const orderType = watch('type');
|
||||||
|
const orderTimeInForce = watch('timeInForce');
|
||||||
|
|
||||||
|
const invalidText = useOrderValidation({
|
||||||
|
step,
|
||||||
|
market,
|
||||||
|
orderType,
|
||||||
|
orderTimeInForce,
|
||||||
|
fieldErrors: errors,
|
||||||
|
});
|
||||||
|
|
||||||
const { submit, transaction } = useOrderSubmit(market);
|
const { submit, transaction } = useOrderSubmit(market);
|
||||||
|
|
||||||
const transactionStatus =
|
const transactionStatus =
|
||||||
@ -43,39 +57,14 @@ export const DealTicketSteps = ({ market }: DealTicketMarketProps) => {
|
|||||||
? 'pending'
|
? 'pending'
|
||||||
: 'default';
|
: 'default';
|
||||||
|
|
||||||
let ticket = null;
|
const onSubmit = React.useCallback(
|
||||||
|
(order: Order) => {
|
||||||
if (order.type === OrderType.Market) {
|
if (transactionStatus !== 'pending') {
|
||||||
ticket = (
|
submit(order);
|
||||||
<DealTicketMarketForm
|
}
|
||||||
size={order.size}
|
},
|
||||||
onSizeChange={(size) => updateOrder({ size })}
|
[transactionStatus, submit]
|
||||||
price={
|
);
|
||||||
market.depth.lastTrade
|
|
||||||
? addDecimal(market.depth.lastTrade.price, market.decimalPlaces)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
quoteName={market.tradableInstrument.instrument.product.quoteName}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (order.type === OrderType.Limit) {
|
|
||||||
ticket = (
|
|
||||||
<DealTicketLimitForm
|
|
||||||
price={order.price}
|
|
||||||
size={order.size}
|
|
||||||
quoteName={market.tradableInstrument.instrument.product.quoteName}
|
|
||||||
onSizeChange={(size) => updateOrder({ size })}
|
|
||||||
onPriceChange={(price) => updateOrder({ price })}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
throw new Error('Invalid ticket type');
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = (e: FormEvent<HTMLFormElement>): Promise<void> => {
|
|
||||||
e.preventDefault();
|
|
||||||
return submit(order);
|
|
||||||
};
|
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{
|
{
|
||||||
@ -87,9 +76,12 @@ export const DealTicketSteps = ({ market }: DealTicketMarketProps) => {
|
|||||||
label: 'Select Order Type',
|
label: 'Select Order Type',
|
||||||
description: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`,
|
description: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`,
|
||||||
component: (
|
component: (
|
||||||
<TypeSelector
|
<Controller
|
||||||
order={order}
|
name="type"
|
||||||
onSelect={(type) => updateOrder({ type })}
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TypeSelector value={field.value} onSelect={field.onChange} />
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -97,9 +89,12 @@ export const DealTicketSteps = ({ market }: DealTicketMarketProps) => {
|
|||||||
label: 'Select Market Position',
|
label: 'Select Market Position',
|
||||||
description: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`,
|
description: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`,
|
||||||
component: (
|
component: (
|
||||||
<SideSelector
|
<Controller
|
||||||
order={order}
|
name="side"
|
||||||
onSelect={(side) => updateOrder({ side })}
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<SideSelector value={field.value} onSelect={field.onChange} />
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -107,27 +102,49 @@ export const DealTicketSteps = ({ market }: DealTicketMarketProps) => {
|
|||||||
label: 'Select Order Size',
|
label: 'Select Order Size',
|
||||||
description:
|
description:
|
||||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
|
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
|
||||||
component: ticket,
|
component: (
|
||||||
|
<DealTicketAmount
|
||||||
|
orderType={orderType}
|
||||||
|
step={0.02}
|
||||||
|
register={register}
|
||||||
|
price={
|
||||||
|
market.depth.lastTrade
|
||||||
|
? addDecimal(market.depth.lastTrade.price, market.decimalPlaces)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
quoteName={market.tradableInstrument.instrument.product.quoteName}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Select Time In Force',
|
label: 'Select Time In Force',
|
||||||
description: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`,
|
description: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`,
|
||||||
component: (
|
component: (
|
||||||
<>
|
<>
|
||||||
<TimeInForceSelector
|
<Controller
|
||||||
order={order}
|
name="timeInForce"
|
||||||
onSelect={(timeInForce) => updateOrder({ timeInForce })}
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TimeInForceSelector
|
||||||
|
value={field.value}
|
||||||
|
orderType={orderType}
|
||||||
|
onSelect={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
{order.timeInForce === OrderTimeInForce.GTT && (
|
{orderType === OrderType.Limit &&
|
||||||
<ExpirySelector
|
orderTimeInForce === OrderTimeInForce.GTT && (
|
||||||
order={order}
|
<Controller
|
||||||
onSelect={(date) => {
|
name="expiration"
|
||||||
if (date) {
|
control={control}
|
||||||
updateOrder({ expiration: date });
|
render={({ field }) => (
|
||||||
}
|
<ExpirySelector
|
||||||
}}
|
value={field.value}
|
||||||
/>
|
onSelect={field.onChange}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -136,11 +153,22 @@ export const DealTicketSteps = ({ market }: DealTicketMarketProps) => {
|
|||||||
description: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`,
|
description: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`,
|
||||||
component: (
|
component: (
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
<SubmitButton
|
{invalidText && (
|
||||||
transactionStatus={transactionStatus}
|
<InputError className="mb-8" data-testid="dealticket-error-message">
|
||||||
market={market}
|
{invalidText}
|
||||||
order={order}
|
</InputError>
|
||||||
/>
|
)}
|
||||||
|
<Button
|
||||||
|
className="w-full mb-8"
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={transactionStatus === 'pending' || !!invalidText}
|
||||||
|
data-testid="place-order"
|
||||||
|
>
|
||||||
|
{transactionStatus === 'pending'
|
||||||
|
? t('Pending...')
|
||||||
|
: t('Place order')}
|
||||||
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
disabled: true,
|
disabled: true,
|
||||||
@ -148,7 +176,7 @@ export const DealTicketSteps = ({ market }: DealTicketMarketProps) => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="px-4 py-8">
|
<form onSubmit={handleSubmit(onSubmit)} className="px-4 py-8">
|
||||||
<Stepper steps={steps} />
|
<Stepper steps={steps} />
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
@ -10,6 +10,7 @@ export const generateDealTicketQuery = (
|
|||||||
market: {
|
market: {
|
||||||
id: 'market-id',
|
id: 'market-id',
|
||||||
decimalPlaces: 2,
|
decimalPlaces: 2,
|
||||||
|
positionDecimalPlaces: 1,
|
||||||
state: MarketState.Active,
|
state: MarketState.Active,
|
||||||
tradingMode: MarketTradingMode.Continuous,
|
tradingMode: MarketTradingMode.Continuous,
|
||||||
tradableInstrument: {
|
tradableInstrument: {
|
||||||
|
@ -72,6 +72,12 @@ export interface DealTicketQuery_market {
|
|||||||
* GBX (pence) 1 4 GBP 0.000001 ( 0.0001p)
|
* GBX (pence) 1 4 GBP 0.000001 ( 0.0001p)
|
||||||
*/
|
*/
|
||||||
decimalPlaces: number;
|
decimalPlaces: number;
|
||||||
|
/**
|
||||||
|
* positionDecimalPlaces indicated the number of decimal places that an integer must be shifted in order to get a correct size (uint64).
|
||||||
|
* i.e. 0 means there are no fractional orders for the market, and order sizes are always whole sizes.
|
||||||
|
* 2 means sizes given as 10^2 * desired size, e.g. a desired size of 1.23 is represented as 123 in this market.
|
||||||
|
*/
|
||||||
|
positionDecimalPlaces: number;
|
||||||
/**
|
/**
|
||||||
* Current state of the market
|
* Current state of the market
|
||||||
*/
|
*/
|
||||||
|
33
libs/deal-ticket/src/components/deal-ticket-amount.tsx
Normal file
33
libs/deal-ticket/src/components/deal-ticket-amount.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import type { UseFormRegister } from 'react-hook-form';
|
||||||
|
import { OrderType } from '@vegaprotocol/wallet';
|
||||||
|
import type { Order } from '../utils/get-default-order';
|
||||||
|
import { DealTicketMarketAmount } from './deal-ticket-market-amount';
|
||||||
|
import { DealTicketLimitAmount } from './deal-ticket-limit-amount';
|
||||||
|
|
||||||
|
export interface DealTicketAmountProps {
|
||||||
|
orderType: OrderType;
|
||||||
|
step: number;
|
||||||
|
register: UseFormRegister<Order>;
|
||||||
|
quoteName: string;
|
||||||
|
price?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAmountComponent = (type: OrderType) => {
|
||||||
|
switch (type) {
|
||||||
|
case OrderType.Market:
|
||||||
|
return DealTicketMarketAmount;
|
||||||
|
case OrderType.Limit:
|
||||||
|
return DealTicketLimitAmount;
|
||||||
|
default: {
|
||||||
|
throw new Error('Invalid ticket type');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DealTicketAmount = ({
|
||||||
|
orderType,
|
||||||
|
...props
|
||||||
|
}: DealTicketAmountProps) => {
|
||||||
|
const AmountComponent = getAmountComponent(orderType);
|
||||||
|
return <AmountComponent {...props} />;
|
||||||
|
};
|
@ -4,7 +4,7 @@ import { DealTicketManager } from './deal-ticket-manager';
|
|||||||
import type {
|
import type {
|
||||||
DealTicketQuery,
|
DealTicketQuery,
|
||||||
DealTicketQuery_market,
|
DealTicketQuery_market,
|
||||||
} from './__generated__/DealTicketQuery';
|
} from '../__generated__/DealTicketQuery';
|
||||||
import { t } from '@vegaprotocol/react-helpers';
|
import { t } from '@vegaprotocol/react-helpers';
|
||||||
|
|
||||||
const DEAL_TICKET_QUERY = gql`
|
const DEAL_TICKET_QUERY = gql`
|
||||||
@ -12,6 +12,7 @@ const DEAL_TICKET_QUERY = gql`
|
|||||||
market(id: $marketId) {
|
market(id: $marketId) {
|
||||||
id
|
id
|
||||||
decimalPlaces
|
decimalPlaces
|
||||||
|
positionDecimalPlaces
|
||||||
state
|
state
|
||||||
tradingMode
|
tradingMode
|
||||||
tradableInstrument {
|
tradableInstrument {
|
@ -1,30 +1,32 @@
|
|||||||
import { FormGroup, Input } from '@vegaprotocol/ui-toolkit';
|
import { FormGroup, Input } from '@vegaprotocol/ui-toolkit';
|
||||||
|
import { validateSize } from '../utils/validate-size';
|
||||||
|
import type { DealTicketAmountProps } from './deal-ticket-amount';
|
||||||
|
|
||||||
export interface DealTicketLimitFormProps {
|
export type DealTicketLimitAmountProps = Omit<
|
||||||
quoteName: string;
|
DealTicketAmountProps,
|
||||||
price?: string;
|
'orderType'
|
||||||
size: string;
|
>;
|
||||||
onSizeChange: (size: string) => void;
|
|
||||||
onPriceChange: (price: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DealTicketLimitForm = ({
|
export const DealTicketLimitAmount = ({
|
||||||
size,
|
register,
|
||||||
price,
|
step,
|
||||||
onSizeChange,
|
|
||||||
onPriceChange,
|
|
||||||
quoteName,
|
quoteName,
|
||||||
}: DealTicketLimitFormProps) => {
|
}: DealTicketLimitAmountProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-8">
|
<div className="flex items-center gap-8">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<FormGroup label="Amount">
|
<FormGroup label="Amount">
|
||||||
<Input
|
<Input
|
||||||
value={size}
|
|
||||||
onChange={(e) => onSizeChange(e.target.value)}
|
|
||||||
className="w-full"
|
className="w-full"
|
||||||
type="number"
|
type="number"
|
||||||
|
step={step}
|
||||||
|
min={step}
|
||||||
data-testid="order-size"
|
data-testid="order-size"
|
||||||
|
{...register('size', {
|
||||||
|
required: true,
|
||||||
|
min: step,
|
||||||
|
validate: validateSize(step),
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
</div>
|
</div>
|
||||||
@ -32,11 +34,12 @@ export const DealTicketLimitForm = ({
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<FormGroup label={`Price (${quoteName})`} labelAlign="right">
|
<FormGroup label={`Price (${quoteName})`} labelAlign="right">
|
||||||
<Input
|
<Input
|
||||||
value={price}
|
|
||||||
onChange={(e) => onPriceChange(e.target.value)}
|
|
||||||
className="w-full"
|
className="w-full"
|
||||||
type="number"
|
type="number"
|
||||||
|
step={step}
|
||||||
|
defaultValue={0}
|
||||||
data-testid="order-price"
|
data-testid="order-price"
|
||||||
|
{...register('price', { required: true, min: 0 })}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
</div>
|
</div>
|
@ -4,9 +4,9 @@ import { Dialog, Intent } from '@vegaprotocol/ui-toolkit';
|
|||||||
import { OrderStatus } from '@vegaprotocol/types';
|
import { OrderStatus } from '@vegaprotocol/types';
|
||||||
import { VegaTxStatus } from '@vegaprotocol/wallet';
|
import { VegaTxStatus } from '@vegaprotocol/wallet';
|
||||||
import { DealTicket } from './deal-ticket';
|
import { DealTicket } from './deal-ticket';
|
||||||
import { useOrderSubmit } from './use-order-submit';
|
|
||||||
import { OrderDialog } from './order-dialog';
|
import { OrderDialog } from './order-dialog';
|
||||||
import type { DealTicketQuery_market } from './__generated__/DealTicketQuery';
|
import { useOrderSubmit } from '../hooks/use-order-submit';
|
||||||
|
import type { DealTicketQuery_market } from '../__generated__/DealTicketQuery';
|
||||||
|
|
||||||
export interface DealTicketManagerProps {
|
export interface DealTicketManagerProps {
|
||||||
market: DealTicketQuery_market;
|
market: DealTicketQuery_market;
|
@ -1,28 +1,33 @@
|
|||||||
import { FormGroup, Input } from '@vegaprotocol/ui-toolkit';
|
import { FormGroup, Input } from '@vegaprotocol/ui-toolkit';
|
||||||
|
import { validateSize } from '../utils/validate-size';
|
||||||
|
import type { DealTicketAmountProps } from './deal-ticket-amount';
|
||||||
|
|
||||||
export interface DealTicketMarketFormProps {
|
export type DealTicketMarketAmountProps = Omit<
|
||||||
quoteName?: string;
|
DealTicketAmountProps,
|
||||||
price?: string;
|
'orderType'
|
||||||
size: string;
|
>;
|
||||||
onSizeChange: (size: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DealTicketMarketForm = ({
|
export const DealTicketMarketAmount = ({
|
||||||
size,
|
register,
|
||||||
onSizeChange,
|
|
||||||
price,
|
price,
|
||||||
|
step,
|
||||||
quoteName,
|
quoteName,
|
||||||
}: DealTicketMarketFormProps) => {
|
}: DealTicketMarketAmountProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-8">
|
<div className="flex items-center gap-8">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<FormGroup label="Amount">
|
<FormGroup label="Amount">
|
||||||
<Input
|
<Input
|
||||||
value={size}
|
|
||||||
onChange={(e) => onSizeChange(e.target.value)}
|
|
||||||
className="w-full"
|
className="w-full"
|
||||||
type="number"
|
type="number"
|
||||||
|
step={step}
|
||||||
|
min={step}
|
||||||
data-testid="order-size"
|
data-testid="order-size"
|
||||||
|
{...register('size', {
|
||||||
|
required: true,
|
||||||
|
min: step,
|
||||||
|
validate: validateSize(step),
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
</div>
|
</div>
|
@ -4,22 +4,17 @@ import {
|
|||||||
OrderType,
|
OrderType,
|
||||||
} from '@vegaprotocol/wallet';
|
} from '@vegaprotocol/wallet';
|
||||||
import { addDecimal } from '@vegaprotocol/react-helpers';
|
import { addDecimal } from '@vegaprotocol/react-helpers';
|
||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen, act } from '@testing-library/react';
|
||||||
import { DealTicket } from './deal-ticket';
|
import { DealTicket } from './deal-ticket';
|
||||||
import type { Order } from './use-order-state';
|
import type { DealTicketQuery_market } from '../__generated__/DealTicketQuery';
|
||||||
import type { DealTicketQuery_market } from './__generated__/DealTicketQuery';
|
import type { Order } from '../utils/get-default-order';
|
||||||
import { MarketState, MarketTradingMode } from '@vegaprotocol/types';
|
import { MarketState, MarketTradingMode } from '@vegaprotocol/types';
|
||||||
|
|
||||||
const order: Order = {
|
|
||||||
type: OrderType.Market,
|
|
||||||
size: '100',
|
|
||||||
timeInForce: OrderTimeInForce.FOK,
|
|
||||||
side: null,
|
|
||||||
};
|
|
||||||
const market: DealTicketQuery_market = {
|
const market: DealTicketQuery_market = {
|
||||||
__typename: 'Market',
|
__typename: 'Market',
|
||||||
id: 'market-id',
|
id: 'market-id',
|
||||||
decimalPlaces: 2,
|
decimalPlaces: 2,
|
||||||
|
positionDecimalPlaces: 1,
|
||||||
tradingMode: MarketTradingMode.Continuous,
|
tradingMode: MarketTradingMode.Continuous,
|
||||||
state: MarketState.Active,
|
state: MarketState.Active,
|
||||||
tradableInstrument: {
|
tradableInstrument: {
|
||||||
@ -43,7 +38,7 @@ const market: DealTicketQuery_market = {
|
|||||||
const submit = jest.fn();
|
const submit = jest.fn();
|
||||||
const transactionStatus = 'default';
|
const transactionStatus = 'default';
|
||||||
|
|
||||||
function generateJsx() {
|
function generateJsx(order?: Order) {
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
<VegaWalletContext.Provider value={{} as any}>
|
<VegaWalletContext.Provider value={{} as any}>
|
||||||
@ -57,21 +52,23 @@ function generateJsx() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
it('Deal ticket defaults', () => {
|
it('Displays ticket defaults', () => {
|
||||||
render(generateJsx());
|
render(generateJsx());
|
||||||
|
|
||||||
// Assert defaults are used
|
// Assert defaults are used
|
||||||
expect(
|
expect(
|
||||||
screen.getByTestId(`order-type-${order.type}-selected`)
|
screen.getByTestId(`order-type-${OrderType.Market}-selected`)
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.queryByTestId('order-side-SIDE_BUY-selected')
|
screen.queryByTestId('order-side-SIDE_BUY-selected')
|
||||||
).not.toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.queryByTestId('order-side-SIDE_SELL-selected')
|
screen.queryByTestId('order-side-SIDE_SELL-selected')
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
expect(screen.getByTestId('order-size')).toHaveDisplayValue(order.size);
|
expect(screen.getByTestId('order-size')).toHaveDisplayValue(
|
||||||
expect(screen.getByTestId('order-tif')).toHaveValue(order.timeInForce);
|
String(1 / Math.pow(10, market.positionDecimalPlaces))
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('order-tif')).toHaveValue(OrderTimeInForce.IOC);
|
||||||
|
|
||||||
// Assert last price is shown
|
// Assert last price is shown
|
||||||
expect(screen.getByTestId('last-price')).toHaveTextContent(
|
expect(screen.getByTestId('last-price')).toHaveTextContent(
|
||||||
@ -82,18 +79,18 @@ it('Deal ticket defaults', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Can edit deal ticket', () => {
|
it('Can edit deal ticket', async () => {
|
||||||
render(generateJsx());
|
render(generateJsx());
|
||||||
|
|
||||||
// Asssert changing values
|
// BUY is selected by default
|
||||||
fireEvent.click(screen.getByTestId('order-side-SIDE_BUY'));
|
screen.getByTestId('order-side-SIDE_BUY-selected');
|
||||||
expect(
|
|
||||||
screen.getByTestId('order-side-SIDE_BUY-selected')
|
|
||||||
).toBeInTheDocument();
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByTestId('order-size'), {
|
await act(async () => {
|
||||||
target: { value: '200' },
|
fireEvent.change(screen.getByTestId('order-size'), {
|
||||||
|
target: { value: '200' },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByTestId('order-size')).toHaveDisplayValue('200');
|
expect(screen.getByTestId('order-size')).toHaveDisplayValue('200');
|
||||||
|
|
||||||
fireEvent.change(screen.getByTestId('order-tif'), {
|
fireEvent.change(screen.getByTestId('order-tif'), {
|
126
libs/deal-ticket/src/components/deal-ticket.tsx
Normal file
126
libs/deal-ticket/src/components/deal-ticket.tsx
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
import { OrderType, OrderTimeInForce } from '@vegaprotocol/wallet';
|
||||||
|
import { t, addDecimal, toDecimal } from '@vegaprotocol/react-helpers';
|
||||||
|
import { Button, InputError } from '@vegaprotocol/ui-toolkit';
|
||||||
|
import { TypeSelector } from './type-selector';
|
||||||
|
import { SideSelector } from './side-selector';
|
||||||
|
import { DealTicketAmount } from './deal-ticket-amount';
|
||||||
|
import { TimeInForceSelector } from './time-in-force-selector';
|
||||||
|
import { ExpirySelector } from './expiry-selector';
|
||||||
|
import type { DealTicketQuery_market } from '../__generated__/DealTicketQuery';
|
||||||
|
import type { Order } from '../utils/get-default-order';
|
||||||
|
import { getDefaultOrder } from '../utils/get-default-order';
|
||||||
|
import { useOrderValidation } from '../hooks/use-order-validation';
|
||||||
|
|
||||||
|
export type TransactionStatus = 'default' | 'pending';
|
||||||
|
|
||||||
|
export interface DealTicketProps {
|
||||||
|
market: DealTicketQuery_market;
|
||||||
|
submit: (order: Order) => void;
|
||||||
|
transactionStatus: TransactionStatus;
|
||||||
|
defaultOrder?: Order;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DealTicket = ({
|
||||||
|
market,
|
||||||
|
submit,
|
||||||
|
transactionStatus,
|
||||||
|
}: DealTicketProps) => {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<Order>({
|
||||||
|
mode: 'onChange',
|
||||||
|
defaultValues: getDefaultOrder(market),
|
||||||
|
});
|
||||||
|
|
||||||
|
const step = toDecimal(market.positionDecimalPlaces);
|
||||||
|
const orderType = watch('type');
|
||||||
|
const orderTimeInForce = watch('timeInForce');
|
||||||
|
const invalidText = useOrderValidation({
|
||||||
|
step,
|
||||||
|
market,
|
||||||
|
orderType,
|
||||||
|
orderTimeInForce,
|
||||||
|
fieldErrors: errors,
|
||||||
|
});
|
||||||
|
const isDisabled = transactionStatus === 'pending' || Boolean(invalidText);
|
||||||
|
|
||||||
|
const onSubmit = useCallback(
|
||||||
|
(order: Order) => {
|
||||||
|
if (!isDisabled && !invalidText) {
|
||||||
|
submit(order);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isDisabled, invalidText, submit]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="px-4 py-8" noValidate>
|
||||||
|
<Controller
|
||||||
|
name="type"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TypeSelector value={field.value} onSelect={field.onChange} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="side"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<SideSelector value={field.value} onSelect={field.onChange} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DealTicketAmount
|
||||||
|
orderType={orderType}
|
||||||
|
step={0.02}
|
||||||
|
register={register}
|
||||||
|
price={
|
||||||
|
market.depth.lastTrade
|
||||||
|
? addDecimal(market.depth.lastTrade.price, market.decimalPlaces)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
quoteName={market.tradableInstrument.instrument.product.quoteName}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="timeInForce"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TimeInForceSelector
|
||||||
|
value={field.value}
|
||||||
|
orderType={orderType}
|
||||||
|
onSelect={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{orderType === OrderType.Limit &&
|
||||||
|
orderTimeInForce === OrderTimeInForce.GTT && (
|
||||||
|
<Controller
|
||||||
|
name="expiration"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<ExpirySelector value={field.value} onSelect={field.onChange} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
className="w-full mb-8"
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={isDisabled}
|
||||||
|
data-testid="place-order"
|
||||||
|
>
|
||||||
|
{transactionStatus === 'pending' ? t('Pending...') : t('Place order')}
|
||||||
|
</Button>
|
||||||
|
{invalidText && (
|
||||||
|
<InputError className="mb-8" data-testid="dealticket-error-message">
|
||||||
|
{invalidText}
|
||||||
|
</InputError>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
@ -1,14 +1,13 @@
|
|||||||
import { FormGroup, Input } from '@vegaprotocol/ui-toolkit';
|
import { FormGroup, Input } from '@vegaprotocol/ui-toolkit';
|
||||||
import type { Order } from './use-order-state';
|
|
||||||
import { formatForInput } from '@vegaprotocol/react-helpers';
|
import { formatForInput } from '@vegaprotocol/react-helpers';
|
||||||
|
|
||||||
interface ExpirySelectorProps {
|
interface ExpirySelectorProps {
|
||||||
order: Order;
|
value?: Date;
|
||||||
onSelect: (expiration: Date | null) => void;
|
onSelect: (expiration: Date | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ExpirySelector = ({ order, onSelect }: ExpirySelectorProps) => {
|
export const ExpirySelector = ({ value, onSelect }: ExpirySelectorProps) => {
|
||||||
const date = order.expiration ? new Date(order.expiration) : new Date();
|
const date = value ? new Date(value) : new Date();
|
||||||
const dateFormatted = formatForInput(date);
|
const dateFormatted = formatForInput(date);
|
||||||
const minDate = formatForInput(date);
|
const minDate = formatForInput(date);
|
||||||
return (
|
return (
|
@ -1,6 +1,6 @@
|
|||||||
import { Icon, Loader } from '@vegaprotocol/ui-toolkit';
|
import { Icon, Loader } from '@vegaprotocol/ui-toolkit';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { OrderEvent_busEvents_event_Order } from './__generated__/OrderEvent';
|
import type { OrderEvent_busEvents_event_Order } from '../__generated__/OrderEvent';
|
||||||
import { addDecimalsFormatNumber, t } from '@vegaprotocol/react-helpers';
|
import { addDecimalsFormatNumber, t } from '@vegaprotocol/react-helpers';
|
||||||
import type { VegaTxState } from '@vegaprotocol/wallet';
|
import type { VegaTxState } from '@vegaprotocol/wallet';
|
||||||
import { VegaTxStatus } from '@vegaprotocol/wallet';
|
import { VegaTxStatus } from '@vegaprotocol/wallet';
|
@ -1,14 +1,13 @@
|
|||||||
import { FormGroup } from '@vegaprotocol/ui-toolkit';
|
import { FormGroup } from '@vegaprotocol/ui-toolkit';
|
||||||
import { OrderSide } from '@vegaprotocol/wallet';
|
import { OrderSide } from '@vegaprotocol/wallet';
|
||||||
import { Toggle } from '@vegaprotocol/ui-toolkit';
|
import { Toggle } from '@vegaprotocol/ui-toolkit';
|
||||||
import type { Order } from './use-order-state';
|
|
||||||
|
|
||||||
interface SideSelectorProps {
|
interface SideSelectorProps {
|
||||||
order: Order;
|
value: OrderSide;
|
||||||
onSelect: (side: OrderSide) => void;
|
onSelect: (side: OrderSide) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SideSelector = ({ order, onSelect }: SideSelectorProps) => {
|
export const SideSelector = ({ value, onSelect }: SideSelectorProps) => {
|
||||||
const toggles = Object.entries(OrderSide).map(([label, value]) => ({
|
const toggles = Object.entries(OrderSide).map(([label, value]) => ({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@ -19,7 +18,7 @@ export const SideSelector = ({ order, onSelect }: SideSelectorProps) => {
|
|||||||
<Toggle
|
<Toggle
|
||||||
name="order-side"
|
name="order-side"
|
||||||
toggles={toggles}
|
toggles={toggles}
|
||||||
checkedValue={order.side}
|
checkedValue={value}
|
||||||
onChange={(e) => onSelect(e.target.value as OrderSide)}
|
onChange={(e) => onSelect(e.target.value as OrderSide)}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
@ -1,28 +1,30 @@
|
|||||||
import { FormGroup, Select } from '@vegaprotocol/ui-toolkit';
|
import { FormGroup, Select } from '@vegaprotocol/ui-toolkit';
|
||||||
import { OrderTimeInForce, OrderType } from '@vegaprotocol/wallet';
|
import { OrderTimeInForce, OrderType } from '@vegaprotocol/wallet';
|
||||||
import type { Order } from './use-order-state';
|
|
||||||
|
|
||||||
interface TimeInForceSelectorProps {
|
interface TimeInForceSelectorProps {
|
||||||
order: Order;
|
value: OrderTimeInForce;
|
||||||
|
orderType: OrderType;
|
||||||
onSelect: (tif: OrderTimeInForce) => void;
|
onSelect: (tif: OrderTimeInForce) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TimeInForceSelector = ({
|
export const TimeInForceSelector = ({
|
||||||
order,
|
value,
|
||||||
|
orderType,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: TimeInForceSelectorProps) => {
|
}: TimeInForceSelectorProps) => {
|
||||||
const options =
|
const options =
|
||||||
order.type === OrderType.Limit
|
orderType === OrderType.Limit
|
||||||
? Object.entries(OrderTimeInForce)
|
? Object.entries(OrderTimeInForce)
|
||||||
: Object.entries(OrderTimeInForce).filter(
|
: Object.entries(OrderTimeInForce).filter(
|
||||||
([_, value]) =>
|
([_, timeInForce]) =>
|
||||||
value === OrderTimeInForce.FOK || value === OrderTimeInForce.IOC
|
timeInForce === OrderTimeInForce.FOK ||
|
||||||
|
timeInForce === OrderTimeInForce.IOC
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormGroup label="Time in force">
|
<FormGroup label="Time in force">
|
||||||
<Select
|
<Select
|
||||||
value={order.timeInForce}
|
value={value}
|
||||||
onChange={(e) => onSelect(e.target.value as OrderTimeInForce)}
|
onChange={(e) => onSelect(e.target.value as OrderTimeInForce)}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
data-testid="order-tif"
|
data-testid="order-tif"
|
@ -1,25 +1,24 @@
|
|||||||
import { FormGroup } from '@vegaprotocol/ui-toolkit';
|
import { FormGroup } from '@vegaprotocol/ui-toolkit';
|
||||||
import { OrderType } from '@vegaprotocol/wallet';
|
import { OrderType } from '@vegaprotocol/wallet';
|
||||||
import { Toggle } from '@vegaprotocol/ui-toolkit';
|
import { Toggle } from '@vegaprotocol/ui-toolkit';
|
||||||
import type { Order } from './use-order-state';
|
|
||||||
|
|
||||||
interface TypeSelectorProps {
|
interface TypeSelectorProps {
|
||||||
order: Order;
|
value: OrderType;
|
||||||
onSelect: (type: OrderType) => void;
|
onSelect: (type: OrderType) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TypeSelector = ({ order, onSelect }: TypeSelectorProps) => {
|
const toggles = Object.entries(OrderType).map(([label, value]) => ({
|
||||||
const toggles = Object.entries(OrderType).map(([label, value]) => ({
|
label,
|
||||||
label,
|
value,
|
||||||
value,
|
}));
|
||||||
}));
|
|
||||||
|
|
||||||
|
export const TypeSelector = ({ value, onSelect }: TypeSelectorProps) => {
|
||||||
return (
|
return (
|
||||||
<FormGroup label="Order type">
|
<FormGroup label="Order type">
|
||||||
<Toggle
|
<Toggle
|
||||||
name="order-type"
|
name="order-type"
|
||||||
toggles={toggles}
|
toggles={toggles}
|
||||||
checkedValue={order.type}
|
checkedValue={value}
|
||||||
onChange={(e) => onSelect(e.target.value as OrderType)}
|
onChange={(e) => onSelect(e.target.value as OrderType)}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
@ -1,57 +0,0 @@
|
|||||||
import { OrderTimeInForce } from '@vegaprotocol/wallet';
|
|
||||||
import type { TransactionStatus } from './deal-ticket';
|
|
||||||
import { ExpirySelector } from './expiry-selector';
|
|
||||||
import { SideSelector } from './side-selector';
|
|
||||||
import { SubmitButton } from './submit-button';
|
|
||||||
import { DealTicketLimitForm } from './deal-ticket-limit-form';
|
|
||||||
import { TimeInForceSelector } from './time-in-force-selector';
|
|
||||||
import { TypeSelector } from './type-selector';
|
|
||||||
import type { Order } from './use-order-state';
|
|
||||||
import type { DealTicketQuery_market } from './__generated__/DealTicketQuery';
|
|
||||||
|
|
||||||
interface DealTicketLimitProps {
|
|
||||||
order: Order;
|
|
||||||
updateOrder: (order: Partial<Order>) => void;
|
|
||||||
transactionStatus: TransactionStatus;
|
|
||||||
market: DealTicketQuery_market;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DealTicketLimit = ({
|
|
||||||
order,
|
|
||||||
updateOrder,
|
|
||||||
transactionStatus,
|
|
||||||
market,
|
|
||||||
}: DealTicketLimitProps) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<TypeSelector order={order} onSelect={(type) => updateOrder({ type })} />
|
|
||||||
<SideSelector order={order} onSelect={(side) => updateOrder({ side })} />
|
|
||||||
<DealTicketLimitForm
|
|
||||||
price={order.price}
|
|
||||||
size={order.size}
|
|
||||||
quoteName={market.tradableInstrument.instrument.product.quoteName}
|
|
||||||
onSizeChange={(size) => updateOrder({ size })}
|
|
||||||
onPriceChange={(price) => updateOrder({ price })}
|
|
||||||
/>
|
|
||||||
<TimeInForceSelector
|
|
||||||
order={order}
|
|
||||||
onSelect={(timeInForce) => updateOrder({ timeInForce })}
|
|
||||||
/>
|
|
||||||
{order.timeInForce === OrderTimeInForce.GTT && (
|
|
||||||
<ExpirySelector
|
|
||||||
order={order}
|
|
||||||
onSelect={(date) => {
|
|
||||||
if (date) {
|
|
||||||
updateOrder({ expiration: date });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<SubmitButton
|
|
||||||
transactionStatus={transactionStatus}
|
|
||||||
market={market}
|
|
||||||
order={order}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,49 +0,0 @@
|
|||||||
import type { TransactionStatus } from './deal-ticket';
|
|
||||||
import { SideSelector } from './side-selector';
|
|
||||||
import { DealTicketMarketForm } from './deal-ticket-market-form';
|
|
||||||
import { SubmitButton } from './submit-button';
|
|
||||||
import { TimeInForceSelector } from './time-in-force-selector';
|
|
||||||
import { TypeSelector } from './type-selector';
|
|
||||||
import type { Order } from './use-order-state';
|
|
||||||
import type { DealTicketQuery_market } from './__generated__/DealTicketQuery';
|
|
||||||
import { addDecimal } from '@vegaprotocol/react-helpers';
|
|
||||||
|
|
||||||
interface DealTicketMarketProps {
|
|
||||||
order: Order;
|
|
||||||
updateOrder: (order: Partial<Order>) => void;
|
|
||||||
transactionStatus: TransactionStatus;
|
|
||||||
market: DealTicketQuery_market;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DealTicketMarket = ({
|
|
||||||
order,
|
|
||||||
updateOrder,
|
|
||||||
transactionStatus,
|
|
||||||
market,
|
|
||||||
}: DealTicketMarketProps) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<TypeSelector order={order} onSelect={(type) => updateOrder({ type })} />
|
|
||||||
<SideSelector order={order} onSelect={(side) => updateOrder({ side })} />
|
|
||||||
<DealTicketMarketForm
|
|
||||||
size={order.size}
|
|
||||||
onSizeChange={(size) => updateOrder({ size })}
|
|
||||||
price={
|
|
||||||
market.depth.lastTrade
|
|
||||||
? addDecimal(market.depth.lastTrade.price, market.decimalPlaces)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
quoteName={market.tradableInstrument.instrument.product.quoteName}
|
|
||||||
/>
|
|
||||||
<TimeInForceSelector
|
|
||||||
order={order}
|
|
||||||
onSelect={(timeInForce) => updateOrder({ timeInForce })}
|
|
||||||
/>
|
|
||||||
<SubmitButton
|
|
||||||
transactionStatus={transactionStatus}
|
|
||||||
market={market}
|
|
||||||
order={order}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,67 +0,0 @@
|
|||||||
import type { FormEvent } from 'react';
|
|
||||||
import { OrderSide, OrderTimeInForce, OrderType } from '@vegaprotocol/wallet';
|
|
||||||
import type { Order } from './use-order-state';
|
|
||||||
import { useOrderState } from './use-order-state';
|
|
||||||
import { DealTicketMarket } from './deal-ticket-market';
|
|
||||||
import { DealTicketLimit } from './deal-ticket-limit';
|
|
||||||
import type { DealTicketQuery_market } from './__generated__/DealTicketQuery';
|
|
||||||
|
|
||||||
const DEFAULT_ORDER: Order = {
|
|
||||||
type: OrderType.Market,
|
|
||||||
side: OrderSide.Buy,
|
|
||||||
size: '1',
|
|
||||||
timeInForce: OrderTimeInForce.IOC,
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TransactionStatus = 'default' | 'pending';
|
|
||||||
|
|
||||||
export interface DealTicketProps {
|
|
||||||
market: DealTicketQuery_market;
|
|
||||||
submit: (order: Order) => void;
|
|
||||||
transactionStatus: TransactionStatus;
|
|
||||||
defaultOrder?: Order;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DealTicket = ({
|
|
||||||
market,
|
|
||||||
submit,
|
|
||||||
transactionStatus,
|
|
||||||
defaultOrder = DEFAULT_ORDER,
|
|
||||||
}: DealTicketProps) => {
|
|
||||||
const [order, updateOrder] = useOrderState(defaultOrder);
|
|
||||||
|
|
||||||
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
submit(order);
|
|
||||||
};
|
|
||||||
|
|
||||||
let ticket = null;
|
|
||||||
|
|
||||||
if (order.type === OrderType.Market) {
|
|
||||||
ticket = (
|
|
||||||
<DealTicketMarket
|
|
||||||
order={order}
|
|
||||||
updateOrder={updateOrder}
|
|
||||||
transactionStatus={transactionStatus}
|
|
||||||
market={market}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (order.type === OrderType.Limit) {
|
|
||||||
ticket = (
|
|
||||||
<DealTicketLimit
|
|
||||||
order={order}
|
|
||||||
updateOrder={updateOrder}
|
|
||||||
transactionStatus={transactionStatus}
|
|
||||||
market={market}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
throw new Error('Invalid ticket type');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit} className="px-4 py-8">
|
|
||||||
{ticket}
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,14 +1,42 @@
|
|||||||
import { MockedProvider } from '@apollo/client/testing';
|
import { MockedProvider } from '@apollo/client/testing';
|
||||||
import { act, renderHook } from '@testing-library/react-hooks';
|
import { act, renderHook } from '@testing-library/react-hooks';
|
||||||
import type { Order } from './use-order-state';
|
import type { Order } from '../utils/get-default-order';
|
||||||
import type {
|
import type {
|
||||||
VegaKeyExtended,
|
VegaKeyExtended,
|
||||||
VegaWalletContextShape,
|
VegaWalletContextShape,
|
||||||
} from '@vegaprotocol/wallet';
|
} from '@vegaprotocol/wallet';
|
||||||
import { VegaTxStatus, VegaWalletContext } from '@vegaprotocol/wallet';
|
import { VegaTxStatus, VegaWalletContext } from '@vegaprotocol/wallet';
|
||||||
import { OrderSide, OrderTimeInForce, OrderType } from '@vegaprotocol/wallet';
|
import { OrderSide, OrderTimeInForce, OrderType } from '@vegaprotocol/wallet';
|
||||||
|
import { MarketState, MarketTradingMode } from '@vegaprotocol/types';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useOrderSubmit } from './use-order-submit';
|
import { useOrderSubmit } from './use-order-submit';
|
||||||
|
import type { DealTicketQuery_market } from '../__generated__/DealTicketQuery';
|
||||||
|
|
||||||
|
const defaultMarket: DealTicketQuery_market = {
|
||||||
|
__typename: 'Market',
|
||||||
|
id: 'market-id',
|
||||||
|
decimalPlaces: 2,
|
||||||
|
positionDecimalPlaces: 1,
|
||||||
|
tradingMode: MarketTradingMode.Continuous,
|
||||||
|
state: MarketState.Active,
|
||||||
|
tradableInstrument: {
|
||||||
|
__typename: 'TradableInstrument',
|
||||||
|
instrument: {
|
||||||
|
__typename: 'Instrument',
|
||||||
|
product: {
|
||||||
|
__typename: 'Future',
|
||||||
|
quoteName: 'quote-name',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
depth: {
|
||||||
|
__typename: 'MarketDepth',
|
||||||
|
lastTrade: {
|
||||||
|
__typename: 'Trade',
|
||||||
|
price: '100',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const defaultWalletContext = {
|
const defaultWalletContext = {
|
||||||
keypair: null,
|
keypair: null,
|
||||||
@ -22,7 +50,7 @@ const defaultWalletContext = {
|
|||||||
|
|
||||||
function setup(
|
function setup(
|
||||||
context?: Partial<VegaWalletContextShape>,
|
context?: Partial<VegaWalletContextShape>,
|
||||||
market = { id: 'market-id', decimalPlaces: 2 }
|
market = defaultMarket
|
||||||
) {
|
) {
|
||||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
<MockedProvider>
|
<MockedProvider>
|
||||||
@ -111,20 +139,16 @@ it('Should submit a correctly formatted order', async () => {
|
|||||||
const keypair = {
|
const keypair = {
|
||||||
pub: '0x123',
|
pub: '0x123',
|
||||||
} as VegaKeyExtended;
|
} as VegaKeyExtended;
|
||||||
const market = {
|
|
||||||
id: 'market-id',
|
|
||||||
decimalPlaces: 2,
|
|
||||||
};
|
|
||||||
const { result } = setup(
|
const { result } = setup(
|
||||||
{
|
{
|
||||||
sendTx: mockSendTx,
|
sendTx: mockSendTx,
|
||||||
keypairs: [keypair],
|
keypairs: [keypair],
|
||||||
keypair,
|
keypair,
|
||||||
},
|
},
|
||||||
market
|
defaultMarket
|
||||||
);
|
);
|
||||||
|
|
||||||
const order = {
|
const order: Order = {
|
||||||
type: OrderType.Limit,
|
type: OrderType.Limit,
|
||||||
size: '10',
|
size: '10',
|
||||||
timeInForce: OrderTimeInForce.GTT,
|
timeInForce: OrderTimeInForce.GTT,
|
||||||
@ -141,12 +165,12 @@ it('Should submit a correctly formatted order', async () => {
|
|||||||
propagate: true,
|
propagate: true,
|
||||||
orderSubmission: {
|
orderSubmission: {
|
||||||
type: OrderType.Limit,
|
type: OrderType.Limit,
|
||||||
marketId: market.id, // Market provided from hook arugment
|
marketId: defaultMarket.id, // Market provided from hook arugment
|
||||||
size: '10',
|
size: '100', // size adjusted based on positionDecimalPlaces
|
||||||
side: OrderSide.Buy,
|
side: OrderSide.Buy,
|
||||||
timeInForce: OrderTimeInForce.GTT,
|
timeInForce: OrderTimeInForce.GTT,
|
||||||
price: '123456789', // Decimal removed
|
price: '123456789', // Decimal removed
|
||||||
expiresAt: order.expiration.getTime() + '000000', // Nanoseconds appened
|
expiresAt: order.expiration?.getTime() + '000000', // Nanoseconds appened
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { gql, useSubscription } from '@apollo/client';
|
import { gql, useSubscription } from '@apollo/client';
|
||||||
import type { Order } from './use-order-state';
|
import type { Order } from '../utils/get-default-order';
|
||||||
import { OrderType, useVegaWallet } from '@vegaprotocol/wallet';
|
import { OrderType, useVegaWallet } from '@vegaprotocol/wallet';
|
||||||
import { determineId, removeDecimal } from '@vegaprotocol/react-helpers';
|
import { determineId, removeDecimal } from '@vegaprotocol/react-helpers';
|
||||||
import { useVegaTransaction } from '@vegaprotocol/wallet';
|
import { useVegaTransaction } from '@vegaprotocol/wallet';
|
||||||
@ -8,7 +8,8 @@ import type {
|
|||||||
OrderEvent,
|
OrderEvent,
|
||||||
OrderEventVariables,
|
OrderEventVariables,
|
||||||
OrderEvent_busEvents_event_Order,
|
OrderEvent_busEvents_event_Order,
|
||||||
} from './__generated__/OrderEvent';
|
} from '../__generated__/OrderEvent';
|
||||||
|
import type { DealTicketQuery_market } from '../__generated__/DealTicketQuery';
|
||||||
|
|
||||||
const ORDER_EVENT_SUB = gql`
|
const ORDER_EVENT_SUB = gql`
|
||||||
subscription OrderEvent($partyId: ID!) {
|
subscription OrderEvent($partyId: ID!) {
|
||||||
@ -35,12 +36,7 @@ const ORDER_EVENT_SUB = gql`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface UseOrderSubmitMarket {
|
export const useOrderSubmit = (market: DealTicketQuery_market) => {
|
||||||
id: string;
|
|
||||||
decimalPlaces: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useOrderSubmit = (market: UseOrderSubmitMarket) => {
|
|
||||||
const { keypair } = useVegaWallet();
|
const { keypair } = useVegaWallet();
|
||||||
const { send, transaction, reset: resetTransaction } = useVegaTransaction();
|
const { send, transaction, reset: resetTransaction } = useVegaTransaction();
|
||||||
const [id, setId] = useState('');
|
const [id, setId] = useState('');
|
||||||
@ -97,7 +93,7 @@ export const useOrderSubmit = (market: UseOrderSubmitMarket) => {
|
|||||||
order.type === OrderType.Limit && order.price
|
order.type === OrderType.Limit && order.price
|
||||||
? removeDecimal(order.price, market.decimalPlaces)
|
? removeDecimal(order.price, market.decimalPlaces)
|
||||||
: undefined,
|
: undefined,
|
||||||
size: order.size,
|
size: removeDecimal(order.size, market.positionDecimalPlaces),
|
||||||
type: order.type,
|
type: order.type,
|
||||||
side: order.side,
|
side: order.side,
|
||||||
timeInForce: order.timeInForce,
|
timeInForce: order.timeInForce,
|
196
libs/deal-ticket/src/hooks/use-order-validation.spec.tsx
Normal file
196
libs/deal-ticket/src/hooks/use-order-validation.spec.tsx
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
import { renderHook } from '@testing-library/react-hooks';
|
||||||
|
import {
|
||||||
|
OrderTimeInForce,
|
||||||
|
OrderType,
|
||||||
|
useVegaWallet,
|
||||||
|
} from '@vegaprotocol/wallet';
|
||||||
|
import type {
|
||||||
|
VegaWalletContextShape,
|
||||||
|
VegaKeyExtended,
|
||||||
|
} from '@vegaprotocol/wallet';
|
||||||
|
import { MarketState, MarketTradingMode } from '@vegaprotocol/types';
|
||||||
|
import type { ValidationProps } from './use-order-validation';
|
||||||
|
import { useOrderValidation } from './use-order-validation';
|
||||||
|
import type { DealTicketQuery_market } from '../__generated__/DealTicketQuery';
|
||||||
|
import { ERROR_SIZE_DECIMAL } from '../utils/validate-size';
|
||||||
|
|
||||||
|
jest.mock('@vegaprotocol/wallet');
|
||||||
|
|
||||||
|
const market: DealTicketQuery_market = {
|
||||||
|
__typename: 'Market',
|
||||||
|
id: 'market-id',
|
||||||
|
decimalPlaces: 2,
|
||||||
|
positionDecimalPlaces: 1,
|
||||||
|
tradingMode: MarketTradingMode.Continuous,
|
||||||
|
state: MarketState.Active,
|
||||||
|
tradableInstrument: {
|
||||||
|
__typename: 'TradableInstrument',
|
||||||
|
instrument: {
|
||||||
|
__typename: 'Instrument',
|
||||||
|
product: {
|
||||||
|
__typename: 'Future',
|
||||||
|
quoteName: 'quote-name',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
depth: {
|
||||||
|
__typename: 'MarketDepth',
|
||||||
|
lastTrade: {
|
||||||
|
__typename: 'Trade',
|
||||||
|
price: '100',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultWalletContext = {
|
||||||
|
keypair: {
|
||||||
|
name: 'keypair0',
|
||||||
|
tainted: false,
|
||||||
|
pub: '111111__111111',
|
||||||
|
} as VegaKeyExtended,
|
||||||
|
keypairs: [],
|
||||||
|
sendTx: jest.fn().mockReturnValue(Promise.resolve(null)),
|
||||||
|
connect: jest.fn(),
|
||||||
|
disconnect: jest.fn(),
|
||||||
|
selectPublicKey: jest.fn(),
|
||||||
|
connector: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultOrder = {
|
||||||
|
market,
|
||||||
|
step: 0.1,
|
||||||
|
orderType: OrderType.Market,
|
||||||
|
orderTimeInForce: OrderTimeInForce.FOK,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ERROR = {
|
||||||
|
KEY_MISSING: 'No public key selected',
|
||||||
|
KEY_TAINTED: 'Selected public key has been tainted',
|
||||||
|
MARKET_SUSPENDED: 'Market is currently suspended',
|
||||||
|
MARKET_INACTIVE: 'Market is no longer active',
|
||||||
|
MARKET_WAITING: 'Market is not active yet',
|
||||||
|
MARKET_CONTINUOUS_LIMIT:
|
||||||
|
'Only limit orders are permitted when market is in auction',
|
||||||
|
MARKET_COUNTINUOUS_TIF:
|
||||||
|
'Only GTT, GTC and GFA are permitted when market is in auction',
|
||||||
|
FIELD_SIZE_REQ: 'An amount needs to be provided',
|
||||||
|
FIELD_SIZE_MIN: `The amount cannot be lower than "${defaultOrder.step}"`,
|
||||||
|
FIELD_PRICE_REQ: 'A price needs to be provided',
|
||||||
|
FIELD_PRICE_MIN: 'The price cannot be negative',
|
||||||
|
FIELD_PRICE_STEP_NULL: 'No decimal amounts allowed for this order',
|
||||||
|
FIELD_PRICE_STEP_DECIMAL: `The amount field only takes up to ${market.positionDecimalPlaces} decimals`,
|
||||||
|
};
|
||||||
|
|
||||||
|
function setup(
|
||||||
|
props?: Partial<ValidationProps>,
|
||||||
|
context?: Partial<VegaWalletContextShape>
|
||||||
|
) {
|
||||||
|
const mockUseVegaWallet = useVegaWallet as jest.Mock;
|
||||||
|
mockUseVegaWallet.mockReturnValue({ ...defaultWalletContext, context });
|
||||||
|
return renderHook(() => useOrderValidation({ ...defaultOrder, ...props }));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('Returns empty string when given valid data', () => {
|
||||||
|
const { result } = setup();
|
||||||
|
expect(result.current).toEqual('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Returns an error message when no keypair found', async () => {
|
||||||
|
const { result } = setup(defaultOrder, { keypair: null });
|
||||||
|
expect(result.current).toEqual('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Returns an error message when the keypair is tainted', async () => {
|
||||||
|
const { result } = setup(defaultOrder, {
|
||||||
|
keypair: { ...defaultWalletContext.keypair, tainted: true },
|
||||||
|
});
|
||||||
|
expect(result.current).toEqual('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each`
|
||||||
|
state | errorMessage
|
||||||
|
${MarketState.Cancelled} | ${ERROR.MARKET_INACTIVE}
|
||||||
|
${MarketState.Closed} | ${ERROR.MARKET_INACTIVE}
|
||||||
|
${MarketState.Rejected} | ${ERROR.MARKET_INACTIVE}
|
||||||
|
${MarketState.Settled} | ${ERROR.MARKET_INACTIVE}
|
||||||
|
${MarketState.TradingTerminated} | ${ERROR.MARKET_INACTIVE}
|
||||||
|
${MarketState.Suspended} | ${ERROR.MARKET_SUSPENDED}
|
||||||
|
${MarketState.Pending} | ${ERROR.MARKET_WAITING}
|
||||||
|
${MarketState.Proposed} | ${ERROR.MARKET_WAITING}
|
||||||
|
`(
|
||||||
|
'Returns an error message for "$marketState" market',
|
||||||
|
async ({ state, errorMessage }) => {
|
||||||
|
const { result } = setup({ market: { ...defaultOrder.market, state } });
|
||||||
|
expect(result.current).toEqual(errorMessage);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each`
|
||||||
|
tradingMode | errorMessage
|
||||||
|
${MarketTradingMode.BatchAuction} | ${ERROR.MARKET_CONTINUOUS_LIMIT}
|
||||||
|
${MarketTradingMode.MonitoringAuction} | ${ERROR.MARKET_CONTINUOUS_LIMIT}
|
||||||
|
${MarketTradingMode.OpeningAuction} | ${ERROR.MARKET_CONTINUOUS_LIMIT}
|
||||||
|
`(
|
||||||
|
'Returns an error message when trying to submit a non-limit order for a "$tradingMode" market',
|
||||||
|
async ({ tradingMode, errorMessage }) => {
|
||||||
|
const { result } = setup({
|
||||||
|
market: { ...defaultOrder.market, tradingMode },
|
||||||
|
orderType: OrderType.Market,
|
||||||
|
});
|
||||||
|
expect(result.current).toEqual(errorMessage);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each`
|
||||||
|
tradingMode | orderTimeInForce | errorMessage
|
||||||
|
${MarketTradingMode.BatchAuction} | ${OrderTimeInForce.FOK} | ${ERROR.MARKET_COUNTINUOUS_TIF}
|
||||||
|
${MarketTradingMode.MonitoringAuction} | ${OrderTimeInForce.FOK} | ${ERROR.MARKET_COUNTINUOUS_TIF}
|
||||||
|
${MarketTradingMode.OpeningAuction} | ${OrderTimeInForce.FOK} | ${ERROR.MARKET_COUNTINUOUS_TIF}
|
||||||
|
${MarketTradingMode.BatchAuction} | ${OrderTimeInForce.IOC} | ${ERROR.MARKET_COUNTINUOUS_TIF}
|
||||||
|
${MarketTradingMode.MonitoringAuction} | ${OrderTimeInForce.IOC} | ${ERROR.MARKET_COUNTINUOUS_TIF}
|
||||||
|
${MarketTradingMode.OpeningAuction} | ${OrderTimeInForce.IOC} | ${ERROR.MARKET_COUNTINUOUS_TIF}
|
||||||
|
${MarketTradingMode.BatchAuction} | ${OrderTimeInForce.GFN} | ${ERROR.MARKET_COUNTINUOUS_TIF}
|
||||||
|
${MarketTradingMode.MonitoringAuction} | ${OrderTimeInForce.GFN} | ${ERROR.MARKET_COUNTINUOUS_TIF}
|
||||||
|
${MarketTradingMode.OpeningAuction} | ${OrderTimeInForce.GFN} | ${ERROR.MARKET_COUNTINUOUS_TIF}
|
||||||
|
`(
|
||||||
|
'Returns an error message when submitting a limit order with a "$orderTimeInForce" value to a "$tradingMode" market',
|
||||||
|
async ({ tradingMode, orderTimeInForce, errorMessage }) => {
|
||||||
|
const { result } = setup({
|
||||||
|
market: { ...defaultOrder.market, tradingMode },
|
||||||
|
orderType: OrderType.Limit,
|
||||||
|
orderTimeInForce,
|
||||||
|
});
|
||||||
|
expect(result.current).toEqual(errorMessage);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each`
|
||||||
|
fieldName | errorType | errorMessage
|
||||||
|
${'size'} | ${'required'} | ${ERROR.FIELD_SIZE_REQ}
|
||||||
|
${'size'} | ${'min'} | ${ERROR.FIELD_SIZE_MIN}
|
||||||
|
${'price'} | ${'required'} | ${ERROR.FIELD_PRICE_REQ}
|
||||||
|
${'price'} | ${'min'} | ${ERROR.FIELD_PRICE_MIN}
|
||||||
|
`(
|
||||||
|
'Returns an error message when the order $fieldName "$errorType" validation fails',
|
||||||
|
async ({ fieldName, errorType, errorMessage }) => {
|
||||||
|
const { result } = setup({
|
||||||
|
fieldErrors: { [fieldName]: { type: errorType } },
|
||||||
|
});
|
||||||
|
expect(result.current).toEqual(errorMessage);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('Returns an error message when the order size incorrectly has decimal values', async () => {
|
||||||
|
const { result } = setup({
|
||||||
|
market: { ...market, positionDecimalPlaces: 0 },
|
||||||
|
fieldErrors: { size: { type: 'validate', message: ERROR_SIZE_DECIMAL } },
|
||||||
|
});
|
||||||
|
expect(result.current).toEqual(ERROR.FIELD_PRICE_STEP_NULL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Returns an error message when the order size has more decimals then allowed', async () => {
|
||||||
|
const { result } = setup({
|
||||||
|
fieldErrors: { size: { type: 'validate', message: ERROR_SIZE_DECIMAL } },
|
||||||
|
});
|
||||||
|
expect(result.current).toEqual(ERROR.FIELD_PRICE_STEP_DECIMAL);
|
||||||
|
});
|
114
libs/deal-ticket/src/hooks/use-order-validation.tsx
Normal file
114
libs/deal-ticket/src/hooks/use-order-validation.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import type { FieldErrors } from 'react-hook-form';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { t } from '@vegaprotocol/react-helpers';
|
||||||
|
import {
|
||||||
|
useVegaWallet,
|
||||||
|
OrderTimeInForce,
|
||||||
|
OrderType,
|
||||||
|
} from '@vegaprotocol/wallet';
|
||||||
|
import { MarketState, MarketTradingMode } from '@vegaprotocol/types';
|
||||||
|
import type { Order } from '../utils/get-default-order';
|
||||||
|
import type { DealTicketQuery_market } from '../__generated__/DealTicketQuery';
|
||||||
|
import { ERROR_SIZE_DECIMAL } from '../utils/validate-size';
|
||||||
|
|
||||||
|
export type ValidationProps = {
|
||||||
|
step: number;
|
||||||
|
market: DealTicketQuery_market;
|
||||||
|
orderType: OrderType;
|
||||||
|
orderTimeInForce: OrderTimeInForce;
|
||||||
|
fieldErrors?: FieldErrors<Order>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useOrderValidation = ({
|
||||||
|
step,
|
||||||
|
market,
|
||||||
|
fieldErrors = {},
|
||||||
|
orderType,
|
||||||
|
orderTimeInForce,
|
||||||
|
}: ValidationProps) => {
|
||||||
|
const { keypair } = useVegaWallet();
|
||||||
|
|
||||||
|
const invalidText = useMemo(() => {
|
||||||
|
if (!keypair) {
|
||||||
|
return t('No public key selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keypair.tainted) {
|
||||||
|
return t('Selected public key has been tainted');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (market.state !== MarketState.Active) {
|
||||||
|
if (market.state === MarketState.Suspended) {
|
||||||
|
return t('Market is currently suspended');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
market.state === MarketState.Proposed ||
|
||||||
|
market.state === MarketState.Pending
|
||||||
|
) {
|
||||||
|
return t('Market is not active yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
return t('Market is no longer active');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (market.tradingMode !== MarketTradingMode.Continuous) {
|
||||||
|
if (orderType !== OrderType.Limit) {
|
||||||
|
return t('Only limit orders are permitted when market is in auction');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
OrderTimeInForce.FOK,
|
||||||
|
OrderTimeInForce.IOC,
|
||||||
|
OrderTimeInForce.GFN,
|
||||||
|
].includes(orderTimeInForce)
|
||||||
|
) {
|
||||||
|
return t(
|
||||||
|
'Only GTT, GTC and GFA are permitted when market is in auction'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldErrors?.size?.type === 'required') {
|
||||||
|
return t('An amount needs to be provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldErrors?.size?.type === 'min') {
|
||||||
|
return t(`The amount cannot be lower than "${step}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldErrors?.price?.type === 'required') {
|
||||||
|
return t('A price needs to be provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldErrors?.price?.type === 'min') {
|
||||||
|
return t(`The price cannot be negative`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
fieldErrors?.size?.type === 'validate' &&
|
||||||
|
fieldErrors?.size?.message === ERROR_SIZE_DECIMAL
|
||||||
|
) {
|
||||||
|
if (market.positionDecimalPlaces === 0) {
|
||||||
|
return t('No decimal amounts allowed for this order');
|
||||||
|
}
|
||||||
|
return t(
|
||||||
|
`The amount field only takes up to ${market.positionDecimalPlaces} decimals`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}, [
|
||||||
|
keypair,
|
||||||
|
step,
|
||||||
|
market,
|
||||||
|
fieldErrors?.size?.type,
|
||||||
|
fieldErrors?.size?.message,
|
||||||
|
fieldErrors?.price?.type,
|
||||||
|
orderType,
|
||||||
|
orderTimeInForce,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return invalidText;
|
||||||
|
};
|
@ -1,15 +1,16 @@
|
|||||||
export * from './expiry-selector';
|
export * from './components/expiry-selector';
|
||||||
export * from './type-selector';
|
export * from './components/type-selector';
|
||||||
export * from './time-in-force-selector';
|
export * from './components/time-in-force-selector';
|
||||||
export * from './submit-button';
|
export * from './components/side-selector';
|
||||||
export * from './side-selector';
|
export * from './components/deal-ticket';
|
||||||
export * from './deal-ticket';
|
export * from './components/deal-ticket-amount';
|
||||||
export * from './deal-ticket-limit-form';
|
export * from './components/deal-ticket-limit-amount';
|
||||||
export * from './deal-ticket-market-form';
|
export * from './components/deal-ticket-market-amount';
|
||||||
export * from './deal-ticket-manager';
|
export * from './components/deal-ticket-manager';
|
||||||
export * from './order-dialog';
|
export * from './components/order-dialog';
|
||||||
export * from './use-order-state';
|
export * from './components/deal-ticket-container';
|
||||||
export * from './use-order-submit';
|
|
||||||
export * from './deal-ticket-container';
|
|
||||||
export * from './__generated__/DealTicketQuery';
|
export * from './__generated__/DealTicketQuery';
|
||||||
export * from './__generated__/OrderEvent';
|
export * from './__generated__/OrderEvent';
|
||||||
|
export * from './utils/get-default-order';
|
||||||
|
export * from './hooks/use-order-submit';
|
||||||
|
export * from './hooks/use-order-validation';
|
||||||
|
@ -1,89 +0,0 @@
|
|||||||
import { Button, InputError } from '@vegaprotocol/ui-toolkit';
|
|
||||||
import { OrderTimeInForce, OrderType } from '@vegaprotocol/wallet';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import type { Order } from './use-order-state';
|
|
||||||
import { useVegaWallet } from '@vegaprotocol/wallet';
|
|
||||||
import type { TransactionStatus } from './deal-ticket';
|
|
||||||
import type { DealTicketQuery_market } from './__generated__/DealTicketQuery';
|
|
||||||
import { MarketState, MarketTradingMode } from '@vegaprotocol/types';
|
|
||||||
import { t } from '@vegaprotocol/react-helpers';
|
|
||||||
|
|
||||||
interface SubmitButtonProps {
|
|
||||||
transactionStatus: TransactionStatus;
|
|
||||||
market: DealTicketQuery_market;
|
|
||||||
order: Order;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SubmitButton = ({
|
|
||||||
market,
|
|
||||||
transactionStatus,
|
|
||||||
order,
|
|
||||||
}: SubmitButtonProps) => {
|
|
||||||
const { keypair } = useVegaWallet();
|
|
||||||
|
|
||||||
const invalidText = useMemo(() => {
|
|
||||||
if (!keypair) {
|
|
||||||
return t('No public key selected');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keypair.tainted) {
|
|
||||||
return t('Selected public key has been tainted');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (market.state !== MarketState.Active) {
|
|
||||||
if (market.state === MarketState.Suspended) {
|
|
||||||
return t('Market is currently suspended');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
market.state === MarketState.Proposed ||
|
|
||||||
market.state === MarketState.Pending
|
|
||||||
) {
|
|
||||||
return t('Market is not active yet');
|
|
||||||
}
|
|
||||||
|
|
||||||
return t('Market is no longer active');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (market.tradingMode !== MarketTradingMode.Continuous) {
|
|
||||||
if (order.type === OrderType.Market) {
|
|
||||||
return t('Only limit orders are permitted when market is in auction');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
[
|
|
||||||
OrderTimeInForce.FOK,
|
|
||||||
OrderTimeInForce.IOC,
|
|
||||||
OrderTimeInForce.GFN,
|
|
||||||
].includes(order.timeInForce)
|
|
||||||
) {
|
|
||||||
return t(
|
|
||||||
'Only GTT, GTC and GFA are permitted when market is in auction'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}, [keypair, market, order]);
|
|
||||||
|
|
||||||
const disabled = transactionStatus === 'pending' || Boolean(invalidText);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
className="w-full mb-8"
|
|
||||||
variant="primary"
|
|
||||||
type="submit"
|
|
||||||
disabled={disabled}
|
|
||||||
data-testid="place-order"
|
|
||||||
>
|
|
||||||
{transactionStatus === 'pending' ? t('Pending...') : t('Place order')}
|
|
||||||
</Button>
|
|
||||||
{invalidText && (
|
|
||||||
<InputError className="mb-8" data-testid="dealticket-error-message">
|
|
||||||
{invalidText}
|
|
||||||
</InputError>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,79 +0,0 @@
|
|||||||
import type { OrderSide } from '@vegaprotocol/wallet';
|
|
||||||
import { OrderTimeInForce, OrderType } from '@vegaprotocol/wallet';
|
|
||||||
import { useState, useCallback } from 'react';
|
|
||||||
|
|
||||||
export interface Order {
|
|
||||||
size: string;
|
|
||||||
type: OrderType;
|
|
||||||
timeInForce: OrderTimeInForce;
|
|
||||||
side: OrderSide | null;
|
|
||||||
price?: string;
|
|
||||||
expiration?: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UpdateOrder = (order: Partial<Order>) => void;
|
|
||||||
|
|
||||||
export const useOrderState = (defaultOrder: Order): [Order, UpdateOrder] => {
|
|
||||||
const [order, setOrder] = useState<Order>(defaultOrder);
|
|
||||||
|
|
||||||
const updateOrder = useCallback((orderUpdate: Partial<Order>) => {
|
|
||||||
setOrder((curr) => {
|
|
||||||
// Type is switching to market so return new market order object with correct defaults
|
|
||||||
if (
|
|
||||||
orderUpdate.type === OrderType.Market &&
|
|
||||||
curr.type !== OrderType.Market
|
|
||||||
) {
|
|
||||||
// Check if provided TIF or current TIF is valid for a market order and default
|
|
||||||
// to IOC if its not
|
|
||||||
|
|
||||||
const isTifValid = (tif: OrderTimeInForce) => {
|
|
||||||
return tif === OrderTimeInForce.FOK || tif === OrderTimeInForce.IOC;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Default
|
|
||||||
let timeInForce = OrderTimeInForce.IOC;
|
|
||||||
|
|
||||||
if (orderUpdate.timeInForce) {
|
|
||||||
if (isTifValid(orderUpdate.timeInForce)) {
|
|
||||||
timeInForce = orderUpdate.timeInForce;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (isTifValid(curr.timeInForce)) {
|
|
||||||
timeInForce = curr.timeInForce;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: orderUpdate.type,
|
|
||||||
size: orderUpdate.size || curr.size,
|
|
||||||
side: orderUpdate.side || curr.side,
|
|
||||||
timeInForce,
|
|
||||||
price: undefined,
|
|
||||||
expiration: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type is switching to limit so return new order object with correct defaults
|
|
||||||
if (
|
|
||||||
orderUpdate.type === OrderType.Limit &&
|
|
||||||
curr.type !== OrderType.Limit
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
type: orderUpdate.type,
|
|
||||||
size: orderUpdate.size || curr.size,
|
|
||||||
side: orderUpdate.side || curr.side,
|
|
||||||
timeInForce: orderUpdate.timeInForce || curr.timeInForce,
|
|
||||||
price: orderUpdate.price || '0',
|
|
||||||
expiration: orderUpdate.expiration || undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...curr,
|
|
||||||
...orderUpdate,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return [order, updateOrder];
|
|
||||||
};
|
|
28
libs/deal-ticket/src/utils/get-default-order.ts
Normal file
28
libs/deal-ticket/src/utils/get-default-order.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { OrderTimeInForce, OrderType, OrderSide } from '@vegaprotocol/wallet';
|
||||||
|
import type { DealTicketQuery_market } from '../__generated__/DealTicketQuery';
|
||||||
|
import { toDecimal } from '@vegaprotocol/react-helpers';
|
||||||
|
|
||||||
|
export type Order =
|
||||||
|
| {
|
||||||
|
size: string;
|
||||||
|
type: OrderType.Market;
|
||||||
|
timeInForce: OrderTimeInForce;
|
||||||
|
side: OrderSide;
|
||||||
|
price?: never;
|
||||||
|
expiration?: never;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
size: string;
|
||||||
|
type: OrderType.Limit;
|
||||||
|
timeInForce: OrderTimeInForce;
|
||||||
|
side: OrderSide;
|
||||||
|
price?: string;
|
||||||
|
expiration?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDefaultOrder = (market: DealTicketQuery_market): Order => ({
|
||||||
|
type: OrderType.Market,
|
||||||
|
side: OrderSide.Buy,
|
||||||
|
timeInForce: OrderTimeInForce.IOC,
|
||||||
|
size: String(toDecimal(market.positionDecimalPlaces)),
|
||||||
|
});
|
12
libs/deal-ticket/src/utils/validate-size.ts
Normal file
12
libs/deal-ticket/src/utils/validate-size.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export const ERROR_SIZE_DECIMAL = 'step';
|
||||||
|
|
||||||
|
export const validateSize = (step: number) => {
|
||||||
|
const [, stepDecimals = ''] = String(step).split('.');
|
||||||
|
return (value: string) => {
|
||||||
|
const [, valueDecimals = ''] = value.split('.');
|
||||||
|
if (stepDecimals.length < valueDecimals.length) {
|
||||||
|
return ERROR_SIZE_DECIMAL;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
};
|
@ -3,7 +3,7 @@
|
|||||||
// @generated
|
// @generated
|
||||||
// This file was automatically generated and should not be edited.
|
// This file was automatically generated and should not be edited.
|
||||||
|
|
||||||
import { Interval, MarketState, MarketTradingMode } from "@vegaprotocol/types";
|
import { Interval } from "@vegaprotocol/types";
|
||||||
|
|
||||||
// ====================================================
|
// ====================================================
|
||||||
// GraphQL query operation: MarketList
|
// GraphQL query operation: MarketList
|
||||||
@ -15,14 +15,6 @@ export interface MarketList_markets_data_market {
|
|||||||
* Market ID
|
* Market ID
|
||||||
*/
|
*/
|
||||||
id: string;
|
id: string;
|
||||||
/**
|
|
||||||
* Current state of the market
|
|
||||||
*/
|
|
||||||
state: MarketState;
|
|
||||||
/**
|
|
||||||
* Current mode of execution of the market
|
|
||||||
*/
|
|
||||||
tradingMode: MarketTradingMode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MarketList_markets_data {
|
export interface MarketList_markets_data {
|
||||||
@ -31,14 +23,6 @@ export interface MarketList_markets_data {
|
|||||||
* market id of the associated mark price
|
* market id of the associated mark price
|
||||||
*/
|
*/
|
||||||
market: MarketList_markets_data_market;
|
market: MarketList_markets_data_market;
|
||||||
/**
|
|
||||||
* the highest price level on an order book for buy orders.
|
|
||||||
*/
|
|
||||||
bestBidPrice: string;
|
|
||||||
/**
|
|
||||||
* the lowest price level on an order book for offer orders.
|
|
||||||
*/
|
|
||||||
bestOfferPrice: string;
|
|
||||||
/**
|
/**
|
||||||
* the mark price (actually an unsigned int)
|
* the mark price (actually an unsigned int)
|
||||||
*/
|
*/
|
||||||
@ -66,7 +50,7 @@ export interface MarketList_markets_tradableInstrument_instrument {
|
|||||||
/**
|
/**
|
||||||
* Metadata for this instrument
|
* Metadata for this instrument
|
||||||
*/
|
*/
|
||||||
metadata?: MarketList_markets_tradableInstrument_instrument_metadata;
|
metadata: MarketList_markets_tradableInstrument_instrument_metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MarketList_markets_tradableInstrument {
|
export interface MarketList_markets_tradableInstrument {
|
||||||
|
@ -2,6 +2,10 @@ import { BigNumber } from 'bignumber.js';
|
|||||||
import memoize from 'lodash/memoize';
|
import memoize from 'lodash/memoize';
|
||||||
import { getUserLocale } from './utils';
|
import { getUserLocale } from './utils';
|
||||||
|
|
||||||
|
export function toDecimal(numberOfDecimals: number) {
|
||||||
|
return Math.pow(10, -numberOfDecimals);
|
||||||
|
}
|
||||||
|
|
||||||
export function addDecimal(
|
export function addDecimal(
|
||||||
value: string | number,
|
value: string | number,
|
||||||
decimals: number,
|
decimals: number,
|
||||||
|
Loading…
Reference in New Issue
Block a user