fix(trading): order store connection to deal ticket (#3100)
Co-authored-by: Madalina Raicu <madalina@raygroup.uk>
This commit is contained in:
parent
46513685c8
commit
8f5a2276de
@ -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
libs/deal-ticket/__mocks__/zustand.ts
Normal file
21
libs/deal-ticket/__mocks__/zustand.ts
Normal file
@ -0,0 +1,21 @@
|
||||
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,8 +21,12 @@ export const ZeroBalanceError = ({
|
||||
testId="dealticket-error-message-zero-balance"
|
||||
message={
|
||||
<>
|
||||
You need {asset.symbol} in your wallet to trade in this market. See
|
||||
all your <Link onClick={onClickCollateral}>collateral</Link>.
|
||||
You need {asset.symbol} in your wallet to trade in this market.
|
||||
{onClickCollateral && (
|
||||
<>
|
||||
See all your <Link onClick={onClickCollateral}>collateral</Link>.
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
buttonProps={{
|
||||
|
@ -1,17 +1,21 @@
|
||||
import type { UseFormRegister } from 'react-hook-form';
|
||||
import type { Control } 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 { DealTicketFormFields } from './deal-ticket';
|
||||
import type { OrderObj } from '@vegaprotocol/orders';
|
||||
import type { OrderFormFields } from '../../hooks/use-order-form';
|
||||
|
||||
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 || (() => null)}
|
||||
onClickCollateral={onClickCollateral}
|
||||
/>
|
||||
) : (
|
||||
<Splash>
|
||||
|
@ -2,6 +2,7 @@ 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'>,
|
||||
@ -9,10 +10,13 @@ export type DealTicketLimitAmountProps = Omit<
|
||||
>;
|
||||
|
||||
export const DealTicketLimitAmount = ({
|
||||
register,
|
||||
control,
|
||||
market,
|
||||
sizeError,
|
||||
priceError,
|
||||
update,
|
||||
price,
|
||||
size,
|
||||
}: DealTicketLimitAmountProps) => {
|
||||
const priceStep = toDecimal(market?.decimalPlaces);
|
||||
const sizeStep = toDecimal(market?.positionDecimalPlaces);
|
||||
@ -47,22 +51,30 @@ export const DealTicketLimitAmount = ({
|
||||
labelFor="input-order-size-limit"
|
||||
className="!mb-1"
|
||||
>
|
||||
<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', {
|
||||
<Controller
|
||||
name="size"
|
||||
control={control}
|
||||
rules={{
|
||||
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>
|
||||
@ -77,14 +89,10 @@ export const DealTicketLimitAmount = ({
|
||||
labelAlign="right"
|
||||
className="!mb-1"
|
||||
>
|
||||
<Input
|
||||
id="input-price-quote"
|
||||
className="w-full"
|
||||
type="number"
|
||||
step={priceStep}
|
||||
data-testid="order-price"
|
||||
onWheel={(e) => e.currentTarget.blur()}
|
||||
{...register('price', {
|
||||
<Controller
|
||||
name="price"
|
||||
control={control}
|
||||
rules={{
|
||||
required: t('You need provide a price'),
|
||||
min: {
|
||||
value: priceStep,
|
||||
@ -92,7 +100,19 @@ 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,6 +8,7 @@ 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,
|
||||
@ -15,10 +16,12 @@ export type DealTicketMarketAmountProps = Omit<
|
||||
>;
|
||||
|
||||
export const DealTicketMarketAmount = ({
|
||||
register,
|
||||
control,
|
||||
market,
|
||||
marketData,
|
||||
sizeError,
|
||||
update,
|
||||
size,
|
||||
}: DealTicketMarketAmountProps) => {
|
||||
const quoteName = market.tradableInstrument.instrument.product.quoteName;
|
||||
const sizeStep = toDecimal(market?.positionDecimalPlaces);
|
||||
@ -47,22 +50,30 @@ export const DealTicketMarketAmount = ({
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<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', {
|
||||
<Controller
|
||||
name="size"
|
||||
control={control}
|
||||
rules={{
|
||||
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,38 +1,28 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { VegaWalletContext } from '@vegaprotocol/wallet';
|
||||
import { fireEvent, render, screen, act } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
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();
|
||||
|
||||
const mockChainId = 'chain-id';
|
||||
|
||||
function generateJsx(order?: OrderSubmissionBody['orderSubmission']) {
|
||||
const chainIdMock: MockedResponse<ChainIdQuery> = {
|
||||
request: {
|
||||
query: ChainIdDocument,
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
statistics: {
|
||||
chainId: mockChainId,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
function generateJsx() {
|
||||
return (
|
||||
<MockedProvider mocks={[chainIdMock]}>
|
||||
<VegaWalletContext.Provider value={{ pubKey: mockChainId } as any}>
|
||||
<MockedProvider>
|
||||
<VegaWalletContext.Provider value={{ pubKey, isReadOnly: false } as any}>
|
||||
<DealTicket market={market} marketData={marketData} submit={submit} />
|
||||
</VegaWalletContext.Provider>
|
||||
</MockedProvider>
|
||||
@ -41,10 +31,11 @@ function generateJsx(order?: OrderSubmissionBody['orderSubmission']) {
|
||||
|
||||
describe('DealTicket', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
localStorage.clear();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
@ -61,9 +52,7 @@ describe('DealTicket', () => {
|
||||
expect(
|
||||
screen.queryByTestId('order-side-SIDE_SELL')?.querySelector('input')
|
||||
).not.toBeChecked();
|
||||
expect(screen.getByTestId('order-size')).toHaveDisplayValue(
|
||||
String(1 / Math.pow(10, market.positionDecimalPlaces))
|
||||
);
|
||||
expect(screen.getByTestId('order-size')).toHaveDisplayValue('0');
|
||||
expect(screen.getByTestId('order-tif')).toHaveValue(
|
||||
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
|
||||
);
|
||||
@ -76,7 +65,49 @@ describe('DealTicket', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('handles TIF select box dependent on order type', () => {
|
||||
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 () => {
|
||||
render(generateJsx());
|
||||
|
||||
// Only FOK and IOC should be present by default (type market order)
|
||||
@ -86,50 +117,72 @@ 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
|
||||
fireEvent.click(screen.getByTestId('order-type-TYPE_LIMIT'));
|
||||
await userEvent.click(screen.getByTestId('order-type-TYPE_LIMIT'));
|
||||
expect(screen.getByTestId('order-tif').children).toHaveLength(
|
||||
Object.keys(Schema.OrderTimeInForce).length
|
||||
);
|
||||
|
||||
// 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 GTC as LIMIT default
|
||||
expect(screen.getByTestId('order-tif')).toHaveValue(
|
||||
Schema.OrderTimeInForce.TIME_IN_FORCE_GTC
|
||||
);
|
||||
|
||||
// Select GTT -> GTT should be selected
|
||||
fireEvent.change(screen.getByTestId('order-tif'), {
|
||||
target: { value: Schema.OrderTimeInForce.TIME_IN_FORCE_GTT },
|
||||
});
|
||||
await userEvent.selectOptions(
|
||||
screen.getByTestId('order-tif'),
|
||||
Schema.OrderTimeInForce.TIME_IN_FORCE_GTT
|
||||
);
|
||||
expect(screen.getByTestId('order-tif')).toHaveValue(
|
||||
Schema.OrderTimeInForce.TIME_IN_FORCE_GTT
|
||||
);
|
||||
|
||||
// Switch to type market order -> IOC should be selected
|
||||
fireEvent.click(screen.getByTestId('order-type-TYPE_MARKET'));
|
||||
// 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'));
|
||||
expect(screen.getByTestId('order-tif')).toHaveValue(
|
||||
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
|
||||
);
|
||||
@ -143,23 +196,20 @@ describe('DealTicket', () => {
|
||||
screen.getByTestId('order-side-SIDE_BUY')?.querySelector('input')
|
||||
).toBeChecked();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByTestId('order-size'), {
|
||||
target: { value: '200' },
|
||||
});
|
||||
});
|
||||
await userEvent.type(screen.getByTestId('order-size'), '200');
|
||||
|
||||
expect(screen.getByTestId('order-size')).toHaveDisplayValue('200');
|
||||
|
||||
fireEvent.change(screen.getByTestId('order-tif'), {
|
||||
target: { value: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC },
|
||||
});
|
||||
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 to limit order
|
||||
fireEvent.click(screen.getByTestId('order-type-TYPE_LIMIT'));
|
||||
await userEvent.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 } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { DealTicketAmount } from './deal-ticket-amount';
|
||||
import { DealTicketButton } from './deal-ticket-button';
|
||||
import { DealTicketFeeDetails } from './deal-ticket-fee-details';
|
||||
@ -9,10 +9,12 @@ import { ExpirySelector } from './expiry-selector';
|
||||
import { SideSelector } from './side-selector';
|
||||
import { TimeInForceSelector } from './time-in-force-selector';
|
||||
import { TypeSelector } from './type-selector';
|
||||
import type { OrderSubmissionBody } from '@vegaprotocol/wallet';
|
||||
import { useVegaWalletDialogStore } from '@vegaprotocol/wallet';
|
||||
import { normalizeOrderSubmission } from '@vegaprotocol/wallet';
|
||||
import { useVegaWallet } from '@vegaprotocol/wallet';
|
||||
import type { OrderSubmission } from '@vegaprotocol/wallet';
|
||||
import {
|
||||
normalizeOrderSubmission,
|
||||
useVegaWallet,
|
||||
useVegaWalletDialogStore,
|
||||
} from '@vegaprotocol/wallet';
|
||||
import {
|
||||
ExternalLink,
|
||||
InputError,
|
||||
@ -22,7 +24,7 @@ import {
|
||||
import { useOrderMarginValidation } from '../../hooks/use-order-margin-validation';
|
||||
import { MarginWarning } from '../deal-ticket-validation/margin-warning';
|
||||
import {
|
||||
getDefaultOrder,
|
||||
validateExpiration,
|
||||
validateMarketState,
|
||||
validateMarketTradingMode,
|
||||
validateTimeInForce,
|
||||
@ -32,27 +34,17 @@ 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 {
|
||||
usePersistedOrderStore,
|
||||
usePersistedOrderStoreSubscription,
|
||||
} from '@vegaprotocol/orders';
|
||||
import { OrderType } from '@vegaprotocol/types';
|
||||
|
||||
export type TransactionStatus = 'default' | 'pending';
|
||||
import { OrderTimeInForce, OrderType } from '@vegaprotocol/types';
|
||||
import { useOrderForm } from '../../hooks/use-order-form';
|
||||
import type { OrderObj } from '@vegaprotocol/orders';
|
||||
|
||||
export interface DealTicketProps {
|
||||
market: Market;
|
||||
marketData: MarketData;
|
||||
submit: (order: OrderSubmissionBody['orderSubmission']) => void;
|
||||
submit: (order: 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,
|
||||
@ -60,44 +52,21 @@ export const DealTicket = ({
|
||||
onClickCollateral,
|
||||
}: DealTicketProps) => {
|
||||
const { pubKey, isReadOnly } = useVegaWallet();
|
||||
const { getPersistedOrder, setPersistedOrder } = usePersistedOrderStore(
|
||||
(store) => ({
|
||||
getPersistedOrder: store.getOrder,
|
||||
setPersistedOrder: store.setOrder,
|
||||
})
|
||||
);
|
||||
|
||||
// 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 {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
watch,
|
||||
errors,
|
||||
order,
|
||||
setError,
|
||||
clearErrors,
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
update,
|
||||
handleSubmit,
|
||||
} = useOrderForm(market.id);
|
||||
const marketStateError = validateMarketState(marketData.marketState);
|
||||
const hasNoBalance = useHasNoBalance(
|
||||
market.tradableInstrument.instrument.product.settlementAsset.id
|
||||
@ -166,7 +135,7 @@ export const DealTicket = ({
|
||||
]);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(order: OrderSubmissionBody['orderSubmission']) => {
|
||||
(order: OrderSubmission) => {
|
||||
checkForErrors();
|
||||
submit(
|
||||
normalizeOrderSubmission(
|
||||
@ -179,9 +148,12 @@ 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 ? () => null : handleSubmit(onSubmit)}
|
||||
onSubmit={isReadOnly ? undefined : handleSubmit(onSubmit)}
|
||||
className="p-4"
|
||||
noValidate
|
||||
>
|
||||
@ -194,10 +166,17 @@ export const DealTicket = ({
|
||||
marketData.trigger
|
||||
),
|
||||
}}
|
||||
render={({ field }) => (
|
||||
render={() => (
|
||||
<TypeSelector
|
||||
value={field.value}
|
||||
onSelect={field.onChange}
|
||||
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,
|
||||
});
|
||||
}}
|
||||
market={market}
|
||||
marketData={marketData}
|
||||
errorMessage={errors.type?.message}
|
||||
@ -207,17 +186,25 @@ export const DealTicket = ({
|
||||
<Controller
|
||||
name="side"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SideSelector value={field.value} onSelect={field.onChange} />
|
||||
render={() => (
|
||||
<SideSelector
|
||||
value={order.side}
|
||||
onSelect={(side) => {
|
||||
update({ side });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<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"
|
||||
@ -228,11 +215,16 @@ export const DealTicket = ({
|
||||
marketData.trigger
|
||||
),
|
||||
}}
|
||||
render={({ field }) => (
|
||||
render={() => (
|
||||
<TimeInForceSelector
|
||||
value={field.value}
|
||||
value={order.timeInForce}
|
||||
orderType={order.type}
|
||||
onSelect={field.onChange}
|
||||
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 }));
|
||||
}}
|
||||
market={market}
|
||||
marketData={marketData}
|
||||
errorMessage={errors.timeInForce?.message}
|
||||
@ -244,12 +236,18 @@ export const DealTicket = ({
|
||||
<Controller
|
||||
name="expiresAt"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
rules={{
|
||||
validate: validateExpiration,
|
||||
}}
|
||||
render={() => (
|
||||
<ExpirySelector
|
||||
value={field.value}
|
||||
onSelect={field.onChange}
|
||||
value={order.expiresAt}
|
||||
onSelect={(expiresAt) =>
|
||||
update({
|
||||
expiresAt: expiresAt || undefined,
|
||||
})
|
||||
}
|
||||
errorMessage={errors.expiresAt?.message}
|
||||
register={register}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@ -261,7 +259,7 @@ export const DealTicket = ({
|
||||
order={order}
|
||||
isReadOnly={isReadOnly}
|
||||
pubKey={pubKey}
|
||||
onClickCollateral={onClickCollateral || (() => null)}
|
||||
onClickCollateral={onClickCollateral}
|
||||
/>
|
||||
<DealTicketButton
|
||||
disabled={Object.keys(errors).length >= 1 || isReadOnly}
|
||||
@ -284,10 +282,10 @@ interface SummaryMessageProps {
|
||||
errorMessage?: string;
|
||||
market: Market;
|
||||
marketData: MarketData;
|
||||
order: OrderSubmissionBody['orderSubmission'];
|
||||
order: OrderObj;
|
||||
isReadOnly: boolean;
|
||||
pubKey: string | null;
|
||||
onClickCollateral: () => void;
|
||||
onClickCollateral?: () => void;
|
||||
}
|
||||
const SummaryMessage = memo(
|
||||
({
|
||||
|
@ -1,22 +1,17 @@
|
||||
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);
|
||||
@ -34,9 +29,6 @@ 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,4 +1,3 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
FormGroup,
|
||||
InputError,
|
||||
@ -22,15 +21,6 @@ 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,
|
||||
@ -47,28 +37,6 @@ 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) {
|
||||
@ -128,10 +96,16 @@ export const TimeInForceSelector = ({
|
||||
id="select-time-in-force"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setPreviousTimeInForce({
|
||||
...previousTimeInForce,
|
||||
[orderType]: e.target.value,
|
||||
});
|
||||
// setPreviousTimeInForce({
|
||||
// ...previousTimeInForce,
|
||||
// [orderType]: e.target.value,
|
||||
// });
|
||||
|
||||
// if (previousOrderType !== orderType) {
|
||||
// setPreviousOrderType(orderType);
|
||||
// const prev = previousTimeInForce[orderType as OrderType];
|
||||
// onSelect(prev);
|
||||
// }
|
||||
onSelect(e.target.value as Schema.OrderTimeInForce);
|
||||
}}
|
||||
className="w-full"
|
||||
|
66
libs/deal-ticket/src/hooks/use-order-form.spec.ts
Normal file
66
libs/deal-ticket/src/hooks/use-order-form.spec.ts
Normal file
@ -0,0 +1,66 @@
|
||||
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();
|
||||
});
|
||||
});
|
74
libs/deal-ticket/src/hooks/use-order-form.ts
Normal file
74
libs/deal-ticket/src/hooks/use-order-form.ts
Normal file
@ -0,0 +1,74 @@
|
||||
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,
|
||||
};
|
||||
};
|
@ -17,7 +17,8 @@
|
||||
"**/*.test.js",
|
||||
"**/*.spec.jsx",
|
||||
"**/*.test.jsx",
|
||||
"jest.config.ts"
|
||||
"jest.config.ts",
|
||||
"__mocks__"
|
||||
],
|
||||
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]
|
||||
}
|
||||
|
@ -15,7 +15,6 @@
|
||||
"**/*.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 { usePersistedOrderStore } from '@vegaprotocol/orders';
|
||||
import { useOrderStore } from '@vegaprotocol/orders';
|
||||
|
||||
interface OrderbookManagerProps {
|
||||
marketId: string;
|
||||
@ -172,7 +172,7 @@ export const OrderbookManager = ({ marketId }: OrderbookManagerProps) => {
|
||||
flush();
|
||||
}, [resolution, flush]);
|
||||
|
||||
const updatePrice = usePersistedOrderStore((store) => store.updatePrice);
|
||||
const updateOrder = useOrderStore((store) => store.update);
|
||||
|
||||
return (
|
||||
<AsyncRenderer
|
||||
@ -190,7 +190,7 @@ export const OrderbookManager = ({ marketId }: OrderbookManagerProps) => {
|
||||
onClick={(price?: string | number) => {
|
||||
if (price) {
|
||||
const priceValue = addDecimal(price, market?.decimalPlaces ?? 0);
|
||||
updatePrice(marketId, priceValue);
|
||||
updateOrder(marketId, { price: priceValue });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
21
libs/orders/__mocks__/zustand.ts
Normal file
21
libs/orders/__mocks__/zustand.ts
Normal file
@ -0,0 +1,21 @@
|
||||
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,6 +8,10 @@ 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 (
|
||||
@ -56,9 +60,6 @@ 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,7 +6,6 @@ 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 {
|
||||
@ -15,6 +14,15 @@ 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(),
|
||||
|
@ -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-persisted-order';
|
||||
export * from './use-order-store';
|
||||
|
121
libs/orders/src/lib/order-hooks/use-order-store.spec.ts
Normal file
121
libs/orders/src/lib/order-hooks/use-order-store.spec.ts
Normal file
@ -0,0 +1,121 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
117
libs/orders/src/lib/order-hooks/use-order-store.ts
Normal file
117
libs/orders/src/lib/order-hooks/use-order-store.ts
Normal file
@ -0,0 +1,117 @@
|
||||
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,
|
||||
});
|
@ -1,78 +0,0 @@
|
||||
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]);
|
||||
};
|
@ -21,7 +21,8 @@
|
||||
"**/*.stories.js",
|
||||
"**/*.stories.jsx",
|
||||
"**/*.stories.tsx",
|
||||
"jest.config.ts"
|
||||
"jest.config.ts",
|
||||
"__mocks__"
|
||||
],
|
||||
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]
|
||||
}
|
||||
|
@ -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 { usePersistedOrderStore } from '@vegaprotocol/orders';
|
||||
import { useOrderStore } 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 updatePrice = usePersistedOrderStore((store) => store.updatePrice);
|
||||
const updateOrder = useOrderStore((store) => store.update);
|
||||
|
||||
const variables = useMemo<TradesQueryVariables>(
|
||||
() => ({ marketId, maxTrades: MAX_TRADES }),
|
||||
@ -115,7 +115,7 @@ export const TradesContainer = ({ marketId }: TradesContainerProps) => {
|
||||
onBodyScroll={onBodyScroll}
|
||||
onClick={(price?: string) => {
|
||||
if (price) {
|
||||
updatePrice(marketId, price);
|
||||
updateOrder(marketId, { price });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
@ -89,6 +89,7 @@ export const Notification = ({
|
||||
onClick={buttonProps.action}
|
||||
className={classNames(buttonProps.className)}
|
||||
data-testid={buttonProps.dataTestId}
|
||||
type="button"
|
||||
>
|
||||
{buttonProps.text}
|
||||
</Button>
|
||||
|
@ -5,11 +5,12 @@ import BigNumber from 'bignumber.js';
|
||||
import { ethers } from 'ethers';
|
||||
import { sha3_256 } from 'js-sha3';
|
||||
import type {
|
||||
OrderAmendmentBody,
|
||||
OrderSubmissionBody,
|
||||
OrderAmendment,
|
||||
OrderSubmission,
|
||||
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
|
||||
@ -28,11 +29,11 @@ export const encodeTransaction = (tx: Transaction): string => {
|
||||
);
|
||||
};
|
||||
|
||||
export const normalizeOrderSubmission = (
|
||||
order: OrderSubmissionBody['orderSubmission'],
|
||||
export const normalizeOrderSubmission = <T extends Exact<OrderSubmission, T>>(
|
||||
order: T,
|
||||
decimalPlaces: number,
|
||||
positionDecimalPlaces: number
|
||||
): OrderSubmissionBody['orderSubmission'] => ({
|
||||
): OrderSubmission => ({
|
||||
...order,
|
||||
price:
|
||||
order.type === OrderType.TYPE_LIMIT && order.price
|
||||
@ -45,12 +46,12 @@ export const normalizeOrderSubmission = (
|
||||
: undefined,
|
||||
});
|
||||
|
||||
export const normalizeOrderAmendment = (
|
||||
export const normalizeOrderAmendment = <T extends Exact<OrderAmendment, T>>(
|
||||
order: Pick<Order, 'id' | 'timeInForce' | 'size' | 'expiresAt'>,
|
||||
market: Pick<Market, 'id' | 'decimalPlaces' | 'positionDecimalPlaces'>,
|
||||
price: string,
|
||||
size: string
|
||||
): OrderAmendmentBody['orderAmendment'] => ({
|
||||
): OrderAmendment => ({
|
||||
orderId: order.id,
|
||||
marketId: market.id,
|
||||
price: removeDecimal(price, market.decimalPlaces),
|
||||
@ -65,7 +66,7 @@ export const normalizeOrderAmendment = (
|
||||
: undefined,
|
||||
});
|
||||
|
||||
export const normalizeTransfer = (
|
||||
export const normalizeTransfer = <T extends Exact<Transfer, T>>(
|
||||
address: string,
|
||||
amount: string,
|
||||
asset: {
|
||||
|
Loading…
Reference in New Issue
Block a user