Compare commits

...
38 changed files with 951 additions and 1101 deletions
@@ -23,7 +23,7 @@ export const Footer = () => {
);
return (
<footer className="grid grid-rows-2 lg:grid-cols-[1fr_auto] text-xs md:text-md lg:flex md:col-span-2 px-4 py-2 gap-4 border-t border-vega-light-200 dark:border-vega-dark-200">
<footer className="grid grid-cols-[1fr_auto] items-center text-xs md:text-md lg:flex md:col-span-2 px-4 pt-2 pb-3 gap-4 border-t border-vega-light-200 dark:border-vega-dark-200">
<div className="flex justify-between gap-2 align-middle">
{GIT_COMMIT_HASH && (
<div className="content-center flex border-r border-neutral-700 dark:border-neutral-300 pr-4">
@@ -43,9 +43,12 @@ export const Footer = () => {
</div>
)}
<div className="content-center flex pl-2 md:border-r border-neutral-700 dark:border-neutral-300 pr-4">
{VEGA_URL && <NodeUrl url={VEGA_URL} />}
<Link className="ml-2" onClick={() => setNodeSwitcherOpen(true)}>
<div className="content-center flex pr-4 md:border-r border-neutral-700 dark:border-neutral-300">
<span className="pr-2">{VEGA_URL && <NodeUrl url={VEGA_URL} />}</span>
<Link
className="ml-2 underline-offset-4"
onClick={() => setNodeSwitcherOpen(true)}
>
{t('Change')}
</Link>
</div>
@@ -59,7 +62,10 @@ export const Footer = () => {
) : null}
</div>
<div className="pl-2 align-center lg:align-right lg:flex lg:justify-end gap-2 align-middle lg:max-w-xs lg:ml-auto">
<RouteLink to={`/${Routes.DISCLAIMER}`} className="underline">
<RouteLink
to={`/${Routes.DISCLAIMER}`}
className="underline underline-offset-4"
>
Disclaimer
</RouteLink>
</div>
@@ -63,6 +63,7 @@ describe('deal ticker order validation', { tags: '@smoke' }, () => {
cy.getByTestId(orderTIFDropDown).select('TIME_IN_FORCE_GTC');
cy.getByTestId(orderSizeField).clear().type('1');
cy.getByTestId(orderPriceField).clear().type('1.123456');
cy.getByTestId(placeOrderBtn).click();
cy.getByTestId('deal-ticket-error-message-price-limit').should(
'have.text',
'Price accepts up to 5 decimal places'
@@ -73,6 +74,7 @@ describe('deal ticker order validation', { tags: '@smoke' }, () => {
describe('market order', () => {
before(() => {
cy.getByTestId(toggleMarket).click();
cy.getByTestId(placeOrderBtn).click();
});
it('must not see the price unit', function () {
@@ -48,6 +48,7 @@ describe('suspended market validation', { tags: '@regression' }, () => {
cy.getByTestId(orderPriceField).clear().type('0.1');
cy.getByTestId(orderSizeField).clear().type('1');
cy.getByTestId(placeOrderBtn).should('be.enabled');
cy.getByTestId(placeOrderBtn).click();
cy.getByTestId('deal-ticket-warning-auction').should(
'have.text',
'Any orders placed now will not trade until the auction ends'
@@ -60,6 +61,7 @@ describe('suspended market validation', { tags: '@regression' }, () => {
TIFlist.filter((item) => item.code === 'FOK')[0].value
);
cy.getByTestId(placeOrderBtn).should('be.enabled');
cy.getByTestId(placeOrderBtn).click();
cy.getByTestId('deal-ticket-error-message-tif').should(
'have.text',
'This market is in auction until it reaches sufficient liquidity. Until the auction ends, you can only place GFA, GTT, or GTC limit orders'
@@ -1,27 +1,15 @@
import { OrderbookManager } from '@vegaprotocol/market-depth';
import { useCreateOrderStore } from '@vegaprotocol/orders';
import { ViewType, useSidebar } from '../sidebar';
import { useStopOrderFormValues } from '@vegaprotocol/deal-ticket';
import { useDealTicketFormValues } from '@vegaprotocol/deal-ticket';
export const OrderbookContainer = ({ marketId }: { marketId: string }) => {
const useOrderStoreRef = useCreateOrderStore();
const updateOrder = useOrderStoreRef((store) => store.update);
const updateStoredFormValues = useStopOrderFormValues(
(state) => state.update
);
const update = useDealTicketFormValues((state) => state.updateAll);
const setView = useSidebar((store) => store.setView);
return (
<OrderbookManager
marketId={marketId}
onClick={({ price, size }) => {
if (price) {
updateOrder(marketId, { price });
updateStoredFormValues(marketId, { price });
}
if (size) {
updateOrder(marketId, { size });
updateStoredFormValues(marketId, { size });
}
onClick={(values) => {
update(marketId, values);
setView({ type: ViewType.Order });
}}
/>
@@ -0,0 +1,31 @@
import type { VegaWalletContextShape } from '@vegaprotocol/wallet';
import { VegaWalletContext } from '@vegaprotocol/wallet';
import { GetStarted } from './get-started';
import { render, screen } from '@testing-library/react';
describe('GetStarted', () => {
const renderComponent = (context: Partial<VegaWalletContextShape> = {}) => {
return render(
<VegaWalletContext.Provider value={context as VegaWalletContextShape}>
<GetStarted />
</VegaWalletContext.Provider>
);
};
it('renders full get started content if not connected and no browser wallet detected', () => {
renderComponent();
expect(screen.getByTestId('get-started-banner')).toBeInTheDocument();
});
it('renders connect prompt if no pubKey but wallet installed', () => {
globalThis.window.vega = {} as Vega;
renderComponent();
expect(screen.getByTestId('order-connect-wallet')).toBeInTheDocument();
globalThis.window.vega = undefined as unknown as Vega;
});
it('renders nothing if connected', () => {
const { container } = renderComponent({ pubKey: 'my-pubkey' });
expect(container).toBeEmptyDOMElement();
});
});
@@ -16,7 +16,6 @@ interface Props {
export const GetStarted = ({ lead }: Props) => {
const { pubKey } = useVegaWallet();
const { VEGA_ENV, VEGA_NETWORKS } = useEnvironment();
const CANONICAL_URL = VEGA_NETWORKS[VEGA_ENV] || 'https://console.vega.xyz';
@@ -40,7 +39,7 @@ export const GetStarted = ({ lead }: Props) => {
{ 'mt-8': !lead }
);
if (!isBrowserWalletInstalled()) {
if (!pubKey && !isBrowserWalletInstalled()) {
return (
<div className={wrapperClasses} data-testid="get-started-banner">
{lead && <h2>{lead}</h2>}
@@ -3,29 +3,25 @@ import type { Market, StaticMarketData } from '@vegaprotocol/markets';
import { DealTicketMarketAmount } from './deal-ticket-market-amount';
import { DealTicketLimitAmount } from './deal-ticket-limit-amount';
import * as Schema from '@vegaprotocol/types';
import type { OrderObj } from '@vegaprotocol/orders';
import type { OrderFormFields } from '../../hooks/use-order-form';
import type { OrderFormValues } from '../../hooks/use-form-values';
export interface DealTicketAmountProps {
control: Control<OrderFormFields>;
orderType: Schema.OrderType;
control: Control<OrderFormValues>;
type: Schema.OrderType;
marketData: StaticMarketData;
marketPrice?: string;
market: Market;
sizeError?: string;
priceError?: string;
update: (obj: Partial<OrderObj>) => void;
size: string;
price?: string;
}
export const DealTicketAmount = ({
orderType,
type,
marketData,
marketPrice,
...props
}: DealTicketAmountProps) => {
switch (orderType) {
switch (type) {
case Schema.OrderType.TYPE_MARKET:
return (
<DealTicketMarketAmount
@@ -37,7 +33,7 @@ export const DealTicketAmount = ({
case Schema.OrderType.TYPE_LIMIT:
return <DealTicketLimitAmount {...props} />;
default: {
throw new Error('Invalid ticket type');
throw new Error('Invalid ticket type ' + type);
}
}
};
@@ -1,8 +1,8 @@
import { useVegaTransactionStore } from '@vegaprotocol/wallet';
import {
DealTicketType,
useDealTicketTypeStore,
} from '../../hooks/use-type-store';
isStopOrderType,
useDealTicketFormValues,
} from '../../hooks/use-form-values';
import { StopOrder } from './deal-ticket-stop-order';
import {
useStaticMarketData,
@@ -25,7 +25,9 @@ export const DealTicketContainer = ({
marketId,
...props
}: DealTicketContainerProps) => {
const type = useDealTicketTypeStore((state) => state.type[marketId]);
const showStopOrder = useDealTicketFormValues((state) =>
isStopOrderType(state.formValues[marketId]?.type)
);
const {
data: market,
error: marketError,
@@ -48,9 +50,7 @@ export const DealTicketContainer = ({
reload={reload}
>
{market && marketData ? (
FLAGS.STOP_ORDERS &&
(type === DealTicketType.StopLimit ||
type === DealTicketType.StopMarket) ? (
FLAGS.STOP_ORDERS && showStopOrder ? (
<StopOrder
market={market}
marketPrice={marketPrice}
@@ -5,8 +5,8 @@ import type { DealTicketAmountProps } from './deal-ticket-amount';
import { Controller } from 'react-hook-form';
export type DealTicketLimitAmountProps = Omit<
Omit<DealTicketAmountProps, 'marketData'>,
'orderType'
DealTicketAmountProps,
'marketData' | 'type'
>;
export const DealTicketLimitAmount = ({
@@ -14,9 +14,6 @@ export const DealTicketLimitAmount = ({
market,
sizeError,
priceError,
update,
price,
size,
}: DealTicketLimitAmountProps) => {
const priceStep = toDecimal(market?.decimalPlaces);
const sizeStep = toDecimal(market?.positionDecimalPlaces);
@@ -62,17 +59,16 @@ export const DealTicketLimitAmount = ({
},
validate: validateAmount(sizeStep, 'Size'),
}}
render={() => (
render={({ field }) => (
<Input
id="input-order-size-limit"
className="w-full"
type="number"
value={size}
onChange={(e) => update({ size: e.target.value })}
step={sizeStep}
min={sizeStep}
data-testid="order-size"
onWheel={(e) => e.currentTarget.blur()}
{...field}
/>
)}
/>
@@ -95,19 +91,17 @@ export const DealTicketLimitAmount = ({
value: priceStep,
message: t('Price cannot be lower than ' + priceStep),
},
// @ts-ignore this fulfills the interface but still errors
validate: validateAmount(priceStep, 'Price'),
}}
render={() => (
render={({ field }) => (
<Input
id="input-price-quote"
className="w-full"
type="number"
value={price}
onChange={(e) => update({ price: e.target.value })}
step={priceStep}
data-testid="order-price"
onWheel={(e) => e.currentTarget.blur()}
{...field}
/>
)}
/>
@@ -10,10 +10,7 @@ import type { DealTicketAmountProps } from './deal-ticket-amount';
import { Controller } from 'react-hook-form';
import classNames from 'classnames';
export type DealTicketMarketAmountProps = Omit<
DealTicketAmountProps,
'orderType'
>;
export type DealTicketMarketAmountProps = Omit<DealTicketAmountProps, 'type'>;
export const DealTicketMarketAmount = ({
control,
@@ -21,8 +18,6 @@ export const DealTicketMarketAmount = ({
marketData,
marketPrice,
sizeError,
update,
size,
}: DealTicketMarketAmountProps) => {
const quoteName = market.tradableInstrument.instrument.product.quoteName;
const sizeStep = toDecimal(market?.positionDecimalPlaces);
@@ -50,17 +45,16 @@ export const DealTicketMarketAmount = ({
},
validate: validateAmount(sizeStep, 'Size'),
}}
render={() => (
render={({ field }) => (
<Input
id="input-order-size-market"
className="w-full"
type="number"
value={size}
onChange={(e) => update({ size: e.target.value })}
step={sizeStep}
min={sizeStep}
onWheel={(e) => e.currentTarget.blur()}
data-testid="order-size"
{...field}
/>
)}
/>
@@ -1,7 +1,6 @@
import { Controller, type Control } from 'react-hook-form';
import type { Market } from '@vegaprotocol/markets';
import type { OrderObj } from '@vegaprotocol/orders';
import type { OrderFormFields } from '../../hooks/use-order-form';
import type { OrderFormValues } from '../../hooks/use-form-values';
import { toDecimal, validateAmount } from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n';
import {
@@ -12,25 +11,21 @@ import {
} from '@vegaprotocol/ui-toolkit';
export interface DealTicketSizeIcebergProps {
control: Control<OrderFormFields>;
control: Control<OrderFormValues>;
market: Market;
peakSizeError?: string;
minimumVisibleSizeError?: string;
update: (obj: Partial<OrderObj>) => void;
peakSize: string;
minimumVisibleSize: string;
size: string;
peakSize?: string;
}
export const DealTicketSizeIceberg = ({
control,
market,
update,
peakSizeError,
minimumVisibleSizeError,
peakSize,
minimumVisibleSize,
size,
peakSize,
}: DealTicketSizeIcebergProps) => {
const sizeStep = toDecimal(market?.positionDecimalPlaces);
@@ -80,7 +75,7 @@ export const DealTicketSizeIceberg = ({
className="!mb-1"
>
<Controller
name="icebergOpts.peakSize"
name="peakSize"
control={control}
rules={{
required: t('You need to provide a peak size'),
@@ -97,25 +92,17 @@ export const DealTicketSizeIceberg = ({
},
validate: validateAmount(sizeStep, 'peakSize'),
}}
render={() => (
render={({ field }) => (
<Input
id="input-order-peak-size"
className="w-full"
type="number"
value={peakSize}
onChange={(e) =>
update({
icebergOpts: {
peakSize: e.target.value,
minimumVisibleSize,
},
})
}
step={sizeStep}
min={sizeStep}
max={size}
data-testid="order-peak-size"
onWheel={(e) => e.currentTarget.blur()}
{...field}
/>
)}
/>
@@ -144,7 +131,7 @@ export const DealTicketSizeIceberg = ({
className="!mb-1"
>
<Controller
name="icebergOpts.minimumVisibleSize"
name="minimumVisibleSize"
control={control}
rules={{
required: t('You need to provide a minimum visible size'),
@@ -154,7 +141,7 @@ export const DealTicketSizeIceberg = ({
'Minimum visible size cannot be lower than ' + sizeStep
),
},
max: {
max: peakSize && {
value: peakSize,
message: t(
'Minimum visible size cannot be greater than the peak size (%s)',
@@ -163,25 +150,17 @@ export const DealTicketSizeIceberg = ({
},
validate: validateAmount(sizeStep, 'minimumVisibleSize'),
}}
render={() => (
render={({ field }) => (
<Input
id="input-order-minimum-size"
className="w-full"
type="number"
value={minimumVisibleSize}
onChange={(e) =>
update({
icebergOpts: {
peakSize,
minimumVisibleSize: e.target.value,
},
})
}
step={sizeStep}
min={sizeStep}
max={peakSize}
data-testid="order-minimum-size"
onWheel={(e) => e.currentTarget.blur()}
{...field}
/>
)}
/>
@@ -6,8 +6,11 @@ import { generateMarket } from '../../test-helpers';
import { StopOrder } from './deal-ticket-stop-order';
import * as Schema from '@vegaprotocol/types';
import { MockedProvider } from '@apollo/client/testing';
import type { StopOrderFormValues } from '../../hooks/use-stop-order-form-values';
import { useStopOrderFormValues } from '../../hooks/use-stop-order-form-values';
import type { StopOrderFormValues } from '../../hooks/use-form-values';
import {
DealTicketType,
useDealTicketFormValues,
} from '../../hooks/use-form-values';
import type { FeatureFlags } from '@vegaprotocol/environment';
jest.mock('zustand');
@@ -131,9 +134,11 @@ describe('StopOrder', () => {
expiresAt: '2023-07-27T16:43:27.000',
};
useStopOrderFormValues.setState({
useDealTicketFormValues.setState({
formValues: {
[market.id]: values,
[market.id]: {
[DealTicketType.StopLimit]: values,
},
},
});
@@ -207,11 +212,13 @@ describe('StopOrder', () => {
// switch to market order type error should disappear
await userEvent.click(screen.getByTestId(orderTypeTrigger));
await userEvent.click(screen.getByTestId(orderTypeMarket));
await userEvent.click(screen.getByTestId(submitButton));
expect(screen.queryByTestId(priceErrorMessage)).toBeNull();
// switch back to limit type
await userEvent.click(screen.getByTestId(orderTypeTrigger));
await userEvent.click(screen.getByTestId(orderTypeLimit));
await userEvent.click(screen.getByTestId(submitButton));
expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument();
// to small value should be invalid
@@ -1,4 +1,3 @@
import type { FormEventHandler } from 'react';
import { useRef, useCallback, useEffect } from 'react';
import { useVegaWallet } from '@vegaprotocol/wallet';
import type { StopOrdersSubmission } from '@vegaprotocol/wallet';
@@ -8,7 +7,7 @@ import {
toDecimal,
validateAmount,
} from '@vegaprotocol/utils';
import { useForm, Controller } from 'react-hook-form';
import { useForm, Controller, useController } from 'react-hook-form';
import * as Schema from '@vegaprotocol/types';
import {
Radio,
@@ -24,22 +23,22 @@ import { getDerivedPrice, type Market } from '@vegaprotocol/markets';
import { t } from '@vegaprotocol/i18n';
import { ExpirySelector } from './expiry-selector';
import { SideSelector } from './side-selector';
import { timeInForceLabel, useOrder } from '@vegaprotocol/orders';
import { timeInForceLabel } from '@vegaprotocol/orders';
import {
NoWalletWarning,
REDUCE_ONLY_TOOLTIP,
useNotionalSize,
stopSubmit,
getNotionalSize,
} from './deal-ticket';
import { TypeToggle } from './type-selector';
import {
useStopOrderFormValues,
type StopOrderFormValues,
} from '../../hooks/use-stop-order-form-values';
import {
useDealTicketFormValues,
DealTicketType,
useDealTicketTypeStore,
} from '../../hooks/use-type-store';
import { mapFormValuesToStopOrdersSubmission } from '../../utils/map-form-values-to-stop-order-submission';
type StopOrderFormValues,
dealTicketTypeToOrderType,
isStopOrderType,
} from '../../hooks/use-form-values';
import { mapFormValuesToStopOrdersSubmission } from '../../utils/map-form-values-to-submission';
import { DealTicketButton } from './deal-ticket-button';
import { DealTicketFeeDetails } from './deal-ticket-fee-details';
import { validateExpiration } from '../../utils';
@@ -50,32 +49,36 @@ export interface StopOrderProps {
submit: (order: StopOrdersSubmission) => void;
}
const defaultValues: Partial<StopOrderFormValues> = {
type: Schema.OrderType.TYPE_LIMIT,
const getDefaultValues = (
type: Schema.OrderType,
storedValues?: Partial<StopOrderFormValues>
): StopOrderFormValues => ({
type,
side: Schema.Side.SIDE_BUY,
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_FOK,
triggerType: 'price',
triggerDirection:
Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_RISES_ABOVE,
expire: false,
expiryStrategy: Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT,
size: '0',
};
const stopSubmit: FormEventHandler = (e) => e.preventDefault();
...storedValues,
});
export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
const { pubKey, isReadOnly } = useVegaWallet();
const setDealTicketType = useDealTicketTypeStore((state) => state.set);
const [, updateOrder] = useOrder(market.id);
const updateStoredFormValues = useStopOrderFormValues(
(state) => state.update
const setType = useDealTicketFormValues((state) => state.setType);
const updateStoredFormValues = useDealTicketFormValues(
(state) => state.updateStopOrder
);
const storedFormValues = useStopOrderFormValues(
const storedFormValues = useDealTicketFormValues(
(state) => state.formValues[market.id]
);
const { handleSubmit, setValue, watch, control, formState } =
const dealTicketType = storedFormValues?.type ?? DealTicketType.StopLimit;
const type = dealTicketTypeToOrderType(dealTicketType);
const { handleSubmit, setValue, watch, control, formState, reset } =
useForm<StopOrderFormValues>({
defaultValues: { ...defaultValues, ...storedFormValues },
defaultValues: getDefaultValues(type, storedFormValues?.[dealTicketType]),
});
const { errors } = formState;
const lastSubmitTime = useRef(0);
@@ -102,16 +105,22 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
const triggerType = watch('triggerType');
const triggerPrice = watch('triggerPrice');
const timeInForce = watch('timeInForce');
const type = watch('type');
const rawPrice = watch('price');
const rawSize = watch('size');
if (storedFormValues?.size && rawSize !== storedFormValues?.size) {
setValue('size', storedFormValues.size);
}
if (storedFormValues?.price && rawPrice !== storedFormValues?.price) {
setValue('price', storedFormValues.price);
}
useEffect(() => {
const size = storedFormValues?.[dealTicketType]?.size;
if (size && rawSize !== size) {
setValue('size', size);
}
}, [storedFormValues, dealTicketType, rawSize, setValue]);
useEffect(() => {
const price = storedFormValues?.[dealTicketType]?.price;
if (price && rawPrice !== price) {
setValue('price', price);
}
}, [storedFormValues, dealTicketType, rawPrice, setValue]);
const isPriceTrigger = triggerType === 'price';
const size = removeDecimal(rawSize, market.positionDecimalPlaces);
@@ -127,7 +136,7 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
: marketPrice
);
const notionalSize = useNotionalSize(
const notionalSize = getNotionalSize(
price,
size,
market.decimalPlaces,
@@ -153,47 +162,28 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
? formatNumber(triggerPrice, market.decimalPlaces)
: undefined;
useController({
name: 'type',
control,
});
return (
<form
onSubmit={isReadOnly || !pubKey ? stopSubmit : handleSubmit(onSubmit)}
noValidate
>
<Controller
name="type"
control={control}
render={({ field }) => {
const { value } = field;
return (
<TypeToggle
value={
value === Schema.OrderType.TYPE_LIMIT
? DealTicketType.StopLimit
: DealTicketType.StopMarket
}
onValueChange={(value) => {
const type = value as DealTicketType;
setDealTicketType(market.id, type);
if (
type === DealTicketType.Limit ||
type === DealTicketType.Market
) {
updateOrder({
type:
type === DealTicketType.Limit
? Schema.OrderType.TYPE_LIMIT
: Schema.OrderType.TYPE_MARKET,
});
return;
}
setValue(
'type',
type === DealTicketType.StopLimit
? Schema.OrderType.TYPE_LIMIT
: Schema.OrderType.TYPE_MARKET
);
}}
/>
);
<TypeToggle
value={dealTicketType}
onValueChange={(dealTicketType) => {
setType(market.id, dealTicketType);
if (isStopOrderType(dealTicketType)) {
reset(
getDefaultValues(
dealTicketTypeToOrderType(dealTicketType),
storedFormValues?.[dealTicketType]
)
);
}
}}
/>
{errors.type && (
@@ -1,12 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { VegaWalletContext } from '@vegaprotocol/wallet';
import {
act,
render,
renderHook,
screen,
waitFor,
} from '@testing-library/react';
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { generateMarket, generateMarketData } from '../../test-helpers';
import { DealTicket } from './deal-ticket';
@@ -15,7 +9,10 @@ import type { MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing';
import { addDecimal } from '@vegaprotocol/utils';
import type { OrdersQuery } from '@vegaprotocol/orders';
import { useCreateOrderStore } from '@vegaprotocol/orders';
import {
DealTicketType,
useDealTicketFormValues,
} from '../../hooks/use-form-values';
import * as positionsTools from '@vegaprotocol/positions';
import { OrdersDocument } from '@vegaprotocol/orders';
@@ -50,9 +47,6 @@ function generateJsx(mocks: MockedResponse[] = []) {
}
describe('DealTicket', () => {
const { result } = renderHook(() => useCreateOrderStore());
const useOrderStore = result.current;
beforeEach(() => {
jest.clearAllMocks();
localStorage.clear();
@@ -166,9 +160,11 @@ describe('DealTicket', () => {
persist: true,
};
useOrderStore.setState({
orders: {
[expectedOrder.marketId]: expectedOrder,
useDealTicketFormValues.setState({
formValues: {
[expectedOrder.marketId]: {
[DealTicketType.Limit]: expectedOrder,
},
},
});
@@ -204,9 +200,11 @@ describe('DealTicket', () => {
reduceOnly: true,
postOnly: false,
};
useOrderStore.setState({
orders: {
[expectedOrder.marketId]: expectedOrder,
useDealTicketFormValues.setState({
formValues: {
[expectedOrder.marketId]: {
[DealTicketType.Limit]: expectedOrder,
},
},
});
@@ -247,9 +245,11 @@ describe('DealTicket', () => {
postOnly: true,
};
useOrderStore.setState({
orders: {
[expectedOrder.marketId]: expectedOrder,
useDealTicketFormValues.setState({
formValues: {
[expectedOrder.marketId]: {
[DealTicketType.Limit]: expectedOrder,
},
},
});
@@ -295,9 +295,11 @@ describe('DealTicket', () => {
},
};
useOrderStore.setState({
orders: {
[expectedOrder.marketId]: expectedOrder,
useDealTicketFormValues.setState({
formValues: {
[expectedOrder.marketId]: {
[DealTicketType.Limit]: expectedOrder,
},
},
});
@@ -339,9 +341,11 @@ describe('DealTicket', () => {
reduceOnly: false,
postOnly: false,
};
useOrderStore.setState({
orders: {
[expectedOrder.marketId]: expectedOrder,
useDealTicketFormValues.setState({
formValues: {
[expectedOrder.marketId]: {
[DealTicketType.Limit]: expectedOrder,
},
},
});
@@ -370,6 +374,7 @@ describe('DealTicket', () => {
expect(screen.getByTestId('iceberg')).not.toBeChecked();
});
// eslint-disable-next-line jest/no-disabled-tests
it('handles TIF select box dependent on order type', async () => {
render(generateJsx());
@@ -1,7 +1,8 @@
import { t } from '@vegaprotocol/i18n';
import * as Schema from '@vegaprotocol/types';
import { memo, useCallback, useEffect, useState, useRef, useMemo } from 'react';
import { Controller } from 'react-hook-form';
import type { FormEventHandler } from 'react';
import { memo, useCallback, useEffect, useRef, useMemo } from 'react';
import { Controller, useController, useForm } from 'react-hook-form';
import { DealTicketAmount } from './deal-ticket-amount';
import { DealTicketButton } from './deal-ticket-button';
import {
@@ -13,7 +14,8 @@ import { SideSelector } from './side-selector';
import { TimeInForceSelector } from './time-in-force-selector';
import { TypeSelector } from './type-selector';
import type { OrderSubmission } from '@vegaprotocol/wallet';
import { normalizeOrderSubmission, useVegaWallet } from '@vegaprotocol/wallet';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { mapFormValuesToOrderSubmission } from '../../utils/map-form-values-to-submission';
import {
Checkbox,
InputError,
@@ -51,14 +53,15 @@ import {
useAccountBalance,
} from '@vegaprotocol/accounts';
import { OrderTimeInForce, OrderType } from '@vegaprotocol/types';
import { useOrderForm } from '../../hooks/use-order-form';
import { OrderType } from '@vegaprotocol/types';
import { useDataProvider } from '@vegaprotocol/data-provider';
import {
DealTicketType,
useDealTicketTypeStore,
} from '../../hooks/use-type-store';
import { useStopOrderFormValues } from '../../hooks/use-stop-order-form-values';
dealTicketTypeToOrderType,
isStopOrderType,
} from '../../hooks/use-form-values';
import type { OrderFormValues } from '../../hooks/use-form-values';
import { useDealTicketFormValues } from '../../hooks/use-form-values';
import { DealTicketSizeIceberg } from './deal-ticket-size-iceberg';
import noop from 'lodash/noop';
@@ -75,23 +78,42 @@ export interface DealTicketProps {
onDeposit: (assetId: string) => void;
}
export const useNotionalSize = (
export const getNotionalSize = (
price: string | null | undefined,
size: string | undefined,
decimalPlaces: number,
positionDecimalPlaces: number
) =>
useMemo(() => {
if (price && size) {
return removeDecimal(
toBigNum(size, positionDecimalPlaces).multipliedBy(
toBigNum(price, decimalPlaces)
),
decimalPlaces
);
}
return null;
}, [price, size, decimalPlaces, positionDecimalPlaces]);
) => {
if (price && size) {
return removeDecimal(
toBigNum(size, positionDecimalPlaces).multipliedBy(
toBigNum(price, decimalPlaces)
),
decimalPlaces
);
}
return null;
};
export const stopSubmit: FormEventHandler = (e) => e.preventDefault();
const getDefaultValues = (
type: Schema.OrderType,
storedValues?: Partial<OrderFormValues>
): OrderFormValues => ({
type,
side: Schema.Side.SIDE_BUY,
timeInForce:
type === Schema.OrderType.TYPE_LIMIT
? Schema.OrderTimeInForce.TIME_IN_FORCE_GTC
: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC,
size: '0',
price: '0',
expiresAt: undefined,
postOnly: false,
reduceOnly: false,
...storedValues,
});
export const DealTicket = ({
market,
@@ -103,32 +125,29 @@ export const DealTicket = ({
onDeposit,
}: DealTicketProps) => {
const { pubKey, isReadOnly } = useVegaWallet();
const setDealTicketType = useDealTicketTypeStore((state) => state.set);
const updateStopOrderFormValues = useStopOrderFormValues(
(state) => state.update
const setType = useDealTicketFormValues((state) => state.setType);
const storedFormValues = useDealTicketFormValues(
(state) => state.formValues[market.id]
);
// store last used tif for market so that when changing OrderType the previous TIF
// selection for that type is used when switching back
const [lastTIF, setLastTIF] = useState({
[OrderType.TYPE_MARKET]: OrderTimeInForce.TIME_IN_FORCE_IOC,
[OrderType.TYPE_LIMIT]: OrderTimeInForce.TIME_IN_FORCE_GTC,
});
const updateStoredFormValues = useDealTicketFormValues(
(state) => state.updateOrder
);
const dealTicketType = storedFormValues?.type ?? DealTicketType.Limit;
const type = dealTicketTypeToOrderType(dealTicketType);
const {
control,
errors,
order,
setError,
clearErrors,
update,
reset,
formState: { errors },
handleSubmit,
} = useOrderForm(market.id);
setValue,
watch,
} = useForm<OrderFormValues>({
defaultValues: getDefaultValues(type, storedFormValues?.[dealTicketType]),
});
const lastSubmitTime = useRef(0);
const asset = market.tradableInstrument.instrument.product.settlementAsset;
const {
accountBalance: marginAccountBalance,
loading: loadingMarginAccountBalance,
@@ -144,24 +163,54 @@ export const DealTicket = ({
).toString();
const { marketState, marketTradingMode } = marketData;
const timeInForce = watch('timeInForce');
const normalizedOrder =
order &&
normalizeOrderSubmission(
order,
market.decimalPlaces,
market.positionDecimalPlaces
);
const side = watch('side');
const rawSize = watch('size');
const rawPrice = watch('price');
const iceberg = watch('iceberg');
const peakSize = watch('peakSize');
const price = useMemo(() => {
return (
normalizedOrder &&
marketPrice &&
getDerivedPrice(normalizedOrder, marketPrice)
);
}, [normalizedOrder, marketPrice]);
useEffect(() => {
const size = storedFormValues?.[dealTicketType]?.size;
if (size && rawSize !== size) {
setValue('size', size);
}
}, [storedFormValues, dealTicketType, rawSize, setValue]);
const notionalSize = useNotionalSize(
useEffect(() => {
const price = storedFormValues?.[dealTicketType]?.price;
if (price && rawPrice !== price) {
setValue('price', price);
}
}, [storedFormValues, dealTicketType, rawPrice, setValue]);
useEffect(() => {
const subscription = watch((value, { name, type }) => {
updateStoredFormValues(market.id, value);
});
return () => subscription.unsubscribe();
}, [watch, market.id, updateStoredFormValues]);
const normalizedOrder = mapFormValuesToOrderSubmission(
{
price: rawPrice || undefined,
side,
size: rawSize,
timeInForce,
type,
},
market.id,
market.decimalPlaces,
market.positionDecimalPlaces
);
const price =
normalizedOrder &&
marketPrice &&
getDerivedPrice(normalizedOrder, marketPrice);
const notionalSize = getNotionalSize(
price,
normalizedOrder?.size,
market.decimalPlaces,
@@ -205,22 +254,20 @@ export const DealTicket = ({
const assetSymbol =
market.tradableInstrument.instrument.product.settlementAsset.symbol;
useEffect(() => {
const summaryError = useMemo(() => {
if (!pubKey) {
setError('summary', {
return {
message: t('No public key selected'),
type: SummaryValidationType.NoPubKey,
});
return;
};
}
const marketStateError = validateMarketState(marketState);
if (marketStateError !== true) {
setError('summary', {
return {
message: marketStateError,
type: SummaryValidationType.MarketState,
});
return;
};
}
const hasNoBalance =
@@ -229,24 +276,21 @@ export const DealTicket = ({
hasNoBalance &&
!(loadingMarginAccountBalance || loadingGeneralAccountBalance)
) {
setError('summary', {
return {
message: SummaryValidationType.NoCollateral,
type: SummaryValidationType.NoCollateral,
});
return;
};
}
const marketTradingModeError = validateMarketTradingMode(marketTradingMode);
if (marketTradingModeError !== true) {
setError('summary', {
return {
message: marketTradingModeError,
type: SummaryValidationType.TradingMode,
});
return;
};
}
// No error found above clear the error in case it was active on a previous render
clearErrors('summary');
return undefined;
}, [
marketState,
marketTradingMode,
@@ -255,156 +299,83 @@ export const DealTicket = ({
loadingMarginAccountBalance,
loadingGeneralAccountBalance,
pubKey,
setError,
clearErrors,
]);
const disablePostOnlyCheckbox = useMemo(() => {
const disabled = order
? [
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC,
Schema.OrderTimeInForce.TIME_IN_FORCE_FOK,
].includes(order.timeInForce)
: true;
return disabled;
}, [order]);
const disablePostOnlyCheckbox = [
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC,
Schema.OrderTimeInForce.TIME_IN_FORCE_FOK,
].includes(timeInForce);
const disableReduceOnlyCheckbox = useMemo(() => {
const disabled = order
? ![
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC,
Schema.OrderTimeInForce.TIME_IN_FORCE_FOK,
].includes(order.timeInForce)
: true;
return disabled;
}, [order]);
const disableReduceOnlyCheckbox = !disablePostOnlyCheckbox;
const onSubmit = useCallback(
(order: OrderSubmission) => {
(formValues: OrderFormValues) => {
const now = new Date().getTime();
if (lastSubmitTime.current && now - lastSubmitTime.current < 1000) {
return;
}
submit(
normalizeOrderSubmission(
order,
mapFormValuesToOrderSubmission(
formValues,
market.id,
market.decimalPlaces,
market.positionDecimalPlaces
)
);
lastSubmitTime.current = now;
},
[submit, market.decimalPlaces, market.positionDecimalPlaces]
[submit, market.decimalPlaces, market.positionDecimalPlaces, market.id]
);
// if an order doesn't exist one will be created by the store immediately
if (!order || !normalizedOrder) {
return null;
}
useController({
name: 'type',
control,
rules: {
validate: validateType(marketData.marketTradingMode, marketData.trigger),
},
});
return (
<form
onSubmit={isReadOnly ? noop : handleSubmit(onSubmit)}
onSubmit={
isReadOnly || !pubKey
? stopSubmit
: handleSubmit(summaryError ? noop : onSubmit)
}
noValidate
data-testid="deal-ticket-form"
>
<Controller
name="type"
control={control}
rules={{
validate: validateType(
marketData.marketTradingMode,
marketData.trigger
),
<TypeSelector
value={dealTicketType}
onValueChange={(dealTicketType) => {
setType(market.id, dealTicketType);
if (!isStopOrderType(dealTicketType)) {
reset(
getDefaultValues(
dealTicketTypeToOrderType(dealTicketType),
storedFormValues?.[dealTicketType]
)
);
}
}}
render={() => (
<TypeSelector
value={
order.type === OrderType.TYPE_LIMIT
? DealTicketType.Limit
: DealTicketType.Market
}
onValueChange={(dealTicketType) => {
setDealTicketType(market.id, dealTicketType);
if (
dealTicketType !== DealTicketType.Limit &&
dealTicketType !== DealTicketType.Market
) {
updateStopOrderFormValues(market.id, {
type:
dealTicketType === DealTicketType.StopLimit
? OrderType.TYPE_LIMIT
: OrderType.TYPE_MARKET,
});
return;
}
const type =
dealTicketType === DealTicketType.Limit
? OrderType.TYPE_LIMIT
: OrderType.TYPE_MARKET;
update({
type,
// when changing type also update the TIF to what was last used of new type
timeInForce: lastTIF[type] || order.timeInForce,
postOnly:
type === OrderType.TYPE_MARKET ? false : order.postOnly,
iceberg:
type === OrderType.TYPE_MARKET ||
[
OrderTimeInForce.TIME_IN_FORCE_FOK,
OrderTimeInForce.TIME_IN_FORCE_IOC,
].includes(lastTIF[type] || order.timeInForce)
? false
: order.iceberg,
icebergOpts:
type === OrderType.TYPE_MARKET ||
[
OrderTimeInForce.TIME_IN_FORCE_FOK,
OrderTimeInForce.TIME_IN_FORCE_IOC,
].includes(lastTIF[type] || order.timeInForce)
? undefined
: order.icebergOpts,
reduceOnly:
type === OrderType.TYPE_LIMIT &&
![
OrderTimeInForce.TIME_IN_FORCE_FOK,
OrderTimeInForce.TIME_IN_FORCE_IOC,
].includes(lastTIF[type] || order.timeInForce)
? false
: order.postOnly,
expiresAt: undefined,
});
clearErrors(['expiresAt', 'price']);
}}
market={market}
marketData={marketData}
errorMessage={errors.type?.message}
/>
)}
market={market}
marketData={marketData}
errorMessage={errors.type?.message}
/>
<Controller
name="side"
control={control}
render={() => (
<SideSelector
value={order.side}
onValueChange={(side) => {
update({ side });
}}
/>
render={({ field }) => (
<SideSelector value={field.value} onValueChange={field.onChange} />
)}
/>
<DealTicketAmount
type={type}
control={control}
orderType={order.type}
market={market}
marketData={marketData}
marketPrice={marketPrice || undefined}
sizeError={errors.size?.message}
priceError={errors.price?.message}
update={update}
size={order.size}
price={order.price}
/>
<Controller
name="timeInForce"
@@ -415,58 +386,29 @@ export const DealTicket = ({
marketData.trigger
),
}}
render={() => (
render={({ field }) => (
<TimeInForceSelector
value={order.timeInForce}
orderType={order.type}
onSelect={(timeInForce) => {
// Reset post only and reduce only when changing TIF
update({
timeInForce,
postOnly: [
OrderTimeInForce.TIME_IN_FORCE_FOK,
OrderTimeInForce.TIME_IN_FORCE_IOC,
].includes(timeInForce)
? false
: order.postOnly,
reduceOnly: ![
OrderTimeInForce.TIME_IN_FORCE_FOK,
OrderTimeInForce.TIME_IN_FORCE_IOC,
].includes(timeInForce)
? false
: order.reduceOnly,
});
// Set TIF value for the given order type, so that when switching
// types we know the last used TIF for the given order type
setLastTIF((curr) => ({
...curr,
[order.type]: timeInForce,
expiresAt: undefined,
}));
clearErrors('expiresAt');
}}
value={field.value}
orderType={type}
onSelect={field.onChange}
market={market}
marketData={marketData}
errorMessage={errors.timeInForce?.message}
/>
)}
/>
{order.type === Schema.OrderType.TYPE_LIMIT &&
order.timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_GTT && (
{type === Schema.OrderType.TYPE_LIMIT &&
timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_GTT && (
<Controller
name="expiresAt"
control={control}
rules={{
validate: validateExpiration,
}}
render={() => (
render={({ field }) => (
<ExpirySelector
value={order.expiresAt}
onSelect={(expiresAt) =>
update({
expiresAt: expiresAt || undefined,
})
}
value={field.value}
onSelect={(expiresAt) => field.onChange(expiresAt)}
errorMessage={errors.expiresAt?.message}
/>
)}
@@ -476,13 +418,14 @@ export const DealTicket = ({
<Controller
name="postOnly"
control={control}
render={() => (
render={({ field }) => (
<Checkbox
name="post-only"
checked={order.postOnly}
checked={!disablePostOnlyCheckbox && field.value}
disabled={disablePostOnlyCheckbox}
onCheckedChange={() => {
update({ postOnly: !order.postOnly, reduceOnly: false });
onCheckedChange={(postOnly) => {
field.onChange(postOnly);
setValue('reduceOnly', false);
}}
label={
<Tooltip
@@ -507,13 +450,14 @@ export const DealTicket = ({
<Controller
name="reduceOnly"
control={control}
render={() => (
render={({ field }) => (
<Checkbox
name="reduce-only"
checked={order.reduceOnly}
checked={!disableReduceOnlyCheckbox && field.value}
disabled={disableReduceOnlyCheckbox}
onCheckedChange={() => {
update({ postOnly: false, reduceOnly: !order.reduceOnly });
onCheckedChange={(reduceOnly) => {
field.onChange(reduceOnly);
setValue('postOnly', false);
}}
label={
<Tooltip
@@ -534,53 +478,49 @@ export const DealTicket = ({
)}
/>
</div>
<div className="flex gap-2 pb-2 justify-between">
{order.type === Schema.OrderType.TYPE_LIMIT && (
<Controller
name="iceberg"
control={control}
render={() => (
<Checkbox
name="iceberg"
checked={order.iceberg}
onCheckedChange={() => {
update({ iceberg: !order.iceberg, icebergOpts: undefined });
}}
label={
<Tooltip
description={
<p>
{t(`Trade only a fraction of the order size at once.
{type === Schema.OrderType.TYPE_LIMIT && (
<>
<div className="flex gap-2 pb-2 justify-between">
<Controller
name="iceberg"
control={control}
render={({ field }) => (
<Checkbox
name="iceberg"
checked={field.value}
onCheckedChange={field.onChange}
label={
<Tooltip
description={
<p>
{t(`Trade only a fraction of the order size at once.
After the peak size of the order has traded, the size is reset. This is repeated until the order is cancelled, expires, or its full volume trades away.
For example, an iceberg order with a size of 1000 and a peak size of 100 will effectively be split into 10 orders with a size of 100 each.
Note that the full volume of the order is not hidden and is still reflected in the order book.`)}
</p>
}
>
<span className="text-xs">{t('Iceberg')}</span>
</Tooltip>
}
/>
)}
/>
)}
</div>
{order.iceberg && (
<DealTicketSizeIceberg
update={update}
market={market}
peakSizeError={errors.icebergOpts?.peakSize?.message}
minimumVisibleSizeError={
errors.icebergOpts?.minimumVisibleSize?.message
}
control={control}
size={order.size}
peakSize={order.icebergOpts?.peakSize || ''}
minimumVisibleSize={order.icebergOpts?.minimumVisibleSize || ''}
/>
</p>
}
>
<span className="text-xs">{t('Iceberg')}</span>
</Tooltip>
}
/>
)}
/>
</div>
{iceberg && (
<DealTicketSizeIceberg
market={market}
peakSizeError={errors.peakSize?.message}
minimumVisibleSizeError={errors.minimumVisibleSize?.message}
control={control}
size={rawSize}
peakSize={peakSize}
/>
)}
</>
)}
<SummaryMessage
errorMessage={errors.summary?.message}
error={summaryError}
asset={asset}
marketTradingMode={marketData.marketTradingMode}
balance={balance}
@@ -593,7 +533,7 @@ export const DealTicket = ({
onClickCollateral={onClickCollateral}
onDeposit={onDeposit}
/>
<DealTicketButton side={order.side} />
<DealTicketButton side={side} />
<DealTicketFeeDetails
order={
normalizedOrder && { ...normalizedOrder, price: price || undefined }
@@ -619,7 +559,7 @@ export const DealTicket = ({
* renders warnings about current state of the market
*/
interface SummaryMessageProps {
errorMessage?: string;
error?: { message: string; type: string };
asset: { id: string; symbol: string; name: string; decimals: number };
marketTradingMode: MarketData['marketTradingMode'];
balance: string;
@@ -649,7 +589,7 @@ export const NoWalletWarning = ({
const SummaryMessage = memo(
({
errorMessage,
error,
asset,
marketTradingMode,
balance,
@@ -665,7 +605,7 @@ const SummaryMessage = memo(
return <NoWalletWarning isReadOnly={isReadOnly} />;
}
if (errorMessage === SummaryValidationType.NoCollateral) {
if (error?.type === SummaryValidationType.NoCollateral) {
return (
<div className="mb-2">
<ZeroBalanceError
@@ -679,11 +619,11 @@ const SummaryMessage = memo(
// If we have any other full error which prevents
// submission render that first
if (errorMessage) {
if (error?.message) {
return (
<div className="mb-2">
<InputError testId="deal-ticket-error-message-summary">
{errorMessage}
{error?.message}
</InputError>
</div>
);
@@ -21,6 +21,13 @@ interface TimeInForceSelectorProps {
errorMessage?: string;
}
const typeLimitOptions = Object.entries(Schema.OrderTimeInForce);
const typeMarketOptions = typeLimitOptions.filter(
([_, timeInForce]) =>
timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_FOK ||
timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
);
export const TimeInForceSelector = ({
value,
orderType,
@@ -31,12 +38,8 @@ export const TimeInForceSelector = ({
}: TimeInForceSelectorProps) => {
const options =
orderType === Schema.OrderType.TYPE_LIMIT
? Object.entries(Schema.OrderTimeInForce)
: Object.entries(Schema.OrderTimeInForce).filter(
([_, timeInForce]) =>
timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_FOK ||
timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
);
? typeLimitOptions
: typeMarketOptions;
const renderError = (errorType: string) => {
if (errorType === MarketModeValidationType.Auction) {
@@ -16,7 +16,7 @@ import { t } from '@vegaprotocol/i18n';
import type { Market, StaticMarketData } from '@vegaprotocol/markets';
import { compileGridData } from '../trading-mode-tooltip';
import { MarketModeValidationType } from '../../constants';
import { DealTicketType } from '../../hooks/use-type-store';
import { DealTicketType } from '../../hooks/use-form-values';
import * as RadioGroup from '@radix-ui/react-radio-group';
import classNames from 'classnames';
import { FLAGS } from '@vegaprotocol/environment';
+1 -2
View File
@@ -1,4 +1,3 @@
export * from './__generated__/EstimateOrder';
export * from './use-estimate-fees';
export * from './use-type-store';
export * from './use-stop-order-form-values';
export * from './use-form-values';
@@ -0,0 +1,144 @@
import { create } from 'zustand';
import { persist, subscribeWithSelector } from 'zustand/middleware';
import type { OrderTimeInForce, Side, OrderType } from '@vegaprotocol/types';
import * as Schema from '@vegaprotocol/types';
import { immer } from 'zustand/middleware/immer';
export enum DealTicketType {
Limit = 'Limit',
Market = 'Market',
StopLimit = 'StopLimit',
StopMarket = 'StopMarket',
}
export interface StopOrderFormValues {
side: Side;
triggerDirection: Schema.StopOrderTriggerDirection;
triggerType: 'price' | 'trailingPercentOffset';
triggerPrice?: string;
triggerTrailingPercentOffset?: string;
type: OrderType;
size: string;
timeInForce: OrderTimeInForce;
price?: string;
expire: boolean;
expiryStrategy?: Schema.StopOrderExpiryStrategy;
expiresAt?: string;
}
export type OrderFormValues = {
type: OrderType;
side: Side;
size: string;
timeInForce: OrderTimeInForce;
price?: string;
expiresAt?: string | undefined;
postOnly?: boolean;
reduceOnly?: boolean;
iceberg?: boolean;
peakSize?: string;
minimumVisibleSize?: string;
};
type UpdateOrder = (marketId: string, values: Partial<OrderFormValues>) => void;
type UpdateStopOrder = (
marketId: string,
values: Partial<StopOrderFormValues>
) => void;
type Store = {
updateOrder: UpdateOrder;
updateStopOrder: UpdateStopOrder;
setType: (marketId: string, value: DealTicketType) => void;
updateAll: (
marketId: string,
values: { size?: string; price?: string }
) => void;
formValues: Record<
string,
| {
[DealTicketType.Limit]?: Partial<OrderFormValues>;
[DealTicketType.Market]?: Partial<OrderFormValues>;
[DealTicketType.StopLimit]?: Partial<StopOrderFormValues>;
[DealTicketType.StopMarket]?: Partial<StopOrderFormValues>;
type?: DealTicketType;
}
| undefined
>;
};
export const dealTicketTypeToOrderType = (dealTicketType?: DealTicketType) =>
dealTicketType === DealTicketType.Limit ||
dealTicketType === DealTicketType.StopLimit
? Schema.OrderType.TYPE_LIMIT
: Schema.OrderType.TYPE_MARKET;
export const isStopOrderType = (dealTicketType?: DealTicketType) =>
dealTicketType === DealTicketType.StopLimit ||
dealTicketType === DealTicketType.StopMarket;
export const useDealTicketFormValues = create<Store>()(
immer(
persist(
subscribeWithSelector((set) => ({
formValues: {},
updateStopOrder: (marketId, formValues) => {
set((state) => {
const type =
formValues.type === Schema.OrderType.TYPE_LIMIT
? DealTicketType.StopLimit
: DealTicketType.StopMarket;
const market = state.formValues[marketId] || {};
if (!state.formValues[marketId]) {
state.formValues[marketId] = market;
}
market[type] = Object.assign(market[type] ?? {}, formValues);
});
},
updateOrder: (marketId, formValues) => {
set((state) => {
const type =
formValues.type === Schema.OrderType.TYPE_LIMIT
? DealTicketType.Limit
: DealTicketType.Market;
const market = state.formValues[marketId] || {};
if (!state.formValues[marketId]) {
state.formValues[marketId] = market;
}
market[type] = Object.assign(market[type] ?? {}, formValues);
});
},
updateAll: (
marketId: string,
formValues: { size?: string; price?: string }
) => {
set((state) => {
const market = state.formValues[marketId] || {};
if (!state.formValues[marketId]) {
state.formValues[marketId] = market;
}
for (const type of Object.values(DealTicketType)) {
market[type] = Object.assign(market[type] ?? {}, formValues);
}
});
},
setType: (marketId, type) => {
set((state) => {
state.formValues[marketId] = Object.assign(
state.formValues[marketId] ?? {},
{ type }
);
});
},
})),
{
name: 'vega_deal_ticket_store',
}
)
)
);
@@ -1,68 +0,0 @@
import omit from 'lodash/omit';
import { act, renderHook } from '@testing-library/react';
import { getDefaultOrder, useCreateOrderStore } from '@vegaprotocol/orders';
import { useOrderForm } from './use-order-form';
jest.mock('zustand');
describe('useOrderForm', () => {
const marketId = 'market-id';
const setup = (marketId: string) => {
return renderHook(() => useOrderForm(marketId));
};
const { result } = renderHook(() => useCreateOrderStore());
const useOrderStore = result.current;
it('updates form fields when the order changes', async () => {
const order = getDefaultOrder(marketId);
const { result } = setup(marketId);
// expect default values
expect(result.current.order).toEqual(order);
expect(result.current.getValues()).toEqual(order);
const priceUpdate = {
...order,
price: '100',
size: '22',
};
await act(async () => {
useOrderStore.setState({
orders: {
[marketId]: priceUpdate,
},
});
});
// check order store has updated fields
expect(result.current.order).toEqual(priceUpdate);
// check react-hook-form has updated fields
expect(result.current.getValues()).toEqual(priceUpdate);
});
it('removes persist key on submit', async () => {
const order = {
...getDefaultOrder(marketId),
price: '99',
size: '22',
};
const onSubmit = jest.fn();
const { result } = setup(marketId);
await act(async () => {
useOrderStore.setState({
orders: {
[marketId]: order,
},
});
});
await act(async () => {
result.current.handleSubmit(onSubmit)();
});
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0]).toEqual(omit(order, 'persist'));
expect(onSubmit.mock.calls[0][0].persist).toBeUndefined();
});
});
@@ -1,71 +0,0 @@
import omit from 'lodash/omit';
import type { OrderObj } from '@vegaprotocol/orders';
import { getDefaultOrder, useOrder } from '@vegaprotocol/orders';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import type { OrderSubmission } from '@vegaprotocol/wallet';
export type OrderFormFields = OrderObj & {
summary: string;
};
/**
* Connects the order store to a react-hook-form instance. Any time a field
* changes in the store the form will be updated so that validation rules
* for those fields are applied
*/
export const useOrderForm = (marketId: string) => {
const [order, update] = useOrder(marketId);
const {
control,
formState: { errors, isSubmitted },
handleSubmit,
setError,
setValue,
clearErrors,
getValues,
} = useForm<OrderFormFields>({
// order can be undefined if there is nothing in the store, it
// will be created but the form still needs some default values
defaultValues: order || getDefaultOrder(marketId),
});
// Keep form fields in sync with the store values,
// inputs are updating the store, fields need updating
// to ensure validation rules are applied
useEffect(() => {
if (!order) return;
const currOrder = getValues();
for (const k in order) {
const key = k as keyof typeof order;
const curr = currOrder[key];
const value = order[key];
if (value !== curr) {
setValue(key, value, {
shouldValidate: isSubmitted, // only apply validation after the form has been submitted and failed
shouldDirty: true,
shouldTouch: true,
});
}
}
}, [order, isSubmitted, getValues, setValue]);
const handleSubmitWrapper = (cb: (o: OrderSubmission) => void) => {
return handleSubmit(() => {
// remove the persist and iceberg key from the order in the store, the wallet will reject
// an order that contains unrecognized additional keys
cb(omit(order, 'persist', 'iceberg'));
});
};
return {
order,
update,
control,
errors,
setError,
clearErrors,
getValues, // returned for test purposes only
handleSubmit: handleSubmitWrapper,
};
};
@@ -1,62 +0,0 @@
import { create } from 'zustand';
import { persist, subscribeWithSelector } from 'zustand/middleware';
import type { OrderTimeInForce, Side, OrderType } from '@vegaprotocol/types';
import type * as Schema from '@vegaprotocol/types';
export interface StopOrderFormValues {
side: Side;
triggerDirection: Schema.StopOrderTriggerDirection;
triggerType: 'price' | 'trailingPercentOffset';
triggerPrice: string;
triggerTrailingPercentOffset: string;
type: OrderType;
size: string;
timeInForce: OrderTimeInForce;
price?: string;
expire: boolean;
expiryStrategy?: Schema.StopOrderExpiryStrategy;
expiresAt?: string;
}
type StopOrderFormValuesMap = {
[marketId: string]: Partial<StopOrderFormValues> | undefined;
};
type Update = (
marketId: string,
formValues: Partial<StopOrderFormValues>,
persist?: boolean
) => void;
interface Store {
formValues: StopOrderFormValuesMap;
update: Update;
}
export const useStopOrderFormValues = create<Store>()(
persist(
subscribeWithSelector((set) => ({
formValues: {},
update: (marketId, formValues, persist = true) => {
set((state) => {
return {
formValues: {
...state.formValues,
[marketId]: {
...state.formValues[marketId],
...formValues,
},
},
};
});
},
})),
{
name: 'vega_stop_order_store',
}
)
);
@@ -1,28 +0,0 @@
import { create } from 'zustand';
import { persist, subscribeWithSelector } from 'zustand/middleware';
export enum DealTicketType {
Limit = 'Limit',
Market = 'Market',
StopLimit = 'StopLimit',
StopMarket = 'StopMarket',
}
export const useDealTicketTypeStore = create<{
set: (marketId: string, type: DealTicketType) => void;
type: Record<string, DealTicketType>;
}>()(
persist(
subscribeWithSelector((set) => ({
type: {},
set: (marketId: string, type: DealTicketType) =>
set((state) => ({
...state,
type: { ...state.type, [marketId]: type },
})),
})),
{
name: 'deal_ticket_type',
}
)
);
@@ -1,12 +1,64 @@
import type {
OrderSubmission,
StopOrderSetup,
StopOrdersSubmission,
} from '@vegaprotocol/wallet';
import { normalizeOrderSubmission } from '@vegaprotocol/wallet';
import type { StopOrderFormValues } from '../hooks/use-stop-order-form-values';
import type {
OrderFormValues,
StopOrderFormValues,
} from '../hooks/use-form-values';
import * as Schema from '@vegaprotocol/types';
import { removeDecimal, toNanoSeconds } from '@vegaprotocol/utils';
export const mapFormValuesToOrderSubmission = (
order: OrderFormValues,
marketId: string,
decimalPlaces: number,
positionDecimalPlaces: number
): OrderSubmission => ({
marketId: marketId,
type: order.type,
side: order.side,
timeInForce: order.timeInForce,
price:
order.type === Schema.OrderType.TYPE_LIMIT && order.price
? removeDecimal(order.price, decimalPlaces)
: undefined,
size: removeDecimal(order.size, positionDecimalPlaces),
expiresAt:
order.expiresAt &&
order.timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_GTT
? toNanoSeconds(order.expiresAt)
: undefined,
postOnly:
order.type === Schema.OrderType.TYPE_MARKET ? false : order.postOnly,
reduceOnly:
order.type === Schema.OrderType.TYPE_LIMIT &&
![
Schema.OrderTimeInForce.TIME_IN_FORCE_FOK,
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC,
].includes(order.timeInForce)
? false
: order.reduceOnly,
icebergOpts:
(order.type === Schema.OrderType.TYPE_MARKET ||
[
Schema.OrderTimeInForce.TIME_IN_FORCE_FOK,
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC,
].includes(order.timeInForce)) &&
order.iceberg &&
order.peakSize &&
order.minimumVisibleSize
? {
peakSize: removeDecimal(order.peakSize, positionDecimalPlaces),
minimumVisibleSize: removeDecimal(
order.minimumVisibleSize,
positionDecimalPlaces
),
}
: undefined,
});
export const mapFormValuesToStopOrdersSubmission = (
data: StopOrderFormValues,
marketId: string,
@@ -15,9 +67,8 @@ export const mapFormValuesToStopOrdersSubmission = (
): StopOrdersSubmission => {
const submission: StopOrdersSubmission = {};
const stopOrderSetup: StopOrderSetup = {
orderSubmission: normalizeOrderSubmission(
orderSubmission: mapFormValuesToOrderSubmission(
{
marketId,
type: data.type,
side: data.side,
size: data.size,
@@ -25,12 +76,16 @@ export const mapFormValuesToStopOrdersSubmission = (
price: data.price,
reduceOnly: true,
},
marketId,
decimalPlaces,
positionDecimalPlaces
),
};
if (data.triggerType === 'price') {
stopOrderSetup.price = removeDecimal(data.triggerPrice, decimalPlaces);
stopOrderSetup.price = removeDecimal(
data.triggerPrice ?? '',
decimalPlaces
);
} else if (data.triggerType === 'trailingPercentOffset') {
stopOrderSetup.trailingPercentOffset = (
Number(data.triggerTrailingPercentOffset) / 100
@@ -0,0 +1,64 @@
import type { OrderSubmissionBody } from '@vegaprotocol/wallet';
import { mapFormValuesToOrderSubmission } from './map-form-values-to-submission';
import * as Schema from '@vegaprotocol/types';
describe('mapFormValuesToOrderSubmission', () => {
it('sets and formats price only for limit orders', () => {
expect(
mapFormValuesToOrderSubmission(
{ price: '100' } as unknown as OrderSubmissionBody['orderSubmission'],
'marketId',
2,
1
).price
).toBeUndefined();
expect(
mapFormValuesToOrderSubmission(
{
price: '100',
type: Schema.OrderType.TYPE_LIMIT,
} as unknown as OrderSubmissionBody['orderSubmission'],
'marketId',
2,
1
).price
).toEqual('10000');
});
it('sets and formats expiresAt only for time in force orders', () => {
expect(
mapFormValuesToOrderSubmission(
{
expiresAt: '2022-01-01T00:00:00.000Z',
} as OrderSubmissionBody['orderSubmission'],
'marketId',
2,
1
).expiresAt
).toBeUndefined();
expect(
mapFormValuesToOrderSubmission(
{
expiresAt: '2022-01-01T00:00:00.000Z',
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_GTT,
} as OrderSubmissionBody['orderSubmission'],
'marketId',
2,
1
).expiresAt
).toEqual('1640995200000000000');
});
it('formats size', () => {
expect(
mapFormValuesToOrderSubmission(
{
size: '100',
} as OrderSubmissionBody['orderSubmission'],
'marketId',
2,
1
).size
).toEqual('1000');
});
});
@@ -34,5 +34,8 @@ query SuccessorMarket($marketId: ID!) {
code
}
}
proposal {
id
}
}
}
+4 -1
View File
@@ -27,7 +27,7 @@ export type SuccessorMarketQueryVariables = Types.Exact<{
}>;
export type SuccessorMarketQuery = { __typename?: 'Query', market?: { __typename?: 'Market', id: string, state: Types.MarketState, tradingMode: Types.MarketTradingMode, positionDecimalPlaces: number, tradableInstrument: { __typename?: 'TradableInstrument', instrument: { __typename?: 'Instrument', name: string, code: string } } } | null };
export type SuccessorMarketQuery = { __typename?: 'Query', market?: { __typename?: 'Market', id: string, state: Types.MarketState, tradingMode: Types.MarketTradingMode, positionDecimalPlaces: number, tradableInstrument: { __typename?: 'TradableInstrument', instrument: { __typename?: 'Instrument', name: string, code: string } }, proposal?: { __typename?: 'Proposal', id?: string | null } | null } | null };
export const SuccessorMarketIdDocument = gql`
@@ -153,6 +153,9 @@ export const SuccessorMarketDocument = gql`
code
}
}
proposal {
id
}
}
}
`;
@@ -1,4 +1,8 @@
import { TokenStaticLinks, useEnvironment } from '@vegaprotocol/environment';
import {
FLAGS,
TokenStaticLinks,
useEnvironment,
} from '@vegaprotocol/environment';
import { removePaginationWrapper } from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n';
import { useDataProvider } from '@vegaprotocol/data-provider';
@@ -34,6 +38,7 @@ import {
RiskModelInfoPanel,
RiskParametersInfoPanel,
SettlementAssetInfoPanel,
SuccessionLineInfoPanel,
} from './market-info-panels';
import type { DataSourceDefinition } from '@vegaprotocol/types';
import isEqual from 'lodash/isEqual';
@@ -291,6 +296,13 @@ export const MarketInfoAccordion = ({
</>
}
/>
{FLAGS.SUCCESSOR_MARKETS && (
<AccordionItem
itemId="succession-line"
title={t('Succession line')}
content={<SuccessionLineInfoPanel market={market} />}
/>
)}
</Accordion>
</div>
)}
@@ -1,9 +1,11 @@
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import {
ConditionOperator,
ConditionOperatorMapping,
} from '@vegaprotocol/types';
import { DataSourceProof } from './market-info-panels';
import { DataSourceProof, SuccessionLineInfoPanel } from './market-info-panels';
import { MockedProvider } from '@apollo/react-testing';
import { SuccessorMarketIdsDocument } from '../../__generated__';
jest.mock('../../hooks/use-oracle-markets', () => ({
useOracleMarkets: () => [],
@@ -137,3 +139,93 @@ describe('DataSourceProof', () => {
).toBeInTheDocument();
});
});
describe('SuccessionLineInfoPanel', () => {
const mocks = [
{
request: {
query: SuccessorMarketIdsDocument,
},
result: {
data: {
__typename: 'Query',
marketsConnection: {
__typename: 'MarketConnection',
edges: [
{
__typename: 'MarketEdge',
node: {
__typename: 'Market',
id: 'abc',
successorMarketID: 'def',
parentMarketID: null,
},
},
{
__typename: 'MarketEdge',
node: {
__typename: 'Market',
id: 'def',
successorMarketID: 'ghi',
parentMarketID: 'abc',
},
},
{
__typename: 'MarketEdge',
node: {
__typename: 'Market',
id: 'ghi',
successorMarketID: null,
parentMarketID: 'def',
},
},
],
},
},
},
},
];
it.each([
['abc', 1],
['def', 2],
['ghi', 3],
])(
'renders succession line for %s (current position %d)',
async (id, number) => {
render(
<MockedProvider mocks={mocks}>
<SuccessionLineInfoPanel
market={{
id,
}}
/>
</MockedProvider>
);
await waitFor(() => {
const items = screen.getAllByTestId('succession-line-item');
expect(items.length).toBe(3);
expect(
items[0].querySelector(
'[data-testid="succession-line-item-market-id"]'
)?.textContent
).toBe('abc');
expect(
items[1].querySelector(
'[data-testid="succession-line-item-market-id"]'
)?.textContent
).toBe('def');
expect(
items[2].querySelector(
'[data-testid="succession-line-item-market-id"]'
)?.textContent
).toBe('ghi');
expect(
items[number - 1].querySelector('[data-testid="icon-bullet"]')
).toBeInTheDocument();
});
}
);
});
@@ -1,11 +1,17 @@
import type { ReactNode } from 'react';
import { useState } from 'react';
import { Fragment, useState } from 'react';
import { useMemo } from 'react';
import { AssetDetailsTable, useAssetDataProvider } from '@vegaprotocol/assets';
import { t } from '@vegaprotocol/i18n';
import { marketDataProvider } from '../../market-data-provider';
import { totalFeesPercentage } from '../../market-utils';
import { ExternalLink, Splash } from '@vegaprotocol/ui-toolkit';
import {
ExternalLink,
Splash,
Tooltip,
VegaIcon,
VegaIconNames,
} from '@vegaprotocol/ui-toolkit';
import {
addDecimalsFormatNumber,
formatNumber,
@@ -23,14 +29,26 @@ import BigNumber from 'bignumber.js';
import type { DataSourceDefinition, SignerKind } from '@vegaprotocol/types';
import { ConditionOperatorMapping } from '@vegaprotocol/types';
import { MarketTradingModeMapping } from '@vegaprotocol/types';
import { FLAGS, useEnvironment } from '@vegaprotocol/environment';
import {
DApp,
FLAGS,
TOKEN_PROPOSAL,
useEnvironment,
useLinks,
} from '@vegaprotocol/environment';
import type { Provider } from '../../oracle-schema';
import { OracleBasicProfile } from '../../components/oracle-basic-profile';
import { useOracleProofs } from '../../hooks';
import { OracleDialog } from '../oracle-dialog/oracle-dialog';
import { useDataProvider } from '@vegaprotocol/data-provider';
import { useParentMarketIdQuery } from '../../__generated__';
import {
useParentMarketIdQuery,
useSuccessorMarketIdsQuery,
useSuccessorMarketQuery,
} from '../../__generated__';
import { useSuccessorMarketProposalDetailsQuery } from '@vegaprotocol/proposals';
import classNames from 'classnames';
import compact from 'lodash/compact';
type MarketInfoProps = {
market: MarketInfo;
@@ -191,6 +209,126 @@ export const KeyDetailsInfoPanel = ({ market }: MarketInfoProps) => {
);
};
const SuccessionLineItem = ({
marketId,
isCurrent,
}: {
marketId: string;
isCurrent?: boolean;
}) => {
const { data } = useSuccessorMarketQuery({
variables: {
marketId,
},
});
const marketData = data?.market;
const governanceLink = useLinks(DApp.Token);
const proposalLink = marketData?.proposal?.id
? governanceLink(TOKEN_PROPOSAL.replace(':id', marketData?.proposal?.id))
: undefined;
return (
<div
data-testid="succession-line-item"
className={classNames(
'rounded p-2 bg-vega-clight-700 dark:bg-vega-cdark-700',
'font-alpha',
'flex flex-col '
)}
>
<div className="flex justify-between">
<div>
{marketData ? (
proposalLink ? (
<ExternalLink href={proposalLink}>
{marketData.tradableInstrument.instrument.code}
</ExternalLink>
) : (
marketData.tradableInstrument.instrument.code
)
) : (
<span className="block w-20 h-4 mb-1 bg-vega-clight-500 dark:bg-vega-cdark-500 animate-pulse"></span>
)}
</div>
{isCurrent && (
<Tooltip description={t('This market')}>
<div className="text-vega-clight-200 dark:text-vega-cdark-200 cursor-help">
<VegaIcon name={VegaIconNames.BULLET} size={16} />
</div>
</Tooltip>
)}
</div>
<div className="text-xs">
{marketData ? (
marketData.tradableInstrument.instrument.name
) : (
<span className="block w-28 h-4 bg-vega-clight-500 dark:bg-vega-cdark-500 animate-pulse"></span>
)}
</div>
<div
data-testid="succession-line-item-market-id"
className="text-xs truncate mt-1"
>
{marketId}
</div>
</div>
);
};
const SuccessionLink = () => (
<div className="text-center leading-none" aria-hidden>
<VegaIcon name={VegaIconNames.ARROW_DOWN} size={12} />
</div>
);
const buildSuccessionLine = (
all: {
id: string;
successorMarketID?: string | null | undefined;
parentMarketID?: string | null | undefined;
}[],
id: string
) => {
let line = [id];
const find = (id: string, dir?: 'up' | 'down') => {
const item = all.find((a) => a.id === id);
const anc = dir === 'up' && item?.parentMarketID;
const des = dir === 'down' && item?.successorMarketID;
if (anc) {
line = [anc, ...line];
find(anc, 'up');
}
if (des) {
line = [...line, des];
find(des, 'down');
}
};
find(id, 'up');
find(id, 'down');
return line;
};
export const SuccessionLineInfoPanel = ({
market,
}: {
market: Pick<MarketInfo, 'id'>;
}) => {
const { data } = useSuccessorMarketIdsQuery();
const ids = compact(data?.marketsConnection?.edges.map((e) => e.node));
const line = buildSuccessionLine(ids, market.id);
return (
<div className="flex flex-col gap-2">
{line.map((id, i) => (
<Fragment key={i}>
{i > 0 && <SuccessionLink />}
<SuccessionLineItem marketId={id} isCurrent={id === market.id} />
</Fragment>
))}
</div>
);
};
export const InstrumentInfoPanel = ({ market }: MarketInfoProps) => (
<MarketInfoTable
data={{
@@ -114,9 +114,11 @@ describe('OrderListTable', () => {
it('should apply correct formatting applied for an iceberg order', async () => {
const icebergOrder = {
...limitOrder,
size: '100',
remaining: '50',
icebergOrder: {
__typename: 'IcebergOrder',
minimumVisibleSize: '100',
minimumVisibleSize: '1',
peakSize: '50',
reservedRemaining: '50',
} as OrderFieldsFragment['icebergOrder'],
@@ -127,8 +129,8 @@ describe('OrderListTable', () => {
const cells = screen.getAllByRole('gridcell');
const expectedValues: string[] = [
icebergOrder.market?.tradableInstrument.instrument.code || '',
'0.05',
'0.10',
'0.00',
'+1.00',
Schema.OrderTypeMapping[
icebergOrder.type || Schema.OrderType.TYPE_LIMIT
] + ' (Iceberg)',
@@ -86,21 +86,13 @@ export const OrderListTable = memo<
valueGetter: ({ data }: VegaValueGetterParams<Order>) => {
if (data?.icebergOrder) {
return data?.size && data.market
? toBigNum(
(
BigInt(data.size) -
BigInt(data.remaining) -
BigInt(data.icebergOrder.reservedRemaining)
).toString(),
data.market.positionDecimalPlaces ?? 0
).toNumber()
? BigInt(data.size) -
BigInt(data.remaining) -
BigInt(data.icebergOrder.reservedRemaining)
: undefined;
}
return data?.size && data.market
? toBigNum(
(BigInt(data.size) - BigInt(data.remaining)).toString(),
data.market.positionDecimalPlaces ?? 0
).toNumber()
? BigInt(data.size) - BigInt(data.remaining)
: undefined;
},
valueFormatter: ({
@@ -114,8 +106,8 @@ export const OrderListTable = memo<
return '-';
}
return addDecimalsFormatNumber(
(BigInt(data.size) - BigInt(data.remaining)).toString(),
data.market.positionDecimalPlaces
value,
data.market.positionDecimalPlaces ?? 0
);
},
minWidth: 50,
-1
View File
@@ -1,4 +1,3 @@
export * from './__generated__/OrdersSubscription';
export * from './use-has-amendable-order';
export * from './use-order-update';
export * from './use-order-store';
@@ -1,122 +0,0 @@
import {
getDefaultOrder,
STORAGE_KEY,
useOrder,
useCreateOrderStore,
} from './use-order-store';
import { act, renderHook } from '@testing-library/react';
import { OrderType } from '@vegaprotocol/types';
jest.mock('zustand');
describe('useCreateOrderStore', () => {
const setup = () => {
const { result } = renderHook(() => useCreateOrderStore());
return renderHook(() => result.current());
};
afterEach(() => {
localStorage.clear();
});
it('has a empty default state', async () => {
const { result } = setup();
expect(result.current).toEqual({
orders: {},
update: expect.any(Function),
});
});
it('can update', () => {
const marketId = 'persisted-market-id';
const expectedOrder = {
...getDefaultOrder(marketId),
type: OrderType.TYPE_LIMIT,
persist: true,
};
const { result } = setup();
act(() => {
result.current.update(marketId, { type: OrderType.TYPE_LIMIT });
});
// order should be stored in memory
expect(result.current.orders).toEqual({
[marketId]: expectedOrder,
});
// order SHOULD also be in localStorage
expect(JSON.parse(localStorage.getItem(STORAGE_KEY) || '')).toEqual({
state: {
orders: {
[marketId]: expectedOrder,
},
},
version: 0,
});
});
it('can update without persisting', () => {
const marketId = 'non-persisted-market-id';
const expectedOrder = {
...getDefaultOrder(marketId),
type: OrderType.TYPE_LIMIT,
persist: false,
};
const { result } = setup();
act(() => {
result.current.update(marketId, { type: OrderType.TYPE_LIMIT }, false);
});
// order should be stored in memory
expect(result.current.orders).toEqual({
[marketId]: expectedOrder,
});
// order should NOT be in localStorage
expect(JSON.parse(localStorage.getItem(STORAGE_KEY) || '')).toEqual({
state: {
orders: {},
},
version: 0,
});
});
});
describe('useOrder', () => {
const setup = (marketId: string) => {
return renderHook(() => useOrder(marketId));
};
afterEach(() => {
localStorage.clear();
});
it('creates a new order if it doesnt exist which is only persisted after editing', () => {
const marketId = 'market-id';
const expectedOrder = {
...getDefaultOrder(marketId),
persist: false,
};
const { result } = setup(marketId);
expect(result.current).toEqual([expectedOrder, expect.any(Function)]);
});
it('only persists an order if edited', () => {
const marketId = 'market-id';
const expectedOrder = {
...getDefaultOrder(marketId),
persist: false,
};
const { result } = setup(marketId);
expect(result.current[0]).toMatchObject({
price: expectedOrder.price,
persist: false,
});
const update = { price: '500' };
act(() => {
result.current[1](update);
});
expect(result.current[0]).toMatchObject({
...update,
persist: true,
});
});
});
@@ -1,139 +0,0 @@
import { OrderTimeInForce, Side } from '@vegaprotocol/types';
import { OrderType } from '@vegaprotocol/types';
import { useCallback, useEffect, useRef } from 'react';
import type { StateCreator, UseBoundStore, Mutate, StoreApi } from 'zustand';
import { create } from 'zustand';
import { persist, subscribeWithSelector } from 'zustand/middleware';
export type OrderObj = {
marketId: string;
type: OrderType;
side: Side;
size: string;
timeInForce: OrderTimeInForce;
price?: string;
expiresAt?: string | undefined;
persist: boolean; // key used to determine if order should be kept in localStorage
postOnly?: boolean;
reduceOnly?: boolean;
iceberg?: boolean;
icebergOpts?: {
peakSize: string;
minimumVisibleSize: string;
};
};
type OrderMap = { [marketId: string]: OrderObj | undefined };
type UpdateOrder = (
marketId: string,
order: Partial<OrderObj>,
persist?: boolean
) => void;
interface Store {
orders: OrderMap;
update: UpdateOrder;
}
export const STORAGE_KEY = 'vega_order_store';
const orderStateCreator: StateCreator<Store> = (set) => ({
orders: {},
update: (marketId, order, persist = true) => {
set((state) => {
const curr = state.orders[marketId];
const defaultOrder = getDefaultOrder(marketId);
return {
orders: {
...state.orders,
[marketId]: {
...defaultOrder,
...curr,
...order,
persist,
},
},
};
});
},
});
let store: UseBoundStore<Mutate<StoreApi<Store>, []>> | null = null;
const getOrderStore = () => {
if (!store) {
store = create<Store>()(
persist(subscribeWithSelector(orderStateCreator), {
name: STORAGE_KEY,
partialize: (state) => {
// only store the order in localStorage if user has edited, this avoids
// bloating localStorage if a user just visits the page but does not
// edit the ticket
const partializedOrders: OrderMap = {};
for (const o in state.orders) {
const order = state.orders[o];
if (order && order.persist) {
partializedOrders[order.marketId] = order;
}
}
return {
...state,
orders: partializedOrders,
};
},
})
);
}
return store as UseBoundStore<Mutate<StoreApi<Store>, []>>;
};
export const useCreateOrderStore = () => {
const useOrderStoreRef = useRef(getOrderStore());
return useOrderStoreRef.current;
};
/**
* Retrieves an order from the store for a market and
* creates one if it doesn't already exist
*/
export const useOrder = (marketId: string) => {
const useOrderStoreRef = useCreateOrderStore();
const [order, _update] = useOrderStoreRef((store) => {
return [store.orders[marketId], store.update];
});
const update = useCallback(
(o: Partial<OrderObj>, persist = true) => {
_update(marketId, o, persist);
},
[marketId, _update]
);
// add new order to store if it doesn't exist, but don't
// persist until user has edited
useEffect(() => {
if (!order) {
update(
getDefaultOrder(marketId),
false // don't persist the order
);
}
}, [order, marketId, update]);
return [order, update] as const; // make result a tuple
};
export const getDefaultOrder = (marketId: string): OrderObj => ({
marketId,
type: OrderType.TYPE_LIMIT,
side: Side.SIDE_BUY,
timeInForce: OrderTimeInForce.TIME_IN_FORCE_GTC,
size: '0',
price: '0',
expiresAt: undefined,
persist: false,
postOnly: false,
reduceOnly: false,
});
+3 -6
View File
@@ -1,7 +1,7 @@
import { useDataProvider } from '@vegaprotocol/data-provider';
import { tradesWithMarketProvider } from './trades-data-provider';
import { TradesTable } from './trades-table';
import { useCreateOrderStore } from '@vegaprotocol/orders';
import { useDealTicketFormValues } from '@vegaprotocol/deal-ticket';
import { t } from '@vegaprotocol/i18n';
interface TradesContainerProps {
@@ -9,8 +9,7 @@ interface TradesContainerProps {
}
export const TradesContainer = ({ marketId }: TradesContainerProps) => {
const useOrderStoreRef = useCreateOrderStore();
const updateOrder = useOrderStoreRef((store) => store.update);
const update = useDealTicketFormValues((state) => state.updateAll);
const { data, error } = useDataProvider({
dataProvider: tradesWithMarketProvider,
@@ -21,9 +20,7 @@ export const TradesContainer = ({ marketId }: TradesContainerProps) => {
<TradesTable
rowData={data}
onClick={(price?: string) => {
if (price) {
updateOrder(marketId, { price });
}
update(marketId, { price });
}}
overlayNoRowsTemplate={error ? error.message : t('No trades')}
/>
+1 -62
View File
@@ -1,9 +1,4 @@
import {
determineId,
normalizeOrderAmendment,
normalizeOrderSubmission,
} from './utils';
import type { OrderSubmissionBody } from './connectors/vega-connector';
import { determineId, normalizeOrderAmendment } from './utils';
import * as Schema from '@vegaprotocol/types';
describe('determineId', () => {
it('produces a known result for an ID', () => {
@@ -16,62 +11,6 @@ describe('determineId', () => {
});
});
describe('normalizeOrderSubmission', () => {
it('sets and formats price only for limit orders', () => {
expect(
normalizeOrderSubmission(
{ price: '100' } as unknown as OrderSubmissionBody['orderSubmission'],
2,
1
).price
).toBeUndefined();
expect(
normalizeOrderSubmission(
{
price: '100',
type: Schema.OrderType.TYPE_LIMIT,
} as unknown as OrderSubmissionBody['orderSubmission'],
2,
1
).price
).toEqual('10000');
});
it('sets and formats expiresAt only for time in force orders', () => {
expect(
normalizeOrderSubmission(
{
expiresAt: '2022-01-01T00:00:00.000Z',
} as OrderSubmissionBody['orderSubmission'],
2,
1
).expiresAt
).toBeUndefined();
expect(
normalizeOrderSubmission(
{
expiresAt: '2022-01-01T00:00:00.000Z',
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_GTT,
} as OrderSubmissionBody['orderSubmission'],
2,
1
).expiresAt
).toEqual('1640995200000000000');
});
it('formats size', () => {
expect(
normalizeOrderSubmission(
{
size: '100',
} as OrderSubmissionBody['orderSubmission'],
2,
1
).size
).toEqual('1000');
});
});
describe('normalizeOrderAmendment', () => {
type Order = Parameters<typeof normalizeOrderAmendment>[0];
type Market = Parameters<typeof normalizeOrderAmendment>[1];
+2 -37
View File
@@ -1,15 +1,10 @@
import { removeDecimal, toNanoSeconds } from '@vegaprotocol/utils';
import type { Market, Order } from '@vegaprotocol/types';
import { OrderTimeInForce, OrderType, AccountType } from '@vegaprotocol/types';
import { AccountType } from '@vegaprotocol/types';
import BigNumber from 'bignumber.js';
import { ethers } from 'ethers';
import { sha3_256 } from 'js-sha3';
import type {
OrderAmendment,
OrderSubmission,
Transaction,
Transfer,
} from './connectors';
import type { OrderAmendment, Transaction, Transfer } from './connectors';
import type { Exact } from 'type-fest';
/**
@@ -29,36 +24,6 @@ export const encodeTransaction = (tx: Transaction): string => {
);
};
export const normalizeOrderSubmission = (
order: OrderSubmission,
decimalPlaces: number,
positionDecimalPlaces: number
): OrderSubmission => ({
marketId: order.marketId,
reference: order.reference,
type: order.type,
side: order.side,
timeInForce: order.timeInForce,
price:
order.type === OrderType.TYPE_LIMIT && order.price
? removeDecimal(order.price, decimalPlaces)
: undefined,
size: removeDecimal(order.size, positionDecimalPlaces),
expiresAt:
order.expiresAt && order.timeInForce === OrderTimeInForce.TIME_IN_FORCE_GTT
? toNanoSeconds(order.expiresAt)
: undefined,
postOnly: order.postOnly,
reduceOnly: order.reduceOnly,
icebergOpts: order.icebergOpts && {
peakSize: removeDecimal(order.icebergOpts.peakSize, positionDecimalPlaces),
minimumVisibleSize: removeDecimal(
order.icebergOpts.minimumVisibleSize,
positionDecimalPlaces
),
},
});
export const normalizeOrderAmendment = <T extends Exact<OrderAmendment, T>>(
order: Pick<Order, 'id' | 'timeInForce' | 'size' | 'expiresAt'>,
market: Pick<Market, 'id' | 'decimalPlaces' | 'positionDecimalPlaces'>,