* 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
apps
simple-trading-app/src/app/components/deal-ticket
trading-e2e/src/support/mocks
libs
deal-ticket/src
__generated__
components
deal-ticket-amount.tsxdeal-ticket-container.tsxdeal-ticket-limit-amount.tsxdeal-ticket-manager.tsxdeal-ticket-market-amount.tsxdeal-ticket.spec.tsxdeal-ticket.tsxexpiry-selector.tsxorder-dialog.tsxside-selector.tsxtime-in-force-selector.tsxtype-selector.tsx
deal-ticket-limit.tsxdeal-ticket-market.tsxdeal-ticket.tsxhooks
index.tssubmit-button.tsxuse-order-state.tsutils
market-list/src/lib/components/markets-container/__generated__
react-helpers/src/lib/format
@ -1,40 +1,54 @@
|
||||
import * as React from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import Box from '@mui/material/Box';
|
||||
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 {
|
||||
ExpirySelector,
|
||||
SideSelector,
|
||||
SubmitButton,
|
||||
TimeInForceSelector,
|
||||
TypeSelector,
|
||||
useOrderState,
|
||||
getDefaultOrder,
|
||||
useOrderValidation,
|
||||
useOrderSubmit,
|
||||
DealTicketLimitForm,
|
||||
DealTicketMarketForm,
|
||||
DealTicketAmount,
|
||||
} from '@vegaprotocol/deal-ticket';
|
||||
import {
|
||||
OrderSide,
|
||||
OrderTimeInForce,
|
||||
OrderType,
|
||||
VegaTxStatus,
|
||||
} from '@vegaprotocol/wallet';
|
||||
import { addDecimal } from '@vegaprotocol/react-helpers';
|
||||
import { t, addDecimal, toDecimal } from '@vegaprotocol/react-helpers';
|
||||
|
||||
interface DealTicketMarketProps {
|
||||
market: DealTicketQuery_market;
|
||||
}
|
||||
|
||||
const DEFAULT_ORDER: Order = {
|
||||
type: OrderType.Market,
|
||||
side: OrderSide.Buy,
|
||||
size: '1',
|
||||
timeInForce: OrderTimeInForce.IOC,
|
||||
};
|
||||
|
||||
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 transactionStatus =
|
||||
@ -43,39 +57,14 @@ export const DealTicketSteps = ({ market }: DealTicketMarketProps) => {
|
||||
? 'pending'
|
||||
: 'default';
|
||||
|
||||
let ticket = null;
|
||||
|
||||
if (order.type === OrderType.Market) {
|
||||
ticket = (
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
} 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 onSubmit = React.useCallback(
|
||||
(order: Order) => {
|
||||
if (transactionStatus !== 'pending') {
|
||||
submit(order);
|
||||
}
|
||||
},
|
||||
[transactionStatus, submit]
|
||||
);
|
||||
|
||||
const steps = [
|
||||
{
|
||||
@ -87,9 +76,12 @@ export const DealTicketSteps = ({ market }: DealTicketMarketProps) => {
|
||||
label: 'Select Order Type',
|
||||
description: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`,
|
||||
component: (
|
||||
<TypeSelector
|
||||
order={order}
|
||||
onSelect={(type) => updateOrder({ type })}
|
||||
<Controller
|
||||
name="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',
|
||||
description: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`,
|
||||
component: (
|
||||
<SideSelector
|
||||
order={order}
|
||||
onSelect={(side) => updateOrder({ side })}
|
||||
<Controller
|
||||
name="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',
|
||||
description:
|
||||
'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',
|
||||
description: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`,
|
||||
component: (
|
||||
<>
|
||||
<TimeInForceSelector
|
||||
order={order}
|
||||
onSelect={(timeInForce) => updateOrder({ timeInForce })}
|
||||
<Controller
|
||||
name="timeInForce"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TimeInForceSelector
|
||||
value={field.value}
|
||||
orderType={orderType}
|
||||
onSelect={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{order.timeInForce === OrderTimeInForce.GTT && (
|
||||
<ExpirySelector
|
||||
order={order}
|
||||
onSelect={(date) => {
|
||||
if (date) {
|
||||
updateOrder({ expiration: date });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{orderType === OrderType.Limit &&
|
||||
orderTimeInForce === OrderTimeInForce.GTT && (
|
||||
<Controller
|
||||
name="expiration"
|
||||
control={control}
|
||||
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.`,
|
||||
component: (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<SubmitButton
|
||||
transactionStatus={transactionStatus}
|
||||
market={market}
|
||||
order={order}
|
||||
/>
|
||||
{invalidText && (
|
||||
<InputError className="mb-8" data-testid="dealticket-error-message">
|
||||
{invalidText}
|
||||
</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>
|
||||
),
|
||||
disabled: true,
|
||||
@ -148,7 +176,7 @@ export const DealTicketSteps = ({ market }: DealTicketMarketProps) => {
|
||||
];
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="px-4 py-8">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="px-4 py-8">
|
||||
<Stepper steps={steps} />
|
||||
</form>
|
||||
);
|
||||
|
@ -10,6 +10,7 @@ export const generateDealTicketQuery = (
|
||||
market: {
|
||||
id: 'market-id',
|
||||
decimalPlaces: 2,
|
||||
positionDecimalPlaces: 1,
|
||||
state: MarketState.Active,
|
||||
tradingMode: MarketTradingMode.Continuous,
|
||||
tradableInstrument: {
|
||||
|
@ -72,6 +72,12 @@ export interface DealTicketQuery_market {
|
||||
* GBX (pence) 1 4 GBP 0.000001 ( 0.0001p)
|
||||
*/
|
||||
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
|
||||
*/
|
||||
|
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 {
|
||||
DealTicketQuery,
|
||||
DealTicketQuery_market,
|
||||
} from './__generated__/DealTicketQuery';
|
||||
} from '../__generated__/DealTicketQuery';
|
||||
import { t } from '@vegaprotocol/react-helpers';
|
||||
|
||||
const DEAL_TICKET_QUERY = gql`
|
||||
@ -12,6 +12,7 @@ const DEAL_TICKET_QUERY = gql`
|
||||
market(id: $marketId) {
|
||||
id
|
||||
decimalPlaces
|
||||
positionDecimalPlaces
|
||||
state
|
||||
tradingMode
|
||||
tradableInstrument {
|
@ -1,30 +1,32 @@
|
||||
import { FormGroup, Input } from '@vegaprotocol/ui-toolkit';
|
||||
import { validateSize } from '../utils/validate-size';
|
||||
import type { DealTicketAmountProps } from './deal-ticket-amount';
|
||||
|
||||
export interface DealTicketLimitFormProps {
|
||||
quoteName: string;
|
||||
price?: string;
|
||||
size: string;
|
||||
onSizeChange: (size: string) => void;
|
||||
onPriceChange: (price: string) => void;
|
||||
}
|
||||
export type DealTicketLimitAmountProps = Omit<
|
||||
DealTicketAmountProps,
|
||||
'orderType'
|
||||
>;
|
||||
|
||||
export const DealTicketLimitForm = ({
|
||||
size,
|
||||
price,
|
||||
onSizeChange,
|
||||
onPriceChange,
|
||||
export const DealTicketLimitAmount = ({
|
||||
register,
|
||||
step,
|
||||
quoteName,
|
||||
}: DealTicketLimitFormProps) => {
|
||||
}: DealTicketLimitAmountProps) => {
|
||||
return (
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="flex-1">
|
||||
<FormGroup label="Amount">
|
||||
<Input
|
||||
value={size}
|
||||
onChange={(e) => onSizeChange(e.target.value)}
|
||||
className="w-full"
|
||||
type="number"
|
||||
step={step}
|
||||
min={step}
|
||||
data-testid="order-size"
|
||||
{...register('size', {
|
||||
required: true,
|
||||
min: step,
|
||||
validate: validateSize(step),
|
||||
})}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
@ -32,11 +34,12 @@ export const DealTicketLimitForm = ({
|
||||
<div className="flex-1">
|
||||
<FormGroup label={`Price (${quoteName})`} labelAlign="right">
|
||||
<Input
|
||||
value={price}
|
||||
onChange={(e) => onPriceChange(e.target.value)}
|
||||
className="w-full"
|
||||
type="number"
|
||||
step={step}
|
||||
defaultValue={0}
|
||||
data-testid="order-price"
|
||||
{...register('price', { required: true, min: 0 })}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
@ -4,9 +4,9 @@ import { Dialog, Intent } from '@vegaprotocol/ui-toolkit';
|
||||
import { OrderStatus } from '@vegaprotocol/types';
|
||||
import { VegaTxStatus } from '@vegaprotocol/wallet';
|
||||
import { DealTicket } from './deal-ticket';
|
||||
import { useOrderSubmit } from './use-order-submit';
|
||||
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 {
|
||||
market: DealTicketQuery_market;
|
@ -1,28 +1,33 @@
|
||||
import { FormGroup, Input } from '@vegaprotocol/ui-toolkit';
|
||||
import { validateSize } from '../utils/validate-size';
|
||||
import type { DealTicketAmountProps } from './deal-ticket-amount';
|
||||
|
||||
export interface DealTicketMarketFormProps {
|
||||
quoteName?: string;
|
||||
price?: string;
|
||||
size: string;
|
||||
onSizeChange: (size: string) => void;
|
||||
}
|
||||
export type DealTicketMarketAmountProps = Omit<
|
||||
DealTicketAmountProps,
|
||||
'orderType'
|
||||
>;
|
||||
|
||||
export const DealTicketMarketForm = ({
|
||||
size,
|
||||
onSizeChange,
|
||||
export const DealTicketMarketAmount = ({
|
||||
register,
|
||||
price,
|
||||
step,
|
||||
quoteName,
|
||||
}: DealTicketMarketFormProps) => {
|
||||
}: DealTicketMarketAmountProps) => {
|
||||
return (
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="flex-1">
|
||||
<FormGroup label="Amount">
|
||||
<Input
|
||||
value={size}
|
||||
onChange={(e) => onSizeChange(e.target.value)}
|
||||
className="w-full"
|
||||
type="number"
|
||||
step={step}
|
||||
min={step}
|
||||
data-testid="order-size"
|
||||
{...register('size', {
|
||||
required: true,
|
||||
min: step,
|
||||
validate: validateSize(step),
|
||||
})}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
@ -4,22 +4,17 @@ import {
|
||||
OrderType,
|
||||
} from '@vegaprotocol/wallet';
|
||||
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 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';
|
||||
|
||||
const order: Order = {
|
||||
type: OrderType.Market,
|
||||
size: '100',
|
||||
timeInForce: OrderTimeInForce.FOK,
|
||||
side: null,
|
||||
};
|
||||
const market: DealTicketQuery_market = {
|
||||
__typename: 'Market',
|
||||
id: 'market-id',
|
||||
decimalPlaces: 2,
|
||||
positionDecimalPlaces: 1,
|
||||
tradingMode: MarketTradingMode.Continuous,
|
||||
state: MarketState.Active,
|
||||
tradableInstrument: {
|
||||
@ -43,7 +38,7 @@ const market: DealTicketQuery_market = {
|
||||
const submit = jest.fn();
|
||||
const transactionStatus = 'default';
|
||||
|
||||
function generateJsx() {
|
||||
function generateJsx(order?: Order) {
|
||||
return (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<VegaWalletContext.Provider value={{} as any}>
|
||||
@ -57,21 +52,23 @@ function generateJsx() {
|
||||
);
|
||||
}
|
||||
|
||||
it('Deal ticket defaults', () => {
|
||||
it('Displays ticket defaults', () => {
|
||||
render(generateJsx());
|
||||
|
||||
// Assert defaults are used
|
||||
expect(
|
||||
screen.getByTestId(`order-type-${order.type}-selected`)
|
||||
screen.getByTestId(`order-type-${OrderType.Market}-selected`)
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('order-side-SIDE_BUY-selected')
|
||||
).not.toBeInTheDocument();
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('order-side-SIDE_SELL-selected')
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('order-size')).toHaveDisplayValue(order.size);
|
||||
expect(screen.getByTestId('order-tif')).toHaveValue(order.timeInForce);
|
||||
expect(screen.getByTestId('order-size')).toHaveDisplayValue(
|
||||
String(1 / Math.pow(10, market.positionDecimalPlaces))
|
||||
);
|
||||
expect(screen.getByTestId('order-tif')).toHaveValue(OrderTimeInForce.IOC);
|
||||
|
||||
// Assert last price is shown
|
||||
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());
|
||||
|
||||
// Asssert changing values
|
||||
fireEvent.click(screen.getByTestId('order-side-SIDE_BUY'));
|
||||
expect(
|
||||
screen.getByTestId('order-side-SIDE_BUY-selected')
|
||||
).toBeInTheDocument();
|
||||
// BUY is selected by default
|
||||
screen.getByTestId('order-side-SIDE_BUY-selected');
|
||||
|
||||
fireEvent.change(screen.getByTestId('order-size'), {
|
||||
target: { value: '200' },
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByTestId('order-size'), {
|
||||
target: { value: '200' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('order-size')).toHaveDisplayValue('200');
|
||||
|
||||
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 type { Order } from './use-order-state';
|
||||
import { formatForInput } from '@vegaprotocol/react-helpers';
|
||||
|
||||
interface ExpirySelectorProps {
|
||||
order: Order;
|
||||
value?: Date;
|
||||
onSelect: (expiration: Date | null) => void;
|
||||
}
|
||||
|
||||
export const ExpirySelector = ({ order, onSelect }: ExpirySelectorProps) => {
|
||||
const date = order.expiration ? new Date(order.expiration) : new Date();
|
||||
export const ExpirySelector = ({ value, onSelect }: ExpirySelectorProps) => {
|
||||
const date = value ? new Date(value) : new Date();
|
||||
const dateFormatted = formatForInput(date);
|
||||
const minDate = formatForInput(date);
|
||||
return (
|
@ -1,6 +1,6 @@
|
||||
import { Icon, Loader } from '@vegaprotocol/ui-toolkit';
|
||||
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 type { VegaTxState } from '@vegaprotocol/wallet';
|
||||
import { VegaTxStatus } from '@vegaprotocol/wallet';
|
@ -1,14 +1,13 @@
|
||||
import { FormGroup } from '@vegaprotocol/ui-toolkit';
|
||||
import { OrderSide } from '@vegaprotocol/wallet';
|
||||
import { Toggle } from '@vegaprotocol/ui-toolkit';
|
||||
import type { Order } from './use-order-state';
|
||||
|
||||
interface SideSelectorProps {
|
||||
order: Order;
|
||||
value: OrderSide;
|
||||
onSelect: (side: OrderSide) => void;
|
||||
}
|
||||
|
||||
export const SideSelector = ({ order, onSelect }: SideSelectorProps) => {
|
||||
export const SideSelector = ({ value, onSelect }: SideSelectorProps) => {
|
||||
const toggles = Object.entries(OrderSide).map(([label, value]) => ({
|
||||
label,
|
||||
value,
|
||||
@ -19,7 +18,7 @@ export const SideSelector = ({ order, onSelect }: SideSelectorProps) => {
|
||||
<Toggle
|
||||
name="order-side"
|
||||
toggles={toggles}
|
||||
checkedValue={order.side}
|
||||
checkedValue={value}
|
||||
onChange={(e) => onSelect(e.target.value as OrderSide)}
|
||||
/>
|
||||
</FormGroup>
|
@ -1,28 +1,30 @@
|
||||
import { FormGroup, Select } from '@vegaprotocol/ui-toolkit';
|
||||
import { OrderTimeInForce, OrderType } from '@vegaprotocol/wallet';
|
||||
import type { Order } from './use-order-state';
|
||||
|
||||
interface TimeInForceSelectorProps {
|
||||
order: Order;
|
||||
value: OrderTimeInForce;
|
||||
orderType: OrderType;
|
||||
onSelect: (tif: OrderTimeInForce) => void;
|
||||
}
|
||||
|
||||
export const TimeInForceSelector = ({
|
||||
order,
|
||||
value,
|
||||
orderType,
|
||||
onSelect,
|
||||
}: TimeInForceSelectorProps) => {
|
||||
const options =
|
||||
order.type === OrderType.Limit
|
||||
orderType === OrderType.Limit
|
||||
? Object.entries(OrderTimeInForce)
|
||||
: Object.entries(OrderTimeInForce).filter(
|
||||
([_, value]) =>
|
||||
value === OrderTimeInForce.FOK || value === OrderTimeInForce.IOC
|
||||
([_, timeInForce]) =>
|
||||
timeInForce === OrderTimeInForce.FOK ||
|
||||
timeInForce === OrderTimeInForce.IOC
|
||||
);
|
||||
|
||||
return (
|
||||
<FormGroup label="Time in force">
|
||||
<Select
|
||||
value={order.timeInForce}
|
||||
value={value}
|
||||
onChange={(e) => onSelect(e.target.value as OrderTimeInForce)}
|
||||
className="w-full"
|
||||
data-testid="order-tif"
|
@ -1,25 +1,24 @@
|
||||
import { FormGroup } from '@vegaprotocol/ui-toolkit';
|
||||
import { OrderType } from '@vegaprotocol/wallet';
|
||||
import { Toggle } from '@vegaprotocol/ui-toolkit';
|
||||
import type { Order } from './use-order-state';
|
||||
|
||||
interface TypeSelectorProps {
|
||||
order: Order;
|
||||
value: OrderType;
|
||||
onSelect: (type: OrderType) => void;
|
||||
}
|
||||
|
||||
export const TypeSelector = ({ order, onSelect }: TypeSelectorProps) => {
|
||||
const toggles = Object.entries(OrderType).map(([label, value]) => ({
|
||||
label,
|
||||
value,
|
||||
}));
|
||||
const toggles = Object.entries(OrderType).map(([label, value]) => ({
|
||||
label,
|
||||
value,
|
||||
}));
|
||||
|
||||
export const TypeSelector = ({ value, onSelect }: TypeSelectorProps) => {
|
||||
return (
|
||||
<FormGroup label="Order type">
|
||||
<Toggle
|
||||
name="order-type"
|
||||
toggles={toggles}
|
||||
checkedValue={order.type}
|
||||
checkedValue={value}
|
||||
onChange={(e) => onSelect(e.target.value as OrderType)}
|
||||
/>
|
||||
</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 { act, renderHook } from '@testing-library/react-hooks';
|
||||
import type { Order } from './use-order-state';
|
||||
import type { Order } from '../utils/get-default-order';
|
||||
import type {
|
||||
VegaKeyExtended,
|
||||
VegaWalletContextShape,
|
||||
} from '@vegaprotocol/wallet';
|
||||
import { VegaTxStatus, VegaWalletContext } from '@vegaprotocol/wallet';
|
||||
import { OrderSide, OrderTimeInForce, OrderType } from '@vegaprotocol/wallet';
|
||||
import { MarketState, MarketTradingMode } from '@vegaprotocol/types';
|
||||
import type { ReactNode } from 'react';
|
||||
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 = {
|
||||
keypair: null,
|
||||
@ -22,7 +50,7 @@ const defaultWalletContext = {
|
||||
|
||||
function setup(
|
||||
context?: Partial<VegaWalletContextShape>,
|
||||
market = { id: 'market-id', decimalPlaces: 2 }
|
||||
market = defaultMarket
|
||||
) {
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<MockedProvider>
|
||||
@ -111,20 +139,16 @@ it('Should submit a correctly formatted order', async () => {
|
||||
const keypair = {
|
||||
pub: '0x123',
|
||||
} as VegaKeyExtended;
|
||||
const market = {
|
||||
id: 'market-id',
|
||||
decimalPlaces: 2,
|
||||
};
|
||||
const { result } = setup(
|
||||
{
|
||||
sendTx: mockSendTx,
|
||||
keypairs: [keypair],
|
||||
keypair,
|
||||
},
|
||||
market
|
||||
defaultMarket
|
||||
);
|
||||
|
||||
const order = {
|
||||
const order: Order = {
|
||||
type: OrderType.Limit,
|
||||
size: '10',
|
||||
timeInForce: OrderTimeInForce.GTT,
|
||||
@ -141,12 +165,12 @@ it('Should submit a correctly formatted order', async () => {
|
||||
propagate: true,
|
||||
orderSubmission: {
|
||||
type: OrderType.Limit,
|
||||
marketId: market.id, // Market provided from hook arugment
|
||||
size: '10',
|
||||
marketId: defaultMarket.id, // Market provided from hook arugment
|
||||
size: '100', // size adjusted based on positionDecimalPlaces
|
||||
side: OrderSide.Buy,
|
||||
timeInForce: OrderTimeInForce.GTT,
|
||||
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 { 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 { determineId, removeDecimal } from '@vegaprotocol/react-helpers';
|
||||
import { useVegaTransaction } from '@vegaprotocol/wallet';
|
||||
@ -8,7 +8,8 @@ import type {
|
||||
OrderEvent,
|
||||
OrderEventVariables,
|
||||
OrderEvent_busEvents_event_Order,
|
||||
} from './__generated__/OrderEvent';
|
||||
} from '../__generated__/OrderEvent';
|
||||
import type { DealTicketQuery_market } from '../__generated__/DealTicketQuery';
|
||||
|
||||
const ORDER_EVENT_SUB = gql`
|
||||
subscription OrderEvent($partyId: ID!) {
|
||||
@ -35,12 +36,7 @@ const ORDER_EVENT_SUB = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
interface UseOrderSubmitMarket {
|
||||
id: string;
|
||||
decimalPlaces: number;
|
||||
}
|
||||
|
||||
export const useOrderSubmit = (market: UseOrderSubmitMarket) => {
|
||||
export const useOrderSubmit = (market: DealTicketQuery_market) => {
|
||||
const { keypair } = useVegaWallet();
|
||||
const { send, transaction, reset: resetTransaction } = useVegaTransaction();
|
||||
const [id, setId] = useState('');
|
||||
@ -97,7 +93,7 @@ export const useOrderSubmit = (market: UseOrderSubmitMarket) => {
|
||||
order.type === OrderType.Limit && order.price
|
||||
? removeDecimal(order.price, market.decimalPlaces)
|
||||
: undefined,
|
||||
size: order.size,
|
||||
size: removeDecimal(order.size, market.positionDecimalPlaces),
|
||||
type: order.type,
|
||||
side: order.side,
|
||||
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 './type-selector';
|
||||
export * from './time-in-force-selector';
|
||||
export * from './submit-button';
|
||||
export * from './side-selector';
|
||||
export * from './deal-ticket';
|
||||
export * from './deal-ticket-limit-form';
|
||||
export * from './deal-ticket-market-form';
|
||||
export * from './deal-ticket-manager';
|
||||
export * from './order-dialog';
|
||||
export * from './use-order-state';
|
||||
export * from './use-order-submit';
|
||||
export * from './deal-ticket-container';
|
||||
export * from './components/expiry-selector';
|
||||
export * from './components/type-selector';
|
||||
export * from './components/time-in-force-selector';
|
||||
export * from './components/side-selector';
|
||||
export * from './components/deal-ticket';
|
||||
export * from './components/deal-ticket-amount';
|
||||
export * from './components/deal-ticket-limit-amount';
|
||||
export * from './components/deal-ticket-market-amount';
|
||||
export * from './components/deal-ticket-manager';
|
||||
export * from './components/order-dialog';
|
||||
export * from './components/deal-ticket-container';
|
||||
export * from './__generated__/DealTicketQuery';
|
||||
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
|
||||
// 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
|
||||
@ -15,14 +15,6 @@ export interface MarketList_markets_data_market {
|
||||
* Market ID
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Current state of the market
|
||||
*/
|
||||
state: MarketState;
|
||||
/**
|
||||
* Current mode of execution of the market
|
||||
*/
|
||||
tradingMode: MarketTradingMode;
|
||||
}
|
||||
|
||||
export interface MarketList_markets_data {
|
||||
@ -31,14 +23,6 @@ export interface MarketList_markets_data {
|
||||
* market id of the associated mark price
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@ -66,7 +50,7 @@ export interface MarketList_markets_tradableInstrument_instrument {
|
||||
/**
|
||||
* Metadata for this instrument
|
||||
*/
|
||||
metadata?: MarketList_markets_tradableInstrument_instrument_metadata;
|
||||
metadata: MarketList_markets_tradableInstrument_instrument_metadata;
|
||||
}
|
||||
|
||||
export interface MarketList_markets_tradableInstrument {
|
||||
@ -110,14 +94,14 @@ export interface MarketList_markets {
|
||||
/**
|
||||
* decimalPlaces indicates the number of decimal places that an integer must be shifted by in order to get a correct
|
||||
* number denominated in the currency of the Market. (uint64)
|
||||
*
|
||||
*
|
||||
* Examples:
|
||||
* Currency Balance decimalPlaces Real Balance
|
||||
* GBP 100 0 GBP 100
|
||||
* GBP 100 2 GBP 1.00
|
||||
* GBP 100 4 GBP 0.01
|
||||
* GBP 1 4 GBP 0.0001 ( 0.01p )
|
||||
*
|
||||
*
|
||||
* GBX (pence) 100 0 GBP 1.00 (100p )
|
||||
* GBX (pence) 100 2 GBP 0.01 ( 1p )
|
||||
* GBX (pence) 100 4 GBP 0.0001 ( 0.01p )
|
||||
|
@ -2,6 +2,10 @@ import { BigNumber } from 'bignumber.js';
|
||||
import memoize from 'lodash/memoize';
|
||||
import { getUserLocale } from './utils';
|
||||
|
||||
export function toDecimal(numberOfDecimals: number) {
|
||||
return Math.pow(10, -numberOfDecimals);
|
||||
}
|
||||
|
||||
export function addDecimal(
|
||||
value: string | number,
|
||||
decimals: number,
|
||||
|
Loading…
Reference in New Issue
Block a user