fix(trading): order store connection to deal ticket (#3100)

Co-authored-by: Madalina Raicu <madalina@raygroup.uk>
This commit is contained in:
Matthew Russell 2023-03-08 04:14:56 -08:00 committed by GitHub
parent 46513685c8
commit 8f5a2276de
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 730 additions and 323 deletions

View File

@ -669,7 +669,7 @@ describe('account validation', { tags: '@regression' }, () => {
'have.text', 'have.text',
'You need ' + 'You need ' +
'tDAI' + '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'); cy.getByTestId('deal-ticket-deposit-dialog-button').should('exist');
}); });

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

View File

@ -7,7 +7,7 @@ interface ZeroBalanceErrorProps {
id: string; id: string;
symbol: string; symbol: string;
}; };
onClickCollateral: () => void; onClickCollateral?: () => void;
} }
export const ZeroBalanceError = ({ export const ZeroBalanceError = ({
@ -21,8 +21,12 @@ export const ZeroBalanceError = ({
testId="dealticket-error-message-zero-balance" testId="dealticket-error-message-zero-balance"
message={ message={
<> <>
You need {asset.symbol} in your wallet to trade in this market. See You need {asset.symbol} in your wallet to trade in this market.
all your <Link onClick={onClickCollateral}>collateral</Link>. {onClickCollateral && (
<>
See all your <Link onClick={onClickCollateral}>collateral</Link>.
</>
)}
</> </>
} }
buttonProps={{ buttonProps={{

View File

@ -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 type { Market, MarketData } from '@vegaprotocol/market-list';
import { DealTicketMarketAmount } from './deal-ticket-market-amount'; import { DealTicketMarketAmount } from './deal-ticket-market-amount';
import { DealTicketLimitAmount } from './deal-ticket-limit-amount'; import { DealTicketLimitAmount } from './deal-ticket-limit-amount';
import * as Schema from '@vegaprotocol/types'; 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 { export interface DealTicketAmountProps {
control: Control<OrderFormFields>;
orderType: Schema.OrderType; orderType: Schema.OrderType;
marketData: MarketData; marketData: MarketData;
market: Market; market: Market;
register: UseFormRegister<DealTicketFormFields>;
sizeError?: string; sizeError?: string;
priceError?: string; priceError?: string;
update: (obj: Partial<OrderObj>) => void;
size: string;
price?: string;
} }
export const DealTicketAmount = ({ export const DealTicketAmount = ({

View File

@ -47,7 +47,7 @@ export const DealTicketContainer = ({
market={market} market={market}
marketData={marketData} marketData={marketData}
submit={(orderSubmission) => create({ orderSubmission })} submit={(orderSubmission) => create({ orderSubmission })}
onClickCollateral={onClickCollateral || (() => null)} onClickCollateral={onClickCollateral}
/> />
) : ( ) : (
<Splash> <Splash>

View File

@ -2,6 +2,7 @@ import { FormGroup, Input, InputError } from '@vegaprotocol/ui-toolkit';
import { toDecimal, validateAmount } from '@vegaprotocol/utils'; import { toDecimal, validateAmount } from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import type { DealTicketAmountProps } from './deal-ticket-amount'; import type { DealTicketAmountProps } from './deal-ticket-amount';
import { Controller } from 'react-hook-form';
export type DealTicketLimitAmountProps = Omit< export type DealTicketLimitAmountProps = Omit<
Omit<DealTicketAmountProps, 'marketData'>, Omit<DealTicketAmountProps, 'marketData'>,
@ -9,10 +10,13 @@ export type DealTicketLimitAmountProps = Omit<
>; >;
export const DealTicketLimitAmount = ({ export const DealTicketLimitAmount = ({
register, control,
market, market,
sizeError, sizeError,
priceError, priceError,
update,
price,
size,
}: DealTicketLimitAmountProps) => { }: DealTicketLimitAmountProps) => {
const priceStep = toDecimal(market?.decimalPlaces); const priceStep = toDecimal(market?.decimalPlaces);
const sizeStep = toDecimal(market?.positionDecimalPlaces); const sizeStep = toDecimal(market?.positionDecimalPlaces);
@ -47,22 +51,30 @@ export const DealTicketLimitAmount = ({
labelFor="input-order-size-limit" labelFor="input-order-size-limit"
className="!mb-1" className="!mb-1"
> >
<Input <Controller
id="input-order-size-limit" name="size"
className="w-full" control={control}
type="number" rules={{
step={sizeStep}
min={sizeStep}
data-testid="order-size"
onWheel={(e) => e.currentTarget.blur()}
{...register('size', {
required: t('You need to provide a size'), required: t('You need to provide a size'),
min: { min: {
value: sizeStep, value: sizeStep,
message: t('Size cannot be lower than ' + sizeStep), message: t('Size cannot be lower than ' + sizeStep),
}, },
validate: validateAmount(sizeStep, 'Size'), 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> </FormGroup>
</div> </div>
@ -77,14 +89,10 @@ export const DealTicketLimitAmount = ({
labelAlign="right" labelAlign="right"
className="!mb-1" className="!mb-1"
> >
<Input <Controller
id="input-price-quote" name="price"
className="w-full" control={control}
type="number" rules={{
step={priceStep}
data-testid="order-price"
onWheel={(e) => e.currentTarget.blur()}
{...register('price', {
required: t('You need provide a price'), required: t('You need provide a price'),
min: { min: {
value: priceStep, value: priceStep,
@ -92,7 +100,19 @@ export const DealTicketLimitAmount = ({
}, },
// @ts-ignore this fulfills the interface but still errors // @ts-ignore this fulfills the interface but still errors
validate: validateAmount(priceStep, 'Price'), 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> </FormGroup>
</div> </div>

View File

@ -8,6 +8,7 @@ import { Input, InputError, Tooltip } from '@vegaprotocol/ui-toolkit';
import { isMarketInAuction } from '../../utils'; import { isMarketInAuction } from '../../utils';
import type { DealTicketAmountProps } from './deal-ticket-amount'; import type { DealTicketAmountProps } from './deal-ticket-amount';
import { getMarketPrice } from '../../utils/get-price'; import { getMarketPrice } from '../../utils/get-price';
import { Controller } from 'react-hook-form';
export type DealTicketMarketAmountProps = Omit< export type DealTicketMarketAmountProps = Omit<
DealTicketAmountProps, DealTicketAmountProps,
@ -15,10 +16,12 @@ export type DealTicketMarketAmountProps = Omit<
>; >;
export const DealTicketMarketAmount = ({ export const DealTicketMarketAmount = ({
register, control,
market, market,
marketData, marketData,
sizeError, sizeError,
update,
size,
}: DealTicketMarketAmountProps) => { }: DealTicketMarketAmountProps) => {
const quoteName = market.tradableInstrument.instrument.product.quoteName; const quoteName = market.tradableInstrument.instrument.product.quoteName;
const sizeStep = toDecimal(market?.positionDecimalPlaces); const sizeStep = toDecimal(market?.positionDecimalPlaces);
@ -47,22 +50,30 @@ export const DealTicketMarketAmount = ({
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex-1"> <div className="flex-1">
<Input <Controller
id="input-order-size-market" name="size"
className="w-full" control={control}
type="number" rules={{
step={sizeStep}
min={sizeStep}
onWheel={(e) => e.currentTarget.blur()}
data-testid="order-size"
{...register('size', {
required: t('You need to provide a size'), required: t('You need to provide a size'),
min: { min: {
value: sizeStep, value: sizeStep,
message: t('Size cannot be lower than ' + sizeStep), message: t('Size cannot be lower than ' + sizeStep),
}, },
validate: validateAmount(sizeStep, 'Size'), 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>@</div> <div>@</div>

View File

@ -1,38 +1,28 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { VegaWalletContext } from '@vegaprotocol/wallet'; 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 { generateMarket, generateMarketData } from '../../test-helpers';
import { DealTicket } from './deal-ticket'; import { DealTicket } from './deal-ticket';
import * as Schema from '@vegaprotocol/types'; 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 { MockedProvider } from '@apollo/client/testing';
import type { ChainIdQuery } from '@vegaprotocol/react-helpers';
import { ChainIdDocument } from '@vegaprotocol/react-helpers';
import { addDecimal } from '@vegaprotocol/utils'; 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 market = generateMarket();
const marketData = generateMarketData(); const marketData = generateMarketData();
const submit = jest.fn(); const submit = jest.fn();
const mockChainId = 'chain-id'; function generateJsx() {
function generateJsx(order?: OrderSubmissionBody['orderSubmission']) {
const chainIdMock: MockedResponse<ChainIdQuery> = {
request: {
query: ChainIdDocument,
},
result: {
data: {
statistics: {
chainId: mockChainId,
},
},
},
};
return ( return (
<MockedProvider mocks={[chainIdMock]}> <MockedProvider>
<VegaWalletContext.Provider value={{ pubKey: mockChainId } as any}> <VegaWalletContext.Provider value={{ pubKey, isReadOnly: false } as any}>
<DealTicket market={market} marketData={marketData} submit={submit} /> <DealTicket market={market} marketData={marketData} submit={submit} />
</VegaWalletContext.Provider> </VegaWalletContext.Provider>
</MockedProvider> </MockedProvider>
@ -41,10 +31,11 @@ function generateJsx(order?: OrderSubmissionBody['orderSubmission']) {
describe('DealTicket', () => { describe('DealTicket', () => {
beforeEach(() => { beforeEach(() => {
window.localStorage.clear(); localStorage.clear();
}); });
afterEach(() => { afterEach(() => {
window.localStorage.clear(); localStorage.clear();
jest.clearAllMocks(); jest.clearAllMocks();
}); });
@ -61,9 +52,7 @@ describe('DealTicket', () => {
expect( expect(
screen.queryByTestId('order-side-SIDE_SELL')?.querySelector('input') screen.queryByTestId('order-side-SIDE_SELL')?.querySelector('input')
).not.toBeChecked(); ).not.toBeChecked();
expect(screen.getByTestId('order-size')).toHaveDisplayValue( expect(screen.getByTestId('order-size')).toHaveDisplayValue('0');
String(1 / Math.pow(10, market.positionDecimalPlaces))
);
expect(screen.getByTestId('order-tif')).toHaveValue( expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC 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()); render(generateJsx());
// Only FOK and IOC should be present by default (type market order) // 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)']); ).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 // 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( expect(screen.getByTestId('order-tif').children).toHaveLength(
Object.keys(Schema.OrderTimeInForce).length Object.keys(Schema.OrderTimeInForce).length
); );
// Select GTC -> GTC should be selected // expect GTC as LIMIT default
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( expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_GTC Schema.OrderTimeInForce.TIME_IN_FORCE_GTC
); );
// Select GTT -> GTT should be selected // Select GTT -> GTT should be selected
fireEvent.change(screen.getByTestId('order-tif'), { await userEvent.selectOptions(
target: { value: Schema.OrderTimeInForce.TIME_IN_FORCE_GTT }, screen.getByTestId('order-tif'),
}); Schema.OrderTimeInForce.TIME_IN_FORCE_GTT
);
expect(screen.getByTestId('order-tif')).toHaveValue( expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_GTT Schema.OrderTimeInForce.TIME_IN_FORCE_GTT
); );
// Switch to type market order -> IOC should be selected // Switch back to type market order -> FOK should be preserved from previous selection
fireEvent.click(screen.getByTestId('order-type-TYPE_MARKET')); 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( expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
); );
@ -143,23 +196,20 @@ describe('DealTicket', () => {
screen.getByTestId('order-side-SIDE_BUY')?.querySelector('input') screen.getByTestId('order-side-SIDE_BUY')?.querySelector('input')
).toBeChecked(); ).toBeChecked();
await act(async () => { await userEvent.type(screen.getByTestId('order-size'), '200');
fireEvent.change(screen.getByTestId('order-size'), {
target: { value: '200' },
});
});
expect(screen.getByTestId('order-size')).toHaveDisplayValue('200'); expect(screen.getByTestId('order-size')).toHaveDisplayValue('200');
fireEvent.change(screen.getByTestId('order-tif'), { await userEvent.selectOptions(
target: { value: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC }, screen.getByTestId('order-tif'),
}); Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
);
expect(screen.getByTestId('order-tif')).toHaveValue( expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
); );
// Switch to limit order // 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 // Check all TIF options shown
expect(screen.getByTestId('order-tif').children).toHaveLength( expect(screen.getByTestId('order-tif').children).toHaveLength(

View File

@ -1,7 +1,7 @@
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import * as Schema from '@vegaprotocol/types'; import * as Schema from '@vegaprotocol/types';
import { memo, useCallback, useEffect } from 'react'; import { memo, useCallback, useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller } from 'react-hook-form';
import { DealTicketAmount } from './deal-ticket-amount'; import { DealTicketAmount } from './deal-ticket-amount';
import { DealTicketButton } from './deal-ticket-button'; import { DealTicketButton } from './deal-ticket-button';
import { DealTicketFeeDetails } from './deal-ticket-fee-details'; import { DealTicketFeeDetails } from './deal-ticket-fee-details';
@ -9,10 +9,12 @@ import { ExpirySelector } from './expiry-selector';
import { SideSelector } from './side-selector'; import { SideSelector } from './side-selector';
import { TimeInForceSelector } from './time-in-force-selector'; import { TimeInForceSelector } from './time-in-force-selector';
import { TypeSelector } from './type-selector'; import { TypeSelector } from './type-selector';
import type { OrderSubmissionBody } from '@vegaprotocol/wallet'; import type { OrderSubmission } from '@vegaprotocol/wallet';
import { useVegaWalletDialogStore } from '@vegaprotocol/wallet'; import {
import { normalizeOrderSubmission } from '@vegaprotocol/wallet'; normalizeOrderSubmission,
import { useVegaWallet } from '@vegaprotocol/wallet'; useVegaWallet,
useVegaWalletDialogStore,
} from '@vegaprotocol/wallet';
import { import {
ExternalLink, ExternalLink,
InputError, InputError,
@ -22,7 +24,7 @@ import {
import { useOrderMarginValidation } from '../../hooks/use-order-margin-validation'; import { useOrderMarginValidation } from '../../hooks/use-order-margin-validation';
import { MarginWarning } from '../deal-ticket-validation/margin-warning'; import { MarginWarning } from '../deal-ticket-validation/margin-warning';
import { import {
getDefaultOrder, validateExpiration,
validateMarketState, validateMarketState,
validateMarketTradingMode, validateMarketTradingMode,
validateTimeInForce, validateTimeInForce,
@ -32,27 +34,17 @@ import { ZeroBalanceError } from '../deal-ticket-validation/zero-balance-error';
import { SummaryValidationType } from '../../constants'; import { SummaryValidationType } from '../../constants';
import { useHasNoBalance } from '../../hooks/use-has-no-balance'; import { useHasNoBalance } from '../../hooks/use-has-no-balance';
import type { Market, MarketData } from '@vegaprotocol/market-list'; import type { Market, MarketData } from '@vegaprotocol/market-list';
import { import { OrderTimeInForce, OrderType } from '@vegaprotocol/types';
usePersistedOrderStore, import { useOrderForm } from '../../hooks/use-order-form';
usePersistedOrderStoreSubscription, import type { OrderObj } from '@vegaprotocol/orders';
} from '@vegaprotocol/orders';
import { OrderType } from '@vegaprotocol/types';
export type TransactionStatus = 'default' | 'pending';
export interface DealTicketProps { export interface DealTicketProps {
market: Market; market: Market;
marketData: MarketData; marketData: MarketData;
submit: (order: OrderSubmissionBody['orderSubmission']) => void; submit: (order: OrderSubmission) => void;
onClickCollateral?: () => 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 = ({ export const DealTicket = ({
market, market,
marketData, marketData,
@ -60,44 +52,21 @@ export const DealTicket = ({
onClickCollateral, onClickCollateral,
}: DealTicketProps) => { }: DealTicketProps) => {
const { pubKey, isReadOnly } = useVegaWallet(); const { pubKey, isReadOnly } = useVegaWallet();
const { getPersistedOrder, setPersistedOrder } = usePersistedOrderStore( // store last used tif for market so that when changing OrderType the previous TIF
(store) => ({ // selection for that type is used when switching back
getPersistedOrder: store.getOrder, const [lastTIF, setLastTIF] = useState({
setPersistedOrder: store.setOrder, [OrderType.TYPE_MARKET]: OrderTimeInForce.TIME_IN_FORCE_IOC,
}) [OrderType.TYPE_LIMIT]: OrderTimeInForce.TIME_IN_FORCE_GTC,
); });
const { const {
register,
control, control,
handleSubmit, errors,
watch, order,
setError, setError,
clearErrors, clearErrors,
formState: { errors }, update,
setValue, handleSubmit,
} = useForm<DealTicketFormFields>({ } = useOrderForm(market.id);
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 marketStateError = validateMarketState(marketData.marketState);
const hasNoBalance = useHasNoBalance( const hasNoBalance = useHasNoBalance(
market.tradableInstrument.instrument.product.settlementAsset.id market.tradableInstrument.instrument.product.settlementAsset.id
@ -166,7 +135,7 @@ export const DealTicket = ({
]); ]);
const onSubmit = useCallback( const onSubmit = useCallback(
(order: OrderSubmissionBody['orderSubmission']) => { (order: OrderSubmission) => {
checkForErrors(); checkForErrors();
submit( submit(
normalizeOrderSubmission( normalizeOrderSubmission(
@ -179,9 +148,12 @@ export const DealTicket = ({
[checkForErrors, submit, market.decimalPlaces, market.positionDecimalPlaces] [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 ( return (
<form <form
onSubmit={isReadOnly ? () => null : handleSubmit(onSubmit)} onSubmit={isReadOnly ? undefined : handleSubmit(onSubmit)}
className="p-4" className="p-4"
noValidate noValidate
> >
@ -194,10 +166,17 @@ export const DealTicket = ({
marketData.trigger marketData.trigger
), ),
}} }}
render={({ field }) => ( render={() => (
<TypeSelector <TypeSelector
value={field.value} value={order.type}
onSelect={field.onChange} 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} market={market}
marketData={marketData} marketData={marketData}
errorMessage={errors.type?.message} errorMessage={errors.type?.message}
@ -207,17 +186,25 @@ export const DealTicket = ({
<Controller <Controller
name="side" name="side"
control={control} control={control}
render={({ field }) => ( render={() => (
<SideSelector value={field.value} onSelect={field.onChange} /> <SideSelector
value={order.side}
onSelect={(side) => {
update({ side });
}}
/>
)} )}
/> />
<DealTicketAmount <DealTicketAmount
control={control}
orderType={order.type} orderType={order.type}
market={market} market={market}
marketData={marketData} marketData={marketData}
register={register}
sizeError={errors.size?.message} sizeError={errors.size?.message}
priceError={errors.price?.message} priceError={errors.price?.message}
update={update}
size={order.size}
price={order.price}
/> />
<Controller <Controller
name="timeInForce" name="timeInForce"
@ -228,11 +215,16 @@ export const DealTicket = ({
marketData.trigger marketData.trigger
), ),
}} }}
render={({ field }) => ( render={() => (
<TimeInForceSelector <TimeInForceSelector
value={field.value} value={order.timeInForce}
orderType={order.type} 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} market={market}
marketData={marketData} marketData={marketData}
errorMessage={errors.timeInForce?.message} errorMessage={errors.timeInForce?.message}
@ -244,12 +236,18 @@ export const DealTicket = ({
<Controller <Controller
name="expiresAt" name="expiresAt"
control={control} control={control}
render={({ field }) => ( rules={{
validate: validateExpiration,
}}
render={() => (
<ExpirySelector <ExpirySelector
value={field.value} value={order.expiresAt}
onSelect={field.onChange} onSelect={(expiresAt) =>
update({
expiresAt: expiresAt || undefined,
})
}
errorMessage={errors.expiresAt?.message} errorMessage={errors.expiresAt?.message}
register={register}
/> />
)} )}
/> />
@ -261,7 +259,7 @@ export const DealTicket = ({
order={order} order={order}
isReadOnly={isReadOnly} isReadOnly={isReadOnly}
pubKey={pubKey} pubKey={pubKey}
onClickCollateral={onClickCollateral || (() => null)} onClickCollateral={onClickCollateral}
/> />
<DealTicketButton <DealTicketButton
disabled={Object.keys(errors).length >= 1 || isReadOnly} disabled={Object.keys(errors).length >= 1 || isReadOnly}
@ -284,10 +282,10 @@ interface SummaryMessageProps {
errorMessage?: string; errorMessage?: string;
market: Market; market: Market;
marketData: MarketData; marketData: MarketData;
order: OrderSubmissionBody['orderSubmission']; order: OrderObj;
isReadOnly: boolean; isReadOnly: boolean;
pubKey: string | null; pubKey: string | null;
onClickCollateral: () => void; onClickCollateral?: () => void;
} }
const SummaryMessage = memo( const SummaryMessage = memo(
({ ({

View File

@ -1,22 +1,17 @@
import { FormGroup, Input, InputError } from '@vegaprotocol/ui-toolkit'; import { FormGroup, Input, InputError } from '@vegaprotocol/ui-toolkit';
import { formatForInput } from '@vegaprotocol/utils'; import { formatForInput } from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import type { UseFormRegister } from 'react-hook-form';
import { validateExpiration } from '../../utils/validate-expiration';
import type { DealTicketFormFields } from '.';
interface ExpirySelectorProps { interface ExpirySelectorProps {
value?: string; value?: string;
onSelect: (expiration: string | null) => void; onSelect: (expiration: string | null) => void;
errorMessage?: string; errorMessage?: string;
register?: UseFormRegister<DealTicketFormFields>;
} }
export const ExpirySelector = ({ export const ExpirySelector = ({
value, value,
onSelect, onSelect,
errorMessage, errorMessage,
register,
}: ExpirySelectorProps) => { }: ExpirySelectorProps) => {
const date = value ? new Date(value) : new Date(); const date = value ? new Date(value) : new Date();
const dateFormatted = formatForInput(date); const dateFormatted = formatForInput(date);
@ -34,9 +29,6 @@ export const ExpirySelector = ({
value={dateFormatted} value={dateFormatted}
onChange={(e) => onSelect(e.target.value)} onChange={(e) => onSelect(e.target.value)}
min={minDate} min={minDate}
{...register?.('expiresAt', {
validate: validateExpiration,
})}
/> />
{errorMessage && ( {errorMessage && (
<InputError testId="dealticket-error-message-expiry"> <InputError testId="dealticket-error-message-expiry">

View File

@ -1,4 +1,3 @@
import { useEffect, useState } from 'react';
import { import {
FormGroup, FormGroup,
InputError, InputError,
@ -22,15 +21,6 @@ interface TimeInForceSelectorProps {
errorMessage?: string; 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 = ({ export const TimeInForceSelector = ({
value, value,
orderType, orderType,
@ -47,28 +37,6 @@ export const TimeInForceSelector = ({
timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_FOK || timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_FOK ||
timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_IOC 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) => { const renderError = (errorType: string) => {
if (errorType === MarketModeValidationType.Auction) { if (errorType === MarketModeValidationType.Auction) {
@ -128,10 +96,16 @@ export const TimeInForceSelector = ({
id="select-time-in-force" id="select-time-in-force"
value={value} value={value}
onChange={(e) => { onChange={(e) => {
setPreviousTimeInForce({ // setPreviousTimeInForce({
...previousTimeInForce, // ...previousTimeInForce,
[orderType]: e.target.value, // [orderType]: e.target.value,
}); // });
// if (previousOrderType !== orderType) {
// setPreviousOrderType(orderType);
// const prev = previousTimeInForce[orderType as OrderType];
// onSelect(prev);
// }
onSelect(e.target.value as Schema.OrderTimeInForce); onSelect(e.target.value as Schema.OrderTimeInForce);
}} }}
className="w-full" className="w-full"

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

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

View File

@ -17,7 +17,8 @@
"**/*.test.js", "**/*.test.js",
"**/*.spec.jsx", "**/*.spec.jsx",
"**/*.test.jsx", "**/*.test.jsx",
"jest.config.ts" "jest.config.ts",
"__mocks__"
], ],
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"] "include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]
} }

View File

@ -15,7 +15,6 @@
"**/*.test.jsx", "**/*.test.jsx",
"**/*.spec.jsx", "**/*.spec.jsx",
"**/*.d.ts", "**/*.d.ts",
"**/__mocks__/*.tsx",
"jest.config.ts" "jest.config.ts"
] ]
} }

View File

@ -20,7 +20,7 @@ import {
getPriceLevel, getPriceLevel,
} from './orderbook-data'; } from './orderbook-data';
import type { OrderbookData } from './orderbook-data'; import type { OrderbookData } from './orderbook-data';
import { usePersistedOrderStore } from '@vegaprotocol/orders'; import { useOrderStore } from '@vegaprotocol/orders';
interface OrderbookManagerProps { interface OrderbookManagerProps {
marketId: string; marketId: string;
@ -172,7 +172,7 @@ export const OrderbookManager = ({ marketId }: OrderbookManagerProps) => {
flush(); flush();
}, [resolution, flush]); }, [resolution, flush]);
const updatePrice = usePersistedOrderStore((store) => store.updatePrice); const updateOrder = useOrderStore((store) => store.update);
return ( return (
<AsyncRenderer <AsyncRenderer
@ -190,7 +190,7 @@ export const OrderbookManager = ({ marketId }: OrderbookManagerProps) => {
onClick={(price?: string | number) => { onClick={(price?: string | number) => {
if (price) { if (price) {
const priceValue = addDecimal(price, market?.decimalPlaces ?? 0); const priceValue = addDecimal(price, market?.decimalPlaces ?? 0);
updatePrice(marketId, priceValue); updateOrder(marketId, { price: priceValue });
} }
}} }}
/> />

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

View File

@ -8,6 +8,10 @@ import type { VegaWalletContextShape } from '@vegaprotocol/wallet';
import { VegaWalletContext } from '@vegaprotocol/wallet'; import { VegaWalletContext } from '@vegaprotocol/wallet';
import { MockedProvider } from '@apollo/client/testing'; 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 generateJsx = () => {
const pubKey = '0x123'; const pubKey = '0x123';
return ( return (
@ -56,9 +60,6 @@ describe('OrderListManager', () => {
}); });
it('should render the order list if orders provided', async () => { 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({ jest.spyOn(useDataProviderHook, 'useDataProvider').mockReturnValue({
data: [{ id: '1' } as OrderFieldsFragment], data: [{ id: '1' } as OrderFieldsFragment],
loading: false, loading: false,

View File

@ -6,7 +6,6 @@ import type { PartialDeep } from 'type-fest';
import type { VegaWalletContextShape } from '@vegaprotocol/wallet'; import type { VegaWalletContextShape } from '@vegaprotocol/wallet';
import { VegaWalletContext } from '@vegaprotocol/wallet'; import { VegaWalletContext } from '@vegaprotocol/wallet';
import { MockedProvider } from '@apollo/client/testing'; import { MockedProvider } from '@apollo/client/testing';
import type { OrderListTableProps } from '../'; import type { OrderListTableProps } from '../';
import { OrderListTable } from '../'; import { OrderListTable } from '../';
import { import {
@ -15,6 +14,15 @@ import {
marketOrder, marketOrder,
} from '../mocks/generate-orders'; } 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 = { const defaultProps: OrderListTableProps = {
rowData: [], rowData: [],
setEditOrder: jest.fn(), setEditOrder: jest.fn(),

View File

@ -4,4 +4,4 @@ export * from './use-order-cancel';
export * from './use-order-submit'; export * from './use-order-submit';
export * from './use-order-edit'; export * from './use-order-edit';
export * from './use-order-update'; export * from './use-order-update';
export * from './use-persisted-order'; export * from './use-order-store';

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

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

View File

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

View File

@ -21,7 +21,8 @@
"**/*.stories.js", "**/*.stories.js",
"**/*.stories.jsx", "**/*.stories.jsx",
"**/*.stories.tsx", "**/*.stories.tsx",
"jest.config.ts" "jest.config.ts",
"__mocks__"
], ],
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"] "include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]
} }

View File

@ -8,7 +8,7 @@ import { MAX_TRADES, tradesWithMarketProvider } from './trades-data-provider';
import { TradesTable } from './trades-table'; import { TradesTable } from './trades-table';
import type { Trade, TradeEdge } from './trades-data-provider'; import type { Trade, TradeEdge } from './trades-data-provider';
import type { TradesQueryVariables } from './__generated__/Trades'; import type { TradesQueryVariables } from './__generated__/Trades';
import { usePersistedOrderStore } from '@vegaprotocol/orders'; import { useOrderStore } from '@vegaprotocol/orders';
interface TradesContainerProps { interface TradesContainerProps {
marketId: string; marketId: string;
@ -20,7 +20,7 @@ export const TradesContainer = ({ marketId }: TradesContainerProps) => {
const totalCountRef = useRef<number | undefined>(undefined); const totalCountRef = useRef<number | undefined>(undefined);
const newRows = useRef(0); const newRows = useRef(0);
const scrolledToTop = useRef(true); const scrolledToTop = useRef(true);
const updatePrice = usePersistedOrderStore((store) => store.updatePrice); const updateOrder = useOrderStore((store) => store.update);
const variables = useMemo<TradesQueryVariables>( const variables = useMemo<TradesQueryVariables>(
() => ({ marketId, maxTrades: MAX_TRADES }), () => ({ marketId, maxTrades: MAX_TRADES }),
@ -115,7 +115,7 @@ export const TradesContainer = ({ marketId }: TradesContainerProps) => {
onBodyScroll={onBodyScroll} onBodyScroll={onBodyScroll}
onClick={(price?: string) => { onClick={(price?: string) => {
if (price) { if (price) {
updatePrice(marketId, price); updateOrder(marketId, { price });
} }
}} }}
/> />

View File

@ -89,6 +89,7 @@ export const Notification = ({
onClick={buttonProps.action} onClick={buttonProps.action}
className={classNames(buttonProps.className)} className={classNames(buttonProps.className)}
data-testid={buttonProps.dataTestId} data-testid={buttonProps.dataTestId}
type="button"
> >
{buttonProps.text} {buttonProps.text}
</Button> </Button>

View File

@ -5,11 +5,12 @@ import BigNumber from 'bignumber.js';
import { ethers } from 'ethers'; import { ethers } from 'ethers';
import { sha3_256 } from 'js-sha3'; import { sha3_256 } from 'js-sha3';
import type { import type {
OrderAmendmentBody, OrderAmendment,
OrderSubmissionBody, OrderSubmission,
Transaction, Transaction,
Transfer, Transfer,
} from './connectors'; } from './connectors';
import type { Exact } from 'type-fest';
/** /**
* Creates an ID in the same way that core does on the backend. This way we * 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 = ( export const normalizeOrderSubmission = <T extends Exact<OrderSubmission, T>>(
order: OrderSubmissionBody['orderSubmission'], order: T,
decimalPlaces: number, decimalPlaces: number,
positionDecimalPlaces: number positionDecimalPlaces: number
): OrderSubmissionBody['orderSubmission'] => ({ ): OrderSubmission => ({
...order, ...order,
price: price:
order.type === OrderType.TYPE_LIMIT && order.price order.type === OrderType.TYPE_LIMIT && order.price
@ -45,12 +46,12 @@ export const normalizeOrderSubmission = (
: undefined, : undefined,
}); });
export const normalizeOrderAmendment = ( export const normalizeOrderAmendment = <T extends Exact<OrderAmendment, T>>(
order: Pick<Order, 'id' | 'timeInForce' | 'size' | 'expiresAt'>, order: Pick<Order, 'id' | 'timeInForce' | 'size' | 'expiresAt'>,
market: Pick<Market, 'id' | 'decimalPlaces' | 'positionDecimalPlaces'>, market: Pick<Market, 'id' | 'decimalPlaces' | 'positionDecimalPlaces'>,
price: string, price: string,
size: string size: string
): OrderAmendmentBody['orderAmendment'] => ({ ): OrderAmendment => ({
orderId: order.id, orderId: order.id,
marketId: market.id, marketId: market.id,
price: removeDecimal(price, market.decimalPlaces), price: removeDecimal(price, market.decimalPlaces),
@ -65,7 +66,7 @@ export const normalizeOrderAmendment = (
: undefined, : undefined,
}); });
export const normalizeTransfer = ( export const normalizeTransfer = <T extends Exact<Transfer, T>>(
address: string, address: string,
amount: string, amount: string,
asset: { asset: {