diff --git a/apps/trading-e2e/src/integration/trading-deal-ticket-order.cy.ts b/apps/trading-e2e/src/integration/trading-deal-ticket-order.cy.ts index 12e3a4678..7de0dcb36 100644 --- a/apps/trading-e2e/src/integration/trading-deal-ticket-order.cy.ts +++ b/apps/trading-e2e/src/integration/trading-deal-ticket-order.cy.ts @@ -63,6 +63,7 @@ describe('deal ticker order validation', { tags: '@smoke' }, () => { cy.getByTestId(orderTIFDropDown).select('TIME_IN_FORCE_GTC'); cy.getByTestId(orderSizeField).clear().type('1'); cy.getByTestId(orderPriceField).clear().type('1.123456'); + cy.getByTestId(placeOrderBtn).click(); cy.getByTestId('deal-ticket-error-message-price-limit').should( 'have.text', 'Price accepts up to 5 decimal places' @@ -73,6 +74,7 @@ describe('deal ticker order validation', { tags: '@smoke' }, () => { describe('market order', () => { before(() => { cy.getByTestId(toggleMarket).click(); + cy.getByTestId(placeOrderBtn).click(); }); it('must not see the price unit', function () { diff --git a/apps/trading-e2e/src/integration/trading-deal-ticket-submit-suspended.cy.ts b/apps/trading-e2e/src/integration/trading-deal-ticket-submit-suspended.cy.ts index 5dd19a79d..6a77fe79a 100644 --- a/apps/trading-e2e/src/integration/trading-deal-ticket-submit-suspended.cy.ts +++ b/apps/trading-e2e/src/integration/trading-deal-ticket-submit-suspended.cy.ts @@ -48,6 +48,7 @@ describe('suspended market validation', { tags: '@regression' }, () => { cy.getByTestId(orderPriceField).clear().type('0.1'); cy.getByTestId(orderSizeField).clear().type('1'); cy.getByTestId(placeOrderBtn).should('be.enabled'); + cy.getByTestId(placeOrderBtn).click(); cy.getByTestId('deal-ticket-warning-auction').should( 'have.text', 'Any orders placed now will not trade until the auction ends' @@ -60,6 +61,7 @@ describe('suspended market validation', { tags: '@regression' }, () => { TIFlist.filter((item) => item.code === 'FOK')[0].value ); cy.getByTestId(placeOrderBtn).should('be.enabled'); + cy.getByTestId(placeOrderBtn).click(); cy.getByTestId('deal-ticket-error-message-tif').should( 'have.text', 'This market is in auction until it reaches sufficient liquidity. Until the auction ends, you can only place GFA, GTT, or GTC limit orders' diff --git a/apps/trading/components/orderbook-container/orderbook-container.tsx b/apps/trading/components/orderbook-container/orderbook-container.tsx index 51c89c46f..07b0eb7ca 100644 --- a/apps/trading/components/orderbook-container/orderbook-container.tsx +++ b/apps/trading/components/orderbook-container/orderbook-container.tsx @@ -1,27 +1,15 @@ import { OrderbookManager } from '@vegaprotocol/market-depth'; -import { useCreateOrderStore } from '@vegaprotocol/orders'; import { ViewType, useSidebar } from '../sidebar'; -import { useStopOrderFormValues } from '@vegaprotocol/deal-ticket'; +import { useDealTicketFormValues } from '@vegaprotocol/deal-ticket'; export const OrderbookContainer = ({ marketId }: { marketId: string }) => { - const useOrderStoreRef = useCreateOrderStore(); - const updateOrder = useOrderStoreRef((store) => store.update); - const updateStoredFormValues = useStopOrderFormValues( - (state) => state.update - ); + const update = useDealTicketFormValues((state) => state.updateAll); const setView = useSidebar((store) => store.setView); return ( { - if (price) { - updateOrder(marketId, { price }); - updateStoredFormValues(marketId, { price }); - } - if (size) { - updateOrder(marketId, { size }); - updateStoredFormValues(marketId, { size }); - } + onClick={(values) => { + update(marketId, values); setView({ type: ViewType.Order }); }} /> diff --git a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-amount.tsx b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-amount.tsx index 0b2e270d4..c1f949da5 100644 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-amount.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-amount.tsx @@ -3,29 +3,25 @@ import type { Market, StaticMarketData } from '@vegaprotocol/markets'; import { DealTicketMarketAmount } from './deal-ticket-market-amount'; import { DealTicketLimitAmount } from './deal-ticket-limit-amount'; import * as Schema from '@vegaprotocol/types'; -import type { OrderObj } from '@vegaprotocol/orders'; -import type { OrderFormFields } from '../../hooks/use-order-form'; +import type { OrderFormValues } from '../../hooks/use-form-values'; export interface DealTicketAmountProps { - control: Control; - orderType: Schema.OrderType; + control: Control; + type: Schema.OrderType; marketData: StaticMarketData; marketPrice?: string; market: Market; sizeError?: string; priceError?: string; - update: (obj: Partial) => void; - size: string; - price?: string; } export const DealTicketAmount = ({ - orderType, + type, marketData, marketPrice, ...props }: DealTicketAmountProps) => { - switch (orderType) { + switch (type) { case Schema.OrderType.TYPE_MARKET: return ( ; default: { - throw new Error('Invalid ticket type'); + throw new Error('Invalid ticket type ' + type); } } }; diff --git a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-container.tsx b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-container.tsx index da10e201a..b454de61d 100644 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-container.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-container.tsx @@ -1,8 +1,8 @@ import { useVegaTransactionStore } from '@vegaprotocol/wallet'; import { - DealTicketType, - useDealTicketTypeStore, -} from '../../hooks/use-type-store'; + isStopOrderType, + useDealTicketFormValues, +} from '../../hooks/use-form-values'; import { StopOrder } from './deal-ticket-stop-order'; import { useStaticMarketData, @@ -25,7 +25,9 @@ export const DealTicketContainer = ({ marketId, ...props }: DealTicketContainerProps) => { - const type = useDealTicketTypeStore((state) => state.type[marketId]); + const showStopOrder = useDealTicketFormValues((state) => + isStopOrderType(state.formValues[marketId]?.type) + ); const { data: market, error: marketError, @@ -48,9 +50,7 @@ export const DealTicketContainer = ({ reload={reload} > {market && marketData ? ( - FLAGS.STOP_ORDERS && - (type === DealTicketType.StopLimit || - type === DealTicketType.StopMarket) ? ( + FLAGS.STOP_ORDERS && showStopOrder ? ( , - 'orderType' + DealTicketAmountProps, + 'marketData' | 'type' >; export const DealTicketLimitAmount = ({ @@ -14,9 +14,6 @@ export const DealTicketLimitAmount = ({ market, sizeError, priceError, - update, - price, - size, }: DealTicketLimitAmountProps) => { const priceStep = toDecimal(market?.decimalPlaces); const sizeStep = toDecimal(market?.positionDecimalPlaces); @@ -62,17 +59,16 @@ export const DealTicketLimitAmount = ({ }, validate: validateAmount(sizeStep, 'Size'), }} - render={() => ( + render={({ field }) => ( update({ size: e.target.value })} step={sizeStep} min={sizeStep} data-testid="order-size" onWheel={(e) => e.currentTarget.blur()} + {...field} /> )} /> @@ -95,19 +91,17 @@ export const DealTicketLimitAmount = ({ value: priceStep, message: t('Price cannot be lower than ' + priceStep), }, - // @ts-ignore this fulfills the interface but still errors validate: validateAmount(priceStep, 'Price'), }} - render={() => ( + render={({ field }) => ( update({ price: e.target.value })} step={priceStep} data-testid="order-price" onWheel={(e) => e.currentTarget.blur()} + {...field} /> )} /> diff --git a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-market-amount.tsx b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-market-amount.tsx index a20b39481..9b3e77e66 100644 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-market-amount.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-market-amount.tsx @@ -10,10 +10,7 @@ import type { DealTicketAmountProps } from './deal-ticket-amount'; import { Controller } from 'react-hook-form'; import classNames from 'classnames'; -export type DealTicketMarketAmountProps = Omit< - DealTicketAmountProps, - 'orderType' ->; +export type DealTicketMarketAmountProps = Omit; export const DealTicketMarketAmount = ({ control, @@ -21,8 +18,6 @@ export const DealTicketMarketAmount = ({ marketData, marketPrice, sizeError, - update, - size, }: DealTicketMarketAmountProps) => { const quoteName = market.tradableInstrument.instrument.product.quoteName; const sizeStep = toDecimal(market?.positionDecimalPlaces); @@ -50,17 +45,16 @@ export const DealTicketMarketAmount = ({ }, validate: validateAmount(sizeStep, 'Size'), }} - render={() => ( + render={({ field }) => ( update({ size: e.target.value })} step={sizeStep} min={sizeStep} onWheel={(e) => e.currentTarget.blur()} data-testid="order-size" + {...field} /> )} /> diff --git a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-size-iceberg.tsx b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-size-iceberg.tsx index a271eb105..40a5480d3 100644 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-size-iceberg.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-size-iceberg.tsx @@ -1,7 +1,6 @@ import { Controller, type Control } from 'react-hook-form'; import type { Market } from '@vegaprotocol/markets'; -import type { OrderObj } from '@vegaprotocol/orders'; -import type { OrderFormFields } from '../../hooks/use-order-form'; +import type { OrderFormValues } from '../../hooks/use-form-values'; import { toDecimal, validateAmount } from '@vegaprotocol/utils'; import { t } from '@vegaprotocol/i18n'; import { @@ -12,25 +11,21 @@ import { } from '@vegaprotocol/ui-toolkit'; export interface DealTicketSizeIcebergProps { - control: Control; + control: Control; market: Market; peakSizeError?: string; minimumVisibleSizeError?: string; - update: (obj: Partial) => void; - peakSize: string; - minimumVisibleSize: string; size: string; + peakSize?: string; } export const DealTicketSizeIceberg = ({ control, market, - update, peakSizeError, minimumVisibleSizeError, - peakSize, - minimumVisibleSize, size, + peakSize, }: DealTicketSizeIcebergProps) => { const sizeStep = toDecimal(market?.positionDecimalPlaces); @@ -80,7 +75,7 @@ export const DealTicketSizeIceberg = ({ className="!mb-1" > ( + render={({ field }) => ( - update({ - icebergOpts: { - peakSize: e.target.value, - minimumVisibleSize, - }, - }) - } step={sizeStep} min={sizeStep} max={size} data-testid="order-peak-size" onWheel={(e) => e.currentTarget.blur()} + {...field} /> )} /> @@ -144,7 +131,7 @@ export const DealTicketSizeIceberg = ({ className="!mb-1" > ( + render={({ field }) => ( - update({ - icebergOpts: { - peakSize, - minimumVisibleSize: e.target.value, - }, - }) - } step={sizeStep} min={sizeStep} max={peakSize} data-testid="order-minimum-size" onWheel={(e) => e.currentTarget.blur()} + {...field} /> )} /> diff --git a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-stop-order.spec.tsx b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-stop-order.spec.tsx index fd18c6f56..4f9c57931 100644 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-stop-order.spec.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-stop-order.spec.tsx @@ -6,8 +6,11 @@ import { generateMarket } from '../../test-helpers'; import { StopOrder } from './deal-ticket-stop-order'; import * as Schema from '@vegaprotocol/types'; import { MockedProvider } from '@apollo/client/testing'; -import type { StopOrderFormValues } from '../../hooks/use-stop-order-form-values'; -import { useStopOrderFormValues } from '../../hooks/use-stop-order-form-values'; +import type { StopOrderFormValues } from '../../hooks/use-form-values'; +import { + DealTicketType, + useDealTicketFormValues, +} from '../../hooks/use-form-values'; import type { FeatureFlags } from '@vegaprotocol/environment'; jest.mock('zustand'); @@ -131,9 +134,11 @@ describe('StopOrder', () => { expiresAt: '2023-07-27T16:43:27.000', }; - useStopOrderFormValues.setState({ + useDealTicketFormValues.setState({ formValues: { - [market.id]: values, + [market.id]: { + [DealTicketType.StopLimit]: values, + }, }, }); @@ -207,11 +212,13 @@ describe('StopOrder', () => { // switch to market order type error should disappear await userEvent.click(screen.getByTestId(orderTypeTrigger)); await userEvent.click(screen.getByTestId(orderTypeMarket)); + await userEvent.click(screen.getByTestId(submitButton)); expect(screen.queryByTestId(priceErrorMessage)).toBeNull(); // switch back to limit type await userEvent.click(screen.getByTestId(orderTypeTrigger)); await userEvent.click(screen.getByTestId(orderTypeLimit)); + await userEvent.click(screen.getByTestId(submitButton)); expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument(); // to small value should be invalid diff --git a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-stop-order.tsx b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-stop-order.tsx index 98a266d7e..5e048d14e 100644 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-stop-order.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-stop-order.tsx @@ -1,4 +1,3 @@ -import type { FormEventHandler } from 'react'; import { useRef, useCallback, useEffect } from 'react'; import { useVegaWallet } from '@vegaprotocol/wallet'; import type { StopOrdersSubmission } from '@vegaprotocol/wallet'; @@ -8,7 +7,7 @@ import { toDecimal, validateAmount, } from '@vegaprotocol/utils'; -import { useForm, Controller } from 'react-hook-form'; +import { useForm, Controller, useController } from 'react-hook-form'; import * as Schema from '@vegaprotocol/types'; import { Radio, @@ -24,22 +23,22 @@ import { getDerivedPrice, type Market } from '@vegaprotocol/markets'; import { t } from '@vegaprotocol/i18n'; import { ExpirySelector } from './expiry-selector'; import { SideSelector } from './side-selector'; -import { timeInForceLabel, useOrder } from '@vegaprotocol/orders'; +import { timeInForceLabel } from '@vegaprotocol/orders'; import { NoWalletWarning, REDUCE_ONLY_TOOLTIP, - useNotionalSize, + stopSubmit, + getNotionalSize, } from './deal-ticket'; import { TypeToggle } from './type-selector'; import { - useStopOrderFormValues, - type StopOrderFormValues, -} from '../../hooks/use-stop-order-form-values'; -import { + useDealTicketFormValues, DealTicketType, - useDealTicketTypeStore, -} from '../../hooks/use-type-store'; -import { mapFormValuesToStopOrdersSubmission } from '../../utils/map-form-values-to-stop-order-submission'; + type StopOrderFormValues, + dealTicketTypeToOrderType, + isStopOrderType, +} from '../../hooks/use-form-values'; +import { mapFormValuesToStopOrdersSubmission } from '../../utils/map-form-values-to-submission'; import { DealTicketButton } from './deal-ticket-button'; import { DealTicketFeeDetails } from './deal-ticket-fee-details'; import { validateExpiration } from '../../utils'; @@ -50,32 +49,36 @@ export interface StopOrderProps { submit: (order: StopOrdersSubmission) => void; } -const defaultValues: Partial = { - type: Schema.OrderType.TYPE_LIMIT, +const getDefaultValues = ( + type: Schema.OrderType, + storedValues?: Partial +): StopOrderFormValues => ({ + type, side: Schema.Side.SIDE_BUY, timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_FOK, triggerType: 'price', triggerDirection: Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_RISES_ABOVE, + expire: false, expiryStrategy: Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT, size: '0', -}; - -const stopSubmit: FormEventHandler = (e) => e.preventDefault(); + ...storedValues, +}); export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => { const { pubKey, isReadOnly } = useVegaWallet(); - const setDealTicketType = useDealTicketTypeStore((state) => state.set); - const [, updateOrder] = useOrder(market.id); - const updateStoredFormValues = useStopOrderFormValues( - (state) => state.update + const setType = useDealTicketFormValues((state) => state.setType); + const updateStoredFormValues = useDealTicketFormValues( + (state) => state.updateStopOrder ); - const storedFormValues = useStopOrderFormValues( + const storedFormValues = useDealTicketFormValues( (state) => state.formValues[market.id] ); - const { handleSubmit, setValue, watch, control, formState } = + const dealTicketType = storedFormValues?.type ?? DealTicketType.StopLimit; + const type = dealTicketTypeToOrderType(dealTicketType); + const { handleSubmit, setValue, watch, control, formState, reset } = useForm({ - defaultValues: { ...defaultValues, ...storedFormValues }, + defaultValues: getDefaultValues(type, storedFormValues?.[dealTicketType]), }); const { errors } = formState; const lastSubmitTime = useRef(0); @@ -102,16 +105,22 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => { const triggerType = watch('triggerType'); const triggerPrice = watch('triggerPrice'); const timeInForce = watch('timeInForce'); - const type = watch('type'); const rawPrice = watch('price'); const rawSize = watch('size'); - if (storedFormValues?.size && rawSize !== storedFormValues?.size) { - setValue('size', storedFormValues.size); - } - if (storedFormValues?.price && rawPrice !== storedFormValues?.price) { - setValue('price', storedFormValues.price); - } + useEffect(() => { + const size = storedFormValues?.[dealTicketType]?.size; + if (size && rawSize !== size) { + setValue('size', size); + } + }, [storedFormValues, dealTicketType, rawSize, setValue]); + + useEffect(() => { + const price = storedFormValues?.[dealTicketType]?.price; + if (price && rawPrice !== price) { + setValue('price', price); + } + }, [storedFormValues, dealTicketType, rawPrice, setValue]); const isPriceTrigger = triggerType === 'price'; const size = removeDecimal(rawSize, market.positionDecimalPlaces); @@ -127,7 +136,7 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => { : marketPrice ); - const notionalSize = useNotionalSize( + const notionalSize = getNotionalSize( price, size, market.decimalPlaces, @@ -153,47 +162,28 @@ export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => { ? formatNumber(triggerPrice, market.decimalPlaces) : undefined; + useController({ + name: 'type', + control, + }); + return (
- { - const { value } = field; - return ( - { - const type = value as DealTicketType; - setDealTicketType(market.id, type); - if ( - type === DealTicketType.Limit || - type === DealTicketType.Market - ) { - updateOrder({ - type: - type === DealTicketType.Limit - ? Schema.OrderType.TYPE_LIMIT - : Schema.OrderType.TYPE_MARKET, - }); - return; - } - setValue( - 'type', - type === DealTicketType.StopLimit - ? Schema.OrderType.TYPE_LIMIT - : Schema.OrderType.TYPE_MARKET - ); - }} - /> - ); + { + setType(market.id, dealTicketType); + if (isStopOrderType(dealTicketType)) { + reset( + getDefaultValues( + dealTicketTypeToOrderType(dealTicketType), + storedFormValues?.[dealTicketType] + ) + ); + } }} /> {errors.type && ( diff --git a/libs/deal-ticket/src/components/deal-ticket/deal-ticket.spec.tsx b/libs/deal-ticket/src/components/deal-ticket/deal-ticket.spec.tsx index 9c97cd8ca..22af36b8b 100644 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket.spec.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/deal-ticket.spec.tsx @@ -1,12 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { VegaWalletContext } from '@vegaprotocol/wallet'; -import { - act, - render, - renderHook, - screen, - waitFor, -} from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { generateMarket, generateMarketData } from '../../test-helpers'; import { DealTicket } from './deal-ticket'; @@ -15,7 +9,10 @@ import type { MockedResponse } from '@apollo/client/testing'; import { MockedProvider } from '@apollo/client/testing'; import { addDecimal } from '@vegaprotocol/utils'; import type { OrdersQuery } from '@vegaprotocol/orders'; -import { useCreateOrderStore } from '@vegaprotocol/orders'; +import { + DealTicketType, + useDealTicketFormValues, +} from '../../hooks/use-form-values'; import * as positionsTools from '@vegaprotocol/positions'; import { OrdersDocument } from '@vegaprotocol/orders'; @@ -50,9 +47,6 @@ function generateJsx(mocks: MockedResponse[] = []) { } describe('DealTicket', () => { - const { result } = renderHook(() => useCreateOrderStore()); - const useOrderStore = result.current; - beforeEach(() => { jest.clearAllMocks(); localStorage.clear(); @@ -166,9 +160,11 @@ describe('DealTicket', () => { persist: true, }; - useOrderStore.setState({ - orders: { - [expectedOrder.marketId]: expectedOrder, + useDealTicketFormValues.setState({ + formValues: { + [expectedOrder.marketId]: { + [DealTicketType.Limit]: expectedOrder, + }, }, }); @@ -204,9 +200,11 @@ describe('DealTicket', () => { reduceOnly: true, postOnly: false, }; - useOrderStore.setState({ - orders: { - [expectedOrder.marketId]: expectedOrder, + useDealTicketFormValues.setState({ + formValues: { + [expectedOrder.marketId]: { + [DealTicketType.Limit]: expectedOrder, + }, }, }); @@ -247,9 +245,11 @@ describe('DealTicket', () => { postOnly: true, }; - useOrderStore.setState({ - orders: { - [expectedOrder.marketId]: expectedOrder, + useDealTicketFormValues.setState({ + formValues: { + [expectedOrder.marketId]: { + [DealTicketType.Limit]: expectedOrder, + }, }, }); @@ -295,9 +295,11 @@ describe('DealTicket', () => { }, }; - useOrderStore.setState({ - orders: { - [expectedOrder.marketId]: expectedOrder, + useDealTicketFormValues.setState({ + formValues: { + [expectedOrder.marketId]: { + [DealTicketType.Limit]: expectedOrder, + }, }, }); @@ -339,9 +341,11 @@ describe('DealTicket', () => { reduceOnly: false, postOnly: false, }; - useOrderStore.setState({ - orders: { - [expectedOrder.marketId]: expectedOrder, + useDealTicketFormValues.setState({ + formValues: { + [expectedOrder.marketId]: { + [DealTicketType.Limit]: expectedOrder, + }, }, }); @@ -370,6 +374,7 @@ describe('DealTicket', () => { expect(screen.getByTestId('iceberg')).not.toBeChecked(); }); + // eslint-disable-next-line jest/no-disabled-tests it('handles TIF select box dependent on order type', async () => { render(generateJsx()); diff --git a/libs/deal-ticket/src/components/deal-ticket/deal-ticket.tsx b/libs/deal-ticket/src/components/deal-ticket/deal-ticket.tsx index 37f60e1e0..49bf6c541 100644 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/deal-ticket.tsx @@ -1,7 +1,8 @@ import { t } from '@vegaprotocol/i18n'; import * as Schema from '@vegaprotocol/types'; -import { memo, useCallback, useEffect, useState, useRef, useMemo } from 'react'; -import { Controller } from 'react-hook-form'; +import type { FormEventHandler } from 'react'; +import { memo, useCallback, useEffect, useRef, useMemo } from 'react'; +import { Controller, useController, useForm } from 'react-hook-form'; import { DealTicketAmount } from './deal-ticket-amount'; import { DealTicketButton } from './deal-ticket-button'; import { @@ -13,7 +14,8 @@ import { SideSelector } from './side-selector'; import { TimeInForceSelector } from './time-in-force-selector'; import { TypeSelector } from './type-selector'; import type { OrderSubmission } from '@vegaprotocol/wallet'; -import { normalizeOrderSubmission, useVegaWallet } from '@vegaprotocol/wallet'; +import { useVegaWallet } from '@vegaprotocol/wallet'; +import { mapFormValuesToOrderSubmission } from '../../utils/map-form-values-to-submission'; import { Checkbox, InputError, @@ -51,14 +53,15 @@ import { useAccountBalance, } from '@vegaprotocol/accounts'; -import { OrderTimeInForce, OrderType } from '@vegaprotocol/types'; -import { useOrderForm } from '../../hooks/use-order-form'; +import { OrderType } from '@vegaprotocol/types'; import { useDataProvider } from '@vegaprotocol/data-provider'; import { DealTicketType, - useDealTicketTypeStore, -} from '../../hooks/use-type-store'; -import { useStopOrderFormValues } from '../../hooks/use-stop-order-form-values'; + dealTicketTypeToOrderType, + isStopOrderType, +} from '../../hooks/use-form-values'; +import type { OrderFormValues } from '../../hooks/use-form-values'; +import { useDealTicketFormValues } from '../../hooks/use-form-values'; import { DealTicketSizeIceberg } from './deal-ticket-size-iceberg'; import noop from 'lodash/noop'; @@ -75,23 +78,42 @@ export interface DealTicketProps { onDeposit: (assetId: string) => void; } -export const useNotionalSize = ( +export const getNotionalSize = ( price: string | null | undefined, size: string | undefined, decimalPlaces: number, positionDecimalPlaces: number -) => - useMemo(() => { - if (price && size) { - return removeDecimal( - toBigNum(size, positionDecimalPlaces).multipliedBy( - toBigNum(price, decimalPlaces) - ), - decimalPlaces - ); - } - return null; - }, [price, size, decimalPlaces, positionDecimalPlaces]); +) => { + if (price && size) { + return removeDecimal( + toBigNum(size, positionDecimalPlaces).multipliedBy( + toBigNum(price, decimalPlaces) + ), + decimalPlaces + ); + } + return null; +}; + +export const stopSubmit: FormEventHandler = (e) => e.preventDefault(); + +const getDefaultValues = ( + type: Schema.OrderType, + storedValues?: Partial +): OrderFormValues => ({ + type, + side: Schema.Side.SIDE_BUY, + timeInForce: + type === Schema.OrderType.TYPE_LIMIT + ? Schema.OrderTimeInForce.TIME_IN_FORCE_GTC + : Schema.OrderTimeInForce.TIME_IN_FORCE_IOC, + size: '0', + price: '0', + expiresAt: undefined, + postOnly: false, + reduceOnly: false, + ...storedValues, +}); export const DealTicket = ({ market, @@ -103,32 +125,29 @@ export const DealTicket = ({ onDeposit, }: DealTicketProps) => { const { pubKey, isReadOnly } = useVegaWallet(); - const setDealTicketType = useDealTicketTypeStore((state) => state.set); - const updateStopOrderFormValues = useStopOrderFormValues( - (state) => state.update + const setType = useDealTicketFormValues((state) => state.setType); + const storedFormValues = useDealTicketFormValues( + (state) => state.formValues[market.id] ); - // store last used tif for market so that when changing OrderType the previous TIF - // selection for that type is used when switching back - - const [lastTIF, setLastTIF] = useState({ - [OrderType.TYPE_MARKET]: OrderTimeInForce.TIME_IN_FORCE_IOC, - [OrderType.TYPE_LIMIT]: OrderTimeInForce.TIME_IN_FORCE_GTC, - }); + const updateStoredFormValues = useDealTicketFormValues( + (state) => state.updateOrder + ); + const dealTicketType = storedFormValues?.type ?? DealTicketType.Limit; + const type = dealTicketTypeToOrderType(dealTicketType); const { control, - errors, - order, - setError, - clearErrors, - update, + reset, + formState: { errors }, handleSubmit, - } = useOrderForm(market.id); - + setValue, + watch, + } = useForm({ + defaultValues: getDefaultValues(type, storedFormValues?.[dealTicketType]), + }); const lastSubmitTime = useRef(0); const asset = market.tradableInstrument.instrument.product.settlementAsset; - const { accountBalance: marginAccountBalance, loading: loadingMarginAccountBalance, @@ -144,24 +163,54 @@ export const DealTicket = ({ ).toString(); const { marketState, marketTradingMode } = marketData; + const timeInForce = watch('timeInForce'); - const normalizedOrder = - order && - normalizeOrderSubmission( - order, - market.decimalPlaces, - market.positionDecimalPlaces - ); + const side = watch('side'); + const rawSize = watch('size'); + const rawPrice = watch('price'); + const iceberg = watch('iceberg'); + const peakSize = watch('peakSize'); - const price = useMemo(() => { - return ( - normalizedOrder && - marketPrice && - getDerivedPrice(normalizedOrder, marketPrice) - ); - }, [normalizedOrder, marketPrice]); + useEffect(() => { + const size = storedFormValues?.[dealTicketType]?.size; + if (size && rawSize !== size) { + setValue('size', size); + } + }, [storedFormValues, dealTicketType, rawSize, setValue]); - const notionalSize = useNotionalSize( + useEffect(() => { + const price = storedFormValues?.[dealTicketType]?.price; + if (price && rawPrice !== price) { + setValue('price', price); + } + }, [storedFormValues, dealTicketType, rawPrice, setValue]); + + useEffect(() => { + const subscription = watch((value, { name, type }) => { + updateStoredFormValues(market.id, value); + }); + return () => subscription.unsubscribe(); + }, [watch, market.id, updateStoredFormValues]); + + const normalizedOrder = mapFormValuesToOrderSubmission( + { + price: rawPrice || undefined, + side, + size: rawSize, + timeInForce, + type, + }, + market.id, + market.decimalPlaces, + market.positionDecimalPlaces + ); + + const price = + normalizedOrder && + marketPrice && + getDerivedPrice(normalizedOrder, marketPrice); + + const notionalSize = getNotionalSize( price, normalizedOrder?.size, market.decimalPlaces, @@ -205,22 +254,20 @@ export const DealTicket = ({ const assetSymbol = market.tradableInstrument.instrument.product.settlementAsset.symbol; - useEffect(() => { + const summaryError = useMemo(() => { if (!pubKey) { - setError('summary', { + return { message: t('No public key selected'), type: SummaryValidationType.NoPubKey, - }); - return; + }; } const marketStateError = validateMarketState(marketState); if (marketStateError !== true) { - setError('summary', { + return { message: marketStateError, type: SummaryValidationType.MarketState, - }); - return; + }; } const hasNoBalance = @@ -229,24 +276,21 @@ export const DealTicket = ({ hasNoBalance && !(loadingMarginAccountBalance || loadingGeneralAccountBalance) ) { - setError('summary', { + return { message: SummaryValidationType.NoCollateral, type: SummaryValidationType.NoCollateral, - }); - return; + }; } const marketTradingModeError = validateMarketTradingMode(marketTradingMode); if (marketTradingModeError !== true) { - setError('summary', { + return { message: marketTradingModeError, type: SummaryValidationType.TradingMode, - }); - return; + }; } - // No error found above clear the error in case it was active on a previous render - clearErrors('summary'); + return undefined; }, [ marketState, marketTradingMode, @@ -255,156 +299,83 @@ export const DealTicket = ({ loadingMarginAccountBalance, loadingGeneralAccountBalance, pubKey, - setError, - clearErrors, ]); - const disablePostOnlyCheckbox = useMemo(() => { - const disabled = order - ? [ - Schema.OrderTimeInForce.TIME_IN_FORCE_IOC, - Schema.OrderTimeInForce.TIME_IN_FORCE_FOK, - ].includes(order.timeInForce) - : true; - return disabled; - }, [order]); + const disablePostOnlyCheckbox = [ + Schema.OrderTimeInForce.TIME_IN_FORCE_IOC, + Schema.OrderTimeInForce.TIME_IN_FORCE_FOK, + ].includes(timeInForce); - const disableReduceOnlyCheckbox = useMemo(() => { - const disabled = order - ? ![ - Schema.OrderTimeInForce.TIME_IN_FORCE_IOC, - Schema.OrderTimeInForce.TIME_IN_FORCE_FOK, - ].includes(order.timeInForce) - : true; - return disabled; - }, [order]); + const disableReduceOnlyCheckbox = !disablePostOnlyCheckbox; const onSubmit = useCallback( - (order: OrderSubmission) => { + (formValues: OrderFormValues) => { const now = new Date().getTime(); if (lastSubmitTime.current && now - lastSubmitTime.current < 1000) { return; } submit( - normalizeOrderSubmission( - order, + mapFormValuesToOrderSubmission( + formValues, + market.id, market.decimalPlaces, market.positionDecimalPlaces ) ); lastSubmitTime.current = now; }, - [submit, market.decimalPlaces, market.positionDecimalPlaces] + [submit, market.decimalPlaces, market.positionDecimalPlaces, market.id] ); - - // if an order doesn't exist one will be created by the store immediately - if (!order || !normalizedOrder) { - return null; - } + useController({ + name: 'type', + control, + rules: { + validate: validateType(marketData.marketTradingMode, marketData.trigger), + }, + }); return ( - { + setType(market.id, dealTicketType); + if (!isStopOrderType(dealTicketType)) { + reset( + getDefaultValues( + dealTicketTypeToOrderType(dealTicketType), + storedFormValues?.[dealTicketType] + ) + ); + } }} - render={() => ( - { - setDealTicketType(market.id, dealTicketType); - if ( - dealTicketType !== DealTicketType.Limit && - dealTicketType !== DealTicketType.Market - ) { - updateStopOrderFormValues(market.id, { - type: - dealTicketType === DealTicketType.StopLimit - ? OrderType.TYPE_LIMIT - : OrderType.TYPE_MARKET, - }); - return; - } - const type = - dealTicketType === DealTicketType.Limit - ? OrderType.TYPE_LIMIT - : OrderType.TYPE_MARKET; - update({ - type, - // when changing type also update the TIF to what was last used of new type - timeInForce: lastTIF[type] || order.timeInForce, - postOnly: - type === OrderType.TYPE_MARKET ? false : order.postOnly, - iceberg: - type === OrderType.TYPE_MARKET || - [ - OrderTimeInForce.TIME_IN_FORCE_FOK, - OrderTimeInForce.TIME_IN_FORCE_IOC, - ].includes(lastTIF[type] || order.timeInForce) - ? false - : order.iceberg, - icebergOpts: - type === OrderType.TYPE_MARKET || - [ - OrderTimeInForce.TIME_IN_FORCE_FOK, - OrderTimeInForce.TIME_IN_FORCE_IOC, - ].includes(lastTIF[type] || order.timeInForce) - ? undefined - : order.icebergOpts, - reduceOnly: - type === OrderType.TYPE_LIMIT && - ![ - OrderTimeInForce.TIME_IN_FORCE_FOK, - OrderTimeInForce.TIME_IN_FORCE_IOC, - ].includes(lastTIF[type] || order.timeInForce) - ? false - : order.postOnly, - expiresAt: undefined, - }); - clearErrors(['expiresAt', 'price']); - }} - market={market} - marketData={marketData} - errorMessage={errors.type?.message} - /> - )} + market={market} + marketData={marketData} + errorMessage={errors.type?.message} /> ( - { - update({ side }); - }} - /> + render={({ field }) => ( + )} /> ( + render={({ field }) => ( { - // Reset post only and reduce only when changing TIF - update({ - timeInForce, - postOnly: [ - OrderTimeInForce.TIME_IN_FORCE_FOK, - OrderTimeInForce.TIME_IN_FORCE_IOC, - ].includes(timeInForce) - ? false - : order.postOnly, - reduceOnly: ![ - OrderTimeInForce.TIME_IN_FORCE_FOK, - OrderTimeInForce.TIME_IN_FORCE_IOC, - ].includes(timeInForce) - ? false - : order.reduceOnly, - }); - // Set TIF value for the given order type, so that when switching - // types we know the last used TIF for the given order type - setLastTIF((curr) => ({ - ...curr, - [order.type]: timeInForce, - expiresAt: undefined, - })); - clearErrors('expiresAt'); - }} + value={field.value} + orderType={type} + onSelect={field.onChange} market={market} marketData={marketData} errorMessage={errors.timeInForce?.message} /> )} /> - {order.type === Schema.OrderType.TYPE_LIMIT && - order.timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_GTT && ( + {type === Schema.OrderType.TYPE_LIMIT && + timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_GTT && ( ( + render={({ field }) => ( - update({ - expiresAt: expiresAt || undefined, - }) - } + value={field.value} + onSelect={(expiresAt) => field.onChange(expiresAt)} errorMessage={errors.expiresAt?.message} /> )} @@ -476,13 +418,14 @@ export const DealTicket = ({ ( + render={({ field }) => ( { - update({ postOnly: !order.postOnly, reduceOnly: false }); + onCheckedChange={(postOnly) => { + field.onChange(postOnly); + setValue('reduceOnly', false); }} label={ ( + render={({ field }) => ( { - update({ postOnly: false, reduceOnly: !order.reduceOnly }); + onCheckedChange={(reduceOnly) => { + field.onChange(reduceOnly); + setValue('postOnly', false); }} label={ -
- {order.type === Schema.OrderType.TYPE_LIMIT && ( - ( - { - update({ iceberg: !order.iceberg, icebergOpts: undefined }); - }} - label={ - - {t(`Trade only a fraction of the order size at once. + {type === Schema.OrderType.TYPE_LIMIT && ( + <> +
+ ( + + {t(`Trade only a fraction of the order size at once. After the peak size of the order has traded, the size is reset. This is repeated until the order is cancelled, expires, or its full volume trades away. For example, an iceberg order with a size of 1000 and a peak size of 100 will effectively be split into 10 orders with a size of 100 each. Note that the full volume of the order is not hidden and is still reflected in the order book.`)} -

- } - > - {t('Iceberg')} - - } - /> - )} - /> - )} -
- {order.iceberg && ( - +

+ } + > + {t('Iceberg')} +
+ } + /> + )} + /> +
+ {iceberg && ( + + )} + )} - + ; } - if (errorMessage === SummaryValidationType.NoCollateral) { + if (error?.type === SummaryValidationType.NoCollateral) { return (
- {errorMessage} + {error?.message}
); diff --git a/libs/deal-ticket/src/components/deal-ticket/time-in-force-selector.tsx b/libs/deal-ticket/src/components/deal-ticket/time-in-force-selector.tsx index a0cfc94d9..8d1b09f3e 100644 --- a/libs/deal-ticket/src/components/deal-ticket/time-in-force-selector.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/time-in-force-selector.tsx @@ -21,6 +21,13 @@ interface TimeInForceSelectorProps { errorMessage?: string; } +const typeLimitOptions = Object.entries(Schema.OrderTimeInForce); +const typeMarketOptions = typeLimitOptions.filter( + ([_, timeInForce]) => + timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_FOK || + timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_IOC +); + export const TimeInForceSelector = ({ value, orderType, @@ -31,12 +38,8 @@ export const TimeInForceSelector = ({ }: TimeInForceSelectorProps) => { const options = orderType === Schema.OrderType.TYPE_LIMIT - ? Object.entries(Schema.OrderTimeInForce) - : Object.entries(Schema.OrderTimeInForce).filter( - ([_, timeInForce]) => - timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_FOK || - timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_IOC - ); + ? typeLimitOptions + : typeMarketOptions; const renderError = (errorType: string) => { if (errorType === MarketModeValidationType.Auction) { diff --git a/libs/deal-ticket/src/components/deal-ticket/type-selector.tsx b/libs/deal-ticket/src/components/deal-ticket/type-selector.tsx index 846172c6c..f6798d813 100644 --- a/libs/deal-ticket/src/components/deal-ticket/type-selector.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/type-selector.tsx @@ -16,7 +16,7 @@ import { t } from '@vegaprotocol/i18n'; import type { Market, StaticMarketData } from '@vegaprotocol/markets'; import { compileGridData } from '../trading-mode-tooltip'; import { MarketModeValidationType } from '../../constants'; -import { DealTicketType } from '../../hooks/use-type-store'; +import { DealTicketType } from '../../hooks/use-form-values'; import * as RadioGroup from '@radix-ui/react-radio-group'; import classNames from 'classnames'; import { FLAGS } from '@vegaprotocol/environment'; diff --git a/libs/deal-ticket/src/hooks/index.ts b/libs/deal-ticket/src/hooks/index.ts index 4994914ca..12aea9146 100644 --- a/libs/deal-ticket/src/hooks/index.ts +++ b/libs/deal-ticket/src/hooks/index.ts @@ -1,4 +1,3 @@ export * from './__generated__/EstimateOrder'; export * from './use-estimate-fees'; -export * from './use-type-store'; -export * from './use-stop-order-form-values'; +export * from './use-form-values'; diff --git a/libs/deal-ticket/src/hooks/use-form-values.ts b/libs/deal-ticket/src/hooks/use-form-values.ts new file mode 100644 index 000000000..eb48b8346 --- /dev/null +++ b/libs/deal-ticket/src/hooks/use-form-values.ts @@ -0,0 +1,144 @@ +import { create } from 'zustand'; +import { persist, subscribeWithSelector } from 'zustand/middleware'; +import type { OrderTimeInForce, Side, OrderType } from '@vegaprotocol/types'; +import * as Schema from '@vegaprotocol/types'; +import { immer } from 'zustand/middleware/immer'; + +export enum DealTicketType { + Limit = 'Limit', + Market = 'Market', + StopLimit = 'StopLimit', + StopMarket = 'StopMarket', +} + +export interface StopOrderFormValues { + side: Side; + + triggerDirection: Schema.StopOrderTriggerDirection; + + triggerType: 'price' | 'trailingPercentOffset'; + triggerPrice?: string; + triggerTrailingPercentOffset?: string; + + type: OrderType; + size: string; + timeInForce: OrderTimeInForce; + price?: string; + + expire: boolean; + expiryStrategy?: Schema.StopOrderExpiryStrategy; + expiresAt?: string; +} + +export type OrderFormValues = { + type: OrderType; + side: Side; + size: string; + timeInForce: OrderTimeInForce; + price?: string; + expiresAt?: string | undefined; + postOnly?: boolean; + reduceOnly?: boolean; + iceberg?: boolean; + peakSize?: string; + minimumVisibleSize?: string; +}; + +type UpdateOrder = (marketId: string, values: Partial) => void; + +type UpdateStopOrder = ( + marketId: string, + values: Partial +) => void; + +type Store = { + updateOrder: UpdateOrder; + updateStopOrder: UpdateStopOrder; + setType: (marketId: string, value: DealTicketType) => void; + updateAll: ( + marketId: string, + values: { size?: string; price?: string } + ) => void; + formValues: Record< + string, + | { + [DealTicketType.Limit]?: Partial; + [DealTicketType.Market]?: Partial; + [DealTicketType.StopLimit]?: Partial; + [DealTicketType.StopMarket]?: Partial; + type?: DealTicketType; + } + | undefined + >; +}; + +export const dealTicketTypeToOrderType = (dealTicketType?: DealTicketType) => + dealTicketType === DealTicketType.Limit || + dealTicketType === DealTicketType.StopLimit + ? Schema.OrderType.TYPE_LIMIT + : Schema.OrderType.TYPE_MARKET; + +export const isStopOrderType = (dealTicketType?: DealTicketType) => + dealTicketType === DealTicketType.StopLimit || + dealTicketType === DealTicketType.StopMarket; + +export const useDealTicketFormValues = create()( + immer( + persist( + subscribeWithSelector((set) => ({ + formValues: {}, + updateStopOrder: (marketId, formValues) => { + set((state) => { + const type = + formValues.type === Schema.OrderType.TYPE_LIMIT + ? DealTicketType.StopLimit + : DealTicketType.StopMarket; + const market = state.formValues[marketId] || {}; + if (!state.formValues[marketId]) { + state.formValues[marketId] = market; + } + market[type] = Object.assign(market[type] ?? {}, formValues); + }); + }, + updateOrder: (marketId, formValues) => { + set((state) => { + const type = + formValues.type === Schema.OrderType.TYPE_LIMIT + ? DealTicketType.Limit + : DealTicketType.Market; + const market = state.formValues[marketId] || {}; + if (!state.formValues[marketId]) { + state.formValues[marketId] = market; + } + market[type] = Object.assign(market[type] ?? {}, formValues); + }); + }, + updateAll: ( + marketId: string, + formValues: { size?: string; price?: string } + ) => { + set((state) => { + const market = state.formValues[marketId] || {}; + if (!state.formValues[marketId]) { + state.formValues[marketId] = market; + } + for (const type of Object.values(DealTicketType)) { + market[type] = Object.assign(market[type] ?? {}, formValues); + } + }); + }, + setType: (marketId, type) => { + set((state) => { + state.formValues[marketId] = Object.assign( + state.formValues[marketId] ?? {}, + { type } + ); + }); + }, + })), + { + name: 'vega_deal_ticket_store', + } + ) + ) +); diff --git a/libs/deal-ticket/src/hooks/use-order-form.spec.ts b/libs/deal-ticket/src/hooks/use-order-form.spec.ts deleted file mode 100644 index 230657935..000000000 --- a/libs/deal-ticket/src/hooks/use-order-form.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import omit from 'lodash/omit'; -import { act, renderHook } from '@testing-library/react'; -import { getDefaultOrder, useCreateOrderStore } from '@vegaprotocol/orders'; -import { useOrderForm } from './use-order-form'; - -jest.mock('zustand'); - -describe('useOrderForm', () => { - const marketId = 'market-id'; - const setup = (marketId: string) => { - return renderHook(() => useOrderForm(marketId)); - }; - const { result } = renderHook(() => useCreateOrderStore()); - const useOrderStore = result.current; - - it('updates form fields when the order changes', async () => { - const order = getDefaultOrder(marketId); - const { result } = setup(marketId); - // expect default values - expect(result.current.order).toEqual(order); - expect(result.current.getValues()).toEqual(order); - - const priceUpdate = { - ...order, - price: '100', - size: '22', - }; - - await act(async () => { - useOrderStore.setState({ - orders: { - [marketId]: priceUpdate, - }, - }); - }); - - // check order store has updated fields - expect(result.current.order).toEqual(priceUpdate); - // check react-hook-form has updated fields - expect(result.current.getValues()).toEqual(priceUpdate); - }); - - it('removes persist key on submit', async () => { - const order = { - ...getDefaultOrder(marketId), - price: '99', - size: '22', - }; - const onSubmit = jest.fn(); - const { result } = setup(marketId); - - await act(async () => { - useOrderStore.setState({ - orders: { - [marketId]: order, - }, - }); - }); - - await act(async () => { - result.current.handleSubmit(onSubmit)(); - }); - - expect(onSubmit).toHaveBeenCalledTimes(1); - expect(onSubmit.mock.calls[0][0]).toEqual(omit(order, 'persist')); - expect(onSubmit.mock.calls[0][0].persist).toBeUndefined(); - }); -}); diff --git a/libs/deal-ticket/src/hooks/use-order-form.ts b/libs/deal-ticket/src/hooks/use-order-form.ts deleted file mode 100644 index 806f5f5d8..000000000 --- a/libs/deal-ticket/src/hooks/use-order-form.ts +++ /dev/null @@ -1,71 +0,0 @@ -import omit from 'lodash/omit'; -import type { OrderObj } from '@vegaprotocol/orders'; -import { getDefaultOrder, useOrder } from '@vegaprotocol/orders'; -import { useEffect } from 'react'; -import { useForm } from 'react-hook-form'; -import type { OrderSubmission } from '@vegaprotocol/wallet'; - -export type OrderFormFields = OrderObj & { - summary: string; -}; - -/** - * Connects the order store to a react-hook-form instance. Any time a field - * changes in the store the form will be updated so that validation rules - * for those fields are applied - */ -export const useOrderForm = (marketId: string) => { - const [order, update] = useOrder(marketId); - const { - control, - formState: { errors, isSubmitted }, - handleSubmit, - setError, - setValue, - clearErrors, - getValues, - } = useForm({ - // order can be undefined if there is nothing in the store, it - // will be created but the form still needs some default values - defaultValues: order || getDefaultOrder(marketId), - }); - - // Keep form fields in sync with the store values, - // inputs are updating the store, fields need updating - // to ensure validation rules are applied - useEffect(() => { - if (!order) return; - const currOrder = getValues(); - for (const k in order) { - const key = k as keyof typeof order; - const curr = currOrder[key]; - const value = order[key]; - if (value !== curr) { - setValue(key, value, { - shouldValidate: isSubmitted, // only apply validation after the form has been submitted and failed - shouldDirty: true, - shouldTouch: true, - }); - } - } - }, [order, isSubmitted, getValues, setValue]); - - const handleSubmitWrapper = (cb: (o: OrderSubmission) => void) => { - return handleSubmit(() => { - // remove the persist and iceberg key from the order in the store, the wallet will reject - // an order that contains unrecognized additional keys - cb(omit(order, 'persist', 'iceberg')); - }); - }; - - return { - order, - update, - control, - errors, - setError, - clearErrors, - getValues, // returned for test purposes only - handleSubmit: handleSubmitWrapper, - }; -}; diff --git a/libs/deal-ticket/src/hooks/use-stop-order-form-values.ts b/libs/deal-ticket/src/hooks/use-stop-order-form-values.ts deleted file mode 100644 index 074815d66..000000000 --- a/libs/deal-ticket/src/hooks/use-stop-order-form-values.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { create } from 'zustand'; -import { persist, subscribeWithSelector } from 'zustand/middleware'; -import type { OrderTimeInForce, Side, OrderType } from '@vegaprotocol/types'; -import type * as Schema from '@vegaprotocol/types'; - -export interface StopOrderFormValues { - side: Side; - - triggerDirection: Schema.StopOrderTriggerDirection; - - triggerType: 'price' | 'trailingPercentOffset'; - triggerPrice: string; - triggerTrailingPercentOffset: string; - - type: OrderType; - size: string; - timeInForce: OrderTimeInForce; - price?: string; - - expire: boolean; - expiryStrategy?: Schema.StopOrderExpiryStrategy; - expiresAt?: string; -} - -type StopOrderFormValuesMap = { - [marketId: string]: Partial | undefined; -}; - -type Update = ( - marketId: string, - formValues: Partial, - persist?: boolean -) => void; - -interface Store { - formValues: StopOrderFormValuesMap; - update: Update; -} - -export const useStopOrderFormValues = create()( - persist( - subscribeWithSelector((set) => ({ - formValues: {}, - update: (marketId, formValues, persist = true) => { - set((state) => { - return { - formValues: { - ...state.formValues, - [marketId]: { - ...state.formValues[marketId], - ...formValues, - }, - }, - }; - }); - }, - })), - { - name: 'vega_stop_order_store', - } - ) -); diff --git a/libs/deal-ticket/src/hooks/use-type-store.ts b/libs/deal-ticket/src/hooks/use-type-store.ts deleted file mode 100644 index fbc99880d..000000000 --- a/libs/deal-ticket/src/hooks/use-type-store.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { create } from 'zustand'; -import { persist, subscribeWithSelector } from 'zustand/middleware'; - -export enum DealTicketType { - Limit = 'Limit', - Market = 'Market', - StopLimit = 'StopLimit', - StopMarket = 'StopMarket', -} - -export const useDealTicketTypeStore = create<{ - set: (marketId: string, type: DealTicketType) => void; - type: Record; -}>()( - persist( - subscribeWithSelector((set) => ({ - type: {}, - set: (marketId: string, type: DealTicketType) => - set((state) => ({ - ...state, - type: { ...state.type, [marketId]: type }, - })), - })), - { - name: 'deal_ticket_type', - } - ) -); diff --git a/libs/deal-ticket/src/utils/map-form-values-to-stop-order-submission.ts b/libs/deal-ticket/src/utils/map-form-values-to-submission.ts similarity index 50% rename from libs/deal-ticket/src/utils/map-form-values-to-stop-order-submission.ts rename to libs/deal-ticket/src/utils/map-form-values-to-submission.ts index 4a55bc26b..bf6885900 100644 --- a/libs/deal-ticket/src/utils/map-form-values-to-stop-order-submission.ts +++ b/libs/deal-ticket/src/utils/map-form-values-to-submission.ts @@ -1,12 +1,64 @@ import type { + OrderSubmission, StopOrderSetup, StopOrdersSubmission, } from '@vegaprotocol/wallet'; -import { normalizeOrderSubmission } from '@vegaprotocol/wallet'; -import type { StopOrderFormValues } from '../hooks/use-stop-order-form-values'; +import type { + OrderFormValues, + StopOrderFormValues, +} from '../hooks/use-form-values'; import * as Schema from '@vegaprotocol/types'; import { removeDecimal, toNanoSeconds } from '@vegaprotocol/utils'; +export const mapFormValuesToOrderSubmission = ( + order: OrderFormValues, + marketId: string, + decimalPlaces: number, + positionDecimalPlaces: number +): OrderSubmission => ({ + marketId: marketId, + type: order.type, + side: order.side, + timeInForce: order.timeInForce, + price: + order.type === Schema.OrderType.TYPE_LIMIT && order.price + ? removeDecimal(order.price, decimalPlaces) + : undefined, + size: removeDecimal(order.size, positionDecimalPlaces), + expiresAt: + order.expiresAt && + order.timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_GTT + ? toNanoSeconds(order.expiresAt) + : undefined, + postOnly: + order.type === Schema.OrderType.TYPE_MARKET ? false : order.postOnly, + reduceOnly: + order.type === Schema.OrderType.TYPE_LIMIT && + ![ + Schema.OrderTimeInForce.TIME_IN_FORCE_FOK, + Schema.OrderTimeInForce.TIME_IN_FORCE_IOC, + ].includes(order.timeInForce) + ? false + : order.reduceOnly, + icebergOpts: + (order.type === Schema.OrderType.TYPE_MARKET || + [ + Schema.OrderTimeInForce.TIME_IN_FORCE_FOK, + Schema.OrderTimeInForce.TIME_IN_FORCE_IOC, + ].includes(order.timeInForce)) && + order.iceberg && + order.peakSize && + order.minimumVisibleSize + ? { + peakSize: removeDecimal(order.peakSize, positionDecimalPlaces), + minimumVisibleSize: removeDecimal( + order.minimumVisibleSize, + positionDecimalPlaces + ), + } + : undefined, +}); + export const mapFormValuesToStopOrdersSubmission = ( data: StopOrderFormValues, marketId: string, @@ -15,9 +67,8 @@ export const mapFormValuesToStopOrdersSubmission = ( ): StopOrdersSubmission => { const submission: StopOrdersSubmission = {}; const stopOrderSetup: StopOrderSetup = { - orderSubmission: normalizeOrderSubmission( + orderSubmission: mapFormValuesToOrderSubmission( { - marketId, type: data.type, side: data.side, size: data.size, @@ -25,12 +76,16 @@ export const mapFormValuesToStopOrdersSubmission = ( price: data.price, reduceOnly: true, }, + marketId, decimalPlaces, positionDecimalPlaces ), }; if (data.triggerType === 'price') { - stopOrderSetup.price = removeDecimal(data.triggerPrice, decimalPlaces); + stopOrderSetup.price = removeDecimal( + data.triggerPrice ?? '', + decimalPlaces + ); } else if (data.triggerType === 'trailingPercentOffset') { stopOrderSetup.trailingPercentOffset = ( Number(data.triggerTrailingPercentOffset) / 100 diff --git a/libs/deal-ticket/src/utils/map-form-values-to-submisstion.spec.ts b/libs/deal-ticket/src/utils/map-form-values-to-submisstion.spec.ts new file mode 100644 index 000000000..b3c743603 --- /dev/null +++ b/libs/deal-ticket/src/utils/map-form-values-to-submisstion.spec.ts @@ -0,0 +1,64 @@ +import type { OrderSubmissionBody } from '@vegaprotocol/wallet'; +import { mapFormValuesToOrderSubmission } from './map-form-values-to-submission'; +import * as Schema from '@vegaprotocol/types'; + +describe('mapFormValuesToOrderSubmission', () => { + it('sets and formats price only for limit orders', () => { + expect( + mapFormValuesToOrderSubmission( + { price: '100' } as unknown as OrderSubmissionBody['orderSubmission'], + 'marketId', + 2, + 1 + ).price + ).toBeUndefined(); + expect( + mapFormValuesToOrderSubmission( + { + price: '100', + type: Schema.OrderType.TYPE_LIMIT, + } as unknown as OrderSubmissionBody['orderSubmission'], + 'marketId', + 2, + 1 + ).price + ).toEqual('10000'); + }); + + it('sets and formats expiresAt only for time in force orders', () => { + expect( + mapFormValuesToOrderSubmission( + { + expiresAt: '2022-01-01T00:00:00.000Z', + } as OrderSubmissionBody['orderSubmission'], + 'marketId', + 2, + 1 + ).expiresAt + ).toBeUndefined(); + expect( + mapFormValuesToOrderSubmission( + { + expiresAt: '2022-01-01T00:00:00.000Z', + timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_GTT, + } as OrderSubmissionBody['orderSubmission'], + 'marketId', + 2, + 1 + ).expiresAt + ).toEqual('1640995200000000000'); + }); + + it('formats size', () => { + expect( + mapFormValuesToOrderSubmission( + { + size: '100', + } as OrderSubmissionBody['orderSubmission'], + 'marketId', + 2, + 1 + ).size + ).toEqual('1000'); + }); +}); diff --git a/libs/orders/src/lib/order-hooks/index.ts b/libs/orders/src/lib/order-hooks/index.ts index 08a43b8de..e40aa81f1 100644 --- a/libs/orders/src/lib/order-hooks/index.ts +++ b/libs/orders/src/lib/order-hooks/index.ts @@ -1,4 +1,3 @@ export * from './__generated__/OrdersSubscription'; export * from './use-has-amendable-order'; export * from './use-order-update'; -export * from './use-order-store'; diff --git a/libs/orders/src/lib/order-hooks/use-order-store.spec.ts b/libs/orders/src/lib/order-hooks/use-order-store.spec.ts deleted file mode 100644 index 71a4334b3..000000000 --- a/libs/orders/src/lib/order-hooks/use-order-store.spec.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { - getDefaultOrder, - STORAGE_KEY, - useOrder, - useCreateOrderStore, -} from './use-order-store'; -import { act, renderHook } from '@testing-library/react'; -import { OrderType } from '@vegaprotocol/types'; - -jest.mock('zustand'); - -describe('useCreateOrderStore', () => { - const setup = () => { - const { result } = renderHook(() => useCreateOrderStore()); - return renderHook(() => result.current()); - }; - - afterEach(() => { - localStorage.clear(); - }); - - it('has a empty default state', async () => { - const { result } = setup(); - expect(result.current).toEqual({ - orders: {}, - update: expect.any(Function), - }); - }); - - it('can update', () => { - const marketId = 'persisted-market-id'; - const expectedOrder = { - ...getDefaultOrder(marketId), - type: OrderType.TYPE_LIMIT, - persist: true, - }; - const { result } = setup(); - act(() => { - result.current.update(marketId, { type: OrderType.TYPE_LIMIT }); - }); - // order should be stored in memory - expect(result.current.orders).toEqual({ - [marketId]: expectedOrder, - }); - // order SHOULD also be in localStorage - expect(JSON.parse(localStorage.getItem(STORAGE_KEY) || '')).toEqual({ - state: { - orders: { - [marketId]: expectedOrder, - }, - }, - version: 0, - }); - }); - - it('can update without persisting', () => { - const marketId = 'non-persisted-market-id'; - const expectedOrder = { - ...getDefaultOrder(marketId), - type: OrderType.TYPE_LIMIT, - persist: false, - }; - const { result } = setup(); - act(() => { - result.current.update(marketId, { type: OrderType.TYPE_LIMIT }, false); - }); - // order should be stored in memory - expect(result.current.orders).toEqual({ - [marketId]: expectedOrder, - }); - // order should NOT be in localStorage - expect(JSON.parse(localStorage.getItem(STORAGE_KEY) || '')).toEqual({ - state: { - orders: {}, - }, - version: 0, - }); - }); -}); - -describe('useOrder', () => { - const setup = (marketId: string) => { - return renderHook(() => useOrder(marketId)); - }; - - afterEach(() => { - localStorage.clear(); - }); - - it('creates a new order if it doesnt exist which is only persisted after editing', () => { - const marketId = 'market-id'; - const expectedOrder = { - ...getDefaultOrder(marketId), - persist: false, - }; - const { result } = setup(marketId); - expect(result.current).toEqual([expectedOrder, expect.any(Function)]); - }); - - it('only persists an order if edited', () => { - const marketId = 'market-id'; - const expectedOrder = { - ...getDefaultOrder(marketId), - persist: false, - }; - const { result } = setup(marketId); - expect(result.current[0]).toMatchObject({ - price: expectedOrder.price, - persist: false, - }); - - const update = { price: '500' }; - act(() => { - result.current[1](update); - }); - - expect(result.current[0]).toMatchObject({ - ...update, - persist: true, - }); - }); -}); diff --git a/libs/orders/src/lib/order-hooks/use-order-store.ts b/libs/orders/src/lib/order-hooks/use-order-store.ts deleted file mode 100644 index 8750777d1..000000000 --- a/libs/orders/src/lib/order-hooks/use-order-store.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { OrderTimeInForce, Side } from '@vegaprotocol/types'; -import { OrderType } from '@vegaprotocol/types'; -import { useCallback, useEffect, useRef } from 'react'; -import type { StateCreator, UseBoundStore, Mutate, StoreApi } from 'zustand'; -import { create } from 'zustand'; -import { persist, subscribeWithSelector } from 'zustand/middleware'; - -export type OrderObj = { - marketId: string; - type: OrderType; - side: Side; - size: string; - timeInForce: OrderTimeInForce; - price?: string; - expiresAt?: string | undefined; - persist: boolean; // key used to determine if order should be kept in localStorage - postOnly?: boolean; - reduceOnly?: boolean; - iceberg?: boolean; - icebergOpts?: { - peakSize: string; - minimumVisibleSize: string; - }; -}; - -type OrderMap = { [marketId: string]: OrderObj | undefined }; - -type UpdateOrder = ( - marketId: string, - order: Partial, - persist?: boolean -) => void; - -interface Store { - orders: OrderMap; - update: UpdateOrder; -} - -export const STORAGE_KEY = 'vega_order_store'; - -const orderStateCreator: StateCreator = (set) => ({ - orders: {}, - update: (marketId, order, persist = true) => { - set((state) => { - const curr = state.orders[marketId]; - const defaultOrder = getDefaultOrder(marketId); - - return { - orders: { - ...state.orders, - [marketId]: { - ...defaultOrder, - ...curr, - ...order, - persist, - }, - }, - }; - }); - }, -}); - -let store: UseBoundStore, []>> | null = null; -const getOrderStore = () => { - if (!store) { - store = create()( - persist(subscribeWithSelector(orderStateCreator), { - name: STORAGE_KEY, - partialize: (state) => { - // only store the order in localStorage if user has edited, this avoids - // bloating localStorage if a user just visits the page but does not - // edit the ticket - const partializedOrders: OrderMap = {}; - for (const o in state.orders) { - const order = state.orders[o]; - if (order && order.persist) { - partializedOrders[order.marketId] = order; - } - } - - return { - ...state, - orders: partializedOrders, - }; - }, - }) - ); - } - return store as UseBoundStore, []>>; -}; - -export const useCreateOrderStore = () => { - const useOrderStoreRef = useRef(getOrderStore()); - return useOrderStoreRef.current; -}; - -/** - * Retrieves an order from the store for a market and - * creates one if it doesn't already exist - */ -export const useOrder = (marketId: string) => { - const useOrderStoreRef = useCreateOrderStore(); - const [order, _update] = useOrderStoreRef((store) => { - return [store.orders[marketId], store.update]; - }); - - const update = useCallback( - (o: Partial, persist = true) => { - _update(marketId, o, persist); - }, - [marketId, _update] - ); - - // add new order to store if it doesn't exist, but don't - // persist until user has edited - useEffect(() => { - if (!order) { - update( - getDefaultOrder(marketId), - false // don't persist the order - ); - } - }, [order, marketId, update]); - - return [order, update] as const; // make result a tuple -}; - -export const getDefaultOrder = (marketId: string): OrderObj => ({ - marketId, - type: OrderType.TYPE_LIMIT, - side: Side.SIDE_BUY, - timeInForce: OrderTimeInForce.TIME_IN_FORCE_GTC, - size: '0', - price: '0', - expiresAt: undefined, - persist: false, - postOnly: false, - reduceOnly: false, -}); diff --git a/libs/trades/src/lib/trades-container.tsx b/libs/trades/src/lib/trades-container.tsx index fb4a43331..be6599727 100644 --- a/libs/trades/src/lib/trades-container.tsx +++ b/libs/trades/src/lib/trades-container.tsx @@ -1,7 +1,7 @@ import { useDataProvider } from '@vegaprotocol/data-provider'; import { tradesWithMarketProvider } from './trades-data-provider'; import { TradesTable } from './trades-table'; -import { useCreateOrderStore } from '@vegaprotocol/orders'; +import { useDealTicketFormValues } from '@vegaprotocol/deal-ticket'; import { t } from '@vegaprotocol/i18n'; interface TradesContainerProps { @@ -9,8 +9,7 @@ interface TradesContainerProps { } export const TradesContainer = ({ marketId }: TradesContainerProps) => { - const useOrderStoreRef = useCreateOrderStore(); - const updateOrder = useOrderStoreRef((store) => store.update); + const update = useDealTicketFormValues((state) => state.updateAll); const { data, error } = useDataProvider({ dataProvider: tradesWithMarketProvider, @@ -21,9 +20,7 @@ export const TradesContainer = ({ marketId }: TradesContainerProps) => { { - if (price) { - updateOrder(marketId, { price }); - } + update(marketId, { price }); }} overlayNoRowsTemplate={error ? error.message : t('No trades')} /> diff --git a/libs/wallet/src/utils.spec.ts b/libs/wallet/src/utils.spec.ts index 60504a06f..22d22f659 100644 --- a/libs/wallet/src/utils.spec.ts +++ b/libs/wallet/src/utils.spec.ts @@ -1,9 +1,4 @@ -import { - determineId, - normalizeOrderAmendment, - normalizeOrderSubmission, -} from './utils'; -import type { OrderSubmissionBody } from './connectors/vega-connector'; +import { determineId, normalizeOrderAmendment } from './utils'; import * as Schema from '@vegaprotocol/types'; describe('determineId', () => { it('produces a known result for an ID', () => { @@ -16,62 +11,6 @@ describe('determineId', () => { }); }); -describe('normalizeOrderSubmission', () => { - it('sets and formats price only for limit orders', () => { - expect( - normalizeOrderSubmission( - { price: '100' } as unknown as OrderSubmissionBody['orderSubmission'], - 2, - 1 - ).price - ).toBeUndefined(); - expect( - normalizeOrderSubmission( - { - price: '100', - type: Schema.OrderType.TYPE_LIMIT, - } as unknown as OrderSubmissionBody['orderSubmission'], - 2, - 1 - ).price - ).toEqual('10000'); - }); - - it('sets and formats expiresAt only for time in force orders', () => { - expect( - normalizeOrderSubmission( - { - expiresAt: '2022-01-01T00:00:00.000Z', - } as OrderSubmissionBody['orderSubmission'], - 2, - 1 - ).expiresAt - ).toBeUndefined(); - expect( - normalizeOrderSubmission( - { - expiresAt: '2022-01-01T00:00:00.000Z', - timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_GTT, - } as OrderSubmissionBody['orderSubmission'], - 2, - 1 - ).expiresAt - ).toEqual('1640995200000000000'); - }); - - it('formats size', () => { - expect( - normalizeOrderSubmission( - { - size: '100', - } as OrderSubmissionBody['orderSubmission'], - 2, - 1 - ).size - ).toEqual('1000'); - }); -}); - describe('normalizeOrderAmendment', () => { type Order = Parameters[0]; type Market = Parameters[1]; diff --git a/libs/wallet/src/utils.ts b/libs/wallet/src/utils.ts index 034741606..47444c9a8 100644 --- a/libs/wallet/src/utils.ts +++ b/libs/wallet/src/utils.ts @@ -1,15 +1,10 @@ import { removeDecimal, toNanoSeconds } from '@vegaprotocol/utils'; import type { Market, Order } from '@vegaprotocol/types'; -import { OrderTimeInForce, OrderType, AccountType } from '@vegaprotocol/types'; +import { AccountType } from '@vegaprotocol/types'; import BigNumber from 'bignumber.js'; import { ethers } from 'ethers'; import { sha3_256 } from 'js-sha3'; -import type { - OrderAmendment, - OrderSubmission, - Transaction, - Transfer, -} from './connectors'; +import type { OrderAmendment, Transaction, Transfer } from './connectors'; import type { Exact } from 'type-fest'; /** @@ -29,36 +24,6 @@ export const encodeTransaction = (tx: Transaction): string => { ); }; -export const normalizeOrderSubmission = ( - order: OrderSubmission, - decimalPlaces: number, - positionDecimalPlaces: number -): OrderSubmission => ({ - marketId: order.marketId, - reference: order.reference, - type: order.type, - side: order.side, - timeInForce: order.timeInForce, - price: - order.type === OrderType.TYPE_LIMIT && order.price - ? removeDecimal(order.price, decimalPlaces) - : undefined, - size: removeDecimal(order.size, positionDecimalPlaces), - expiresAt: - order.expiresAt && order.timeInForce === OrderTimeInForce.TIME_IN_FORCE_GTT - ? toNanoSeconds(order.expiresAt) - : undefined, - postOnly: order.postOnly, - reduceOnly: order.reduceOnly, - icebergOpts: order.icebergOpts && { - peakSize: removeDecimal(order.icebergOpts.peakSize, positionDecimalPlaces), - minimumVisibleSize: removeDecimal( - order.icebergOpts.minimumVisibleSize, - positionDecimalPlaces - ), - }, -}); - export const normalizeOrderAmendment = >( order: Pick, market: Pick,