Compare commits

..
Author SHA1 Message Date
Madalina Raicu 3461eec2df fix(trdaing): add loss socialisation docs links 2023-03-07 13:58:20 +00:00
29 changed files with 333 additions and 732 deletions
@@ -669,7 +669,7 @@ describe('account validation', { tags: '@regression' }, () => {
'have.text',
'You need ' +
'tDAI' +
' in your wallet to trade in this market.See all your collateral.Make a deposit'
' in your wallet to trade in this market. See all your collateral.Make a deposit'
);
cy.getByTestId('deal-ticket-deposit-dialog-button').should('exist');
});
-21
View File
@@ -1,21 +0,0 @@
import type { StateCreator } from 'zustand';
import { act } from 'react-dom/test-utils';
const { create: actualCreate } = jest.requireActual('zustand'); // if using jest
// a variable to hold reset functions for all stores declared in the app
const storeResetFns = new Set<() => void>();
// when creating a store, we get its initial state, create a reset function and add it in the set
export const create =
() =>
<S>(createState: StateCreator<S>) => {
const store = actualCreate(createState);
const initialState = store.getState();
storeResetFns.add(() => store.setState(initialState, true));
return store;
};
// Reset all stores after each test run
beforeEach(() => {
act(() => storeResetFns.forEach((resetFn) => resetFn()));
});
@@ -7,7 +7,7 @@ interface ZeroBalanceErrorProps {
id: string;
symbol: string;
};
onClickCollateral?: () => void;
onClickCollateral: () => void;
}
export const ZeroBalanceError = ({
@@ -21,12 +21,8 @@ export const ZeroBalanceError = ({
testId="dealticket-error-message-zero-balance"
message={
<>
You need {asset.symbol} in your wallet to trade in this market.
{onClickCollateral && (
<>
See all your <Link onClick={onClickCollateral}>collateral</Link>.
</>
)}
You need {asset.symbol} in your wallet to trade in this market. See
all your <Link onClick={onClickCollateral}>collateral</Link>.
</>
}
buttonProps={{
@@ -1,21 +1,17 @@
import type { Control } from 'react-hook-form';
import type { UseFormRegister } from 'react-hook-form';
import type { Market, MarketData } from '@vegaprotocol/market-list';
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 { DealTicketFormFields } from './deal-ticket';
export interface DealTicketAmountProps {
control: Control<OrderFormFields>;
orderType: Schema.OrderType;
marketData: MarketData;
market: Market;
register: UseFormRegister<DealTicketFormFields>;
sizeError?: string;
priceError?: string;
update: (obj: Partial<OrderObj>) => void;
size: string;
price?: string;
}
export const DealTicketAmount = ({
@@ -47,7 +47,7 @@ export const DealTicketContainer = ({
market={market}
marketData={marketData}
submit={(orderSubmission) => create({ orderSubmission })}
onClickCollateral={onClickCollateral}
onClickCollateral={onClickCollateral || (() => null)}
/>
) : (
<Splash>
@@ -2,7 +2,6 @@ import { FormGroup, Input, InputError } from '@vegaprotocol/ui-toolkit';
import { toDecimal, validateAmount } from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n';
import type { DealTicketAmountProps } from './deal-ticket-amount';
import { Controller } from 'react-hook-form';
export type DealTicketLimitAmountProps = Omit<
Omit<DealTicketAmountProps, 'marketData'>,
@@ -10,13 +9,10 @@ export type DealTicketLimitAmountProps = Omit<
>;
export const DealTicketLimitAmount = ({
control,
register,
market,
sizeError,
priceError,
update,
price,
size,
}: DealTicketLimitAmountProps) => {
const priceStep = toDecimal(market?.decimalPlaces);
const sizeStep = toDecimal(market?.positionDecimalPlaces);
@@ -51,30 +47,22 @@ export const DealTicketLimitAmount = ({
labelFor="input-order-size-limit"
className="!mb-1"
>
<Controller
name="size"
control={control}
rules={{
<Input
id="input-order-size-limit"
className="w-full"
type="number"
step={sizeStep}
min={sizeStep}
data-testid="order-size"
onWheel={(e) => e.currentTarget.blur()}
{...register('size', {
required: t('You need to provide a size'),
min: {
value: sizeStep,
message: t('Size cannot be lower than ' + sizeStep),
},
validate: validateAmount(sizeStep, 'Size'),
}}
render={() => (
<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()}
/>
)}
})}
/>
</FormGroup>
</div>
@@ -89,10 +77,14 @@ export const DealTicketLimitAmount = ({
labelAlign="right"
className="!mb-1"
>
<Controller
name="price"
control={control}
rules={{
<Input
id="input-price-quote"
className="w-full"
type="number"
step={priceStep}
data-testid="order-price"
onWheel={(e) => e.currentTarget.blur()}
{...register('price', {
required: t('You need provide a price'),
min: {
value: priceStep,
@@ -100,19 +92,7 @@ export const DealTicketLimitAmount = ({
},
// @ts-ignore this fulfills the interface but still errors
validate: validateAmount(priceStep, 'Price'),
}}
render={() => (
<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()}
/>
)}
})}
/>
</FormGroup>
</div>
@@ -8,7 +8,6 @@ import { Input, InputError, Tooltip } from '@vegaprotocol/ui-toolkit';
import { isMarketInAuction } from '../../utils';
import type { DealTicketAmountProps } from './deal-ticket-amount';
import { getMarketPrice } from '../../utils/get-price';
import { Controller } from 'react-hook-form';
export type DealTicketMarketAmountProps = Omit<
DealTicketAmountProps,
@@ -16,12 +15,10 @@ export type DealTicketMarketAmountProps = Omit<
>;
export const DealTicketMarketAmount = ({
control,
register,
market,
marketData,
sizeError,
update,
size,
}: DealTicketMarketAmountProps) => {
const quoteName = market.tradableInstrument.instrument.product.quoteName;
const sizeStep = toDecimal(market?.positionDecimalPlaces);
@@ -50,30 +47,22 @@ export const DealTicketMarketAmount = ({
</div>
<div className="flex items-center gap-4">
<div className="flex-1">
<Controller
name="size"
control={control}
rules={{
<Input
id="input-order-size-market"
className="w-full"
type="number"
step={sizeStep}
min={sizeStep}
onWheel={(e) => e.currentTarget.blur()}
data-testid="order-size"
{...register('size', {
required: t('You need to provide a size'),
min: {
value: sizeStep,
message: t('Size cannot be lower than ' + sizeStep),
},
validate: validateAmount(sizeStep, 'Size'),
}}
render={() => (
<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"
/>
)}
})}
/>
</div>
<div>@</div>
@@ -1,28 +1,38 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { VegaWalletContext } from '@vegaprotocol/wallet';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { fireEvent, render, screen, act } from '@testing-library/react';
import { generateMarket, generateMarketData } from '../../test-helpers';
import { DealTicket } from './deal-ticket';
import * as Schema from '@vegaprotocol/types';
import type { OrderSubmissionBody } from '@vegaprotocol/wallet';
import type { MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing';
import type { ChainIdQuery } from '@vegaprotocol/react-helpers';
import { ChainIdDocument } from '@vegaprotocol/react-helpers';
import { addDecimal } from '@vegaprotocol/utils';
import { useOrderStore } from '@vegaprotocol/orders';
jest.mock('zustand');
jest.mock('./deal-ticket-fee-details', () => ({
DealTicketFeeDetails: () => <div data-testid="deal-ticket-fee-details" />,
}));
const pubKey = 'pubKey';
const market = generateMarket();
const marketData = generateMarketData();
const submit = jest.fn();
function generateJsx() {
const mockChainId = 'chain-id';
function generateJsx(order?: OrderSubmissionBody['orderSubmission']) {
const chainIdMock: MockedResponse<ChainIdQuery> = {
request: {
query: ChainIdDocument,
},
result: {
data: {
statistics: {
chainId: mockChainId,
},
},
},
};
return (
<MockedProvider>
<VegaWalletContext.Provider value={{ pubKey, isReadOnly: false } as any}>
<MockedProvider mocks={[chainIdMock]}>
<VegaWalletContext.Provider value={{ pubKey: mockChainId } as any}>
<DealTicket market={market} marketData={marketData} submit={submit} />
</VegaWalletContext.Provider>
</MockedProvider>
@@ -31,11 +41,10 @@ function generateJsx() {
describe('DealTicket', () => {
beforeEach(() => {
localStorage.clear();
window.localStorage.clear();
});
afterEach(() => {
localStorage.clear();
window.localStorage.clear();
jest.clearAllMocks();
});
@@ -52,7 +61,9 @@ describe('DealTicket', () => {
expect(
screen.queryByTestId('order-side-SIDE_SELL')?.querySelector('input')
).not.toBeChecked();
expect(screen.getByTestId('order-size')).toHaveDisplayValue('0');
expect(screen.getByTestId('order-size')).toHaveDisplayValue(
String(1 / Math.pow(10, market.positionDecimalPlaces))
);
expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
);
@@ -65,49 +76,7 @@ describe('DealTicket', () => {
);
});
it('should use local storage state for initial values', () => {
const expectedOrder = {
marketId: market.id,
type: Schema.OrderType.TYPE_LIMIT,
side: Schema.Side.SIDE_SELL,
size: '0.1',
price: '300.22',
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC,
persist: true,
};
useOrderStore.setState({
orders: {
[expectedOrder.marketId]: expectedOrder,
},
});
render(generateJsx());
// Assert correct defaults are used from store
expect(
screen
.getByTestId(`order-type-${Schema.OrderType.TYPE_LIMIT}`)
.querySelector('input')
).toBeChecked();
expect(
screen.queryByTestId('order-side-SIDE_SELL')?.querySelector('input')
).toBeChecked();
expect(
screen.queryByTestId('order-side-SIDE_BUY')?.querySelector('input')
).not.toBeChecked();
expect(screen.getByTestId('order-size')).toHaveDisplayValue(
expectedOrder.size
);
expect(screen.getByTestId('order-tif')).toHaveValue(
expectedOrder.timeInForce
);
expect(screen.getByTestId('order-price')).toHaveDisplayValue(
expectedOrder.price
);
});
it('handles TIF select box dependent on order type', async () => {
it('handles TIF select box dependent on order type', () => {
render(generateJsx());
// Only FOK and IOC should be present by default (type market order)
@@ -117,72 +86,50 @@ describe('DealTicket', () => {
)
).toEqual(['Fill or Kill (FOK)', 'Immediate or Cancel (IOC)']);
// IOC should be default
expect(screen.getByTestId('order-tif')).toHaveDisplayValue(
'Immediate or Cancel (IOC)'
);
// Select FOK - FOK should be selected
await userEvent.selectOptions(
screen.getByTestId('order-tif'),
Schema.OrderTimeInForce.TIME_IN_FORCE_FOK
);
expect(screen.getByTestId('order-tif')).toHaveDisplayValue(
'Fill or Kill (FOK)'
);
// Switch to type limit order -> all TIF options should be shown
await userEvent.click(screen.getByTestId('order-type-TYPE_LIMIT'));
fireEvent.click(screen.getByTestId('order-type-TYPE_LIMIT'));
expect(screen.getByTestId('order-tif').children).toHaveLength(
Object.keys(Schema.OrderTimeInForce).length
);
// expect GTC as LIMIT default
// Select GTC -> GTC should be selected
fireEvent.change(screen.getByTestId('order-tif'), {
target: { value: Schema.OrderTimeInForce.TIME_IN_FORCE_GTC },
});
expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_GTC
);
// Switch to type market order -> IOC should be selected (default)
fireEvent.click(screen.getByTestId('order-type-TYPE_MARKET'));
expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
);
// Select IOC -> IOC should be selected
fireEvent.change(screen.getByTestId('order-tif'), {
target: { value: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC },
});
expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
);
// Switch to type limit order -> GTC should be selected
fireEvent.click(screen.getByTestId('order-type-TYPE_LIMIT'));
expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_GTC
);
// Select GTT -> GTT should be selected
await userEvent.selectOptions(
screen.getByTestId('order-tif'),
Schema.OrderTimeInForce.TIME_IN_FORCE_GTT
);
fireEvent.change(screen.getByTestId('order-tif'), {
target: { value: Schema.OrderTimeInForce.TIME_IN_FORCE_GTT },
});
expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_GTT
);
// Switch back to type market order -> FOK should be preserved from previous selection
await userEvent.click(screen.getByTestId('order-type-TYPE_MARKET'));
expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_FOK
);
// Select IOC -> IOC should be selected
await userEvent.selectOptions(
screen.getByTestId('order-tif'),
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
);
expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
);
// Switch back type limit order -> GTT should be preserved
await userEvent.click(screen.getByTestId('order-type-TYPE_LIMIT'));
expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_GTT
);
// Select GFN -> GFN should be selected
await userEvent.selectOptions(
screen.getByTestId('order-tif'),
Schema.OrderTimeInForce.TIME_IN_FORCE_GFN
);
expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_GFN
);
// Switch to type market order -> IOC should be preserved
await userEvent.click(screen.getByTestId('order-type-TYPE_MARKET'));
// Switch to type market order -> IOC should be selected
fireEvent.click(screen.getByTestId('order-type-TYPE_MARKET'));
expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
);
@@ -196,20 +143,23 @@ describe('DealTicket', () => {
screen.getByTestId('order-side-SIDE_BUY')?.querySelector('input')
).toBeChecked();
await userEvent.type(screen.getByTestId('order-size'), '200');
await act(async () => {
fireEvent.change(screen.getByTestId('order-size'), {
target: { value: '200' },
});
});
expect(screen.getByTestId('order-size')).toHaveDisplayValue('200');
await userEvent.selectOptions(
screen.getByTestId('order-tif'),
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
);
fireEvent.change(screen.getByTestId('order-tif'), {
target: { value: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC },
});
expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
);
// Switch to limit order
await userEvent.click(screen.getByTestId('order-type-TYPE_LIMIT'));
fireEvent.click(screen.getByTestId('order-type-TYPE_LIMIT'));
// Check all TIF options shown
expect(screen.getByTestId('order-tif').children).toHaveLength(
@@ -1,7 +1,7 @@
import { t } from '@vegaprotocol/i18n';
import * as Schema from '@vegaprotocol/types';
import { memo, useCallback, useEffect, useState } from 'react';
import { Controller } from 'react-hook-form';
import { memo, useCallback, useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { DealTicketAmount } from './deal-ticket-amount';
import { DealTicketButton } from './deal-ticket-button';
import { DealTicketFeeDetails } from './deal-ticket-fee-details';
@@ -9,12 +9,10 @@ import { ExpirySelector } from './expiry-selector';
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,
useVegaWalletDialogStore,
} from '@vegaprotocol/wallet';
import type { OrderSubmissionBody } from '@vegaprotocol/wallet';
import { useVegaWalletDialogStore } from '@vegaprotocol/wallet';
import { normalizeOrderSubmission } from '@vegaprotocol/wallet';
import { useVegaWallet } from '@vegaprotocol/wallet';
import {
ExternalLink,
InputError,
@@ -24,7 +22,7 @@ import {
import { useOrderMarginValidation } from '../../hooks/use-order-margin-validation';
import { MarginWarning } from '../deal-ticket-validation/margin-warning';
import {
validateExpiration,
getDefaultOrder,
validateMarketState,
validateMarketTradingMode,
validateTimeInForce,
@@ -34,17 +32,27 @@ import { ZeroBalanceError } from '../deal-ticket-validation/zero-balance-error';
import { SummaryValidationType } from '../../constants';
import { useHasNoBalance } from '../../hooks/use-has-no-balance';
import type { Market, MarketData } from '@vegaprotocol/market-list';
import { OrderTimeInForce, OrderType } from '@vegaprotocol/types';
import { useOrderForm } from '../../hooks/use-order-form';
import type { OrderObj } from '@vegaprotocol/orders';
import {
usePersistedOrderStore,
usePersistedOrderStoreSubscription,
} from '@vegaprotocol/orders';
import { OrderType } from '@vegaprotocol/types';
export type TransactionStatus = 'default' | 'pending';
export interface DealTicketProps {
market: Market;
marketData: MarketData;
submit: (order: OrderSubmission) => void;
submit: (order: OrderSubmissionBody['orderSubmission']) => void;
onClickCollateral?: () => void;
}
export type DealTicketFormFields = OrderSubmissionBody['orderSubmission'] & {
// This is not a field used in the form but allows us to set a
// summary error message
summary: string;
};
export const DealTicket = ({
market,
marketData,
@@ -52,21 +60,44 @@ export const DealTicket = ({
onClickCollateral,
}: DealTicketProps) => {
const { pubKey, isReadOnly } = useVegaWallet();
// 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 { getPersistedOrder, setPersistedOrder } = usePersistedOrderStore(
(store) => ({
getPersistedOrder: store.getOrder,
setPersistedOrder: store.setOrder,
})
);
const {
register,
control,
errors,
order,
handleSubmit,
watch,
setError,
clearErrors,
update,
handleSubmit,
} = useOrderForm(market.id);
formState: { errors },
setValue,
} = useForm<DealTicketFormFields>({
defaultValues: getPersistedOrder(market.id) || getDefaultOrder(market),
});
const order = watch();
watch((orderData) => {
const persistable = !(
orderData.type === OrderType.TYPE_LIMIT && orderData.price === ''
);
if (persistable) {
setPersistedOrder(orderData as DealTicketFormFields);
}
});
usePersistedOrderStoreSubscription(market.id, (storedOrder) => {
if (order.price !== storedOrder.price) {
clearErrors('price');
setValue('price', storedOrder.price);
}
});
const marketStateError = validateMarketState(marketData.marketState);
const hasNoBalance = useHasNoBalance(
market.tradableInstrument.instrument.product.settlementAsset.id
@@ -135,7 +166,7 @@ export const DealTicket = ({
]);
const onSubmit = useCallback(
(order: OrderSubmission) => {
(order: OrderSubmissionBody['orderSubmission']) => {
checkForErrors();
submit(
normalizeOrderSubmission(
@@ -148,12 +179,9 @@ export const DealTicket = ({
[checkForErrors, submit, market.decimalPlaces, market.positionDecimalPlaces]
);
// if an order doesn't exist one will be created by the store immediately
if (!order) return null;
return (
<form
onSubmit={isReadOnly ? undefined : handleSubmit(onSubmit)}
onSubmit={isReadOnly ? () => null : handleSubmit(onSubmit)}
className="p-4"
noValidate
>
@@ -166,17 +194,10 @@ export const DealTicket = ({
marketData.trigger
),
}}
render={() => (
render={({ field }) => (
<TypeSelector
value={order.type}
onSelect={(type) => {
if (type === OrderType.TYPE_NETWORK) return;
update({
type,
// when changing type also update the tif to what was last used of new type
timeInForce: lastTIF[type] || order.timeInForce,
});
}}
value={field.value}
onSelect={field.onChange}
market={market}
marketData={marketData}
errorMessage={errors.type?.message}
@@ -186,25 +207,17 @@ export const DealTicket = ({
<Controller
name="side"
control={control}
render={() => (
<SideSelector
value={order.side}
onSelect={(side) => {
update({ side });
}}
/>
render={({ field }) => (
<SideSelector value={field.value} onSelect={field.onChange} />
)}
/>
<DealTicketAmount
control={control}
orderType={order.type}
market={market}
marketData={marketData}
register={register}
sizeError={errors.size?.message}
priceError={errors.price?.message}
update={update}
size={order.size}
price={order.price}
/>
<Controller
name="timeInForce"
@@ -215,16 +228,11 @@ export const DealTicket = ({
marketData.trigger
),
}}
render={() => (
render={({ field }) => (
<TimeInForceSelector
value={order.timeInForce}
value={field.value}
orderType={order.type}
onSelect={(timeInForce) => {
update({ timeInForce });
// 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 }));
}}
onSelect={field.onChange}
market={market}
marketData={marketData}
errorMessage={errors.timeInForce?.message}
@@ -236,18 +244,12 @@ export const DealTicket = ({
<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={field.onChange}
errorMessage={errors.expiresAt?.message}
register={register}
/>
)}
/>
@@ -259,7 +261,7 @@ export const DealTicket = ({
order={order}
isReadOnly={isReadOnly}
pubKey={pubKey}
onClickCollateral={onClickCollateral}
onClickCollateral={onClickCollateral || (() => null)}
/>
<DealTicketButton
disabled={Object.keys(errors).length >= 1 || isReadOnly}
@@ -282,10 +284,10 @@ interface SummaryMessageProps {
errorMessage?: string;
market: Market;
marketData: MarketData;
order: OrderObj;
order: OrderSubmissionBody['orderSubmission'];
isReadOnly: boolean;
pubKey: string | null;
onClickCollateral?: () => void;
onClickCollateral: () => void;
}
const SummaryMessage = memo(
({
@@ -1,17 +1,22 @@
import { FormGroup, Input, InputError } from '@vegaprotocol/ui-toolkit';
import { formatForInput } from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n';
import type { UseFormRegister } from 'react-hook-form';
import { validateExpiration } from '../../utils/validate-expiration';
import type { DealTicketFormFields } from '.';
interface ExpirySelectorProps {
value?: string;
onSelect: (expiration: string | null) => void;
errorMessage?: string;
register?: UseFormRegister<DealTicketFormFields>;
}
export const ExpirySelector = ({
value,
onSelect,
errorMessage,
register,
}: ExpirySelectorProps) => {
const date = value ? new Date(value) : new Date();
const dateFormatted = formatForInput(date);
@@ -29,6 +34,9 @@ export const ExpirySelector = ({
value={dateFormatted}
onChange={(e) => onSelect(e.target.value)}
min={minDate}
{...register?.('expiresAt', {
validate: validateExpiration,
})}
/>
{errorMessage && (
<InputError testId="dealticket-error-message-expiry">
@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react';
import {
FormGroup,
InputError,
@@ -21,6 +22,15 @@ interface TimeInForceSelectorProps {
errorMessage?: string;
}
type OrderType = Schema.OrderType.TYPE_MARKET | Schema.OrderType.TYPE_LIMIT;
type PreviousTimeInForce = {
[key in OrderType]: Schema.OrderTimeInForce;
};
const DEFAULT_TIME_IN_FORCE: PreviousTimeInForce = {
[Schema.OrderType.TYPE_MARKET]: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC,
[Schema.OrderType.TYPE_LIMIT]: Schema.OrderTimeInForce.TIME_IN_FORCE_GTC,
};
export const TimeInForceSelector = ({
value,
orderType,
@@ -37,6 +47,28 @@ export const TimeInForceSelector = ({
timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_FOK ||
timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
);
const [previousOrderType, setPreviousOrderType] = useState(
Schema.OrderType.TYPE_MARKET
);
const [previousTimeInForce, setPreviousTimeInForce] =
useState<PreviousTimeInForce>({
...DEFAULT_TIME_IN_FORCE,
[orderType]: value,
});
useEffect(() => {
if (previousOrderType !== orderType) {
setPreviousOrderType(orderType);
const prev = previousTimeInForce[orderType as OrderType];
onSelect(prev);
}
}, [
onSelect,
orderType,
previousTimeInForce,
previousOrderType,
setPreviousOrderType,
]);
const renderError = (errorType: string) => {
if (errorType === MarketModeValidationType.Auction) {
@@ -96,16 +128,10 @@ export const TimeInForceSelector = ({
id="select-time-in-force"
value={value}
onChange={(e) => {
// setPreviousTimeInForce({
// ...previousTimeInForce,
// [orderType]: e.target.value,
// });
// if (previousOrderType !== orderType) {
// setPreviousOrderType(orderType);
// const prev = previousTimeInForce[orderType as OrderType];
// onSelect(prev);
// }
setPreviousTimeInForce({
...previousTimeInForce,
[orderType]: e.target.value,
});
onSelect(e.target.value as Schema.OrderTimeInForce);
}}
className="w-full"
@@ -1,66 +0,0 @@
import omit from 'lodash/omit';
import { act, renderHook } from '@testing-library/react';
import { getDefaultOrder, useOrderStore } 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));
};
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,74 +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';
import type { Exact } from 'type-fest';
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: <T>(o: Exact<OrderSubmission, T>) => void
) => {
return handleSubmit(() => {
// remove the persist key from the order in the store, the wallet will reject
// an order that contains unrecognized additional keys
cb(omit(order, 'persist'));
});
};
return {
order,
update,
control,
errors,
setError,
clearErrors,
getValues, // returned for test purposes only
handleSubmit: handleSubmitWrapper,
};
};
+1 -2
View File
@@ -17,8 +17,7 @@
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx",
"jest.config.ts",
"__mocks__"
"jest.config.ts"
],
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]
}
+1
View File
@@ -15,6 +15,7 @@
"**/*.test.jsx",
"**/*.spec.jsx",
"**/*.d.ts",
"**/__mocks__/*.tsx",
"jest.config.ts"
]
}
@@ -20,7 +20,7 @@ import {
getPriceLevel,
} from './orderbook-data';
import type { OrderbookData } from './orderbook-data';
import { useOrderStore } from '@vegaprotocol/orders';
import { usePersistedOrderStore } from '@vegaprotocol/orders';
interface OrderbookManagerProps {
marketId: string;
@@ -172,7 +172,7 @@ export const OrderbookManager = ({ marketId }: OrderbookManagerProps) => {
flush();
}, [resolution, flush]);
const updateOrder = useOrderStore((store) => store.update);
const updatePrice = usePersistedOrderStore((store) => store.updatePrice);
return (
<AsyncRenderer
@@ -190,7 +190,7 @@ export const OrderbookManager = ({ marketId }: OrderbookManagerProps) => {
onClick={(price?: string | number) => {
if (price) {
const priceValue = addDecimal(price, market?.decimalPlaces ?? 0);
updateOrder(marketId, { price: priceValue });
updatePrice(marketId, priceValue);
}
}}
/>
-21
View File
@@ -1,21 +0,0 @@
import type { StateCreator } from 'zustand';
import { act } from 'react-dom/test-utils';
const { create: actualCreate } = jest.requireActual('zustand'); // if using jest
// a variable to hold reset functions for all stores declared in the app
const storeResetFns = new Set<() => void>();
// when creating a store, we get its initial state, create a reset function and add it in the set
export const create =
() =>
<S>(createState: StateCreator<S>) => {
const store = actualCreate(createState);
const initialState = store.getState();
storeResetFns.add(() => store.setState(initialState, true));
return store;
};
// Reset all stores after each test run
beforeEach(() => {
act(() => storeResetFns.forEach((resetFn) => resetFn()));
});
@@ -8,10 +8,6 @@ import type { VegaWalletContextShape } from '@vegaprotocol/wallet';
import { VegaWalletContext } from '@vegaprotocol/wallet';
import { MockedProvider } from '@apollo/client/testing';
// @ts-ignore OrderList is read only but we need to override with the forwardRef to
// avoid warnings about padding refs
orderListMock.OrderListTable = forwardRef(() => <div>OrderList</div>);
const generateJsx = () => {
const pubKey = '0x123';
return (
@@ -60,6 +56,9 @@ describe('OrderListManager', () => {
});
it('should render the order list if orders provided', async () => {
// @ts-ignore OrderList is read only but we need to override with the forwardRef to
// avoid warnings about padding refs
orderListMock.OrderListTable = forwardRef(() => <div>OrderList</div>);
jest.spyOn(useDataProviderHook, 'useDataProvider').mockReturnValue({
data: [{ id: '1' } as OrderFieldsFragment],
loading: false,
@@ -6,6 +6,7 @@ import type { PartialDeep } from 'type-fest';
import type { VegaWalletContextShape } from '@vegaprotocol/wallet';
import { VegaWalletContext } from '@vegaprotocol/wallet';
import { MockedProvider } from '@apollo/client/testing';
import type { OrderListTableProps } from '../';
import { OrderListTable } from '../';
import {
@@ -14,15 +15,6 @@ import {
marketOrder,
} from '../mocks/generate-orders';
// Mock theme switcher to get around inconsistent mocking of zustand
// stores
jest.mock('@vegaprotocol/react-helpers', () => ({
...jest.requireActual('@vegaprotocol/react-helpers'),
useThemeSwitcher: () => ({
theme: 'light',
}),
}));
const defaultProps: OrderListTableProps = {
rowData: [],
setEditOrder: jest.fn(),
+1 -1
View File
@@ -4,4 +4,4 @@ export * from './use-order-cancel';
export * from './use-order-submit';
export * from './use-order-edit';
export * from './use-order-update';
export * from './use-order-store';
export * from './use-persisted-order';
@@ -1,121 +0,0 @@
import {
getDefaultOrder,
STORAGE_KEY,
useOrder,
useOrderStore,
} from './use-order-store';
import { act, renderHook } from '@testing-library/react';
import { OrderType } from '@vegaprotocol/types';
jest.mock('zustand');
describe('useOrderStore', () => {
const setup = () => {
return renderHook(() => useOrderStore());
};
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,117 +0,0 @@
import { OrderTimeInForce, Side } from '@vegaprotocol/types';
import { OrderType } from '@vegaprotocol/types';
import { useCallback, useEffect } from 'react';
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
};
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';
export const useOrderStore = create<Store>()(
persist(
subscribeWithSelector((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,
},
},
};
});
},
})),
{
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,
};
},
}
)
);
/**
* Retrieves an order from the store for a market and
* creates one if it doesn't already exist
*/
export const useOrder = (marketId: string) => {
const [order, _update] = useOrderStore((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 doesnt exist, but don't
// persist until user has edited
useEffect(() => {
if (!order) {
update(
getDefaultOrder(marketId),
false // dont 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_MARKET,
side: Side.SIDE_BUY,
timeInForce: OrderTimeInForce.TIME_IN_FORCE_IOC,
size: '0',
price: '0',
expiresAt: undefined,
persist: false,
});
@@ -0,0 +1,78 @@
import type { OrderSubmissionBody } from '@vegaprotocol/wallet';
import produce from 'immer';
import { create } from 'zustand';
import { persist, subscribeWithSelector } from 'zustand/middleware';
import isEqual from 'lodash/isEqual';
import { useEffect } from 'react';
type OrderData = OrderSubmissionBody['orderSubmission'] | null;
type PersistedOrderStore = {
orders: OrderData[];
getOrder: (marketId: string) => OrderData | undefined;
setOrder: (order: OrderData) => void;
clear: () => void;
updatePrice: (marketId: string, price: string) => void;
};
export const usePersistedOrderStore = create<PersistedOrderStore>()(
persist(
subscribeWithSelector((set, get) => ({
orders: [],
getOrder: (marketId: string) => {
const current = get() as PersistedOrderStore;
const persisted = current.orders.find((o) => o?.marketId === marketId);
return persisted;
},
setOrder: (order: OrderData) => {
set(
produce((store: PersistedOrderStore) => {
const persisted = store.orders.find(
(o) => o?.marketId === order?.marketId
);
if (persisted) {
if (!isEqual(persisted, order)) {
Object.assign(persisted, order);
} else {
// NOOP
}
} else {
store.orders.push(order);
}
})
);
},
clear: () => set({ orders: [] }),
updatePrice: (marketId: string, price: string) =>
set(
produce((store: PersistedOrderStore) => {
const persisted = store.orders.find(
(o) => o?.marketId === marketId
);
if (persisted) {
persisted.price = price;
}
})
),
})),
{
name: 'VEGA_DEAL_TICKET_ORDER_STORE',
}
)
);
export const usePersistedOrderStoreSubscription = (
marketId: string,
onOrderChange: (order: NonNullable<OrderData>) => void
) => {
const selector = (state: PersistedOrderStore) =>
state.orders.find((o) => o?.marketId === marketId);
const action = (storedOrder: OrderData | undefined) => {
if (storedOrder) {
onOrderChange(storedOrder);
}
};
const unsubscribe = usePersistedOrderStore.subscribe(selector, action);
useEffect(() => () => unsubscribe(), [unsubscribe]);
};
+1 -2
View File
@@ -21,8 +21,7 @@
"**/*.stories.js",
"**/*.stories.jsx",
"**/*.stories.tsx",
"jest.config.ts",
"__mocks__"
"jest.config.ts"
],
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]
}
+8 -2
View File
@@ -30,6 +30,7 @@ import {
formatNumber,
getDateTimeFormat,
addDecimalsFormatNumber,
createDocsLinks,
} from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n';
import { AgGridColumn } from 'ag-grid-react';
@@ -422,6 +423,8 @@ export const PNLCell = ({
data,
}: VegaICellRendererParams<Position, 'realisedPNL'>) => {
const { VEGA_DOCS_URL } = useEnvironment();
const LOSS_SOCIALIZATION_LINK =
VEGA_DOCS_URL && createDocsLinks(VEGA_DOCS_URL).LOSS_SOCIALIZATION;
if (!data) {
return <>-</>;
@@ -446,7 +449,7 @@ export const PNLCell = ({
{t('Lifetime loss socialisation deductions: %s', lossesFormatted)}
</p>
{VEGA_DOCS_URL && (
<ExternalLink href={VEGA_DOCS_URL}>
<ExternalLink href={LOSS_SOCIALIZATION_LINK}>
{t('Read more about loss socialisation')}
</ExternalLink>
)}
@@ -473,6 +476,9 @@ export const OpenVolumeCell = ({
return <>{valueFormatted}</>;
}
const POSITION_RESOLUTION_LINK =
VEGA_DOCS_URL && createDocsLinks(VEGA_DOCS_URL).POSITION_RESOLUTION;
return (
<WarningCell
tooltipContent={
@@ -489,7 +495,7 @@ export const OpenVolumeCell = ({
)}
</p>
{VEGA_DOCS_URL && (
<ExternalLink href={VEGA_DOCS_URL}>
<ExternalLink href={POSITION_RESOLUTION_LINK}>
{t('Read more about position resolution')}
</ExternalLink>
)}
+3 -3
View File
@@ -8,7 +8,7 @@ import { MAX_TRADES, tradesWithMarketProvider } from './trades-data-provider';
import { TradesTable } from './trades-table';
import type { Trade, TradeEdge } from './trades-data-provider';
import type { TradesQueryVariables } from './__generated__/Trades';
import { useOrderStore } from '@vegaprotocol/orders';
import { usePersistedOrderStore } from '@vegaprotocol/orders';
interface TradesContainerProps {
marketId: string;
@@ -20,7 +20,7 @@ export const TradesContainer = ({ marketId }: TradesContainerProps) => {
const totalCountRef = useRef<number | undefined>(undefined);
const newRows = useRef(0);
const scrolledToTop = useRef(true);
const updateOrder = useOrderStore((store) => store.update);
const updatePrice = usePersistedOrderStore((store) => store.updatePrice);
const variables = useMemo<TradesQueryVariables>(
() => ({ marketId, maxTrades: MAX_TRADES }),
@@ -115,7 +115,7 @@ export const TradesContainer = ({ marketId }: TradesContainerProps) => {
onBodyScroll={onBodyScroll}
onClick={(price?: string) => {
if (price) {
updateOrder(marketId, { price });
updatePrice(marketId, price);
}
}}
/>
@@ -89,7 +89,6 @@ export const Notification = ({
onClick={buttonProps.action}
className={classNames(buttonProps.className)}
data-testid={buttonProps.dataTestId}
type="button"
>
{buttonProps.text}
</Button>
+2
View File
@@ -12,6 +12,8 @@ export const createDocsLinks = (docsUrl: string) => ({
VEGA_WALLET_CONCEPTS_URL: `${docsUrl}/concepts/vega-wallet`,
PROPOSALS_GUIDE: `${docsUrl}/tutorials/proposals`,
NODE_OPERATORS: `${docsUrl}/node-operators`,
LOSS_SOCIALIZATION: `${docsUrl}/concepts/trading-on-vega/market-protections#loss-socialisation`,
POSITION_RESOLUTION: `${docsUrl}/concepts/trading-on-vega/market-protections#position-resolution`,
});
export const ExternalLinks = {
+8 -9
View File
@@ -5,12 +5,11 @@ import BigNumber from 'bignumber.js';
import { ethers } from 'ethers';
import { sha3_256 } from 'js-sha3';
import type {
OrderAmendment,
OrderSubmission,
OrderAmendmentBody,
OrderSubmissionBody,
Transaction,
Transfer,
} from './connectors';
import type { Exact } from 'type-fest';
/**
* Creates an ID in the same way that core does on the backend. This way we
@@ -29,11 +28,11 @@ export const encodeTransaction = (tx: Transaction): string => {
);
};
export const normalizeOrderSubmission = <T extends Exact<OrderSubmission, T>>(
order: T,
export const normalizeOrderSubmission = (
order: OrderSubmissionBody['orderSubmission'],
decimalPlaces: number,
positionDecimalPlaces: number
): OrderSubmission => ({
): OrderSubmissionBody['orderSubmission'] => ({
...order,
price:
order.type === OrderType.TYPE_LIMIT && order.price
@@ -46,12 +45,12 @@ export const normalizeOrderSubmission = <T extends Exact<OrderSubmission, T>>(
: undefined,
});
export const normalizeOrderAmendment = <T extends Exact<OrderAmendment, T>>(
export const normalizeOrderAmendment = (
order: Pick<Order, 'id' | 'timeInForce' | 'size' | 'expiresAt'>,
market: Pick<Market, 'id' | 'decimalPlaces' | 'positionDecimalPlaces'>,
price: string,
size: string
): OrderAmendment => ({
): OrderAmendmentBody['orderAmendment'] => ({
orderId: order.id,
marketId: market.id,
price: removeDecimal(price, market.decimalPlaces),
@@ -66,7 +65,7 @@ export const normalizeOrderAmendment = <T extends Exact<OrderAmendment, T>>(
: undefined,
});
export const normalizeTransfer = <T extends Exact<Transfer, T>>(
export const normalizeTransfer = (
address: string,
amount: string,
asset: {