feat(#372): fractional orders (#486)

* 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:
botond 2022-05-31 23:20:01 +01:00 committed by GitHub
parent 707ccc0136
commit bf07dac445
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 765 additions and 548 deletions

View File

@ -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 })}
price={
market.depth.lastTrade
? addDecimal(market.depth.lastTrade.price, market.decimalPlaces)
: undefined
} }
quoteName={market.tradableInstrument.instrument.product.quoteName} },
/> [transactionStatus, submit]
); );
} 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,25 +102,47 @@ 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: (
<> <>
<Controller
name="timeInForce"
control={control}
render={({ field }) => (
<TimeInForceSelector <TimeInForceSelector
order={order} value={field.value}
onSelect={(timeInForce) => updateOrder({ timeInForce })} orderType={orderType}
onSelect={field.onChange}
/> />
{order.timeInForce === OrderTimeInForce.GTT && ( )}
/>
{orderType === OrderType.Limit &&
orderTimeInForce === OrderTimeInForce.GTT && (
<Controller
name="expiration"
control={control}
render={({ field }) => (
<ExpirySelector <ExpirySelector
order={order} value={field.value}
onSelect={(date) => { onSelect={field.onChange}
if (date) { />
updateOrder({ expiration: date }); )}
}
}}
/> />
)} )}
</> </>
@ -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>
); );

View File

@ -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: {

View File

@ -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
*/ */

View 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} />;
};

View File

@ -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 {

View File

@ -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>

View File

@ -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;

View File

@ -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>

View File

@ -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();
await act(async () => {
fireEvent.change(screen.getByTestId('order-size'), { fireEvent.change(screen.getByTestId('order-size'), {
target: { value: '200' }, 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'), {

View 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>
);
};

View File

@ -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 (

View File

@ -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';

View File

@ -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>

View File

@ -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"

View File

@ -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>

View File

@ -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}
/>
</>
);
};

View File

@ -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}
/>
</>
);
};

View File

@ -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>
);
};

View File

@ -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
}, },
}); });
}); });

View File

@ -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,

View 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);
});

View 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;
};

View File

@ -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';

View File

@ -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>
)}
</>
);
};

View File

@ -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];
};

View 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)),
});

View 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;
};
};

View File

@ -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 {

View File

@ -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,