From 375d447fa8cad9bf8211df5a8361ef969975f374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20G=C5=82ownia?= Date: Wed, 2 Aug 2023 12:28:33 +0200 Subject: [PATCH] feat(trading): add stop orders table and form (#4265) Co-authored-by: Dariusz Majcherczyk --- .../src/integration/markets-proposed.cy.ts | 6 +- .../trading-deal-ticket-basics.cy.ts | 10 +- .../src/integration/withdraw-key-to-key.cy.ts | 3 +- apps/trading-e2e/src/support/create-order.ts | 5 +- apps/trading-e2e/src/support/deal-ticket.ts | 4 +- apps/trading/.env | 2 +- apps/trading/.env.capsule | 2 +- apps/trading/.env.devnet | 2 +- apps/trading/.env.mainnet | 2 +- apps/trading/.env.mainnet-mirror | 2 +- apps/trading/.env.stagnet1 | 2 +- apps/trading/.env.testnet | 2 +- apps/trading/.env.validators-testnet | 2 +- .../client-pages/market/trade-grid.tsx | 7 + .../client-pages/market/trade-views.tsx | 5 + .../orderbook-container.tsx | 6 + .../components/stop-orders-container/index.ts | 1 + .../stop-orders-container.tsx | 41 ++ libs/accounts/src/lib/use-account-balance.tsx | 6 +- .../src/lib/use-market-account-balance.tsx | 6 +- .../src/lib/cells/market-name-cell.tsx | 2 +- .../deal-ticket/deal-ticket-amount.tsx | 14 +- .../deal-ticket/deal-ticket-button.tsx | 5 +- .../deal-ticket/deal-ticket-container.tsx | 57 +- .../deal-ticket/deal-ticket-fee-details.tsx | 110 ++-- .../deal-ticket/deal-ticket-limit-amount.tsx | 11 +- .../deal-ticket/deal-ticket-market-amount.tsx | 57 +- .../deal-ticket-stop-order.spec.tsx | 314 ++++++++++ .../deal-ticket/deal-ticket-stop-order.tsx | 590 ++++++++++++++++++ .../deal-ticket/deal-ticket.spec.tsx | 136 ++-- .../components/deal-ticket/deal-ticket.tsx | 238 ++++--- .../deal-ticket/expiry-selector.tsx | 4 +- .../src/components/deal-ticket/index.ts | 2 + .../components/deal-ticket/side-selector.tsx | 63 +- .../components/deal-ticket/type-selector.tsx | 130 +++- libs/deal-ticket/src/hooks/index.ts | 2 + .../src/hooks/use-stop-order-form-values.ts | 62 ++ libs/deal-ticket/src/hooks/use-type-store.ts | 28 + libs/deal-ticket/src/test-helpers.ts | 26 +- libs/deal-ticket/src/utils/index.ts | 1 - ...ap-form-values-to-stop-order-submission.ts | 71 +++ .../src/utils/validate-time-in-force.ts | 2 +- libs/deal-ticket/src/utils/validate-type.ts | 2 +- .../utils => markets/src/lib}/get-price.ts | 15 +- libs/markets/src/lib/index.ts | 2 + .../src/lib}/is-market-in-auction.ts | 0 libs/markets/src/lib/market-data-provider.ts | 16 + libs/orders/src/lib/components/index.ts | 1 + .../components/mocks/generate-stop-orders.ts | 113 ++++ .../order-data-provider/Orders.graphql | 51 ++ .../__generated__/Orders.ts | 95 ++- .../stop-orders-data-provider.ts | 57 ++ .../components/stop-orders-manager/index.ts | 1 + .../stop-orders-manager.spec.tsx | 43 ++ .../stop-orders-manager.tsx | 66 ++ .../stop-orders-table.spec.tsx | 237 +++++++ .../stop-orders-table/stop-orders-table.tsx | 300 +++++++++ .../src/lib/order-hooks/use-order-store.ts | 4 +- libs/types/src/global-types-mappings.ts | 16 + .../components/radio-group/radio-group.tsx | 59 +- libs/ui-toolkit/src/components/tabs/tabs.tsx | 11 +- .../trading-dropdown/trading-dropdown.tsx | 2 +- .../utils/src/lib/validate/validate-amount.ts | 4 +- libs/wallet/src/connectors/vega-connector.ts | 38 ++ .../wallet/src/use-vega-transaction-store.tsx | 11 +- .../src/lib/use-vega-transaction-toasts.tsx | 6 + 66 files changed, 2780 insertions(+), 411 deletions(-) create mode 100644 apps/trading/components/stop-orders-container/index.ts create mode 100644 apps/trading/components/stop-orders-container/stop-orders-container.tsx create mode 100644 libs/deal-ticket/src/components/deal-ticket/deal-ticket-stop-order.spec.tsx create mode 100644 libs/deal-ticket/src/components/deal-ticket/deal-ticket-stop-order.tsx create mode 100644 libs/deal-ticket/src/hooks/use-stop-order-form-values.ts create mode 100644 libs/deal-ticket/src/hooks/use-type-store.ts create mode 100644 libs/deal-ticket/src/utils/map-form-values-to-stop-order-submission.ts rename libs/{deal-ticket/src/utils => markets/src/lib}/get-price.ts (83%) rename libs/{deal-ticket/src/utils => markets/src/lib}/is-market-in-auction.ts (100%) create mode 100644 libs/orders/src/lib/components/mocks/generate-stop-orders.ts create mode 100644 libs/orders/src/lib/components/order-data-provider/stop-orders-data-provider.ts create mode 100644 libs/orders/src/lib/components/stop-orders-manager/index.ts create mode 100644 libs/orders/src/lib/components/stop-orders-manager/stop-orders-manager.spec.tsx create mode 100644 libs/orders/src/lib/components/stop-orders-manager/stop-orders-manager.tsx create mode 100644 libs/orders/src/lib/components/stop-orders-table/stop-orders-table.spec.tsx create mode 100644 libs/orders/src/lib/components/stop-orders-table/stop-orders-table.tsx diff --git a/apps/trading-e2e/src/integration/markets-proposed.cy.ts b/apps/trading-e2e/src/integration/markets-proposed.cy.ts index bac1b6e32..afabbd5bd 100644 --- a/apps/trading-e2e/src/integration/markets-proposed.cy.ts +++ b/apps/trading-e2e/src/integration/markets-proposed.cy.ts @@ -100,7 +100,6 @@ describe('markets proposed table', { tags: '@smoke' }, () => { 'VEGA_TOKEN_URL' )}/proposals/e9ec6d5c46a7e7bcabf9ba7a893fa5a5eeeec08b731f06f7a6eb7bf0e605b829` ); - cy.getByTestId('proposal-actions-content').click(); }); // 6001-MARK-060 @@ -214,11 +213,12 @@ describe('no markets proposed', { tags: '@smoke', testIsolation: true }, () => { aliasGQLQuery(req, 'ProposalsList', proposal); }); cy.mockSubscription(); - cy.visit('/#/markets/all'); - cy.get('[data-testid="Proposed markets"]').click(); }); it('can see no markets message', () => { + cy.visit('/#/markets/all'); + cy.get('[data-testid="Proposed markets"]').click(); + // 6001-MARK-061 cy.getByTestId('tab-proposed-markets').should('contain.text', 'No markets'); }); diff --git a/apps/trading-e2e/src/integration/trading-deal-ticket-basics.cy.ts b/apps/trading-e2e/src/integration/trading-deal-ticket-basics.cy.ts index 9512f317a..11ab02e02 100644 --- a/apps/trading-e2e/src/integration/trading-deal-ticket-basics.cy.ts +++ b/apps/trading-e2e/src/integration/trading-deal-ticket-basics.cy.ts @@ -28,16 +28,16 @@ describe('deal ticket basics', { tags: '@smoke' }, () => { it('must be able to select order direction - long/short', function () { // 7002-SORD-004 - cy.getByTestId(toggleShort).click().children('input').should('be.checked'); - cy.getByTestId(toggleLong).click().children('input').should('be.checked'); + cy.getByTestId(toggleShort).click().next('input').should('be.checked'); + cy.getByTestId(toggleLong).click().next('input').should('be.checked'); }); it('must be able to select order type - limit/market', function () { // 7002-SORD-005 // 7002-SORD-006 // 7002-SORD-007 - cy.getByTestId(toggleLimit).click().children('input').should('be.checked'); - cy.getByTestId(toggleMarket).click().children('input').should('be.checked'); + cy.getByTestId(toggleLimit).click().next('input').should('be.checked'); + cy.getByTestId(toggleMarket).click().next('input').should('be.checked'); }); it('order connect vega wallet button should connect', () => { @@ -51,7 +51,7 @@ describe('deal ticket basics', { tags: '@smoke' }, () => { .click(); cy.wait('@walletReq'); cy.getByTestId(placeOrderBtn).should('be.visible'); - cy.getByTestId(toggleLimit).children('input').should('be.checked'); + cy.getByTestId(toggleLimit).next('input').should('be.checked'); cy.getByTestId(orderPriceField).should('have.value', '101'); }); }); diff --git a/apps/trading-e2e/src/integration/withdraw-key-to-key.cy.ts b/apps/trading-e2e/src/integration/withdraw-key-to-key.cy.ts index 12da868f8..72b50e3b9 100644 --- a/apps/trading-e2e/src/integration/withdraw-key-to-key.cy.ts +++ b/apps/trading-e2e/src/integration/withdraw-key-to-key.cy.ts @@ -35,6 +35,8 @@ describe('transfer fees', { tags: '@regression', testIsolation: true }, () => { cy.setVegaWallet(); cy.visit('/'); + cy.getByTestId(manageVegaWallet).click(); + cy.getByTestId(walletTransfer).click(); cy.wait('@Assets'); cy.wait('@Accounts'); @@ -57,7 +59,6 @@ describe('transfer fees', { tags: '@regression', testIsolation: true }, () => { // 1003-TRAN-019 cy.getByTestId(transferForm); cy.contains('Enter manually').click(); - cy.getByTestId(transferForm) .find(toAddressField) .type('7f9cf07d3a9905b1a61a1069f7a758855da428bc0f4a97de87f48644bfc25535'); diff --git a/apps/trading-e2e/src/support/create-order.ts b/apps/trading-e2e/src/support/create-order.ts index 80f1690ab..17a7b0ede 100644 --- a/apps/trading-e2e/src/support/create-order.ts +++ b/apps/trading-e2e/src/support/create-order.ts @@ -1,3 +1,4 @@ +import { OrderType } from '@vegaprotocol/types'; import type { OrderSubmission } from '@vegaprotocol/wallet'; const orderSizeField = 'order-size'; @@ -9,7 +10,9 @@ export const createOrder = (order: OrderSubmission): void => { cy.log('Placing order', order); const { type, side, size, price, timeInForce, expiresAt } = order; - cy.getByTestId(`order-type-${type}`).click(); + cy.getByTestId( + `order-type-${type === OrderType.TYPE_LIMIT ? 'Limit' : 'Market'}` + ).click(); cy.getByTestId(`order-side-${side}`).click(); cy.getByTestId(orderSizeField).clear().type(size); if (price) { diff --git a/apps/trading-e2e/src/support/deal-ticket.ts b/apps/trading-e2e/src/support/deal-ticket.ts index c1fdcdd32..02902226e 100644 --- a/apps/trading-e2e/src/support/deal-ticket.ts +++ b/apps/trading-e2e/src/support/deal-ticket.ts @@ -6,8 +6,8 @@ export const orderTIFDropDown = 'order-tif'; export const placeOrderBtn = 'place-order'; export const toggleShort = 'order-side-SIDE_SELL'; export const toggleLong = 'order-side-SIDE_BUY'; -export const toggleLimit = 'order-type-TYPE_LIMIT'; -export const toggleMarket = 'order-type-TYPE_MARKET'; +export const toggleLimit = 'order-type-Limit'; +export const toggleMarket = 'order-type-Market'; export const TIFlist = Object.values(Schema.OrderTimeInForce).map((value) => { return { diff --git a/apps/trading/.env b/apps/trading/.env index deee2e496..ea12adf76 100644 --- a/apps/trading/.env +++ b/apps/trading/.env @@ -16,6 +16,6 @@ NX_WALLETCONNECT_PROJECT_ID=fe8091dc35738863e509fc4947525c72 # Cosmic elevator flags NX_SUCCESSOR_MARKETS=true -# NX_STOP_ORDERS +NX_STOP_ORDERS=true # NX_ICEBERG_ORDERS # NX_PRODUCT_PERPETUALS \ No newline at end of file diff --git a/apps/trading/.env.capsule b/apps/trading/.env.capsule index 2b88ee4b6..edee645ed 100644 --- a/apps/trading/.env.capsule +++ b/apps/trading/.env.capsule @@ -18,6 +18,6 @@ NX_ETH_WALLET_MNEMONIC="ozone access unlock valid olympic save include omit supp # Cosmic elevator flags NX_SUCCESSOR_MARKETS=false -# NX_STOP_ORDERS +NX_STOP_ORDERS=false # NX_ICEBERG_ORDERS # NX_PRODUCT_PERPETUALS \ No newline at end of file diff --git a/apps/trading/.env.devnet b/apps/trading/.env.devnet index 7dd43dbdf..110dbeec2 100644 --- a/apps/trading/.env.devnet +++ b/apps/trading/.env.devnet @@ -16,6 +16,6 @@ NX_VEGA_INCIDENT_URL=https://blog.vega.xyz/tagged/vega-incident-reports # Cosmic elevator flags NX_SUCCESSOR_MARKETS=true -# NX_STOP_ORDERS +NX_STOP_ORDERS=true # NX_ICEBERG_ORDERS # NX_PRODUCT_PERPETUALS \ No newline at end of file diff --git a/apps/trading/.env.mainnet b/apps/trading/.env.mainnet index e3d2e9b73..1e8df7ff6 100644 --- a/apps/trading/.env.mainnet +++ b/apps/trading/.env.mainnet @@ -18,6 +18,6 @@ NX_APP_VERSION=v0.20.21-core-0.71.6 # Cosmic elevator flags NX_SUCCESSOR_MARKETS=false -# NX_STOP_ORDERS +NX_STOP_ORDERS=false # NX_ICEBERG_ORDERS # NX_PRODUCT_PERPETUALS \ No newline at end of file diff --git a/apps/trading/.env.mainnet-mirror b/apps/trading/.env.mainnet-mirror index 3773ee43a..3523bef3c 100644 --- a/apps/trading/.env.mainnet-mirror +++ b/apps/trading/.env.mainnet-mirror @@ -18,6 +18,6 @@ NX_APP_VERSION=v0.20.19-core-0.71.6 # Cosmic elevator flags NX_SUCCESSOR_MARKETS=false -# NX_STOP_ORDERS +NX_STOP_ORDERS=false # NX_ICEBERG_ORDERS # NX_PRODUCT_PERPETUALS \ No newline at end of file diff --git a/apps/trading/.env.stagnet1 b/apps/trading/.env.stagnet1 index b4b8c08cb..7a75a6ee0 100644 --- a/apps/trading/.env.stagnet1 +++ b/apps/trading/.env.stagnet1 @@ -16,6 +16,6 @@ NX_VEGA_INCIDENT_URL=https://blog.vega.xyz/tagged/vega-incident-reports # Cosmic elevator flags NX_SUCCESSOR_MARKETS=true -# NX_STOP_ORDERS +NX_STOP_ORDERS=true # NX_ICEBERG_ORDERS # NX_PRODUCT_PERPETUALS \ No newline at end of file diff --git a/apps/trading/.env.testnet b/apps/trading/.env.testnet index a85f32c4d..43c21bba4 100644 --- a/apps/trading/.env.testnet +++ b/apps/trading/.env.testnet @@ -17,6 +17,6 @@ NX_VEGA_CONSOLE_URL=https://console.fairground.wtf # Cosmic elevator flags NX_SUCCESSOR_MARKETS=true -# NX_STOP_ORDERS +NX_STOP_ORDERS=true # NX_ICEBERG_ORDERS # NX_PRODUCT_PERPETUALS \ No newline at end of file diff --git a/apps/trading/.env.validators-testnet b/apps/trading/.env.validators-testnet index cf6001055..6412a59ab 100644 --- a/apps/trading/.env.validators-testnet +++ b/apps/trading/.env.validators-testnet @@ -17,6 +17,6 @@ NX_VEGA_CONSOLE_URL=https://trading.validators-testnet.vega.rocks # Cosmic elevator flags NX_SUCCESSOR_MARKETS=false -# NX_STOP_ORDERS +NX_STOP_ORDERS=false # NX_ICEBERG_ORDERS # NX_PRODUCT_PERPETUALS \ No newline at end of file diff --git a/apps/trading/client-pages/market/trade-grid.tsx b/apps/trading/client-pages/market/trade-grid.tsx index 2fe417333..c6089cc46 100644 --- a/apps/trading/client-pages/market/trade-grid.tsx +++ b/apps/trading/client-pages/market/trade-grid.tsx @@ -130,6 +130,13 @@ const MainGrid = memo( + {FLAGS.STOP_ORDERS ? ( + + + + + + ) : null} { const useOrderStoreRef = useCreateOrderStore(); const updateOrder = useOrderStoreRef((store) => store.update); + const updateStoredFormValues = useStopOrderFormValues( + (state) => state.update + ); const setView = useSidebar((store) => store.setView); return ( { onClick={({ price, size }) => { if (price) { updateOrder(marketId, { price }); + updateStoredFormValues(marketId, { price }); } if (size) { updateOrder(marketId, { size }); + updateStoredFormValues(marketId, { size }); } setView({ type: ViewType.Order }); }} diff --git a/apps/trading/components/stop-orders-container/index.ts b/apps/trading/components/stop-orders-container/index.ts new file mode 100644 index 000000000..da49afbcd --- /dev/null +++ b/apps/trading/components/stop-orders-container/index.ts @@ -0,0 +1 @@ +export * from './stop-orders-container'; diff --git a/apps/trading/components/stop-orders-container/stop-orders-container.tsx b/apps/trading/components/stop-orders-container/stop-orders-container.tsx new file mode 100644 index 000000000..9dbfed11f --- /dev/null +++ b/apps/trading/components/stop-orders-container/stop-orders-container.tsx @@ -0,0 +1,41 @@ +import { useDataGridEvents } from '@vegaprotocol/datagrid'; +import { t } from '@vegaprotocol/i18n'; +import { StopOrdersManager } from '@vegaprotocol/orders'; +import { Splash } from '@vegaprotocol/ui-toolkit'; +import { useVegaWallet } from '@vegaprotocol/wallet'; +import { useMarketClickHandler } from '../../lib/hooks/use-market-click-handler'; +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { DataGridSlice } from '../../stores/datagrid-store-slice'; +import { createDataGridSlice } from '../../stores/datagrid-store-slice'; + +export const StopOrdersContainer = () => { + const { pubKey, isReadOnly } = useVegaWallet(); + const onMarketClick = useMarketClickHandler(true); + + const gridStore = useStopOrdersStore((store) => store.gridStore); + const updateGridStore = useStopOrdersStore((store) => store.updateGridStore); + + const gridStoreCallbacks = useDataGridEvents(gridStore, (colState) => { + updateGridStore(colState); + }); + + if (!pubKey) { + return {t('Please connect Vega wallet')}; + } + + return ( + + ); +}; + +const useStopOrdersStore = create()( + persist(createDataGridSlice, { + name: 'vega_fills_store', + }) +); diff --git a/libs/accounts/src/lib/use-account-balance.tsx b/libs/accounts/src/lib/use-account-balance.tsx index c6be87f8a..adbfc5a99 100644 --- a/libs/accounts/src/lib/use-account-balance.tsx +++ b/libs/accounts/src/lib/use-account-balance.tsx @@ -23,7 +23,7 @@ export const useAccountBalance = (assetId?: string) => { }, [assetId] ); - useDataProvider({ + const { loading, error } = useDataProvider({ dataProvider: accountsDataProvider, variables, skip: !pubKey || !assetId, @@ -34,7 +34,9 @@ export const useAccountBalance = (assetId?: string) => { () => ({ accountBalance: pubKey ? accountBalance : '', accountDecimals: pubKey ? accountDecimals : null, + loading, + error, }), - [accountBalance, accountDecimals, pubKey] + [accountBalance, accountDecimals, pubKey, loading, error] ); }; diff --git a/libs/accounts/src/lib/use-market-account-balance.tsx b/libs/accounts/src/lib/use-market-account-balance.tsx index bb7ff3c26..e13877dbb 100644 --- a/libs/accounts/src/lib/use-market-account-balance.tsx +++ b/libs/accounts/src/lib/use-market-account-balance.tsx @@ -22,7 +22,7 @@ export const useMarketAccountBalance = (marketId: string) => { }, [marketId] ); - useDataProvider({ + const { loading, error } = useDataProvider({ dataProvider: accountsDataProvider, variables: { partyId: pubKey || '' }, skip: !pubKey || !marketId, @@ -33,7 +33,9 @@ export const useMarketAccountBalance = (marketId: string) => { () => ({ accountBalance: pubKey ? accountBalance : '', accountDecimals: pubKey ? accountDecimals : null, + loading, + error, }), - [accountBalance, accountDecimals, pubKey] + [accountBalance, accountDecimals, pubKey, loading, error] ); }; diff --git a/libs/datagrid/src/lib/cells/market-name-cell.tsx b/libs/datagrid/src/lib/cells/market-name-cell.tsx index 2ef42db1a..e88c47275 100644 --- a/libs/datagrid/src/lib/cells/market-name-cell.tsx +++ b/libs/datagrid/src/lib/cells/market-name-cell.tsx @@ -3,7 +3,7 @@ import { useCallback } from 'react'; import get from 'lodash/get'; interface MarketNameCellProps { - value?: string; + value?: string | null; data?: { id?: string; marketId?: string; market?: { id: string } }; idPath?: string; onMarketClick?: (marketId: string, metaKey?: boolean) => void; 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 efcf53f88..0b2e270d4 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 @@ -1,5 +1,5 @@ import type { Control } from 'react-hook-form'; -import type { Market, MarketData } from '@vegaprotocol/markets'; +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'; @@ -9,7 +9,8 @@ import type { OrderFormFields } from '../../hooks/use-order-form'; export interface DealTicketAmountProps { control: Control; orderType: Schema.OrderType; - marketData: MarketData; + marketData: StaticMarketData; + marketPrice?: string; market: Market; sizeError?: string; priceError?: string; @@ -21,11 +22,18 @@ export interface DealTicketAmountProps { export const DealTicketAmount = ({ orderType, marketData, + marketPrice, ...props }: DealTicketAmountProps) => { switch (orderType) { case Schema.OrderType.TYPE_MARKET: - return ; + return ( + + ); case Schema.OrderType.TYPE_LIMIT: return ; default: { diff --git a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-button.tsx b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-button.tsx index 391480036..39070688c 100644 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-button.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-button.tsx @@ -4,9 +4,10 @@ import classNames from 'classnames'; interface Props { side: Side; + label?: string; } -export const DealTicketButton = ({ side }: Props) => { +export const DealTicketButton = ({ side, label }: Props) => { const buttonClasses = classNames( 'px-10 py-2 uppercase rounded-md text-white w-full', { @@ -17,7 +18,7 @@ export const DealTicketButton = ({ side }: Props) => { return (
); 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 ab1646ce1..da10e201a 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,11 +1,20 @@ +import { useVegaTransactionStore } from '@vegaprotocol/wallet'; +import { + DealTicketType, + useDealTicketTypeStore, +} from '../../hooks/use-type-store'; +import { StopOrder } from './deal-ticket-stop-order'; +import { + useStaticMarketData, + useMarket, + useMarketPrice, +} from '@vegaprotocol/markets'; import { AsyncRenderer, Splash } from '@vegaprotocol/ui-toolkit'; import { t } from '@vegaprotocol/i18n'; -import { useThrottledDataProvider } from '@vegaprotocol/data-provider'; -import { useVegaTransactionStore } from '@vegaprotocol/wallet'; -import { useMarket, marketDataProvider } from '@vegaprotocol/markets'; import { DealTicket } from './deal-ticket'; +import { FLAGS } from '@vegaprotocol/environment'; -export interface DealTicketContainerProps { +interface DealTicketContainerProps { marketId: string; onMarketClick?: (marketId: string, metaKey?: boolean) => void; onClickCollateral?: () => void; @@ -14,10 +23,9 @@ export interface DealTicketContainerProps { export const DealTicketContainer = ({ marketId, - onMarketClick, - onClickCollateral, - onDeposit, + ...props }: DealTicketContainerProps) => { + const type = useDealTicketTypeStore((state) => state.type[marketId]); const { data: market, error: marketError, @@ -29,15 +37,9 @@ export const DealTicketContainer = ({ error: marketDataError, loading: marketDataLoading, reload, - } = useThrottledDataProvider( - { - dataProvider: marketDataProvider, - variables: { marketId }, - }, - 1000 - ); + } = useStaticMarketData(marketId); + const { data: marketPrice } = useMarketPrice(market?.id); const create = useVegaTransactionStore((state) => state.create); - return ( {market && marketData ? ( - create({ orderSubmission })} - onClickCollateral={onClickCollateral} - onMarketClick={onMarketClick} - onDeposit={onDeposit} - /> + FLAGS.STOP_ORDERS && + (type === DealTicketType.StopLimit || + type === DealTicketType.StopMarket) ? ( + create({ stopOrdersSubmission })} + /> + ) : ( + create({ orderSubmission })} + /> + ) ) : (

{t('Could not load market')}

diff --git a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-fee-details.tsx b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-fee-details.tsx index cf573debe..7df3041f3 100644 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-fee-details.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-fee-details.tsx @@ -4,11 +4,11 @@ import classnames from 'classnames'; import type { ReactNode } from 'react'; import { t } from '@vegaprotocol/i18n'; import { FeesBreakdown } from '@vegaprotocol/markets'; +import type { OrderSubmissionBody } from '@vegaprotocol/wallet'; import { useVegaWallet } from '@vegaprotocol/wallet'; import type { Market } from '@vegaprotocol/markets'; import type { EstimatePositionQuery } from '@vegaprotocol/positions'; -import type { EstimateFeesQuery } from '../../hooks/__generated__/EstimateOrder'; import { AccountBreakdownDialog } from '@vegaprotocol/accounts'; import { formatRange, formatValue } from '@vegaprotocol/utils'; @@ -24,6 +24,7 @@ import { EST_TOTAL_MARGIN_TOOLTIP_TEXT, MARGIN_ACCOUNT_TOOLTIP_TEXT, } from '../../constants'; +import { useEstimateFees } from '../../hooks'; const emptyValue = '-'; @@ -76,26 +77,82 @@ export const DealTicketFeeDetail = ({ }; export interface DealTicketFeeDetailsProps { + assetSymbol: string; + order: OrderSubmissionBody['orderSubmission']; + market: Market; + notionalSize: string | null; +} + +export const DealTicketFeeDetails = ({ + assetSymbol, + order, + market, + notionalSize, +}: DealTicketFeeDetailsProps) => { + const feeEstimate = useEstimateFees(order); + const { settlementAsset: asset } = + market.tradableInstrument.instrument.product; + const { decimals: assetDecimals, quantum } = asset; + const marketDecimals = market.decimalPlaces; + const quoteName = market.tradableInstrument.instrument.product.quoteName; + + return ( + <> + + + + {t( + `An estimate of the most you would be expected to pay in fees, in the market's settlement asset ${assetSymbol}.` + )} + + + + } + symbol={assetSymbol} + /> + + ); +}; + +export interface DealTicketMarginDetailsProps { generalAccountBalance?: string; marginAccountBalance?: string; market: Market; onMarketClick?: (marketId: string, metaKey?: boolean) => void; assetSymbol: string; - notionalSize: string | null; - feeEstimate: EstimateFeesQuery['estimateFees'] | undefined; positionEstimate: EstimatePositionQuery['estimatePosition']; } -export const DealTicketFeeDetails = ({ +export const DealTicketMarginDetails = ({ marginAccountBalance, generalAccountBalance, assetSymbol, - feeEstimate, market, onMarketClick, - notionalSize, positionEstimate, -}: DealTicketFeeDetailsProps) => { +}: DealTicketMarginDetailsProps) => { const [breakdownDialog, setBreakdownDialog] = useState(false); const { pubKey: partyId } = useVegaWallet(); const { data: currentMargins } = useDataProvider({ @@ -110,7 +167,6 @@ export const DealTicketFeeDetails = ({ const { settlementAsset: asset } = market.tradableInstrument.instrument.product; const { decimals: assetDecimals, quantum } = asset; - const marketDecimals = market.decimalPlaces; let marginRequiredBestCase: string | undefined = undefined; let marginRequiredWorstCase: string | undefined = undefined; if (marginEstimate) { @@ -251,41 +307,7 @@ export const DealTicketFeeDetails = ({ const quoteName = market.tradableInstrument.instrument.product.quoteName; return ( -
- - - - {t( - `An estimate of the most you would be expected to pay in fees, in the market's settlement asset ${assetSymbol}.` - )} - - - - } - symbol={assetSymbol} - /> + <> )} -
+ ); }; diff --git a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-limit-amount.tsx b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-limit-amount.tsx index 0db3f545a..aca0d3bf7 100644 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-limit-amount.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-limit-amount.tsx @@ -44,12 +44,12 @@ export const DealTicketLimitAmount = ({ return (
-
+
-
-
 
-
@
-
+
@
{ const quoteName = market.tradableInstrument.instrument.product.quoteName; const sizeStep = toDecimal(market?.positionDecimalPlaces); - const price = getMarketPrice(marketData); + const price = marketPrice; const priceFormatted = price ? addDecimalsFormatNumber(price, market.decimalPlaces) : undefined; + const inAuction = isMarketInAuction(marketData.marketTradingMode); + return (
-
-
{t('Size')}
-
-
- {isMarketInAuction(marketData.marketTradingMode) && ( - -
{t(`Indicative price`)}
-
- )} -
-
-
+
+
{t('Size')}
-
@
-
- {priceFormatted && quoteName ? ( - <> - ~{priceFormatted} {quoteName} - - ) : ( - '-' +
@
+
+ {inAuction && ( + +
{t(`Indicative price`)}
+
)} +
+ {priceFormatted && quoteName ? ( + <> + ~{priceFormatted} {quoteName} + + ) : ( + '-' + )} +
{sizeError && ( 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 new file mode 100644 index 000000000..3ddf3aa3b --- /dev/null +++ b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-stop-order.spec.tsx @@ -0,0 +1,314 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { VegaWalletContext } from '@vegaprotocol/wallet'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +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 { FeatureFlags } from '@vegaprotocol/environment'; + +jest.mock('zustand'); +jest.mock('./deal-ticket-fee-details', () => ({ + DealTicketFeeDetails: () =>
, +})); + +jest.mock('@vegaprotocol/environment', () => { + const actual = jest.requireActual('@vegaprotocol/environment'); + return { + ...actual, + FLAGS: { + ...actual.FLAGS, + STOP_ORDERS: true, + } as FeatureFlags, + }; +}); + +const marketPrice = '200'; +const market = generateMarket(); +const submit = jest.fn(); + +function generateJsx(pubKey: string | null = 'pubKey', isReadOnly = false) { + return ( + + + + + + ); +} + +const submitButton = 'place-order'; +const sizeInput = 'order-size'; +const priceInput = 'order-price'; +const triggerPriceInput = 'triggerPrice'; +const triggerTrailingPercentOffsetInput = 'triggerTrailingPercentOffset'; + +const orderTypeTrigger = 'order-type-Stop'; +const orderTypeLimit = 'order-type-StopLimit'; +const orderTypeMarket = 'order-type-StopMarket'; + +const orderSideBuy = 'order-side-SIDE_BUY'; +const orderSideSell = 'order-side-SIDE_SELL'; + +const triggerDirectionRisesAbove = 'triggerDirection-risesAbove'; +// const triggerDirectionFallsBelow = 'triggerDirection-fallsBelow'; + +const expiryStrategySubmit = 'expiryStrategy-submit'; +const expiryStrategyCancel = 'expiryStrategy-cancel'; + +const triggerTypePrice = 'triggerType-price'; +const triggerTypeTrailingPercentOffset = 'triggerType-trailingPercentOffset'; + +const expire = 'expire'; +const datePicker = 'date-picker-field'; +const timeInForce = 'order-tif'; + +const sizeErrorMessage = 'stop-order-error-message-size'; +const priceErrorMessage = 'stop-order-error-message-price'; +const triggerPriceErrorMessage = 'stop-order-error-message-trigger-price'; +const triggerTrailingPercentOffsetErrorMessage = + 'stop-order-error-message-trigger-trailing-percent-offset'; + +describe('StopOrder', () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + jest.clearAllMocks(); + }); + + it('should display ticket defaults', async () => { + render(generateJsx()); + // place order button should always be enabled + expect(screen.getByTestId(submitButton)).toBeEnabled(); + + // Assert defaults are used + await userEvent.click(screen.getByTestId(orderTypeTrigger)); + expect(screen.getByTestId(orderTypeLimit).dataset.state).toEqual('checked'); + await userEvent.click(screen.getByTestId(orderTypeLimit)); + expect(screen.getByTestId(orderSideBuy).dataset.state).toEqual('checked'); + expect(screen.getByTestId(sizeInput)).toHaveDisplayValue('0'); + expect(screen.getByTestId(timeInForce)).toHaveValue( + Schema.OrderTimeInForce.TIME_IN_FORCE_FOK + ); + expect( + screen.getByTestId(triggerDirectionRisesAbove).dataset.state + ).toEqual('checked'); + expect(screen.getByTestId(triggerTypePrice).dataset.state).toEqual( + 'checked' + ); + expect(screen.getByTestId(expire).dataset.state).toEqual('unchecked'); + await userEvent.click(screen.getByTestId(expire)); + await waitFor(() => { + expect(screen.getByTestId(expiryStrategySubmit).dataset.state).toEqual( + 'checked' + ); + }); + }); + + it('should display trigger price as price for market type order', async () => { + render(generateJsx()); + await userEvent.click(screen.getByTestId(orderTypeTrigger)); + await userEvent.click(screen.getByTestId(orderTypeMarket)); + await userEvent.type(screen.getByTestId(triggerPriceInput), '10'); + expect(screen.getByTestId('price')).toHaveTextContent('10.0'); + }); + + it('should use local storage state for initial values', async () => { + const values: Partial = { + type: Schema.OrderType.TYPE_LIMIT, + side: Schema.Side.SIDE_SELL, + size: '0.1', + price: '300.22', + timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC, + expire: true, + expiryStrategy: Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_CANCELS, + expiresAt: '2023-07-27T16:43:27.000', + }; + + useStopOrderFormValues.setState({ + formValues: { + [market.id]: values, + }, + }); + + render(generateJsx()); + // Assert correct defaults are used from store + await userEvent.click(screen.getByTestId(orderTypeTrigger)); + expect(screen.queryByTestId(orderTypeLimit)).toBeChecked(); + expect(screen.getByTestId(orderSideSell).dataset.state).toEqual('checked'); + expect(screen.getByTestId(sizeInput)).toHaveDisplayValue( + values.size as string + ); + expect(screen.getByTestId('order-tif')).toHaveValue(values.timeInForce); + expect(screen.getByTestId(priceInput)).toHaveDisplayValue( + values.price as string + ); + expect(screen.getByTestId(expire).dataset.state).toEqual('checked'); + expect(screen.getByTestId(expiryStrategyCancel).dataset.state).toEqual( + 'checked' + ); + expect(screen.getByTestId(datePicker)).toHaveDisplayValue( + values.expiresAt as string + ); + }); + + it('shows no wallet warning and do not submit if no wallet connected', async () => { + render(generateJsx(null)); + await userEvent.type(screen.getByTestId(sizeInput), '1'); + await userEvent.type(screen.getByTestId(priceInput), '1'); + await userEvent.type(screen.getByTestId(triggerPriceInput), '1'); + await userEvent.click(screen.getByTestId(submitButton)); + expect(submit).not.toBeCalled(); + expect( + screen.getByTestId('deal-ticket-connect-wallet') + ).toBeInTheDocument(); + }); + + it('calls submit if form is valid', async () => { + render(generateJsx()); + await userEvent.type(screen.getByTestId(sizeInput), '1'); + await userEvent.type(screen.getByTestId(priceInput), '1'); + await userEvent.type(screen.getByTestId(triggerPriceInput), '1'); + await userEvent.click(screen.getByTestId(submitButton)); + expect(submit).toBeCalled(); + }); + + it('validates size field', async () => { + render(generateJsx()); + + await userEvent.click(screen.getByTestId(submitButton)); + + // default value should be invalid + expect(screen.getByTestId(sizeErrorMessage)).toBeInTheDocument(); + // to small value should be invalid + await userEvent.type(screen.getByTestId(sizeInput), '0.01'); + expect(screen.getByTestId(sizeErrorMessage)).toBeInTheDocument(); + + // clear and fill using valid value + await userEvent.clear(screen.getByTestId(sizeInput)); + await userEvent.type(screen.getByTestId(sizeInput), '0.1'); + expect(screen.queryByTestId(sizeErrorMessage)).toBeNull(); + }); + + it('validates price field', async () => { + render(generateJsx()); + + await userEvent.click(screen.getByTestId(submitButton)); + // price error message should not show if size has error + expect(screen.queryByTestId(priceErrorMessage)).toBeNull(); + await userEvent.type(screen.getByTestId(sizeInput), '0.1'); + expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument(); + await userEvent.type(screen.getByTestId(priceInput), '0.001'); + expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument(); + + // switch to market order type error should disappear + await userEvent.click(screen.getByTestId(orderTypeTrigger)); + await userEvent.click(screen.getByTestId(orderTypeMarket)); + expect(screen.queryByTestId(priceErrorMessage)).toBeNull(); + + // switch back to limit type + await userEvent.click(screen.getByTestId(orderTypeTrigger)); + await userEvent.click(screen.getByTestId(orderTypeLimit)); + expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument(); + + // to small value should be invalid + await userEvent.type(screen.getByTestId(priceInput), '0.001'); + expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument(); + + // clear and fill using valid value + await userEvent.clear(screen.getByTestId(priceInput)); + await userEvent.type(screen.getByTestId(priceInput), '0.01'); + expect(screen.queryByTestId(priceErrorMessage)).toBeNull(); + }); + + it('validates trigger price field', async () => { + render(generateJsx()); + + await userEvent.click(screen.getByTestId(submitButton)); + expect(screen.getByTestId(triggerPriceErrorMessage)).toBeInTheDocument(); + + // switch to trailing percentage offset trigger type + await userEvent.click(screen.getByTestId(triggerTypeTrailingPercentOffset)); + expect(screen.queryByTestId(triggerPriceErrorMessage)).toBeNull(); + + // switch back to price trigger type + await userEvent.click(screen.getByTestId(triggerTypePrice)); + expect(screen.getByTestId(triggerPriceErrorMessage)).toBeInTheDocument(); + + // to small value should be invalid + await userEvent.type(screen.getByTestId(triggerPriceInput), '0.001'); + expect(screen.getByTestId(triggerPriceErrorMessage)).toBeInTheDocument(); + + // clear and fill using valid value + await userEvent.clear(screen.getByTestId(triggerPriceInput)); + await userEvent.type(screen.getByTestId(triggerPriceInput), '0.01'); + expect(screen.queryByTestId(triggerPriceErrorMessage)).toBeNull(); + }); + + it('validates trigger trailing percentage offset field', async () => { + render(generateJsx()); + + // should not show error with default form values + await userEvent.click(screen.getByTestId(submitButton)); + expect( + screen.queryByTestId(triggerTrailingPercentOffsetErrorMessage) + ).toBeNull(); + + // switch to trailing percentage offset trigger type + await userEvent.click(screen.getByTestId(triggerTypeTrailingPercentOffset)); + expect( + screen.getByTestId(triggerTrailingPercentOffsetErrorMessage) + ).toBeInTheDocument(); + + // to small value should be invalid + await userEvent.type( + screen.getByTestId(triggerTrailingPercentOffsetInput), + '0.09' + ); + expect( + screen.getByTestId(triggerTrailingPercentOffsetErrorMessage) + ).toBeInTheDocument(); + + // clear and fill using valid value + await userEvent.clear( + screen.getByTestId(triggerTrailingPercentOffsetInput) + ); + await userEvent.type( + screen.getByTestId(triggerTrailingPercentOffsetInput), + '0.1' + ); + expect( + screen.queryByTestId(triggerTrailingPercentOffsetErrorMessage) + ).toBeNull(); + + // to big value should be invalid + await userEvent.clear( + screen.getByTestId(triggerTrailingPercentOffsetInput) + ); + await userEvent.type( + screen.getByTestId(triggerTrailingPercentOffsetInput), + '99.91' + ); + expect( + screen.getByTestId(triggerTrailingPercentOffsetErrorMessage) + ).toBeInTheDocument(); + + // clear and fill using valid value + await userEvent.clear( + screen.getByTestId(triggerTrailingPercentOffsetInput) + ); + await userEvent.type( + screen.getByTestId(triggerTrailingPercentOffsetInput), + '99.9' + ); + expect( + screen.queryByTestId(triggerTrailingPercentOffsetErrorMessage) + ).toBeNull(); + }); +}); 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 new file mode 100644 index 000000000..826fb1034 --- /dev/null +++ b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-stop-order.tsx @@ -0,0 +1,590 @@ +import type { FormEventHandler } from 'react'; +import { useRef, useCallback, useEffect } from 'react'; +import { useVegaWallet } from '@vegaprotocol/wallet'; +import type { StopOrdersSubmission } from '@vegaprotocol/wallet'; +import { + formatNumber, + removeDecimal, + toDecimal, + validateAmount, +} from '@vegaprotocol/utils'; +import { useForm, Controller } from 'react-hook-form'; +import * as Schema from '@vegaprotocol/types'; +import { + Radio, + RadioGroup, + Input, + Checkbox, + FormGroup, + InputError, + Select, + Tooltip, +} from '@vegaprotocol/ui-toolkit'; +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 { + NoWalletWarning, + REDUCE_ONLY_TOOLTIP, + useNotionalSize, +} from './deal-ticket'; +import { TypeToggle } from './type-selector'; +import { + useStopOrderFormValues, + type StopOrderFormValues, +} from '../../hooks/use-stop-order-form-values'; +import { + DealTicketType, + useDealTicketTypeStore, +} from '../../hooks/use-type-store'; +import { mapFormValuesToStopOrdersSubmission } from '../../utils/map-form-values-to-stop-order-submission'; +import { DealTicketButton } from './deal-ticket-button'; +import { DealTicketFeeDetails } from './deal-ticket-fee-details'; +import { validateExpiration } from '../../utils'; + +export interface StopOrderProps { + market: Market; + marketPrice?: string | null; + submit: (order: StopOrdersSubmission) => void; +} + +const defaultValues: Partial = { + type: Schema.OrderType.TYPE_LIMIT, + side: Schema.Side.SIDE_BUY, + timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_FOK, + triggerType: 'price', + triggerDirection: + Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_RISES_ABOVE, + expiryStrategy: Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT, + size: '0', +}; + +const stopSubmit: FormEventHandler = (e) => e.preventDefault(); + +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 storedFormValues = useStopOrderFormValues( + (state) => state.formValues[market.id] + ); + const { handleSubmit, setValue, watch, control, formState } = + useForm({ + defaultValues: { ...defaultValues, ...storedFormValues }, + }); + const { errors } = formState; + const lastSubmitTime = useRef(0); + const onSubmit = useCallback( + (data: StopOrderFormValues) => { + const now = new Date().getTime(); + if (lastSubmitTime.current && now - lastSubmitTime.current < 1000) { + return; + } + submit( + mapFormValuesToStopOrdersSubmission( + data, + market.id, + market.decimalPlaces, + market.positionDecimalPlaces + ) + ); + lastSubmitTime.current = now; + }, + [market.id, market.decimalPlaces, market.positionDecimalPlaces, submit] + ); + const side = watch('side'); + const expire = watch('expire'); + 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); + } + + const isPriceTrigger = triggerType === 'price'; + const size = removeDecimal(rawSize, market.positionDecimalPlaces); + const price = + marketPrice && + getDerivedPrice( + { + type, + price: rawPrice && removeDecimal(rawPrice, market.decimalPlaces), + }, + type === Schema.OrderType.TYPE_MARKET && isPriceTrigger && triggerPrice + ? removeDecimal(triggerPrice, market.decimalPlaces) + : marketPrice + ); + + const notionalSize = useNotionalSize( + price, + size, + market.decimalPlaces, + market.positionDecimalPlaces + ); + + useEffect(() => { + const subscription = watch((value, { name, type }) => { + updateStoredFormValues(market.id, value); + }); + return () => subscription.unsubscribe(); + }, [watch, market.id, updateStoredFormValues]); + + const { quoteName, settlementAsset: asset } = + market.tradableInstrument.instrument.product; + + const sizeStep = toDecimal(market?.positionDecimalPlaces); + const priceStep = toDecimal(market?.decimalPlaces); + const trailingPercentOffsetStep = '0.1'; + + const priceFormatted = + isPriceTrigger && triggerPrice + ? formatNumber(triggerPrice, market.decimalPlaces) + : undefined; + + 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 + ); + }} + /> + ); + }} + /> + {errors.type && ( + + {errors.type.message} + + )} + + ( + + )} + /> + + { + const { onChange, value } = field; + return ( + + + + + ); + }} + /> + {isPriceTrigger && ( +
+ { + const { value, ...props } = field; + return ( +
+ +
+ ); + }} + /> + {errors.triggerPrice && ( + + {errors.triggerPrice.message} + + )} +
+ )} + {!isPriceTrigger && ( +
+ { + const { value, ...props } = field; + return ( +
+ +
+ ); + }} + /> + {errors.triggerTrailingPercentOffset && ( + + {errors.triggerTrailingPercentOffset.message} + + )} +
+ )} + { + const { onChange, value } = field; + return ( + + + + + ); + }} + /> +
+
+
+ + { + const { value, ...props } = field; + return ( + e.currentTarget.blur()} + data-testid="order-size" + value={value || ''} + {...props} + /> + ); + }} + /> + +
@
+
+ {type === Schema.OrderType.TYPE_LIMIT ? ( + + { + const { value, ...props } = field; + return ( + e.currentTarget.blur()} + value={value || ''} + {...props} + /> + ); + }} + /> + + ) : ( +
+ {priceFormatted && quoteName + ? `~${priceFormatted} ${quoteName}` + : '-'} +
+ )} +
+
+ {errors.size && ( + + {errors.size.message} + + )} + + {!errors.size && + errors.price && + type === Schema.OrderType.TYPE_LIMIT && ( + + {errors.price.message} + + )} +
+
+ + ( + + )} + /> + + {errors.timeInForce && ( + + {errors.timeInForce.message} + + )} +
+
+ {t(REDUCE_ONLY_TOOLTIP)}}> + {t('Reduce only')} + + } + /> +
+
+ { + const { onChange: onCheckedChange, value } = field; + return ( + + ); + }} + /> +
+ {expire && ( + <> + + { + return ( + + + + + ); + }} + /> + +
+ { + const { value, onChange: onSelect } = field; + return ( + + ); + }} + /> +
+ + )} + + + + + ); +}; 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 b87351a6e..9c97cd8ca 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 @@ -22,8 +22,12 @@ import { OrdersDocument } from '@vegaprotocol/orders'; jest.mock('zustand'); jest.mock('./deal-ticket-fee-details', () => ({ DealTicketFeeDetails: () =>
, + DealTicketMarginDetails: () => ( +
+ ), })); +const marketPrice = '200'; const pubKey = 'pubKey'; const market = generateMarket(); const marketData = generateMarketData(); @@ -36,6 +40,7 @@ function generateJsx(mocks: MockedResponse[] = []) { @@ -114,30 +119,22 @@ describe('DealTicket', () => { }); it('should display ticket defaults', () => { - const { container } = render(generateJsx()); + render(generateJsx()); // place order button should always be enabled expect(screen.getByTestId('place-order')).toBeEnabled(); // Assert defaults are used - expect( - screen.getByTestId(`order-type-${Schema.OrderType.TYPE_MARKET}`) - ).toBeInTheDocument(); - expect( - screen.getByTestId(`order-type-${Schema.OrderType.TYPE_LIMIT}`) - ).toBeInTheDocument(); + expect(screen.getByTestId('order-type-Market')).toBeInTheDocument(); + expect(screen.getByTestId('order-type-Limit')).toBeInTheDocument(); - const oderTypeLimitToggle = container.querySelector( - `[data-testid="order-type-${Schema.OrderType.TYPE_LIMIT}"] input[type="radio"]` + expect(screen.getByTestId('order-type-Limit').dataset.state).toEqual( + 'checked' ); - expect(oderTypeLimitToggle).toBeChecked(); - expect( - screen.queryByTestId('order-side-SIDE_BUY')?.querySelector('input') - ).toBeChecked(); - expect( - screen.queryByTestId('order-side-SIDE_SELL')?.querySelector('input') - ).not.toBeChecked(); + expect(screen.getByTestId('order-side-SIDE_BUY').dataset.state).toEqual( + 'checked' + ); expect(screen.getByTestId('order-size')).toHaveDisplayValue('0'); expect(screen.getByTestId('order-tif')).toHaveValue( Schema.OrderTimeInForce.TIME_IN_FORCE_GTC @@ -147,12 +144,12 @@ describe('DealTicket', () => { it('should display last price for market type order', () => { render(generateJsx()); act(() => { - screen.getByTestId(`order-type-${Schema.OrderType.TYPE_MARKET}`).click(); + screen.getByTestId('order-type-Market').click(); }); // Assert last price is shown expect(screen.getByTestId('last-price')).toHaveTextContent( // eslint-disable-next-line - `~${addDecimal(marketData.markPrice, market.decimalPlaces)} ${ + `~${addDecimal(marketPrice, market.decimalPlaces)} ${ market.tradableInstrument.instrument.product.quoteName }` ); @@ -178,17 +175,12 @@ describe('DealTicket', () => { 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-type-Limit').dataset.state).toEqual( + 'checked' + ); + expect(screen.getByTestId('order-side-SIDE_SELL').dataset.state).toEqual( + 'checked' + ); expect(screen.getByTestId('order-size')).toHaveDisplayValue( expectedOrder.size ); @@ -221,17 +213,12 @@ describe('DealTicket', () => { 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-type-Limit').dataset.state).toEqual( + 'checked' + ); + expect(screen.getByTestId('order-side-SIDE_SELL').dataset.state).toEqual( + 'checked' + ); expect(screen.getByTestId('order-size')).toHaveDisplayValue( expectedOrder.size ); @@ -269,17 +256,12 @@ describe('DealTicket', () => { 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-type-Limit').dataset.state).toEqual( + 'checked' + ); + expect(screen.getByTestId('order-side-SIDE_SELL').dataset.state).toEqual( + 'checked' + ); expect(screen.getByTestId('order-size')).toHaveDisplayValue( expectedOrder.size ); @@ -322,17 +304,12 @@ describe('DealTicket', () => { 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-type-Limit').dataset.state).toEqual( + 'checked' + ); + expect(screen.getByTestId('order-side-SIDE_SELL').dataset.state).toEqual( + 'checked' + ); expect(screen.getByTestId('order-size')).toHaveDisplayValue( expectedOrder.size ); @@ -371,17 +348,12 @@ describe('DealTicket', () => { 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-type-Limit').dataset.state).toEqual( + 'checked' + ); + expect(screen.getByTestId('order-side-SIDE_SELL').dataset.state).toEqual( + 'checked' + ); expect(screen.getByTestId('order-size')).toHaveDisplayValue( expectedOrder.size ); @@ -402,7 +374,7 @@ describe('DealTicket', () => { render(generateJsx()); act(() => { - screen.getByTestId(`order-type-${Schema.OrderType.TYPE_MARKET}`).click(); + screen.getByTestId('order-type-Market').click(); }); // Only FOK and IOC should be present for type market order @@ -427,7 +399,7 @@ describe('DealTicket', () => { ); // Switch to type limit order -> all TIF options should be shown - await userEvent.click(screen.getByTestId('order-type-TYPE_LIMIT')); + await userEvent.click(screen.getByTestId('order-type-Limit')); expect(screen.getByTestId('order-tif').children).toHaveLength( Object.keys(Schema.OrderTimeInForce).length ); @@ -447,7 +419,7 @@ describe('DealTicket', () => { ); // Switch back to type market order -> FOK should be preserved from previous selection - await userEvent.click(screen.getByTestId('order-type-TYPE_MARKET')); + await userEvent.click(screen.getByTestId('order-type-Market')); expect(screen.getByTestId('order-tif')).toHaveValue( Schema.OrderTimeInForce.TIME_IN_FORCE_FOK ); @@ -462,7 +434,7 @@ describe('DealTicket', () => { ); // Switch back type limit order -> GTT should be preserved - await userEvent.click(screen.getByTestId('order-type-TYPE_LIMIT')); + await userEvent.click(screen.getByTestId('order-type-Limit')); expect(screen.getByTestId('order-tif')).toHaveValue( Schema.OrderTimeInForce.TIME_IN_FORCE_GTT ); @@ -477,7 +449,7 @@ describe('DealTicket', () => { ); // Switch to type market order -> IOC should be preserved - await userEvent.click(screen.getByTestId('order-type-TYPE_MARKET')); + await userEvent.click(screen.getByTestId('order-type-Market')); expect(screen.getByTestId('order-tif')).toHaveValue( Schema.OrderTimeInForce.TIME_IN_FORCE_IOC ); @@ -487,9 +459,9 @@ describe('DealTicket', () => { render(generateJsx()); // BUY is selected by default - expect( - screen.getByTestId('order-side-SIDE_BUY')?.querySelector('input') - ).toBeChecked(); + expect(screen.getByTestId('order-side-SIDE_BUY').dataset.state).toEqual( + 'checked' + ); await userEvent.type(screen.getByTestId('order-size'), '200'); @@ -504,7 +476,7 @@ describe('DealTicket', () => { ); // Switch to limit order - await userEvent.click(screen.getByTestId('order-type-TYPE_LIMIT')); + await userEvent.click(screen.getByTestId('order-type-Limit')); // Check all TIF options shown expect(screen.getByTestId('order-tif').children).toHaveLength( 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 9c885b77a..c658e0b72 100644 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/deal-ticket.tsx @@ -4,7 +4,10 @@ import { memo, useCallback, useEffect, useState, useRef, useMemo } from 'react'; import { Controller } from 'react-hook-form'; import { DealTicketAmount } from './deal-ticket-amount'; import { DealTicketButton } from './deal-ticket-button'; -import { DealTicketFeeDetails } from './deal-ticket-fee-details'; +import { + DealTicketFeeDetails, + DealTicketMarginDetails, +} from './deal-ticket-fee-details'; import { ExpirySelector } from './expiry-selector'; import { SideSelector } from './side-selector'; import { TimeInForceSelector } from './time-in-force-selector'; @@ -30,8 +33,7 @@ import { } from '@vegaprotocol/positions'; import { toBigNum, removeDecimal } from '@vegaprotocol/utils'; import { activeOrdersProvider } from '@vegaprotocol/orders'; -import { useEstimateFees } from '../../hooks/use-estimate-fees'; -import { getDerivedPrice } from '../../utils/get-price'; +import { getDerivedPrice } from '@vegaprotocol/markets'; import type { OrderInfo } from '@vegaprotocol/types'; import { @@ -43,7 +45,11 @@ import { } from '../../utils'; import { ZeroBalanceError } from '../deal-ticket-validation/zero-balance-error'; import { SummaryValidationType } from '../../constants'; -import type { Market, MarketData } from '@vegaprotocol/markets'; +import type { + Market, + MarketData, + StaticMarketData, +} from '@vegaprotocol/markets'; import { MarginWarning } from '../deal-ticket-validation/margin-warning'; import { useMarketAccountBalance, @@ -53,26 +59,59 @@ import { import { OrderTimeInForce, OrderType } from '@vegaprotocol/types'; import { useOrderForm } from '../../hooks/use-order-form'; import { useDataProvider } from '@vegaprotocol/data-provider'; +import { + DealTicketType, + useDealTicketTypeStore, +} from '../../hooks/use-type-store'; +import { useStopOrderFormValues } from '../../hooks/use-stop-order-form-values'; import { DealTicketSizeIceberg } from './deal-ticket-size-iceberg'; +import noop from 'lodash/noop'; + +export const REDUCE_ONLY_TOOLTIP = + '"Reduce only" will ensure that this order will not increase the size of an open position. When the order is matched, it will only trade enough volume to bring your open volume towards 0 but never change the direction of your position. If applied to a limit order that is not instantly filled, the order will be stopped.'; export interface DealTicketProps { market: Market; - marketData: MarketData; + marketData: StaticMarketData; + marketPrice?: string | null; onMarketClick?: (marketId: string, metaKey?: boolean) => void; submit: (order: OrderSubmission) => void; onClickCollateral?: () => void; onDeposit: (assetId: string) => void; } +export const useNotionalSize = ( + 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]); + export const DealTicket = ({ market, onMarketClick, marketData, + marketPrice, submit, onClickCollateral, onDeposit, }: DealTicketProps) => { const { pubKey, isReadOnly } = useVegaWallet(); + const setDealTicketType = useDealTicketTypeStore((state) => state.set); + const updateStopOrderFormValues = useStopOrderFormValues( + (state) => state.update + ); // store last used tif for market so that when changing OrderType the previous TIF // selection for that type is used when switching back @@ -95,11 +134,15 @@ export const DealTicket = ({ const asset = market.tradableInstrument.instrument.product.settlementAsset; - const { accountBalance: marginAccountBalance } = useMarketAccountBalance( - market.id - ); + const { + accountBalance: marginAccountBalance, + loading: loadingMarginAccountBalance, + } = useMarketAccountBalance(market.id); - const { accountBalance: generalAccountBalance } = useAccountBalance(asset.id); + const { + accountBalance: generalAccountBalance, + loading: loadingGeneralAccountBalance, + } = useAccountBalance(asset.id); const balance = ( BigInt(marginAccountBalance) + BigInt(generalAccountBalance) @@ -116,30 +159,20 @@ export const DealTicket = ({ ); const price = useMemo(() => { - return normalizedOrder && getDerivedPrice(normalizedOrder, marketData); - }, [normalizedOrder, marketData]); + return ( + normalizedOrder && + marketPrice && + getDerivedPrice(normalizedOrder, marketPrice) + ); + }, [normalizedOrder, marketPrice]); - const notionalSize = useMemo(() => { - if (price && normalizedOrder?.size) { - return removeDecimal( - toBigNum( - normalizedOrder.size, - market.positionDecimalPlaces - ).multipliedBy(toBigNum(price, market.decimalPlaces)), - market.decimalPlaces - ); - } - return null; - }, [ + const notionalSize = useNotionalSize( price, normalizedOrder?.size, market.decimalPlaces, - market.positionDecimalPlaces, - ]); - - const feeEstimate = useEstimateFees( - normalizedOrder && { ...normalizedOrder, price } + market.positionDecimalPlaces ); + const { data: activeOrders } = useDataProvider({ dataProvider: activeOrdersProvider, variables: { partyId: pubKey || '', marketId: market.id }, @@ -197,7 +230,10 @@ export const DealTicket = ({ const hasNoBalance = !BigInt(generalAccountBalance) && !BigInt(marginAccountBalance); - if (hasNoBalance) { + if ( + hasNoBalance && + !(loadingMarginAccountBalance || loadingGeneralAccountBalance) + ) { setError('summary', { message: SummaryValidationType.NoCollateral, type: SummaryValidationType.NoCollateral, @@ -221,6 +257,8 @@ export const DealTicket = ({ marketTradingMode, generalAccountBalance, marginAccountBalance, + loadingMarginAccountBalance, + loadingGeneralAccountBalance, pubKey, setError, clearErrors, @@ -265,11 +303,13 @@ export const DealTicket = ({ ); // if an order doesn't exist one will be created by the store immediately - if (!order || !normalizedOrder) return null; + if (!order || !normalizedOrder) { + return null; + } return (
@@ -284,9 +324,29 @@ export const DealTicket = ({ }} render={() => ( { - if (type === OrderType.TYPE_NETWORK) return; + value={ + order.type === OrderType.TYPE_LIMIT + ? DealTicketType.Limit + : DealTicketType.Market + } + onValueChange={(dealTicketType) => { + setDealTicketType(market.id, dealTicketType); + if ( + dealTicketType !== DealTicketType.Limit && + dealTicketType !== DealTicketType.Market + ) { + updateStopOrderFormValues(market.id, { + type: + dealTicketType === DealTicketType.StopLimit + ? OrderType.TYPE_LIMIT + : OrderType.TYPE_MARKET, + }); + return; + } + const type = + dealTicketType === DealTicketType.Limit + ? OrderType.TYPE_LIMIT + : OrderType.TYPE_MARKET; update({ type, // when changing type also update the TIF to what was last used of new type @@ -333,7 +393,7 @@ export const DealTicket = ({ render={() => ( { + onValueChange={(side) => { update({ side }); }} /> @@ -344,6 +404,7 @@ export const DealTicket = ({ orderType={order.type} market={market} marketData={marketData} + marketPrice={marketPrice || undefined} sizeError={errors.size?.message} priceError={errors.price?.message} update={update} @@ -467,9 +528,7 @@ export const DealTicket = ({ ? t( '"Reduce only" can be used only with non-persistent orders, such as "Fill or Kill" or "Immediate or Cancel".' ) - : t( - '"Reduce only" will ensure that this order will not increase the size of an open position. When the order is matched, it will only trade enough volume to bring your open volume towards 0 but never change the direction of your position. If applied to a limit order that is not instantly filled, the order will be stopped.' - )} + : t(REDUCE_ONLY_TOOLTIP)} } > @@ -541,10 +600,16 @@ export const DealTicket = ({ /> + void; onDeposit: (assetId: string) => void; } + +export const NoWalletWarning = ({ + isReadOnly, + pubKey, + asset, +}: Pick) => { + const assetSymbol = asset.symbol; + const openVegaWalletDialog = useVegaWalletDialogStore( + (store) => store.openVegaWalletDialog + ); + if (isReadOnly) { + return ( +
+ + { + 'You need to connect your own wallet to start trading on this market' + } + +
+ ); + } + if (!pubKey) { + return ( +
+ + You need a{' '} + + Vega wallet + {' '} + with {assetSymbol} to start trading in this market. +

+ } + buttonProps={{ + text: t('Connect wallet'), + action: openVegaWalletDialog, + dataTestId: 'order-connect-wallet', + size: 'small', + }} + /> +
+ ); + } + return null; +}; + const SummaryMessage = memo( ({ errorMessage, @@ -583,46 +697,16 @@ const SummaryMessage = memo( }: SummaryMessageProps) => { // Specific error UI for if balance is so we can // render a deposit dialog - const assetSymbol = asset.symbol; - const openVegaWalletDialog = useVegaWalletDialogStore( - (store) => store.openVegaWalletDialog - ); - if (isReadOnly) { + if (isReadOnly || !pubKey) { return ( -
- - { - 'You need to connect your own wallet to start trading on this market' - } - -
- ); - } - if (!pubKey) { - return ( -
- - You need a{' '} - - Vega wallet - {' '} - with {assetSymbol} to start trading in this market. -

- } - buttonProps={{ - text: t('Connect wallet'), - action: openVegaWalletDialog, - dataTestId: 'order-connect-wallet', - size: 'small', - }} - /> -
+ ); } + if (errorMessage === SummaryValidationType.NoCollateral) { return (
diff --git a/libs/deal-ticket/src/components/deal-ticket/expiry-selector.tsx b/libs/deal-ticket/src/components/deal-ticket/expiry-selector.tsx index 9ba7a938d..811edc9b4 100644 --- a/libs/deal-ticket/src/components/deal-ticket/expiry-selector.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/expiry-selector.tsx @@ -1,6 +1,7 @@ import { FormGroup, Input, InputError } from '@vegaprotocol/ui-toolkit'; import { formatForInput } from '@vegaprotocol/utils'; import { t } from '@vegaprotocol/i18n'; +import { useRef } from 'react'; interface ExpirySelectorProps { value?: string; @@ -13,7 +14,8 @@ export const ExpirySelector = ({ onSelect, errorMessage, }: ExpirySelectorProps) => { - const date = value ? new Date(value) : new Date(); + const now = useRef(new Date()); + const date = value ? new Date(value) : now.current; const dateFormatted = formatForInput(date); const minDate = formatForInput(date); return ( diff --git a/libs/deal-ticket/src/components/deal-ticket/index.ts b/libs/deal-ticket/src/components/deal-ticket/index.ts index f2c27a654..8c65c8c77 100644 --- a/libs/deal-ticket/src/components/deal-ticket/index.ts +++ b/libs/deal-ticket/src/components/deal-ticket/index.ts @@ -3,6 +3,8 @@ export * from './deal-ticket-container'; export * from './deal-ticket-limit-amount'; export * from './deal-ticket-market-amount'; export * from './deal-ticket'; +export * from './deal-ticket-stop-order'; +export * from './deal-ticket-container'; export * from './expiry-selector'; export * from './side-selector'; export * from './time-in-force-selector'; diff --git a/libs/deal-ticket/src/components/deal-ticket/side-selector.tsx b/libs/deal-ticket/src/components/deal-ticket/side-selector.tsx index ba9e9efce..a7bfd6e43 100644 --- a/libs/deal-ticket/src/components/deal-ticket/side-selector.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/side-selector.tsx @@ -1,46 +1,41 @@ -import { FormGroup } from '@vegaprotocol/ui-toolkit'; -import { Toggle } from '@vegaprotocol/ui-toolkit'; import { t } from '@vegaprotocol/i18n'; import * as Schema from '@vegaprotocol/types'; +import * as RadioGroup from '@radix-ui/react-radio-group'; +import classNames from 'classnames'; interface SideSelectorProps { value: Schema.Side; - onSelect: (side: Schema.Side) => void; + onValueChange: (side: Schema.Side) => void; } -export const SideSelector = ({ value, onSelect }: SideSelectorProps) => { - const toggles = [ - { label: t('Long'), value: Schema.Side.SIDE_BUY }, - { label: t('Short'), value: Schema.Side.SIDE_SELL }, - ]; - - const toggleType = (e: Schema.Side) => { - switch (e) { - case Schema.Side.SIDE_BUY: - return 'buy'; - case Schema.Side.SIDE_SELL: - return 'sell'; - default: - return 'primary'; - } - }; +const toggles = [ + { label: t('Long'), value: Schema.Side.SIDE_BUY }, + { label: t('Short'), value: Schema.Side.SIDE_SELL }, +]; +export const SideSelector = (props: SideSelectorProps) => { return ( - - { - onSelect(e.target.value as Schema.Side); - }} - /> - + {toggles.map(({ label, value }) => ( + + + + ))} + ); }; 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 037835e15..b69e9a0c9 100644 --- a/libs/deal-ticket/src/components/deal-ticket/type-selector.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/type-selector.tsx @@ -1,32 +1,127 @@ import { - FormGroup, + Icon, InputError, SimpleGrid, Tooltip, + TradingDropdown, + TradingDropdownContent, + TradingDropdownItemIndicator, + TradingDropdownPortal, + TradingDropdownRadioGroup, + TradingDropdownRadioItem, + TradingDropdownTrigger, } from '@vegaprotocol/ui-toolkit'; import { t } from '@vegaprotocol/i18n'; -import * as Schema from '@vegaprotocol/types'; -import { Toggle } from '@vegaprotocol/ui-toolkit'; -import type { Market, MarketData } from '@vegaprotocol/markets'; +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 * as RadioGroup from '@radix-ui/react-radio-group'; +import classNames from 'classnames'; +import { FLAGS } from '@vegaprotocol/environment'; interface TypeSelectorProps { - value: Schema.OrderType; - onSelect: (type: Schema.OrderType) => void; + value: DealTicketType; + onValueChange: (type: DealTicketType) => void; market: Market; - marketData: MarketData; + marketData: StaticMarketData; errorMessage?: string; } const toggles = [ - { label: t('Limit'), value: Schema.OrderType.TYPE_LIMIT }, - { label: t('Market'), value: Schema.OrderType.TYPE_MARKET }, + { label: t('Limit'), value: DealTicketType.Limit }, + { label: t('Market'), value: DealTicketType.Market }, ]; +const options = [ + { label: t('Stop Limit'), value: DealTicketType.StopLimit }, + { label: t('Stop Market'), value: DealTicketType.StopMarket }, +]; + +export const TypeToggle = ({ + value, + onValueChange, +}: Pick) => { + const selectedOption = options.find((t) => t.value === value); + return ( + + {toggles.map(({ label, value: itemValue }) => ( + + + + ))} + {FLAGS.STOP_ORDERS && ( + + + + } + > + + + + onValueChange(value as DealTicketType) + } + value={value} + > + {options.map(({ label, value: itemValue }) => ( + + {t(label)} + + + ))} + + + + + )} + + ); +}; export const TypeSelector = ({ value, - onSelect, + onValueChange, market, marketData, errorMessage, @@ -74,19 +169,18 @@ export const TypeSelector = ({ }; return ( - - onSelect(e.target.value as Schema.OrderType)} + <> + { + onValueChange(value as DealTicketType); + }} + value={value} /> {errorMessage && ( {renderError(errorMessage as MarketModeValidationType)} )} - + ); }; diff --git a/libs/deal-ticket/src/hooks/index.ts b/libs/deal-ticket/src/hooks/index.ts index 776b08278..4994914ca 100644 --- a/libs/deal-ticket/src/hooks/index.ts +++ b/libs/deal-ticket/src/hooks/index.ts @@ -1,2 +1,4 @@ export * from './__generated__/EstimateOrder'; export * from './use-estimate-fees'; +export * from './use-type-store'; +export * from './use-stop-order-form-values'; 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 new file mode 100644 index 000000000..074815d66 --- /dev/null +++ b/libs/deal-ticket/src/hooks/use-stop-order-form-values.ts @@ -0,0 +1,62 @@ +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 new file mode 100644 index 000000000..fbc99880d --- /dev/null +++ b/libs/deal-ticket/src/hooks/use-type-store.ts @@ -0,0 +1,28 @@ +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/test-helpers.ts b/libs/deal-ticket/src/test-helpers.ts index 1081752f8..27e1b8baf 100644 --- a/libs/deal-ticket/src/test-helpers.ts +++ b/libs/deal-ticket/src/test-helpers.ts @@ -1,4 +1,4 @@ -import type { Market, MarketData } from '@vegaprotocol/markets'; +import type { Market, StaticMarketData } from '@vegaprotocol/markets'; import * as Schema from '@vegaprotocol/types'; import merge from 'lodash/merge'; import type { PartialDeep } from 'type-fest'; @@ -85,33 +85,15 @@ export function generateMarket(override?: PartialDeep): Market { } export function generateMarketData( - override?: PartialDeep -): MarketData { - const defaultMarketData: MarketData = { - __typename: 'MarketData', - market: { - id: 'market-id', - __typename: 'Market', - }, + override?: PartialDeep +): StaticMarketData { + const defaultMarketData: StaticMarketData = { auctionEnd: '2022-06-21T17:18:43.484055236Z', auctionStart: '2022-06-21T17:18:43.484055236Z', - bestBidPrice: '0', - bestBidVolume: '0', - bestOfferPrice: '0', - bestOfferVolume: '0', - bestStaticBidPrice: '0', - bestStaticBidVolume: '0', - bestStaticOfferPrice: '0', - bestStaticOfferVolume: '0', indicativePrice: '100', indicativeVolume: '10', marketState: Schema.MarketState.STATE_ACTIVE, marketTradingMode: Schema.MarketTradingMode.TRADING_MODE_CONTINUOUS, - marketValueProxy: '', - markPrice: '200', - midPrice: '0', - openInterest: '', - staticMidPrice: '0', suppliedStake: '1000', targetStake: '1000000', trigger: Schema.AuctionTrigger.AUCTION_TRIGGER_BATCH, diff --git a/libs/deal-ticket/src/utils/index.ts b/libs/deal-ticket/src/utils/index.ts index d121f44a9..a4d8c7ce4 100644 --- a/libs/deal-ticket/src/utils/index.ts +++ b/libs/deal-ticket/src/utils/index.ts @@ -1,5 +1,4 @@ export * from './get-default-order'; -export * from './is-market-in-auction'; export * from './validate-expiration'; export * from './validate-market-state'; export * from './validate-market-trading-mode'; 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-stop-order-submission.ts new file mode 100644 index 000000000..4a55bc26b --- /dev/null +++ b/libs/deal-ticket/src/utils/map-form-values-to-stop-order-submission.ts @@ -0,0 +1,71 @@ +import type { + StopOrderSetup, + StopOrdersSubmission, +} from '@vegaprotocol/wallet'; +import { normalizeOrderSubmission } from '@vegaprotocol/wallet'; +import type { StopOrderFormValues } from '../hooks/use-stop-order-form-values'; +import * as Schema from '@vegaprotocol/types'; +import { removeDecimal, toNanoSeconds } from '@vegaprotocol/utils'; + +export const mapFormValuesToStopOrdersSubmission = ( + data: StopOrderFormValues, + marketId: string, + decimalPlaces: number, + positionDecimalPlaces: number +): StopOrdersSubmission => { + const submission: StopOrdersSubmission = {}; + const stopOrderSetup: StopOrderSetup = { + orderSubmission: normalizeOrderSubmission( + { + marketId, + type: data.type, + side: data.side, + size: data.size, + timeInForce: data.timeInForce, + price: data.price, + reduceOnly: true, + }, + decimalPlaces, + positionDecimalPlaces + ), + }; + if (data.triggerType === 'price') { + stopOrderSetup.price = removeDecimal(data.triggerPrice, decimalPlaces); + } else if (data.triggerType === 'trailingPercentOffset') { + stopOrderSetup.trailingPercentOffset = ( + Number(data.triggerTrailingPercentOffset) / 100 + ).toFixed(3); + } + + if (data.expire) { + stopOrderSetup.expiresAt = data.expiresAt && toNanoSeconds(data.expiresAt); + if ( + data.expiryStrategy === + Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_CANCELS + ) { + stopOrderSetup.expiryStrategy = + Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_CANCELS; + } else if ( + data.expiryStrategy === + Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT + ) { + stopOrderSetup.expiryStrategy = + Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT; + } + } + + if ( + data.triggerDirection === + Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_RISES_ABOVE + ) { + submission.risesAbove = stopOrderSetup; + } + if ( + data.triggerDirection === + Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_FALLS_BELOW + ) { + submission.fallsBelow = stopOrderSetup; + } + + return submission; +}; diff --git a/libs/deal-ticket/src/utils/validate-time-in-force.ts b/libs/deal-ticket/src/utils/validate-time-in-force.ts index d54fe1161..38e6d5892 100644 --- a/libs/deal-ticket/src/utils/validate-time-in-force.ts +++ b/libs/deal-ticket/src/utils/validate-time-in-force.ts @@ -1,6 +1,6 @@ import * as Schema from '@vegaprotocol/types'; import { MarketModeValidationType } from '../constants'; -import { isMarketInAuction } from './is-market-in-auction'; +import { isMarketInAuction } from '@vegaprotocol/markets'; export const validateTimeInForce = ( marketTradingMode: Schema.MarketTradingMode, diff --git a/libs/deal-ticket/src/utils/validate-type.ts b/libs/deal-ticket/src/utils/validate-type.ts index 5725f93ab..71ce35bc1 100644 --- a/libs/deal-ticket/src/utils/validate-type.ts +++ b/libs/deal-ticket/src/utils/validate-type.ts @@ -1,6 +1,6 @@ import * as Schema from '@vegaprotocol/types'; import { MarketModeValidationType } from '../constants'; -import { isMarketInAuction } from './is-market-in-auction'; +import { isMarketInAuction } from '@vegaprotocol/markets'; export const validateType = ( marketTradingMode: Schema.MarketTradingMode, diff --git a/libs/deal-ticket/src/utils/get-price.ts b/libs/markets/src/lib/get-price.ts similarity index 83% rename from libs/deal-ticket/src/utils/get-price.ts rename to libs/markets/src/lib/get-price.ts index a67bf882e..763acbfcf 100644 --- a/libs/deal-ticket/src/utils/get-price.ts +++ b/libs/markets/src/lib/get-price.ts @@ -1,6 +1,6 @@ import * as Schema from '@vegaprotocol/types'; import { isMarketInAuction } from './is-market-in-auction'; -import type { MarketData } from '@vegaprotocol/markets'; +import type { MarketData } from './market-data-provider'; /** * Get the market price based on market mode (auction or not auction) @@ -33,19 +33,16 @@ export const getDerivedPrice = ( type: Schema.OrderType; price?: string | undefined; }, - marketData: MarketData + marketPrice: string ) => { // If order type is market we should use either the mark price // or the uncrossing price. If order type is limit use the price // the user has input // Use the market price if order is a market order - let price; - if (order.type === Schema.OrderType.TYPE_LIMIT && order.price) { - price = order.price; - } else { - price = getMarketPrice(marketData); - } - + const price = + order.type === Schema.OrderType.TYPE_LIMIT && order.price + ? order.price + : marketPrice; return price === '0' ? undefined : price; }; diff --git a/libs/markets/src/lib/index.ts b/libs/markets/src/lib/index.ts index 3c89ed770..236d75093 100644 --- a/libs/markets/src/lib/index.ts +++ b/libs/markets/src/lib/index.ts @@ -6,6 +6,8 @@ export * from './hooks'; export * from './market-utils'; export { marketCandlesProvider } from './market-candles-provider'; export type { Candle } from './market-candles-provider'; +export * from './get-price'; +export * from './is-market-in-auction'; export * from './market-data-provider'; export * from './markets-candles-provider'; export * from './markets-data-provider'; diff --git a/libs/deal-ticket/src/utils/is-market-in-auction.ts b/libs/markets/src/lib/is-market-in-auction.ts similarity index 100% rename from libs/deal-ticket/src/utils/is-market-in-auction.ts rename to libs/markets/src/lib/is-market-in-auction.ts diff --git a/libs/markets/src/lib/market-data-provider.ts b/libs/markets/src/lib/market-data-provider.ts index 2ce13a43a..41d5a22b0 100644 --- a/libs/markets/src/lib/market-data-provider.ts +++ b/libs/markets/src/lib/market-data-provider.ts @@ -15,6 +15,7 @@ import type { MarketDataUpdateFieldsFragment, MarketDataQueryVariables, } from './__generated__/market-data'; +import { getMarketPrice } from './get-price'; export type MarketData = MarketDataFieldsFragment; @@ -58,6 +59,21 @@ export const markPriceProvider = makeDerivedDataProvider< MarketDataQueryVariables >([marketDataProvider], ([marketData]) => (marketData as MarketData).markPrice); +export const marketPriceProvider = makeDerivedDataProvider< + string | undefined, + never, + MarketDataQueryVariables +>([marketDataProvider], ([marketData]) => + getMarketPrice(marketData as MarketData) +); + +export const useMarketPrice = (marketId?: string, skip?: boolean) => + useDataProvider({ + dataProvider: marketPriceProvider, + variables: { marketId: marketId || '' }, + skip: skip || !marketId, + }); + export type StaticMarketData = Pick< MarketData, | 'marketTradingMode' diff --git a/libs/orders/src/lib/components/index.ts b/libs/orders/src/lib/components/index.ts index 031574634..5bfd13d7e 100644 --- a/libs/orders/src/lib/components/index.ts +++ b/libs/orders/src/lib/components/index.ts @@ -1,4 +1,5 @@ export * from './order-data-provider'; export * from './order-list'; export * from './order-list-manager'; +export * from './stop-orders-manager'; export * from './mocks/generate-orders'; diff --git a/libs/orders/src/lib/components/mocks/generate-stop-orders.ts b/libs/orders/src/lib/components/mocks/generate-stop-orders.ts new file mode 100644 index 000000000..e6e7bf4d3 --- /dev/null +++ b/libs/orders/src/lib/components/mocks/generate-stop-orders.ts @@ -0,0 +1,113 @@ +import merge from 'lodash/merge'; +import * as Schema from '@vegaprotocol/types'; +import type { StopOrder } from '../order-data-provider/stop-orders-data-provider'; +import type { PartialDeep } from 'type-fest'; + +export const generateStopOrder = ( + partialStopOrder?: PartialDeep +) => { + const stopOrder: StopOrder = { + __typename: 'StopOrder', + id: 'stop-order-id', + marketId: 'market-id', + partyId: 'party-id', + triggerDirection: + Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_RISES_ABOVE, + trigger: { + trailingPercentOffset: '5', + }, + ocoLinkId: undefined, + market: { + __typename: 'Market', + id: 'market-id', + decimalPlaces: 1, + fees: { + __typename: 'Fees', + factors: { + __typename: 'FeeFactors', + infrastructureFee: '0.1', + liquidityFee: '0.1', + makerFee: '0.1', + }, + }, + marketTimestamps: { + __typename: 'MarketTimestamps', + close: '', + open: '', + }, + positionDecimalPlaces: 2, + state: Schema.MarketState.STATE_ACTIVE, + tradableInstrument: { + __typename: 'TradableInstrument', + instrument: { + __typename: 'Instrument', + code: 'XYZ', + id: 'XYZ', + metadata: { + __typename: 'InstrumentMetadata', + tags: ['xyz asset'], + }, + name: 'XYZ intrument', + product: { + __typename: 'Future', + quoteName: '', + settlementAsset: { + __typename: 'Asset', + id: 'asset-id', + decimals: 1, + symbol: 'XYZ', + name: 'XYZ', + quantum: '1', + }, + dataSourceSpecForTradingTermination: { + __typename: 'DataSourceSpec', + id: 'oracleId', + data: { + __typename: 'DataSourceDefinition', + sourceType: { + __typename: 'DataSourceDefinitionExternal', + sourceType: { + __typename: 'DataSourceSpecConfiguration', + }, + }, + }, + }, + dataSourceSpecForSettlementData: { + __typename: 'DataSourceSpec', + id: 'oracleId', + data: { + __typename: 'DataSourceDefinition', + sourceType: { + __typename: 'DataSourceDefinitionExternal', + sourceType: { + __typename: 'DataSourceSpecConfiguration', + }, + }, + }, + }, + dataSourceSpecBinding: { + __typename: 'DataSourceSpecToFutureBinding', + tradingTerminationProperty: 'trading-termination-property', + settlementDataProperty: 'settlement-data-property', + }, + }, + }, + }, + tradingMode: Schema.MarketTradingMode.TRADING_MODE_CONTINUOUS, + }, + submission: { + marketId: 'market-id', + size: '10', + type: Schema.OrderType.TYPE_MARKET, + side: Schema.Side.SIDE_BUY, + price: '', + timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC, + expiresAt: null, + }, + status: Schema.StopOrderStatus.STATUS_PENDING, + createdAt: new Date().toISOString(), + updatedAt: null, + expiresAt: null, + }; + return merge(stopOrder, partialStopOrder); +}; diff --git a/libs/orders/src/lib/components/order-data-provider/Orders.graphql b/libs/orders/src/lib/components/order-data-provider/Orders.graphql index 89d5611f1..212083402 100644 --- a/libs/orders/src/lib/components/order-data-provider/Orders.graphql +++ b/libs/orders/src/lib/components/order-data-provider/Orders.graphql @@ -99,3 +99,54 @@ subscription OrdersUpdate($partyId: ID!, $marketIds: [ID!]) { ...OrderUpdateFields } } + +fragment OrderSubmissionFields on OrderSubmission { + marketId + price + size + side + timeInForce + expiresAt + type + reference + peggedOrder { + reference + offset + } + postOnly + reduceOnly +} + +fragment StopOrderFields on StopOrder { + id + ocoLinkId + expiresAt + expiryStrategy + triggerDirection + status + createdAt + updatedAt + partyId + marketId + trigger { + ... on StopOrderPrice { + price + } + ... on StopOrderTrailingPercentOffset { + trailingPercentOffset + } + } + submission { + ...OrderSubmissionFields + } +} + +query StopOrders($partyId: ID!) { + stopOrders(filter: { parties: [$partyId] }) { + edges { + node { + ...StopOrderFields + } + } + } +} diff --git a/libs/orders/src/lib/components/order-data-provider/__generated__/Orders.ts b/libs/orders/src/lib/components/order-data-provider/__generated__/Orders.ts index 4c3071990..e7ed1ba1e 100644 --- a/libs/orders/src/lib/components/order-data-provider/__generated__/Orders.ts +++ b/libs/orders/src/lib/components/order-data-provider/__generated__/Orders.ts @@ -32,6 +32,17 @@ export type OrdersUpdateSubscriptionVariables = Types.Exact<{ export type OrdersUpdateSubscription = { __typename?: 'Subscription', orders?: Array<{ __typename?: 'OrderUpdate', id: string, marketId: string, type?: Types.OrderType | null, side: Types.Side, size: string, status: Types.OrderStatus, rejectionReason?: Types.OrderRejectionReason | null, price: string, timeInForce: Types.OrderTimeInForce, remaining: string, expiresAt?: any | null, createdAt: any, updatedAt?: any | null, liquidityProvisionId?: string | null, peggedOrder?: { __typename: 'PeggedOrder', reference: Types.PeggedReference, offset: string } | null, icebergOrder?: { __typename: 'IcebergOrder', peakSize: string, minimumVisibleSize: string, reservedRemaining: string } | null }> | null }; +export type OrderSubmissionFieldsFragment = { __typename?: 'OrderSubmission', marketId: string, price: string, size: string, side: Types.Side, timeInForce: Types.OrderTimeInForce, expiresAt: any, type: Types.OrderType, reference?: string | null, postOnly?: boolean | null, reduceOnly?: boolean | null, peggedOrder?: { __typename?: 'PeggedOrder', reference: Types.PeggedReference, offset: string } | null}; + +export type StopOrderFieldsFragment = { __typename?: 'StopOrder', id: string, ocoLinkId?: string | null, expiresAt?: any | null, expiryStrategy?: Types.StopOrderExpiryStrategy | null, triggerDirection: Types.StopOrderTriggerDirection, status: Types.StopOrderStatus, createdAt: any, updatedAt?: any | null, partyId: string, marketId: string, trigger?: { __typename?: 'StopOrderPrice', price: string } | { __typename?: 'StopOrderTrailingPercentOffset', trailingPercentOffset: string } | null, submission: { __typename?: 'OrderSubmission', marketId: string, price: string, size: string, side: Types.Side, timeInForce: Types.OrderTimeInForce, expiresAt: any, type: Types.OrderType, reference?: string | null, postOnly?: boolean | null, reduceOnly?: boolean | null, peggedOrder?: { __typename?: 'PeggedOrder', reference: Types.PeggedReference, offset: string } | null } }; + +export type StopOrdersQueryVariables = Types.Exact<{ + partyId: Types.Scalars['ID']; +}>; + + +export type StopOrdersQuery = { __typename?: 'Query', stopOrders?: { __typename?: 'StopOrderConnection', edges?: Array<{ __typename?: 'StopOrderEdge', node?: { __typename?: 'StopOrder', id: string, ocoLinkId?: string | null, expiresAt?: any | null, expiryStrategy?: Types.StopOrderExpiryStrategy | null, triggerDirection: Types.StopOrderTriggerDirection, status: Types.StopOrderStatus, createdAt: any, updatedAt?: any | null, partyId: string, marketId: string, trigger?: { __typename?: 'StopOrderPrice', price: string } | { __typename?: 'StopOrderTrailingPercentOffset', trailingPercentOffset: string } | null, submission: { __typename?: 'OrderSubmission', marketId: string, price: string, size: string, side: Types.Side, timeInForce: Types.OrderTimeInForce, expiresAt: any, type: Types.OrderType, reference?: string | null, postOnly?: boolean | null, reduceOnly?: boolean | null, peggedOrder?: { __typename?: 'PeggedOrder', reference: Types.PeggedReference, offset: string } | null } } | null }> | null } | null }; + export const OrderFieldsFragmentDoc = gql` fragment OrderFields on Order { id @@ -96,6 +107,49 @@ export const OrderUpdateFieldsFragmentDoc = gql` } } `; +export const OrderSubmissionFieldsFragmentDoc = gql` + fragment OrderSubmissionFields on OrderSubmission { + marketId + price + size + side + timeInForce + expiresAt + type + reference + peggedOrder { + reference + offset + } + postOnly + reduceOnly +} + `; +export const StopOrderFieldsFragmentDoc = gql` + fragment StopOrderFields on StopOrder { + id + ocoLinkId + expiresAt + expiryStrategy + triggerDirection + status + createdAt + updatedAt + partyId + marketId + trigger { + ... on StopOrderPrice { + price + } + ... on StopOrderTrailingPercentOffset { + trailingPercentOffset + } + } + submission { + ...OrderSubmissionFields + } +} + ${OrderSubmissionFieldsFragmentDoc}`; export const OrderByIdDocument = gql` query OrderById($orderId: ID!) { orderByID(id: $orderId) { @@ -216,4 +270,43 @@ export function useOrdersUpdateSubscription(baseOptions: Apollo.SubscriptionHook return Apollo.useSubscription(OrdersUpdateDocument, options); } export type OrdersUpdateSubscriptionHookResult = ReturnType; -export type OrdersUpdateSubscriptionResult = Apollo.SubscriptionResult; \ No newline at end of file +export type OrdersUpdateSubscriptionResult = Apollo.SubscriptionResult; +export const StopOrdersDocument = gql` + query StopOrders($partyId: ID!) { + stopOrders(filter: {parties: [$partyId]}) { + edges { + node { + ...StopOrderFields + } + } + } +} + ${StopOrderFieldsFragmentDoc}`; + +/** + * __useStopOrdersQuery__ + * + * To run a query within a React component, call `useStopOrdersQuery` and pass it any options that fit your needs. + * When your component renders, `useStopOrdersQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useStopOrdersQuery({ + * variables: { + * partyId: // value for 'partyId' + * }, + * }); + */ +export function useStopOrdersQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(StopOrdersDocument, options); + } +export function useStopOrdersLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(StopOrdersDocument, options); + } +export type StopOrdersQueryHookResult = ReturnType; +export type StopOrdersLazyQueryHookResult = ReturnType; +export type StopOrdersQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/libs/orders/src/lib/components/order-data-provider/stop-orders-data-provider.ts b/libs/orders/src/lib/components/order-data-provider/stop-orders-data-provider.ts new file mode 100644 index 000000000..d903eb136 --- /dev/null +++ b/libs/orders/src/lib/components/order-data-provider/stop-orders-data-provider.ts @@ -0,0 +1,57 @@ +import { + makeDataProvider, + makeDerivedDataProvider, +} from '@vegaprotocol/data-provider'; +import type { Market } from '@vegaprotocol/markets'; +import { marketsMapProvider } from '@vegaprotocol/markets'; +import type { + StopOrderFieldsFragment, + StopOrdersQuery, + StopOrdersQueryVariables, +} from './__generated__/Orders'; +import { StopOrdersDocument } from './__generated__/Orders'; + +export type StopOrder = StopOrderFieldsFragment & { + market: Market; +}; + +const getData = ( + responseData: StopOrdersQuery | null +): StopOrderFieldsFragment[] => + responseData?.stopOrders?.edges + ?.map((edge) => edge.node) + .filter((node): node is StopOrderFieldsFragment => !!node) || []; + +export const stopOrdersProvider = makeDataProvider< + StopOrdersQuery, + ReturnType, + never, + never, + StopOrdersQueryVariables +>({ + query: StopOrdersDocument, + getData, +}); + +export const stopOrdersWithMarketProvider = makeDerivedDataProvider< + StopOrder[], + never, + StopOrdersQueryVariables +>( + [ + stopOrdersProvider, + (callback, client) => marketsMapProvider(callback, client, undefined), + ], + (partsData): StopOrder[] => { + return ((partsData[0] as ReturnType) || []).map( + (stopOrder) => { + return { + ...stopOrder, + market: (partsData[1] as Record)[ + stopOrder.submission.marketId + ], + }; + } + ); + } +); diff --git a/libs/orders/src/lib/components/stop-orders-manager/index.ts b/libs/orders/src/lib/components/stop-orders-manager/index.ts new file mode 100644 index 000000000..608debc61 --- /dev/null +++ b/libs/orders/src/lib/components/stop-orders-manager/index.ts @@ -0,0 +1 @@ +export * from './stop-orders-manager'; diff --git a/libs/orders/src/lib/components/stop-orders-manager/stop-orders-manager.spec.tsx b/libs/orders/src/lib/components/stop-orders-manager/stop-orders-manager.spec.tsx new file mode 100644 index 000000000..d2b443271 --- /dev/null +++ b/libs/orders/src/lib/components/stop-orders-manager/stop-orders-manager.spec.tsx @@ -0,0 +1,43 @@ +import { render, screen, act } from '@testing-library/react'; +import { StopOrdersManager } from './stop-orders-manager'; +import * as useDataProviderHook from '@vegaprotocol/data-provider'; +import type { StopOrder } from '../order-data-provider/stop-orders-data-provider'; +import * as stopOrdersTableMock from '../stop-orders-table/stop-orders-table'; +import { forwardRef } from 'react'; +import type { VegaWalletContextShape } from '@vegaprotocol/wallet'; +import { VegaWalletContext } from '@vegaprotocol/wallet'; +import { MockedProvider } from '@apollo/client/testing'; + +// @ts-ignore StopOrdersTable is read only but we need to override with the forwardRef to +// avoid warnings about padding refs +stopOrdersTableMock.StopOrdersTable = forwardRef(() => ( +
StopOrdersTable
+)); + +const generateJsx = () => { + const pubKey = '0x123'; + return ( + + + + + + ); +}; + +describe('StopOrdersManager', () => { + it('should render the stop orders table if data provided', async () => { + jest.spyOn(useDataProviderHook, 'useDataProvider').mockReturnValue({ + data: [{ id: '1' } as StopOrder], + loading: false, + error: undefined, + flush: jest.fn(), + reload: jest.fn(), + load: jest.fn(), + }); + await act(async () => { + render(generateJsx()); + }); + expect(await screen.findByText('StopOrdersTable')).toBeInTheDocument(); + }); +}); diff --git a/libs/orders/src/lib/components/stop-orders-manager/stop-orders-manager.tsx b/libs/orders/src/lib/components/stop-orders-manager/stop-orders-manager.tsx new file mode 100644 index 000000000..7da41a1e3 --- /dev/null +++ b/libs/orders/src/lib/components/stop-orders-manager/stop-orders-manager.tsx @@ -0,0 +1,66 @@ +import { t } from '@vegaprotocol/i18n'; +import { useCallback, useEffect } from 'react'; +import { StopOrdersTable } from '../stop-orders-table/stop-orders-table'; +import type { useDataGridEvents } from '@vegaprotocol/datagrid'; +import { useVegaTransactionStore } from '@vegaprotocol/wallet'; +import type { StopOrder } from '../order-data-provider/stop-orders-data-provider'; +import { useDataProvider } from '@vegaprotocol/data-provider'; +import { stopOrdersWithMarketProvider } from '../order-data-provider/stop-orders-data-provider'; + +export interface StopOrdersManagerProps { + partyId: string; + onMarketClick?: (marketId: string, metaKey?: boolean) => void; + isReadOnly: boolean; + gridProps?: ReturnType; +} + +const POLLING_TIME = 2000; + +export const StopOrdersManager = ({ + partyId, + onMarketClick, + isReadOnly, + gridProps, +}: StopOrdersManagerProps) => { + const create = useVegaTransactionStore((state) => state.create); + const variables = { partyId }; + + const { data, error, reload } = useDataProvider({ + dataProvider: stopOrdersWithMarketProvider, + variables, + }); + + useEffect(() => { + const interval = setInterval(() => { + reload(); + }, POLLING_TIME); + return () => { + clearInterval(interval); + }; + }, [reload]); + + const cancel = useCallback( + (order: StopOrder) => { + if (!order.submission.marketId) return; + create({ + stopOrdersCancellation: { + stopOrderId: order.id, + marketId: order.submission.marketId, + }, + }); + }, + [create] + ); + + return ( + + ); +}; diff --git a/libs/orders/src/lib/components/stop-orders-table/stop-orders-table.spec.tsx b/libs/orders/src/lib/components/stop-orders-table/stop-orders-table.spec.tsx new file mode 100644 index 000000000..fa47676c5 --- /dev/null +++ b/libs/orders/src/lib/components/stop-orders-table/stop-orders-table.spec.tsx @@ -0,0 +1,237 @@ +import { act, render, screen } from '@testing-library/react'; +import * as Schema from '@vegaprotocol/types'; +import type { PartialDeep } from 'type-fest'; +import type { VegaWalletContextShape } from '@vegaprotocol/wallet'; +import { VegaWalletContext } from '@vegaprotocol/wallet'; +import { MockedProvider } from '@apollo/client/testing'; +import { + StopOrdersTable, + type StopOrdersTableProps, +} from './stop-orders-table'; +import { generateStopOrder } from '../mocks/generate-stop-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', + }), +})); + +jest.mock('@vegaprotocol/utils', () => ({ + ...jest.requireActual('@vegaprotocol/utils'), + getDateTimeFormat: jest.fn(() => ({ + format: (date: Date) => date.toISOString(), + })), +})); + +const defaultProps: StopOrdersTableProps = { + rowData: [], + onCancel: jest.fn(), + isReadOnly: false, +}; + +const generateJsx = ( + props: Partial = defaultProps, + context: PartialDeep = { pubKey: '0x123' } +) => { + return ( + + + + + + ); +}; + +const rowData = [ + generateStopOrder({ + id: 'stop-order-1', + trigger: { price: '80', __typename: 'StopOrderPrice' }, + triggerDirection: + Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_FALLS_BELOW, + expiresAt: '2023-08-26T08:31:22Z', + expiryStrategy: Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT, + submission: { + size: '100', + side: Schema.Side.SIDE_BUY, + type: Schema.OrderType.TYPE_LIMIT, + price: '120', + timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_FOK, + }, + status: Schema.StopOrderStatus.STATUS_CANCELLED, + }), + generateStopOrder({ + id: 'stop-order-2', + trigger: { price: '90', __typename: 'StopOrderPrice' }, + triggerDirection: + Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_RISES_ABOVE, + expiresAt: '2023-08-26T08:31:22Z', + expiryStrategy: Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_CANCELS, + submission: { + size: '110', + side: Schema.Side.SIDE_SELL, + type: Schema.OrderType.TYPE_MARKET, + timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC, + }, + status: Schema.StopOrderStatus.STATUS_EXPIRED, + }), + generateStopOrder({ + id: 'stop-order-3', + trigger: { + trailingPercentOffset: '0.1', + __typename: 'StopOrderTrailingPercentOffset', + }, + triggerDirection: + Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_FALLS_BELOW, + status: Schema.StopOrderStatus.STATUS_PENDING, + }), + generateStopOrder({ + id: 'stop-order-4', + trigger: { + trailingPercentOffset: '0.2', + __typename: 'StopOrderTrailingPercentOffset', + }, + triggerDirection: + Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_RISES_ABOVE, + status: Schema.StopOrderStatus.STATUS_REJECTED, + }), + generateStopOrder({ + id: 'stop-order-5', + status: Schema.StopOrderStatus.STATUS_STOPPED, + }), + generateStopOrder({ + id: 'stop-order-6', + status: Schema.StopOrderStatus.STATUS_TRIGGERED, + }), +]; + +describe('StopOrdersTable', () => { + it('should render correct columns', async () => { + await act(async () => { + render(generateJsx({ rowData })); + }); + const expectedHeaders = [ + 'Market', + 'Trigger', + 'Expires At', + 'Size', + 'Submission Type', + 'Status', + 'Submission Price', + 'Submission Time In Force', + 'Updated At', + '', // no cell header for edit/cancel + ]; + const headers = screen.getAllByRole('columnheader'); + expect(headers).toHaveLength(expectedHeaders.length); + expect(headers.map((h) => h.textContent?.trim())).toEqual(expectedHeaders); + }); + + it('formats trigger column', async () => { + await act(async () => { + render(generateJsx({ rowData })); + }); + const grid = screen.getByRole('treegrid'); + const cells = grid.querySelectorAll('.ag-body [col-id="trigger"]'); + + const expectedValues: string[] = [ + 'Mark < 8.0', + 'Mark > 9.0', + 'Mark +10.0%', + 'Mark -20.0%', + ]; + expectedValues.forEach((expectedValue, i) => + expect(cells[i]).toHaveTextContent(expectedValue) + ); + }); + + it('formats expires at column', async () => { + await act(async () => { + render(generateJsx({ rowData })); + }); + const grid = screen.getByRole('treegrid'); + const cells = grid.querySelectorAll('.ag-body [col-id="expiresAt"]'); + + const expectedValues: string[] = [ + 'Submit 2023-08-26T08:31:22.000Z', + 'Cancels 2023-08-26T08:31:22.000Z', + ]; + expectedValues.forEach((expectedValue, i) => + expect(cells[i]).toHaveTextContent(expectedValue) + ); + }); + + it('formats size column', async () => { + await act(async () => { + render(generateJsx({ rowData })); + }); + const grid = screen.getByRole('treegrid'); + const cells = grid.querySelectorAll('.ag-body [col-id="submission.size"]'); + + const expectedValues: string[] = ['+1.00', '-1.10']; + expectedValues.forEach((expectedValue, i) => + expect(cells[i]).toHaveTextContent(expectedValue) + ); + }); + + it('formats type column', async () => { + await act(async () => { + render(generateJsx({ rowData })); + }); + const grid = screen.getByRole('treegrid'); + const cells = grid.querySelectorAll('.ag-body [col-id="submission.type"]'); + + const expectedValues: string[] = ['Limit', 'Market']; + expectedValues.forEach((expectedValue, i) => + expect(cells[i]).toHaveTextContent(expectedValue) + ); + }); + it('formats status column', async () => { + await act(async () => { + render(generateJsx({ rowData })); + }); + const grid = screen.getByRole('treegrid'); + const cells = grid.querySelectorAll('.ag-body [col-id="status"]'); + + const expectedValues: string[] = [ + 'Cancelled', + 'Expired', + 'Pending', + 'Rejected', + 'Stopped', + 'Triggered', + ]; + expectedValues.forEach((expectedValue, i) => + expect(cells[i]).toHaveTextContent(expectedValue) + ); + }); + + it('formats price column', async () => { + await act(async () => { + render(generateJsx({ rowData })); + }); + const grid = screen.getByRole('treegrid'); + const cells = grid.querySelectorAll('.ag-body [col-id="submission.price"]'); + + const expectedValues: string[] = ['12.0', '-']; + expectedValues.forEach((expectedValue, i) => + expect(cells[i]).toHaveTextContent(expectedValue) + ); + }); + + it('shows cancel button only for pending stop orders', async () => { + await act(async () => { + render(generateJsx({ rowData })); + }); + const cancelButtons = screen.getAllByTestId('cancel'); + expect(cancelButtons).toHaveLength(1); + cancelButtons.forEach((cancelButton) => { + const id = cancelButton.closest('[role="row"]')?.getAttribute('row-id'); + expect(rowData.find((row) => row.id === id)?.status).toEqual( + Schema.StopOrderStatus.STATUS_PENDING + ); + }); + }); +}); diff --git a/libs/orders/src/lib/components/stop-orders-table/stop-orders-table.tsx b/libs/orders/src/lib/components/stop-orders-table/stop-orders-table.tsx new file mode 100644 index 000000000..ca7f47a09 --- /dev/null +++ b/libs/orders/src/lib/components/stop-orders-table/stop-orders-table.tsx @@ -0,0 +1,300 @@ +import { + addDecimalsFormatNumber, + getDateTimeFormat, + isNumeric, + toBigNum, +} from '@vegaprotocol/utils'; +import { t } from '@vegaprotocol/i18n'; +import * as Schema from '@vegaprotocol/types'; +import { ButtonLink } from '@vegaprotocol/ui-toolkit'; +import type { ForwardedRef } from 'react'; +import { memo, forwardRef, useMemo } from 'react'; +import { + AgGridLazy as AgGrid, + SetFilter, + DateRangeFilter, + negativeClassNames, + positiveClassNames, + MarketNameCell, + COL_DEFS, +} from '@vegaprotocol/datagrid'; +import type { + TypedDataAgGrid, + VegaICellRendererParams, + VegaValueFormatterParams, + VegaValueGetterParams, +} from '@vegaprotocol/datagrid'; +import type { AgGridReact } from 'ag-grid-react'; +import type { StopOrder } from '../order-data-provider/stop-orders-data-provider'; +import type { ColDef } from 'ag-grid-community'; + +export type StopOrdersTableProps = TypedDataAgGrid & { + onCancel: (order: StopOrder) => void; + onMarketClick?: (marketId: string, metaKey?: boolean) => void; + isReadOnly: boolean; +}; + +export const StopOrdersTable = memo< + StopOrdersTableProps & { ref?: ForwardedRef } +>( + forwardRef( + ({ onCancel, onMarketClick, ...props }, ref) => { + const showAllActions = !props.isReadOnly; + const columnDefs: ColDef[] = useMemo( + () => [ + { + headerName: t('Market'), + field: 'market.tradableInstrument.instrument.code', + cellRenderer: 'MarketNameCell', + cellRendererParams: { idPath: 'market.id', onMarketClick }, + minWidth: 150, + }, + { + headerName: t('Trigger'), + field: 'trigger', + cellClass: 'font-mono text-right', + type: 'rightAligned', + sortable: false, + valueFormatter: ({ + data, + value, + }: VegaValueFormatterParams): string => { + if (data && value?.__typename === 'StopOrderPrice') { + return `${t('Mark')} ${ + data?.triggerDirection === + Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_FALLS_BELOW + ? '<' + : '>' + } ${addDecimalsFormatNumber( + value.price, + data.market.decimalPlaces + )}`; + } + if ( + data && + value?.__typename === 'StopOrderTrailingPercentOffset' + ) { + return `${t('Mark')} ${ + data?.triggerDirection === + Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_FALLS_BELOW + ? '+' + : '-' + }${(Number(value.trailingPercentOffset) * 100).toFixed(1)}%`; + } + return '-'; + }, + minWidth: 100, + }, + { + field: 'expiresAt', + valueFormatter: ({ + value, + data, + }: VegaValueFormatterParams) => { + if ( + data && + value && + data?.expiryStrategy !== + Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_UNSPECIFIED + ) { + const expiresAt = getDateTimeFormat().format(new Date(value)); + const expiryStrategy = + data.expiryStrategy === + Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT + ? t('Submit') + : t('Cancels'); + return `${expiryStrategy} ${expiresAt}`; + } + return ''; + }, + minWidth: 150, + }, + { + headerName: t('Size'), + field: 'submission.size', + cellClass: 'font-mono text-right', + type: 'rightAligned', + cellClassRules: { + [positiveClassNames]: ({ data }: { data: StopOrder }) => + data?.submission.size === Schema.Side.SIDE_BUY, + [negativeClassNames]: ({ data }: { data: StopOrder }) => + data?.submission.size === Schema.Side.SIDE_SELL, + }, + valueGetter: ({ data }: VegaValueGetterParams) => { + return data?.submission.size && data.market + ? toBigNum( + data.submission.size, + data.market.positionDecimalPlaces ?? 0 + ) + .multipliedBy( + data.submission.side === Schema.Side.SIDE_SELL ? -1 : 1 + ) + .toNumber() + : undefined; + }, + valueFormatter: ({ + data, + }: VegaValueFormatterParams) => { + if (!data) { + return ''; + } + if (!data?.market || !isNumeric(data.submission.size)) { + return '-'; + } + const prefix = data + ? data.submission.side === Schema.Side.SIDE_BUY + ? '+' + : '-' + : ''; + return ( + prefix + + addDecimalsFormatNumber( + data.submission.size, + data.market.positionDecimalPlaces + ) + ); + }, + minWidth: 80, + }, + { + field: 'submission.type', + filter: SetFilter, + filterParams: { + set: Schema.OrderTypeMapping, + }, + cellRenderer: ({ + value, + }: VegaICellRendererParams) => + value ? Schema.OrderTypeMapping[value] : '', + minWidth: 80, + }, + { + field: 'status', + filter: SetFilter, + filterParams: { + set: Schema.StopOrderStatusMapping, + }, + valueFormatter: ({ + value, + }: VegaValueFormatterParams) => { + return value ? Schema.StopOrderStatusMapping[value] : ''; + }, + cellRenderer: ({ + valueFormatted, + data, + }: { + valueFormatted: string; + data: StopOrder; + }) => ( + + {valueFormatted} + + ), + minWidth: 100, + }, + { + field: 'submission.price', + type: 'rightAligned', + cellClass: 'font-mono text-right', + valueFormatter: ({ + value, + data, + }: VegaValueFormatterParams) => { + if (!data) { + return ''; + } + if ( + !data?.market || + data.submission.type === Schema.OrderType.TYPE_MARKET || + !isNumeric(value) + ) { + return '-'; + } + return addDecimalsFormatNumber(value, data.market.decimalPlaces); + }, + minWidth: 100, + }, + { + field: 'submission.timeInForce', + filter: SetFilter, + filterParams: { + set: Schema.OrderTimeInForceMapping, + }, + valueFormatter: ({ + value, + }: VegaValueFormatterParams< + StopOrder, + 'submission.timeInForce' + >) => { + return value ? Schema.OrderTimeInForceCode[value] : ''; + }, + minWidth: 150, + }, + { + field: 'updatedAt', + filter: DateRangeFilter, + valueGetter: ({ data }: VegaValueGetterParams) => + data?.updatedAt || data?.createdAt, + cellRenderer: ({ + data, + }: VegaICellRendererParams) => { + if (!data) { + return undefined; + } + const value = data.updatedAt || data.createdAt; + return ( + + {value ? getDateTimeFormat().format(new Date(value)) : '-'} + + ); + }, + minWidth: 150, + }, + { + colId: 'actions', + ...COL_DEFS.actions, + minWidth: showAllActions ? 120 : COL_DEFS.actions.minWidth, + maxWidth: showAllActions ? 120 : COL_DEFS.actions.minWidth, + cellRenderer: ({ data }: { data?: StopOrder }) => { + if (!data) return null; + + return ( +
+ {data.status === Schema.StopOrderStatus.STATUS_PENDING && + !props.isReadOnly && ( + onCancel(data)} + > + {t('Cancel')} + + )} +
+ ); + }, + }, + ], + [onCancel, onMarketClick, props.isReadOnly, showAllActions] + ); + + return ( + data.id} + components={{ MarketNameCell }} + {...props} + /> + ); + } + ) +); diff --git a/libs/orders/src/lib/order-hooks/use-order-store.ts b/libs/orders/src/lib/order-hooks/use-order-store.ts index dd8ffaba8..8750777d1 100644 --- a/libs/orders/src/lib/order-hooks/use-order-store.ts +++ b/libs/orders/src/lib/order-hooks/use-order-store.ts @@ -111,13 +111,13 @@ export const useOrder = (marketId: string) => { [marketId, _update] ); - // add new order to store if it doesnt exist, but don't + // 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 // dont persist the order + false // don't persist the order ); } }, [order, marketId, update]); diff --git a/libs/types/src/global-types-mappings.ts b/libs/types/src/global-types-mappings.ts index 78c033d34..4458530e2 100644 --- a/libs/types/src/global-types-mappings.ts +++ b/libs/types/src/global-types-mappings.ts @@ -23,6 +23,7 @@ import type { VoteValue, WithdrawalStatus, DispatchMetric, + StopOrderStatus, } from './__generated__/types'; export const AccountTypeMapping: { @@ -234,6 +235,21 @@ export const OrderStatusMapping: { STATUS_STOPPED: 'Stopped', }; +/** + * Stop order statuses, these determine several states for an stop order that cannot be expressed with other fields in StopOrder. + */ +export const StopOrderStatusMapping: { + [T in StopOrderStatus]: string; +} = { + STATUS_CANCELLED: 'Cancelled', + STATUS_EXPIRED: 'Expired', + STATUS_PENDING: 'Pending', + STATUS_REJECTED: 'Rejected', + STATUS_STOPPED: 'Stopped', + STATUS_TRIGGERED: 'Triggered', + STATUS_UNSPECIFIED: 'Unspecified', +}; + /** * Valid order types, these determine what happens when an order is added to the book */ diff --git a/libs/ui-toolkit/src/components/radio-group/radio-group.tsx b/libs/ui-toolkit/src/components/radio-group/radio-group.tsx index cd0ddff22..f7cb5b173 100644 --- a/libs/ui-toolkit/src/components/radio-group/radio-group.tsx +++ b/libs/ui-toolkit/src/components/radio-group/radio-group.tsx @@ -1,3 +1,4 @@ +import { forwardRef } from 'react'; import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'; import classNames from 'classnames'; import type { ReactNode } from 'react'; @@ -9,31 +10,43 @@ export interface RadioGroupProps { value?: string; orientation?: 'horizontal' | 'vertical'; onChange?: (value: string) => void; + className?: string; } -export const RadioGroup = ({ - children, - name, - value, - orientation = 'vertical', - onChange, -}: RadioGroupProps) => { - const groupClasses = classNames('flex text-sm', { - 'flex-col gap-2': orientation === 'vertical', - 'flex-row gap-4': orientation === 'horizontal', - }); - return ( - - {children} - - ); -}; +export const RadioGroup = forwardRef( + ( + { + children, + name, + value, + orientation = 'vertical', + onChange, + className, + }: RadioGroupProps, + ref + ) => { + const groupClasses = classNames( + 'flex text-sm', + { + 'flex-col gap-2': orientation === 'vertical', + 'flex-row gap-4': orientation === 'horizontal', + }, + className + ); + return ( + + {children} + + ); + } +); interface RadioProps { id: string; diff --git a/libs/ui-toolkit/src/components/tabs/tabs.tsx b/libs/ui-toolkit/src/components/tabs/tabs.tsx index 7f405b946..28726fee5 100644 --- a/libs/ui-toolkit/src/components/tabs/tabs.tsx +++ b/libs/ui-toolkit/src/components/tabs/tabs.tsx @@ -7,7 +7,7 @@ import classNames from 'classnames'; import type { ReactElement, ReactNode } from 'react'; import { Children, isValidElement, useState } from 'react'; export interface TabsProps extends TabsPrimitive.TabsProps { - children: ReactElement[]; + children: (ReactElement | null)[]; } export const Tabs = ({ @@ -17,11 +17,11 @@ export const Tabs = ({ onValueChange, ...props }: TabsProps) => { - const [activeTab, setActiveTab] = useState(() => { + const [activeTab, setActiveTab] = useState(() => { if (defaultValue) { return defaultValue; } - return children[0].props.id; + return children.find((v) => v)?.props.id; }); return ( @@ -112,7 +112,10 @@ export const LocalStoragePersistTabs = ({ children={children} value={getValidItem( value, - Children.map(children, (child) => child.props.id), + Children.map( + children.filter((c): c is ReactElement => c !== null), + (child) => child.props.id + ), undefined )} onValueChange={onValueChange} diff --git a/libs/ui-toolkit/src/components/trading-dropdown/trading-dropdown.tsx b/libs/ui-toolkit/src/components/trading-dropdown/trading-dropdown.tsx index 8d35d3590..7d3db2ad3 100644 --- a/libs/ui-toolkit/src/components/trading-dropdown/trading-dropdown.tsx +++ b/libs/ui-toolkit/src/components/trading-dropdown/trading-dropdown.tsx @@ -75,7 +75,7 @@ export const TradingDropdownContent = forwardRef< { const [, stepDecimals = ''] = String(step).split('.'); - return (value: string) => { - const [, valueDecimals = ''] = value.split('.'); + return (value?: string) => { + const [, valueDecimals = ''] = (value || '').split('.'); if (stepDecimals.length < valueDecimals.length) { if (stepDecimals === '') { return t(`${field} must be whole numbers for this market`); diff --git a/libs/wallet/src/connectors/vega-connector.ts b/libs/wallet/src/connectors/vega-connector.ts index 2d8978cd8..79d411345 100644 --- a/libs/wallet/src/connectors/vega-connector.ts +++ b/libs/wallet/src/connectors/vega-connector.ts @@ -79,6 +79,32 @@ export interface OrderAmendmentBody { orderAmendment: OrderAmendment; } +export interface StopOrderSetup { + orderSubmission: OrderSubmission; + expiresAt?: string; + expiryStrategy?: Schema.StopOrderExpiryStrategy; + price?: string; + trailingPercentOffset?: string; +} + +export interface StopOrdersSubmission { + risesAbove?: StopOrderSetup; + fallsBelow?: StopOrderSetup; +} + +export interface StopOrdersCancellation { + stopOrderId?: string; + marketId?: string; +} + +export interface StopOrdersSubmissionBody { + stopOrdersSubmission: StopOrdersSubmission; +} + +export interface StopOrdersCancellationBody { + stopOrdersCancellation: StopOrdersCancellation; +} + export interface VoteSubmissionBody { voteSubmission: { value: Schema.VoteValue; @@ -357,6 +383,8 @@ export interface TransferBody { } export type Transaction = + | StopOrdersSubmissionBody + | StopOrdersCancellationBody | OrderSubmissionBody | OrderCancellationBody | WithdrawSubmissionBody @@ -381,6 +409,16 @@ export const isOrderCancellationTransaction = ( transaction: Transaction ): transaction is OrderCancellationBody => 'orderCancellation' in transaction; +export const isStopOrdersSubmissionTransaction = ( + transaction: Transaction +): transaction is StopOrdersSubmissionBody => + 'stopOrdersSubmission' in transaction; + +export const isStopOrdersCancellationTransaction = ( + transaction: Transaction +): transaction is StopOrdersCancellationBody => + 'stopOrdersCancellation' in transaction; + export const isOrderAmendmentTransaction = ( transaction: Transaction ): transaction is OrderAmendmentBody => 'orderAmendment' in transaction; diff --git a/libs/wallet/src/use-vega-transaction-store.tsx b/libs/wallet/src/use-vega-transaction-store.tsx index 42aaf1440..884856834 100644 --- a/libs/wallet/src/use-vega-transaction-store.tsx +++ b/libs/wallet/src/use-vega-transaction-store.tsx @@ -7,6 +7,8 @@ import { isOrderAmendmentTransaction, isBatchMarketInstructionsTransaction, isTransferTransaction, + isStopOrdersSubmissionTransaction, + isStopOrdersCancellationTransaction, } from './connectors'; import { determineId } from './utils'; @@ -196,9 +198,16 @@ export const useVegaTransactionStore = create()( isOrderCancellationTransaction(transaction.body) && !transaction.body.orderCancellation.orderId; const isConfirmedTransfer = isTransferTransaction(transaction.body); + const isConfirmedStopOrderCancellation = + isStopOrdersCancellationTransaction(transaction.body); + const isConfirmedStopOrderSubmission = + isStopOrdersSubmissionTransaction(transaction.body); if ( - (isConfirmedOrderCancellation || isConfirmedTransfer) && + (isConfirmedOrderCancellation || + isConfirmedTransfer || + isConfirmedStopOrderCancellation || + isConfirmedStopOrderSubmission) && !transactionResult.error && transactionResult.status ) { diff --git a/libs/web3/src/lib/use-vega-transaction-toasts.tsx b/libs/web3/src/lib/use-vega-transaction-toasts.tsx index 1fb4ad9b1..71332b3d9 100644 --- a/libs/web3/src/lib/use-vega-transaction-toasts.tsx +++ b/libs/web3/src/lib/use-vega-transaction-toasts.tsx @@ -22,6 +22,8 @@ import { isWithdrawTransaction, useVegaTransactionStore, VegaTxStatus, + isStopOrdersSubmissionTransaction, + isStopOrdersCancellationTransaction, } from '@vegaprotocol/wallet'; import type { Toast, ToastContent } from '@vegaprotocol/ui-toolkit'; import { ToastHeading } from '@vegaprotocol/ui-toolkit'; @@ -85,6 +87,8 @@ const isTransactionTypeSupported = (tx: VegaStoredTxState) => { const withdraw = isWithdrawTransaction(tx.body); const submitOrder = isOrderSubmissionTransaction(tx.body); const cancelOrder = isOrderCancellationTransaction(tx.body); + const submitStopOrder = isStopOrdersSubmissionTransaction(tx.body); + const cancelStopOrder = isStopOrdersCancellationTransaction(tx.body); const editOrder = isOrderAmendmentTransaction(tx.body); const batchMarketInstructions = isBatchMarketInstructionsTransaction(tx.body); const transfer = isTransferTransaction(tx.body); @@ -92,6 +96,8 @@ const isTransactionTypeSupported = (tx: VegaStoredTxState) => { withdraw || submitOrder || cancelOrder || + submitStopOrder || + cancelStopOrder || editOrder || batchMarketInstructions || transfer