From 3077a634d1a1977d8f9b23e3b0566da51268591e Mon Sep 17 00:00:00 2001 From: Matthew Russell Date: Tue, 15 Nov 2022 18:00:14 -0600 Subject: [PATCH] fix(#1691,#2040): deal ticket validation (#2045) * feat: update validation for size and price so they are reported simultaneously * feat: adjust styles so error messages for price/size are rendered on one line * fix: missing key in map error * feat: split validation out on a field by field basis * refactor: remove unnecessary price setting and just use props to calc market price * refactor: rename setOrder func and add a comment * chore: add type for form fields to allow for summary field * fix: layout of market order size and price * fix: casing of size/price in validation message * refactor: fix type errors for summary react nodes and for tif and type validation * feat: break out functions for testing market.state and market.tradingmode on submit * fix: deal ticket test for last price value * chore: remove unused files and move hooks and utils to correct dirs * chore: tidy up constants * fix: const before enum invalid syntax * feat: dont block submission if not enough margin but do if zero balance * chore: remove duplicated margin warning message * feat: dont allow margin warning and error message to render at the same time * feat: make trading mode check just a warning rather than error message * test: make markets active so they are tradable, renaming describe blocks for clarity * test: adjust test ids and disabled state and fix tests * test: include AC codes in tests * chore: remove click of in test as form is already dirty * fix: lint errors, only show margin warning if you have balance * chore: add ts ignore * chore: re add use-order-validation hook for console-lite specifically * chore: update use-order-validation test after consumed margin check hook changed * fix: circular deps issue in console-lite * chore: update use order validation hook to use Schema rather than direct import --- .../deal-ticket/deal-ticket-slippage.tsx | 2 +- .../deal-ticket/deal-ticket-steps.tsx | 10 +- .../use-order-validation.spec.tsx | 87 +++--- .../deal-ticket}/use-order-validation.tsx | 50 ++-- .../src/integration/market-info.cy.ts | 2 +- .../src/integration/trading-deal-ticket.cy.ts | 69 +++-- .../src/components/deal-ticket-estimates.tsx | 2 +- .../deal-ticket-validation/index.ts | 6 +- ...validate-margin.tsx => margin-warning.tsx} | 38 ++- .../deal-ticket-validation/validate-size.ts | 12 - .../zero-balance-error.tsx | 35 +++ .../deal-ticket/deal-ticket-amount.tsx | 10 +- .../deal-ticket/deal-ticket-button.tsx | 25 +- .../deal-ticket/deal-ticket-error.tsx | 34 --- .../deal-ticket/deal-ticket-limit-amount.tsx | 56 ++-- .../deal-ticket/deal-ticket-market-amount.tsx | 133 +++++---- .../deal-ticket/deal-ticket.spec.tsx | 2 +- .../components/deal-ticket/deal-ticket.tsx | 262 ++++++++++++------ .../deal-ticket/expiry-selector.tsx | 23 +- .../deal-ticket/time-in-force-selector.tsx | 71 ++++- .../components/deal-ticket/type-selector.tsx | 60 +++- libs/deal-ticket/src/components/index.ts | 1 - .../src/{components => }/constants.ts | 21 +- libs/deal-ticket/src/hooks/index.ts | 1 + .../src/hooks/use-fee-deal-ticket-details.tsx | 2 +- .../use-order-margin-validation.ts} | 28 +- .../use-persisted-order.ts | 0 libs/deal-ticket/src/index.ts | 2 + .../get-default-order.ts | 0 libs/deal-ticket/src/utils/index.ts | 8 + .../src/utils/is-market-in-auction.ts | 10 + libs/deal-ticket/src/utils/validate-amount.ts | 16 ++ .../validate-expiration.ts | 5 +- .../src/utils/validate-market-state.ts | 42 +++ .../src/utils/validate-market-trading-mode.ts | 12 + .../src/utils/validate-time-in-force.ts | 38 +++ libs/deal-ticket/src/utils/validate-type.ts | 31 +++ libs/deposits/src/lib/deposit-limits.tsx | 2 +- .../components/input-error/input-error.tsx | 2 +- 39 files changed, 798 insertions(+), 412 deletions(-) rename {libs/deal-ticket/src/components/deal-ticket-validation => apps/console-lite/src/app/components/deal-ticket}/use-order-validation.spec.tsx (84%) rename {libs/deal-ticket/src/components/deal-ticket-validation => apps/console-lite/src/app/components/deal-ticket}/use-order-validation.tsx (91%) rename libs/deal-ticket/src/components/deal-ticket-validation/{validate-margin.tsx => margin-warning.tsx} (53%) delete mode 100644 libs/deal-ticket/src/components/deal-ticket-validation/validate-size.ts create mode 100644 libs/deal-ticket/src/components/deal-ticket-validation/zero-balance-error.tsx delete mode 100644 libs/deal-ticket/src/components/deal-ticket/deal-ticket-error.tsx rename libs/deal-ticket/src/{components => }/constants.ts (87%) rename libs/deal-ticket/src/{components/deal-ticket-validation/use-order-margin-validation.tsx => hooks/use-order-margin-validation.ts} (63%) rename libs/deal-ticket/src/{components/deal-ticket-validation => hooks}/use-persisted-order.ts (100%) rename libs/deal-ticket/src/{components/deal-ticket-validation => utils}/get-default-order.ts (100%) create mode 100644 libs/deal-ticket/src/utils/index.ts create mode 100644 libs/deal-ticket/src/utils/is-market-in-auction.ts create mode 100644 libs/deal-ticket/src/utils/validate-amount.ts rename libs/deal-ticket/src/{components/deal-ticket-validation => utils}/validate-expiration.ts (67%) create mode 100644 libs/deal-ticket/src/utils/validate-market-state.ts create mode 100644 libs/deal-ticket/src/utils/validate-market-trading-mode.ts create mode 100644 libs/deal-ticket/src/utils/validate-time-in-force.ts create mode 100644 libs/deal-ticket/src/utils/validate-type.ts diff --git a/apps/console-lite/src/app/components/deal-ticket/deal-ticket-slippage.tsx b/apps/console-lite/src/app/components/deal-ticket/deal-ticket-slippage.tsx index df71a19fd..f15527d5d 100644 --- a/apps/console-lite/src/app/components/deal-ticket/deal-ticket-slippage.tsx +++ b/apps/console-lite/src/app/components/deal-ticket/deal-ticket-slippage.tsx @@ -11,8 +11,8 @@ import { InputSetter } from '../../components/input-setter'; import { IconNames } from '@blueprintjs/icons'; import { DataTitle, - EST_SLIPPAGE, ValueTooltipRow, + EST_SLIPPAGE, } from '@vegaprotocol/deal-ticket'; interface DealTicketSlippageProps { diff --git a/apps/console-lite/src/app/components/deal-ticket/deal-ticket-steps.tsx b/apps/console-lite/src/app/components/deal-ticket/deal-ticket-steps.tsx index 0f5649b31..69a50b776 100644 --- a/apps/console-lite/src/app/components/deal-ticket/deal-ticket-steps.tsx +++ b/apps/console-lite/src/app/components/deal-ticket/deal-ticket-steps.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { useForm, Controller } from 'react-hook-form'; import { Stepper } from '../stepper'; import type { DealTicketMarketFragment } from '@vegaprotocol/deal-ticket'; +import { useOrderValidation } from './use-order-validation'; import { useOrderCloseOut, useOrderMargin, @@ -10,11 +11,7 @@ import { useMaximumPositionSize, useCalculateSlippage, } from '@vegaprotocol/deal-ticket'; -import { - getDefaultOrder, - useOrderValidation, - validateSize, -} from '@vegaprotocol/deal-ticket'; +import { getDefaultOrder, validateAmount } from '@vegaprotocol/deal-ticket'; import { InputError } from '@vegaprotocol/ui-toolkit'; import { BigNumber } from 'bignumber.js'; import { MarketSelector } from '@vegaprotocol/deal-ticket'; @@ -180,7 +177,8 @@ export const DealTicketSteps = ({ market }: DealTicketMarketProps) => { const newVal = new BigNumber(value) .decimalPlaces(market.positionDecimalPlaces) .toString(); - const isValid = validateSize(step)(newVal); + // @ts-ignore validateAmount ts problem here + const isValid = validateAmount(step)(newVal); if (isValid !== 'step') { setValue('size', newVal); } diff --git a/libs/deal-ticket/src/components/deal-ticket-validation/use-order-validation.spec.tsx b/apps/console-lite/src/app/components/deal-ticket/use-order-validation.spec.tsx similarity index 84% rename from libs/deal-ticket/src/components/deal-ticket-validation/use-order-validation.spec.tsx rename to apps/console-lite/src/app/components/deal-ticket/use-order-validation.spec.tsx index b8be63ea3..7c4bd3ec7 100644 --- a/libs/deal-ticket/src/components/deal-ticket-validation/use-order-validation.spec.tsx +++ b/apps/console-lite/src/app/components/deal-ticket/use-order-validation.spec.tsx @@ -6,12 +6,27 @@ import type { VegaWalletContextShape } from '@vegaprotocol/wallet'; import { MarketStateMapping, Schema } from '@vegaprotocol/types'; import type { ValidationProps } from './use-order-validation'; import { marketTranslations, useOrderValidation } from './use-order-validation'; -import type { DealTicketMarketFragment } from '../deal-ticket/__generated__/DealTicket'; -import * as OrderMarginValidation from './use-order-margin-validation'; -import { ValidateMargin } from './validate-margin'; -import { ERROR_SIZE_DECIMAL } from '../constants'; +import type { DealTicketMarketFragment } from '@vegaprotocol/deal-ticket'; +import * as DealTicket from '@vegaprotocol/deal-ticket'; +import BigNumber from 'bignumber.js'; jest.mock('@vegaprotocol/wallet'); +jest.mock('@vegaprotocol/deal-ticket', () => { + return { + ...jest.requireActual('@vegaprotocol/deal-ticket'), + useOrderMarginValidation: jest.fn(), + }; +}); + +type SettlementAsset = + DealTicketMarketFragment['tradableInstrument']['instrument']['product']['settlementAsset']; +const asset: SettlementAsset = { + __typename: 'Asset', + id: 'asset-id', + symbol: 'asset-symbol', + name: 'asset-name', + decimals: 2, +}; const market: DealTicketMarketFragment = { id: 'market-id', @@ -28,13 +43,7 @@ const market: DealTicketMarketFragment = { product: { __typename: 'Future', quoteName: 'quote-name', - settlementAsset: { - __typename: 'Asset', - id: 'asset-id', - symbol: 'asset-symbol', - name: 'asset-name', - decimals: 2, - }, + settlementAsset: asset, }, }, }, @@ -117,9 +126,11 @@ describe('useOrderValidation', () => { }); it('Returns empty string when given valid data', () => { - jest - .spyOn(OrderMarginValidation, 'useOrderMarginValidation') - .mockReturnValue(false); + jest.spyOn(DealTicket, 'useOrderMarginValidation').mockReturnValue({ + balance: new BigNumber(0), + margin: new BigNumber(100), + asset, + }); const { result } = setup(); expect(result.current).toStrictEqual({ @@ -130,9 +141,11 @@ describe('useOrderValidation', () => { }); it('Returns an error message when no keypair found', () => { - jest - .spyOn(OrderMarginValidation, 'useOrderMarginValidation') - .mockReturnValue(false); + jest.spyOn(DealTicket, 'useOrderMarginValidation').mockReturnValue({ + balance: new BigNumber(0), + margin: new BigNumber(100), + asset, + }); const { result } = setup(defaultOrder, { pubKey: null }); expect(result.current).toStrictEqual({ isDisabled: false, @@ -169,9 +182,11 @@ describe('useOrderValidation', () => { `( 'Returns an error message for market state suspended or pending', ({ state }) => { - jest - .spyOn(OrderMarginValidation, 'useOrderMarginValidation') - .mockReturnValue(false); + jest.spyOn(DealTicket, 'useOrderMarginValidation').mockReturnValue({ + balance: new BigNumber(0), + margin: new BigNumber(100), + asset, + }); const { result } = setup({ market: { ...defaultOrder.market, @@ -259,7 +274,9 @@ describe('useOrderValidation', () => { it('Returns an error message when the order size incorrectly has decimal values', () => { const { result } = setup({ market: { ...market, positionDecimalPlaces: 0 }, - fieldErrors: { size: { type: `validate`, message: ERROR_SIZE_DECIMAL } }, + fieldErrors: { + size: { type: `validate`, message: DealTicket.ERROR_SIZE_DECIMAL }, + }, }); expect(result.current).toStrictEqual({ isDisabled: true, @@ -270,7 +287,9 @@ describe('useOrderValidation', () => { it('Returns an error message when the order size has more decimals than allowed', () => { const { result } = setup({ - fieldErrors: { size: { type: `validate`, message: ERROR_SIZE_DECIMAL } }, + fieldErrors: { + size: { type: `validate`, message: DealTicket.ERROR_SIZE_DECIMAL }, + }, }); expect(result.current).toStrictEqual({ isDisabled: true, @@ -281,20 +300,26 @@ describe('useOrderValidation', () => { it('Returns an error message when the estimated margin is higher than collateral', async () => { const invalidatedMockValue = { - balance: '0000000,1', - margin: '000,1', - id: 'instrument-id', - symbol: 'asset-symbol', - decimals: 5, + balance: new BigNumber(100), + margin: new BigNumber(200), + asset, }; + jest - .spyOn(OrderMarginValidation, 'useOrderMarginValidation') + .spyOn(DealTicket, 'useOrderMarginValidation') .mockReturnValue(invalidatedMockValue); + const { result } = setup({}); - expect(result.current.isDisabled).toBe(true); + expect(result.current.isDisabled).toBe(false); - const testElement = ; + const testElement = ( + + ); expect((result.current.message as React.ReactElement)?.props).toEqual( testElement.props ); @@ -312,7 +337,7 @@ describe('useOrderValidation', () => { ({ state }) => { const { result } = setup({ fieldErrors: { - size: { type: `validate`, message: ERROR_SIZE_DECIMAL }, + size: { type: `validate`, message: DealTicket.ERROR_SIZE_DECIMAL }, }, market: { ...market, diff --git a/libs/deal-ticket/src/components/deal-ticket-validation/use-order-validation.tsx b/apps/console-lite/src/app/components/deal-ticket/use-order-validation.tsx similarity index 91% rename from libs/deal-ticket/src/components/deal-ticket-validation/use-order-validation.tsx rename to apps/console-lite/src/app/components/deal-ticket/use-order-validation.tsx index fb2b642a0..7dcfd4ef9 100644 --- a/libs/deal-ticket/src/components/deal-ticket-validation/use-order-validation.tsx +++ b/apps/console-lite/src/app/components/deal-ticket/use-order-validation.tsx @@ -6,23 +6,30 @@ import { useVegaWallet } from '@vegaprotocol/wallet'; import { MarketStateMapping, Schema } from '@vegaprotocol/types'; import type { OrderSubmissionBody } from '@vegaprotocol/wallet'; import { Tooltip } from '@vegaprotocol/ui-toolkit'; -import { MarketDataGrid } from '../trading-mode-tooltip'; -import { compileGridData } from '../trading-mode-tooltip/compile-grid-data'; -import type { DealTicketMarketFragment } from '../deal-ticket/__generated__/DealTicket'; -import { ValidateMargin } from './validate-margin'; -import type { OrderMargin } from '../../hooks/use-order-margin'; -import { useOrderMarginValidation } from './use-order-margin-validation'; -import { ERROR_EXPIRATION_IN_THE_PAST } from './validate-expiration'; -import { DEAL_TICKET_SECTION, ERROR_SIZE_DECIMAL } from '../constants'; +import type { + DealTicketMarketFragment, + OrderMargin, +} from '@vegaprotocol/deal-ticket'; +import { + MarketDataGrid, + compileGridData, + MarginWarning, + isMarketInAuction, + ERROR_SIZE_DECIMAL, + useOrderMarginValidation, +} from '@vegaprotocol/deal-ticket'; -export const isMarketInAuction = (market: DealTicketMarketFragment) => { - return [ - Schema.MarketTradingMode.TRADING_MODE_BATCH_AUCTION, - Schema.MarketTradingMode.TRADING_MODE_MONITORING_AUCTION, - Schema.MarketTradingMode.TRADING_MODE_OPENING_AUCTION, - ].includes(market.tradingMode); +export const DEAL_TICKET_SECTION = { + TYPE: 'sec-type', + SIZE: 'sec-size', + PRICE: 'sec-price', + FORCE: 'sec-force', + EXPIRY: 'sec-expiry', + SUMMARY: 'sec-summary', }; +export const ERROR_EXPIRATION_IN_THE_PAST = 'ERROR_EXPIRATION_IN_THE_PAST'; + export type ValidationProps = { step?: number; market: DealTicketMarketFragment; @@ -336,10 +343,19 @@ export const useOrderValidation = ({ return fieldErrorChecking; } - if (isInvalidOrderMargin) { + if ( + isInvalidOrderMargin.balance.isGreaterThan(0) && + isInvalidOrderMargin.balance.isLessThan(isInvalidOrderMargin.margin) + ) { return { - isDisabled: true, - message: , + isDisabled: false, + message: ( + + ), section: DEAL_TICKET_SECTION.PRICE, }; } diff --git a/apps/trading-e2e/src/integration/market-info.cy.ts b/apps/trading-e2e/src/integration/market-info.cy.ts index 371c2e100..49f259db9 100644 --- a/apps/trading-e2e/src/integration/market-info.cy.ts +++ b/apps/trading-e2e/src/integration/market-info.cy.ts @@ -235,7 +235,7 @@ describe('market states', { tags: '@smoke' }, function () { //7002-/SORD-/061 no state displayed it('must display that market is not accepting orders', function () { cy.getByTestId('place-order').click(); - cy.getByTestId('dealticket-error-message').should( + cy.getByTestId('dealticket-error-message-summary').should( 'have.text', `This market is ${marketState .split('_') diff --git a/apps/trading-e2e/src/integration/trading-deal-ticket.cy.ts b/apps/trading-e2e/src/integration/trading-deal-ticket.cy.ts index 1d4027fd6..9b15fedf8 100644 --- a/apps/trading-e2e/src/integration/trading-deal-ticket.cy.ts +++ b/apps/trading-e2e/src/integration/trading-deal-ticket.cy.ts @@ -235,11 +235,6 @@ describe('must submit order', { tags: '@smoke' }, () => { new Date(order.expiresAt as string).getTime().toString() + '000000', }); }); - - it.skip('must not allow to place an order if balance is 0 (no collateral)', function () { - //7002-/SORD-/003 - // it will be covered in https://github.com/vegaprotocol/frontend-monorepo/issues/1660 - }); }); describe( @@ -277,7 +272,7 @@ describe( side: 'SIDE_SELL', size: '100', price: '50000', - timeInForce: 'TIME_IN_FORCE_GFN', + timeInForce: 'TIME_IN_FORCE_GTC', }; testOrder(order, { price: '5000000000' }); }); @@ -302,7 +297,7 @@ describe( ); describe( - 'must submit order for market in batch auction', + 'must submit order for market in opening auction', { tags: '@regression' }, () => { before(() => { @@ -336,7 +331,7 @@ describe( side: 'SIDE_SELL', size: '100', price: '50000', - timeInForce: 'TIME_IN_FORCE_GFN', + timeInForce: 'TIME_IN_FORCE_GTC', }; testOrder(order, { price: '5000000000' }); }); @@ -361,7 +356,7 @@ describe( ); describe( - 'must submit order for market in batch auction', + 'must submit order for market in monitoring auction', { tags: '@regression' }, () => { before(() => { @@ -395,7 +390,7 @@ describe( side: 'SIDE_SELL', size: '100', price: '50000', - timeInForce: 'TIME_IN_FORCE_GFN', + timeInForce: 'TIME_IN_FORCE_GTC', }; testOrder(order, { price: '5000000000' }); }); @@ -474,6 +469,7 @@ describe('deal ticket size validation', { tags: '@smoke' }, function () { cy.wait('@Market'); connectVegaWallet(); }); + it('must warn if order size input has too many digits after the decimal place', function () { //7002-SORD-016 cy.getByTestId('order-type-TYPE_MARKET').click(); @@ -481,9 +477,9 @@ describe('deal ticket size validation', { tags: '@smoke' }, function () { cy.getByTestId(placeOrderBtn).should('not.be.disabled'); cy.getByTestId(placeOrderBtn).click(); cy.getByTestId(placeOrderBtn).should('be.disabled'); - cy.getByTestId('dealticket-error-message-price-market').should( + cy.getByTestId('dealticket-error-message-size-market').should( 'have.text', - 'Order sizes must be in whole numbers for this market' + 'Size must be whole numbers for this market' ); }); @@ -493,9 +489,9 @@ describe('deal ticket size validation', { tags: '@smoke' }, function () { cy.getByTestId(placeOrderBtn).should('not.be.disabled'); cy.getByTestId(placeOrderBtn).click(); cy.getByTestId(placeOrderBtn).should('be.disabled'); - cy.getByTestId('dealticket-error-message-price-market').should( + cy.getByTestId('dealticket-error-message-size-market').should( 'have.text', - 'Size cannot be lower than "1"' + 'Size cannot be lower than 1' ); }); }); @@ -513,7 +509,7 @@ describe('limit order validations', { tags: '@smoke' }, () => { //7002-SORD-018 cy.getByTestId(orderPriceField) .siblings('label') - .should('have.text', 'Price (BTC)'); + .should('have.text', 'Price (tBTC)'); }); it('must see warning when placing an order with expiry date in past', function () { @@ -529,15 +525,22 @@ describe('limit order validations', { tags: '@smoke' }, () => { cy.getByTestId(placeOrderBtn).click(); - cy.getByTestId('dealticket-error-message-force').should( + cy.getByTestId('dealticket-error-message-expiry').should( 'have.text', 'The expiry date that you have entered appears to be in the past' ); }); - it.skip('must receive warning if price has too many digits after decimal place', function () { - //7002/-SORD-/059 - // Skipped until https://github.com/vegaprotocol/frontend-monorepo/issues/1686 resolved + it('must see warning if price has too many digits after decimal place', function () { + //7002-SORD-059 + cy.getByTestId(toggleLimit).click(); + cy.getByTestId(orderTIFDropDown).select('TIME_IN_FORCE_GTC'); + cy.getByTestId(orderSizeField).clear().type('1'); + cy.getByTestId(orderPriceField).clear().type('1.123456'); + cy.getByTestId('dealticket-error-message-price-limit').should( + 'have.text', + 'Price accepts up to 5 decimal places' + ); }); describe('time in force validations', function () { @@ -651,7 +654,7 @@ describe('suspended market validation', { tags: '@regression' }, () => { cy.getByTestId(orderPriceField).clear().type('0.1'); cy.getByTestId(orderSizeField).clear().type('1'); cy.getByTestId(placeOrderBtn).should('be.enabled'); - cy.getByTestId(errorMessage).should( + cy.getByTestId('dealticket-warning-auction').should( 'have.text', 'Any orders placed now will not trade until the auction ends' ); @@ -663,15 +666,15 @@ describe('suspended market validation', { tags: '@regression' }, () => { TIFlist.filter((item) => item.code === 'FOK')[0].value ); cy.getByTestId(placeOrderBtn).should('be.disabled'); - cy.getByTestId('dealticket-error-message-force').should( + cy.getByTestId('dealticket-error-message-tif').should( 'have.text', 'This market is in auction until it reaches sufficient liquidity. Until the auction ends, you can only place GFA, GTT, or GTC limit orders' ); }); }); -describe('margin required validation', { tags: '@regression' }, () => { - before(() => { +describe('account validation', { tags: '@regression' }, () => { + beforeEach(() => { cy.mockTradingPage(); cy.mockGQL((req) => { aliasQuery( @@ -689,15 +692,25 @@ describe('margin required validation', { tags: '@regression' }, () => { cy.wait('@Market'); }); - it('should display info and button for deposit', () => { + it('should show an error if your balance is zero', () => { cy.getByTestId('place-order').should('not.be.disabled'); cy.getByTestId('place-order').click(); cy.getByTestId('place-order').should('be.disabled'); - cy.getByTestId('deal-ticket-margin-invalidated').should( - 'contain.text', - "You don't have enough margin available to open this position" + //7002-SORD-003 + cy.getByTestId('dealticket-error-message-zero-balance').should( + 'have.text', + 'Insufficient balance. Deposit ' + 'tBTC' ); - cy.getByTestId('deal-ticket-margin-invalidated').should( + cy.getByTestId('deal-ticket-deposit-dialog-button').should('exist'); + }); + + it('should display info and button for deposit', () => { + // warning should show immediately + cy.getByTestId('dealticket-warning-margin').should( + 'contain.text', + 'You may not have enough margin available to open this position' + ); + cy.getByTestId('dealticket-warning-margin').should( 'contain.text', '0.01 tBTC currently required, 0.001 tBTC available' ); diff --git a/libs/deal-ticket/src/components/deal-ticket-estimates.tsx b/libs/deal-ticket/src/components/deal-ticket-estimates.tsx index 33e8a50e2..42ae03e36 100644 --- a/libs/deal-ticket/src/components/deal-ticket-estimates.tsx +++ b/libs/deal-ticket/src/components/deal-ticket-estimates.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from 'react'; import { t } from '@vegaprotocol/react-helpers'; import { Icon, Tooltip, TrafficLight } from '@vegaprotocol/ui-toolkit'; import { IconNames } from '@blueprintjs/icons'; -import * as constants from './constants'; +import * as constants from '../constants'; interface DealTicketEstimatesProps { quoteName?: string; diff --git a/libs/deal-ticket/src/components/deal-ticket-validation/index.ts b/libs/deal-ticket/src/components/deal-ticket-validation/index.ts index 82e729572..c910c2f8d 100644 --- a/libs/deal-ticket/src/components/deal-ticket-validation/index.ts +++ b/libs/deal-ticket/src/components/deal-ticket-validation/index.ts @@ -1,5 +1 @@ -export * from './get-default-order'; -export * from './use-order-validation'; -export * from './validate-size'; -export * from './use-persisted-order'; -export * from './validate-expiration'; +export * from './margin-warning'; diff --git a/libs/deal-ticket/src/components/deal-ticket-validation/validate-margin.tsx b/libs/deal-ticket/src/components/deal-ticket-validation/margin-warning.tsx similarity index 53% rename from libs/deal-ticket/src/components/deal-ticket-validation/validate-margin.tsx rename to libs/deal-ticket/src/components/deal-ticket-validation/margin-warning.tsx index 3b3d3c391..e326924b1 100644 --- a/libs/deal-ticket/src/components/deal-ticket-validation/validate-margin.tsx +++ b/libs/deal-ticket/src/components/deal-ticket-validation/margin-warning.tsx @@ -1,52 +1,48 @@ import { normalizeFormatNumber, t } from '@vegaprotocol/react-helpers'; import { ButtonLink } from '@vegaprotocol/ui-toolkit'; -import React from 'react'; import { useState } from 'react'; import { DepositDialog } from '@vegaprotocol/deposits'; interface Props { margin: string; - symbol: string; - id: string; balance: string; - decimals: number; + asset: { + id: string; + symbol: string; + decimals: number; + }; } -export const ValidateMargin = ({ - margin, - symbol, - id, - balance, - decimals, -}: Props) => { +export const MarginWarning = ({ margin, balance, asset }: Props) => { const [depositDialog, setDepositDialog] = useState(false); return ( <>

- {t("You don't have enough margin available to open this position.")}{' '} + {t('You may not have enough margin available to open this position.')}{' '} setDepositDialog(true)} > - {t(`Deposit ${symbol}`)} + {t(`Deposit ${asset.symbol}`)}

- {`${normalizeFormatNumber(margin, decimals)} ${symbol} ${t( - 'currently required' - )}, ${normalizeFormatNumber(balance, decimals)} ${symbol} ${t( - 'available' - )}`} + {`${normalizeFormatNumber(margin, asset.decimals)} ${ + asset.symbol + } ${t('currently required')}, ${normalizeFormatNumber( + balance, + asset.decimals + )} ${asset.symbol} ${t('available')}`}

); diff --git a/libs/deal-ticket/src/components/deal-ticket-validation/validate-size.ts b/libs/deal-ticket/src/components/deal-ticket-validation/validate-size.ts deleted file mode 100644 index c57d27640..000000000 --- a/libs/deal-ticket/src/components/deal-ticket-validation/validate-size.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ERROR_SIZE_DECIMAL } from '../constants'; - -export const validateSize = (step: number) => { - const [, stepDecimals = ''] = String(step).split('.'); - return (value: string) => { - const [, valueDecimals = ''] = value.split('.'); - if (stepDecimals.length < valueDecimals.length) { - return ERROR_SIZE_DECIMAL; - } - return true; - }; -}; diff --git a/libs/deal-ticket/src/components/deal-ticket-validation/zero-balance-error.tsx b/libs/deal-ticket/src/components/deal-ticket-validation/zero-balance-error.tsx new file mode 100644 index 000000000..48a095b92 --- /dev/null +++ b/libs/deal-ticket/src/components/deal-ticket-validation/zero-balance-error.tsx @@ -0,0 +1,35 @@ +import { t } from '@vegaprotocol/react-helpers'; +import { ButtonLink, InputError } from '@vegaprotocol/ui-toolkit'; +import { useState } from 'react'; +import { DepositDialog } from '@vegaprotocol/deposits'; + +interface ZeroBalanceErrorProps { + asset: { + id: string; + symbol: string; + }; +} + +export const ZeroBalanceError = ({ asset }: ZeroBalanceErrorProps) => { + const [depositDialog, setDepositDialog] = useState(false); + return ( + <> + +

+ {t('Insufficient balance. ')} + setDepositDialog(true)} + > + {t(`Deposit ${asset.symbol}`)} + +

+
+ + + ); +}; 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 55a945dc2..95bed6573 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,18 +1,16 @@ import type { UseFormRegister } from 'react-hook-form'; -import type { OrderSubmissionBody } from '@vegaprotocol/wallet'; import { DealTicketMarketAmount } from './deal-ticket-market-amount'; import { DealTicketLimitAmount } from './deal-ticket-limit-amount'; import type { DealTicketMarketFragment } from './__generated__/DealTicket'; import { Schema } from '@vegaprotocol/types'; -import type { DealTicketErrorMessage } from './deal-ticket-error'; +import type { DealTicketFormFields } from './deal-ticket'; export interface DealTicketAmountProps { orderType: Schema.OrderType; market: DealTicketMarketFragment; - register: UseFormRegister; - quoteName: string; - price?: string; - errorMessage?: DealTicketErrorMessage; + register: UseFormRegister; + sizeError?: string; + priceError?: string; } export const DealTicketAmount = ({ 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 b10161450..28a117e3f 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 @@ -2,41 +2,28 @@ import { t } from '@vegaprotocol/react-helpers'; import { Button } from '@vegaprotocol/ui-toolkit'; import { useVegaWallet, useVegaWalletDialogStore } from '@vegaprotocol/wallet'; -import { DEAL_TICKET_SECTION } from '../constants'; -import { DealTicketError } from './deal-ticket-error'; - -import type { DealTicketErrorMessage } from './deal-ticket-error'; interface Props { transactionStatus: 'default' | 'pending'; - isDisabled: boolean; - errorMessage?: DealTicketErrorMessage; + disabled: boolean; } -export const DealTicketButton = ({ - transactionStatus, - errorMessage, - isDisabled, -}: Props) => { +export const DealTicketButton = ({ transactionStatus, disabled }: Props) => { const { pubKey } = useVegaWallet(); const { openVegaWalletDialog } = useVegaWalletDialogStore((store) => ({ openVegaWalletDialog: store.openVegaWalletDialog, })); + const isPending = transactionStatus === 'pending'; return pubKey ? ( -
+
-
) : (
- + {renderError()} ); }; diff --git a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-market-amount.tsx b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-market-amount.tsx index db41c3c39..1d233960a 100644 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-market-amount.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-market-amount.tsx @@ -1,10 +1,10 @@ -import { formatNumber, t, toDecimal } from '@vegaprotocol/react-helpers'; -import { FormGroup, Input, Tooltip } from '@vegaprotocol/ui-toolkit'; - -import { DEAL_TICKET_SECTION } from '../constants'; -import { isMarketInAuction } from '../deal-ticket-validation/use-order-validation'; -import { validateSize } from '../deal-ticket-validation/validate-size'; -import { DealTicketError } from './deal-ticket-error'; +import { + addDecimalsFormatNumber, + t, + toDecimal, +} from '@vegaprotocol/react-helpers'; +import { Input, InputError, Tooltip } from '@vegaprotocol/ui-toolkit'; +import { isMarketInAuction, validateAmount } from '../../utils'; import type { DealTicketAmountProps } from './deal-ticket-amount'; @@ -15,71 +15,88 @@ export type DealTicketMarketAmountProps = Omit< export const DealTicketMarketAmount = ({ register, - price, market, - quoteName, - errorMessage, + sizeError, }: DealTicketMarketAmountProps) => { + const quoteName = + market.tradableInstrument.instrument.product.settlementAsset.symbol; const sizeStep = toDecimal(market?.positionDecimalPlaces); + + let price; + if (isMarketInAuction(market)) { + // 0 can never be a valid uncrossing price + // as it would require there being orders on the book at that price. + if ( + market.data?.indicativePrice && + market.data.indicativePrice !== '0' && + BigInt(market.data?.indicativePrice) !== BigInt(0) + ) { + price = market.data.indicativePrice; + } + } else { + price = market.depth.lastTrade?.price; + } + + const priceFormatted = price + ? addDecimalsFormatNumber(price, market.decimalPlaces) + : undefined; + return (
-
-
- - e.currentTarget.blur()} - data-testid="order-size" - {...register('size', { - required: true, - min: sizeStep, - validate: validateSize(sizeStep), - })} - /> - -
-
-
 
-
@
-
-
- {isMarketInAuction(market) ? ( +
+
Size
+
+
+ {isMarketInAuction(market) && ( -
- {t(`Estimated uncrossing price`)} -
+
{t(`Estimated uncrossing price`)}
- ) : ( -
 
)} -
- {price && quoteName ? ( - <> - ~{formatNumber(price, market.decimalPlaces)} {quoteName} - - ) : ( - '-' - )} -
- +
+
+ e.currentTarget.blur()} + data-testid="order-size" + {...register('size', { + required: t('You need to provide a size'), + min: { + value: sizeStep, + message: t('Size cannot be lower than ' + sizeStep), + }, + validate: validateAmount(sizeStep, 'Size'), + })} + /> +
+
@
+
+ {priceFormatted && quoteName ? ( + <> + ~{priceFormatted} {quoteName} + + ) : ( + '-' + )} +
+
+ {sizeError && ( + + {sizeError} + + )}
); }; 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 200c7b4bc..aa0f0f8c7 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 @@ -110,7 +110,7 @@ describe('DealTicket', () => { expect(screen.getByTestId('last-price')).toHaveTextContent( // eslint-disable-next-line `~${addDecimal(market.depth.lastTrade!.price, market.decimalPlaces)} ${ - market.tradableInstrument.instrument.product.quoteName + market.tradableInstrument.instrument.product.settlementAsset.symbol }` ); }); 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 659839734..efecaa04b 100644 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/deal-ticket.tsx @@ -1,17 +1,12 @@ -import { addDecimal, removeDecimal } from '@vegaprotocol/react-helpers'; +import { removeDecimal, t } from '@vegaprotocol/react-helpers'; import { Schema } from '@vegaprotocol/types'; -import { useCallback, useEffect, useState } from 'react'; +import { memo, useCallback, useEffect } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { getFeeDetailsValues, useFeeDealTicketDetails, } from '../../hooks/use-fee-deal-ticket-details'; -import { getDefaultOrder, usePersistedOrder } from '../deal-ticket-validation'; -import { - isMarketInAuction, - useOrderValidation, -} from '../deal-ticket-validation/use-order-validation'; import { DealTicketAmount } from './deal-ticket-amount'; import { DealTicketButton } from './deal-ticket-button'; import { DealTicketFeeDetails } from './deal-ticket-fee-details'; @@ -22,8 +17,21 @@ import { TypeSelector } from './type-selector'; import type { DealTicketMarketFragment } from './__generated__/DealTicket'; import type { OrderSubmissionBody } from '@vegaprotocol/wallet'; -import type { DealTicketErrorMessage } from './deal-ticket-error'; -import { DEAL_TICKET_SECTION } from '../constants'; +import { useVegaWallet } from '@vegaprotocol/wallet'; +import { InputError } from '@vegaprotocol/ui-toolkit'; +import { useOrderMarginValidation } from '../../hooks/use-order-margin-validation'; +import { MarginWarning } from '../deal-ticket-validation/margin-warning'; +import { usePersistedOrder } from '../../hooks/use-persisted-order'; +import { + getDefaultOrder, + validateMarketState, + validateMarketTradingMode, + validateTimeInForce, + validateType, +} from '../../utils'; +import { ZeroBalanceError } from '../deal-ticket-validation/zero-balance-error'; +import { AccountValidationType } from '../../constants'; +import type BigNumber from 'bignumber.js'; export type TransactionStatus = 'default' | 'pending'; @@ -34,116 +42,104 @@ export interface DealTicketProps { defaultOrder?: OrderSubmissionBody['orderSubmission']; } +export type DealTicketFormFields = OrderSubmissionBody['orderSubmission'] & { + // This is not a field used in the form but allows us to set a + // summary error message + summary: string; +}; + export const DealTicket = ({ market, submit, transactionStatus, }: DealTicketProps) => { - const [errorMessage, setErrorMessage] = useState< - DealTicketErrorMessage | undefined - >(undefined); - const [persistedOrder, setOrder] = usePersistedOrder(market); + const { pubKey } = useVegaWallet(); + const [persistedOrder, setPersistedOrder] = usePersistedOrder(market); const { register, control, handleSubmit, watch, - setValue, - clearErrors, setError, - formState: { errors, isSubmitted }, - } = useForm({ - mode: 'onChange', + formState: { errors }, + } = useForm({ defaultValues: persistedOrder || getDefaultOrder(market), }); - const order = watch(); + const order = watch(); const feeDetails = useFeeDealTicketDetails(order, market); const details = getFeeDetailsValues(feeDetails); - useEffect(() => setOrder(order), [order, setOrder]); + // When order state changes persist it in local storage + useEffect(() => setPersistedOrder(order), [order, setPersistedOrder]); - const { - isDisabled: disabled, - message, - section: errorSection, - } = useOrderValidation({ + const accountData = useOrderMarginValidation({ market, - orderType: order.type, - orderTimeInForce: order.timeInForce, - fieldErrors: errors, estMargin: feeDetails.estMargin, }); - useEffect(() => { - if (disabled) { - setError('marketId', {}); - } else { - clearErrors('marketId'); - } - }, [disabled, setError, clearErrors]); - - useEffect(() => { - if (isSubmitted || errorSection === DEAL_TICKET_SECTION.SUMMARY) { - setErrorMessage({ message, isDisabled: disabled, errorSection }); - } else { - setErrorMessage(undefined); - } - }, [disabled, message, errorSection, isSubmitted]); - - const isDisabled = transactionStatus === 'pending' || disabled; - const onSubmit = useCallback( (order: OrderSubmissionBody['orderSubmission']) => { - if (!isDisabled) { - submit({ - ...order, - price: - order.price && removeDecimal(order.price, market.decimalPlaces), - size: removeDecimal(order.size, market.positionDecimalPlaces), - expiresAt: - order.timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_GTT - ? order.expiresAt - : undefined, - }); + if (!pubKey) { + setError('summary', { message: t('No public key selected') }); + return; } - }, - [isDisabled, submit, market.decimalPlaces, market.positionDecimalPlaces] - ); - const getEstimatedMarketPrice = () => { - if (isMarketInAuction(market)) { - // 0 can never be a valid uncrossing price - // as it would require there being orders on the book at that price. - if ( - market.data?.indicativePrice && - BigInt(market.data?.indicativePrice) !== BigInt(0) - ) { - return market.data.indicativePrice; + const marketStateError = validateMarketState(market.state); + if (marketStateError !== true) { + setError('summary', { message: marketStateError }); + return; } - return undefined; - } - return market.depth.lastTrade?.price; - }; - const marketPrice = getEstimatedMarketPrice(); - const marketPriceFormatted = - marketPrice && addDecimal(marketPrice, market.decimalPlaces); - useEffect(() => { - if (marketPriceFormatted && order.type === Schema.OrderType.TYPE_MARKET) { - setValue('price', marketPriceFormatted); - } - }, [marketPriceFormatted, order.type, setValue]); + + if (accountData.balance.isZero()) { + setError('summary', { message: AccountValidationType.NoCollateral }); + return; + } + + const marketTradingModeError = validateMarketTradingMode( + market.tradingMode + ); + if (marketTradingModeError !== true) { + setError('summary', { message: marketTradingModeError }); + return; + } + + submit({ + ...order, + price: order.price && removeDecimal(order.price, market.decimalPlaces), + size: removeDecimal(order.size, market.positionDecimalPlaces), + expiresAt: + order.timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_GTT + ? order.expiresAt + : undefined, + }); + }, + [ + submit, + pubKey, + accountData, + market.positionDecimalPlaces, + market.decimalPlaces, + market.state, + market.tradingMode, + setError, + ] + ); return (
( )} /> @@ -158,19 +154,22 @@ export const DealTicket = ({ orderType={order.type} market={market} register={register} - price={order.price} - quoteName={market.tradableInstrument.instrument.product.quoteName} - errorMessage={errorMessage} + sizeError={errors.size?.message} + priceError={errors.price?.message} /> ( )} /> @@ -183,18 +182,103 @@ export const DealTicket = ({ )} /> )} = 1} transactionStatus={transactionStatus} - isDisabled={isSubmitted && isDisabled} - errorMessage={errorMessage} + /> + ); }; + +/** + * Renders an error message if errors.summary is present otherwise + * renders warnings about current state of the market + */ +interface SummaryMessageProps { + errorMessage?: string; + market: DealTicketMarketFragment; + accountData: { + balance: BigNumber; + margin: BigNumber; + asset: { + id: string; + symbol: string; + decimals: number; + name: string; + }; + }; +} +const SummaryMessage = memo( + ({ errorMessage, market, accountData }: SummaryMessageProps) => { + // Specific error UI for if balance is so we can + // render a deposit dialog + if (errorMessage === AccountValidationType.NoCollateral) { + return ( + + ); + } + + // If we have any other full error which prevents + // submission render that first + if (errorMessage) { + return ( +
+ + {errorMessage} + +
+ ); + } + + // If there is no blocking error but user doesn't have enough + // balance render the margin warning, but still allow submission + if ( + accountData.balance.isGreaterThan(0) && + accountData.balance.isLessThan(accountData.margin) + ) { + return ( + + ); + } + + // Show auction mode warning + if ( + [ + Schema.MarketTradingMode.TRADING_MODE_BATCH_AUCTION, + Schema.MarketTradingMode.TRADING_MODE_MONITORING_AUCTION, + Schema.MarketTradingMode.TRADING_MODE_OPENING_AUCTION, + ].includes(market.tradingMode) + ) { + return ( +
+

+ {t('Any orders placed now will not trade until the auction ends')} +

+
+ ); + } + + return null; + } +); 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 fada0f604..dcebc4325 100644 --- a/libs/deal-ticket/src/components/deal-ticket/expiry-selector.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/expiry-selector.tsx @@ -1,18 +1,15 @@ -import { FormGroup, Input } from '@vegaprotocol/ui-toolkit'; +import { FormGroup, Input, InputError } from '@vegaprotocol/ui-toolkit'; import { formatForInput } from '@vegaprotocol/react-helpers'; import { t } from '@vegaprotocol/react-helpers'; import type { UseFormRegister } from 'react-hook-form'; -import type { OrderSubmissionBody } from '@vegaprotocol/wallet'; -import { validateExpiration } from '../deal-ticket-validation/validate-expiration'; -import type { DealTicketErrorMessage } from './deal-ticket-error'; -import { DealTicketError } from './deal-ticket-error'; -import { DEAL_TICKET_SECTION } from '../constants'; +import { validateExpiration } from '../../utils/validate-expiration'; +import type { DealTicketFormFields } from '.'; interface ExpirySelectorProps { value?: string; onSelect: (expiration: string | null) => void; - errorMessage?: DealTicketErrorMessage; - register?: UseFormRegister; + errorMessage?: string; + register?: UseFormRegister; } export const ExpirySelector = ({ @@ -37,11 +34,11 @@ export const ExpirySelector = ({ validate: validateExpiration, })} /> - + {errorMessage && ( + + {errorMessage} + + )} ); }; diff --git a/libs/deal-ticket/src/components/deal-ticket/time-in-force-selector.tsx b/libs/deal-ticket/src/components/deal-ticket/time-in-force-selector.tsx index 2dcbf70c3..14940a9f0 100644 --- a/libs/deal-ticket/src/components/deal-ticket/time-in-force-selector.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/time-in-force-selector.tsx @@ -1,17 +1,23 @@ import { useEffect, useState } from 'react'; -import { FormGroup, Select } from '@vegaprotocol/ui-toolkit'; +import { + FormGroup, + InputError, + Select, + Tooltip, +} from '@vegaprotocol/ui-toolkit'; import { Schema } from '@vegaprotocol/types'; import { t } from '@vegaprotocol/react-helpers'; import { timeInForceLabel } from '@vegaprotocol/orders'; -import type { DealTicketErrorMessage } from './deal-ticket-error'; -import { DealTicketError } from './deal-ticket-error'; -import { DEAL_TICKET_SECTION } from '../constants'; +import type { DealTicketMarketFragment } from './__generated__/DealTicket'; +import { compileGridData, MarketDataGrid } from '../trading-mode-tooltip'; +import { MarketModeValidationType } from '../../constants'; interface TimeInForceSelectorProps { value: Schema.OrderTimeInForce; orderType: Schema.OrderType; onSelect: (tif: Schema.OrderTimeInForce) => void; - errorMessage?: DealTicketErrorMessage; + market: DealTicketMarketFragment; + errorMessage?: string; } type OrderType = Schema.OrderType.TYPE_MARKET | Schema.OrderType.TYPE_LIMIT; @@ -27,6 +33,7 @@ export const TimeInForceSelector = ({ value, orderType, onSelect, + market, errorMessage, }: TimeInForceSelectorProps) => { const options = @@ -60,6 +67,50 @@ export const TimeInForceSelector = ({ setPreviousOrderType, ]); + const renderError = (errorType: string) => { + if (errorType === MarketModeValidationType.Auction) { + return t( + `Until the auction ends, you can only place GFA, GTT, or GTC limit orders` + ); + } + + if (errorType === MarketModeValidationType.LiquidityMonitoringAuction) { + return ( + + {t('This market is in auction until it reaches')}{' '} + } + > + {t('sufficient liquidity')} + + {'. '} + {t( + `Until the auction ends, you can only place GFA, GTT, or GTC limit orders` + )} + + ); + } + + if (errorType === MarketModeValidationType.PriceMonitoringAuction) { + return ( + + {t('This market is in auction due to')}{' '} + } + > + {t('high price volatility')} + + {'. '} + {t( + `Until the auction ends, you can only place GFA, GTT, or GTC limit orders` + )} + + ); + } + + return null; + }; + return ( - + {errorMessage && ( + + {renderError(errorMessage)} + + )} ); }; 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 55cd67da7..5ec1b49bb 100644 --- a/libs/deal-ticket/src/components/deal-ticket/type-selector.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/type-selector.tsx @@ -1,15 +1,16 @@ -import { FormGroup } from '@vegaprotocol/ui-toolkit'; +import { FormGroup, InputError, Tooltip } from '@vegaprotocol/ui-toolkit'; import { t } from '@vegaprotocol/react-helpers'; import { Schema } from '@vegaprotocol/types'; import { Toggle } from '@vegaprotocol/ui-toolkit'; -import type { DealTicketErrorMessage } from './deal-ticket-error'; -import { DealTicketError } from './deal-ticket-error'; -import { DEAL_TICKET_SECTION } from '../constants'; +import { compileGridData, MarketDataGrid } from '../trading-mode-tooltip'; +import type { DealTicketMarketFragment } from './__generated__/DealTicket'; +import { MarketModeValidationType } from '../../constants'; interface TypeSelectorProps { value: Schema.OrderType; onSelect: (type: Schema.OrderType) => void; - errorMessage?: DealTicketErrorMessage; + market: DealTicketMarketFragment; + errorMessage?: string; } const toggles = [ @@ -20,8 +21,47 @@ const toggles = [ export const TypeSelector = ({ value, onSelect, + market, errorMessage, }: TypeSelectorProps) => { + const renderError = (errorType: MarketModeValidationType) => { + if (errorType === MarketModeValidationType.Auction) { + return t('Only limit orders are permitted when market is in auction'); + } + + if (errorType === MarketModeValidationType.LiquidityMonitoringAuction) { + return ( + + {t('This market is in auction until it reaches')}{' '} + } + > + {t('sufficient liquidity')} + + {'. '} + {t('Only limit orders are permitted when market is in auction')} + + ); + } + + if (errorType === MarketModeValidationType.PriceMonitoringAuction) { + return ( + + {t('This market is in auction due to')}{' '} + } + > + {t('high price volatility')} + + {'. '} + {t('Only limit orders are permitted when market is in auction')} + + ); + } + + return null; + }; + return ( onSelect(e.target.value as Schema.OrderType)} /> - + {errorMessage && ( + + {renderError(errorMessage as MarketModeValidationType)} + + )} ); }; diff --git a/libs/deal-ticket/src/components/index.ts b/libs/deal-ticket/src/components/index.ts index d5f1acb6b..dbc74f55b 100644 --- a/libs/deal-ticket/src/components/index.ts +++ b/libs/deal-ticket/src/components/index.ts @@ -2,4 +2,3 @@ export * from './deal-ticket'; export * from './deal-ticket-validation'; export * from './trading-mode-tooltip'; export * from './deal-ticket-estimates'; -export * from './constants'; diff --git a/libs/deal-ticket/src/components/constants.ts b/libs/deal-ticket/src/constants.ts similarity index 87% rename from libs/deal-ticket/src/components/constants.ts rename to libs/deal-ticket/src/constants.ts index 4192763bb..5b618b3ff 100644 --- a/libs/deal-ticket/src/components/constants.ts +++ b/libs/deal-ticket/src/constants.ts @@ -20,13 +20,16 @@ export const EST_SLIPPAGE = t( 'When you execute a trade on Vega, the price obtained in the market may differ from the best available price displayed at the time of placing the trade. The estimated slippage shows the difference between the best available price and the estimated execution price, determined by market liquidity and your chosen order size.' ); -export const DEAL_TICKET_SECTION = { - TYPE: 'sec-type', - SIZE: 'sec-size', - PRICE: 'sec-price', - FORCE: 'sec-force', - EXPIRY: 'sec-expiry', - SUMMARY: 'sec-summary', -}; +export const ERROR_SIZE_DECIMAL = t( + 'The size field accepts up to X decimal places' +); -export const ERROR_SIZE_DECIMAL = 'step'; +export enum MarketModeValidationType { + PriceMonitoringAuction = 'PriceMonitoringAuction', + LiquidityMonitoringAuction = 'LiquidityMonitoringAuction', + Auction = 'Auction', +} + +export enum AccountValidationType { + NoCollateral = 'NoCollateral', +} diff --git a/libs/deal-ticket/src/hooks/index.ts b/libs/deal-ticket/src/hooks/index.ts index b6f3ddd8b..a76bf58c3 100644 --- a/libs/deal-ticket/src/hooks/index.ts +++ b/libs/deal-ticket/src/hooks/index.ts @@ -10,4 +10,5 @@ export * from './use-market-positions'; export * from './use-maximum-position-size'; export * from './use-order-closeout'; export * from './use-order-margin'; +export * from './use-order-margin-validation'; export * from './use-settlement-account'; diff --git a/libs/deal-ticket/src/hooks/use-fee-deal-ticket-details.tsx b/libs/deal-ticket/src/hooks/use-fee-deal-ticket-details.tsx index f4631050a..a80614dd3 100644 --- a/libs/deal-ticket/src/hooks/use-fee-deal-ticket-details.tsx +++ b/libs/deal-ticket/src/hooks/use-fee-deal-ticket-details.tsx @@ -13,7 +13,7 @@ import { EST_CLOSEOUT_TOOLTIP_TEXT, EST_MARGIN_TOOLTIP_TEXT, NOTIONAL_SIZE_TOOLTIP_TEXT, -} from '../components/constants'; +} from '../constants'; import { usePartyBalanceQuery } from './__generated__/PartyBalance'; import { useCalculateSlippage } from './use-calculate-slippage'; import { useOrderCloseOut } from './use-order-closeout'; diff --git a/libs/deal-ticket/src/components/deal-ticket-validation/use-order-margin-validation.tsx b/libs/deal-ticket/src/hooks/use-order-margin-validation.ts similarity index 63% rename from libs/deal-ticket/src/components/deal-ticket-validation/use-order-margin-validation.tsx rename to libs/deal-ticket/src/hooks/use-order-margin-validation.ts index 819f1bc30..c72e2c737 100644 --- a/libs/deal-ticket/src/components/deal-ticket-validation/use-order-margin-validation.tsx +++ b/libs/deal-ticket/src/hooks/use-order-margin-validation.ts @@ -2,9 +2,10 @@ import { useMemo } from 'react'; import { useVegaWallet } from '@vegaprotocol/wallet'; import { Schema } from '@vegaprotocol/types'; import { toBigNum } from '@vegaprotocol/react-helpers'; -import type { DealTicketMarketFragment } from '../deal-ticket/__generated__/DealTicket'; -import type { OrderMargin } from '../../hooks/use-order-margin'; -import { usePartyBalanceQuery, useSettlementAccount } from '../../hooks'; +import type { DealTicketMarketFragment } from '../components/deal-ticket/__generated__/DealTicket'; +import type { OrderMargin } from './use-order-margin'; +import { usePartyBalanceQuery } from './__generated__/PartyBalance'; +import { useSettlementAccount } from './use-settlement-account'; interface Props { market: DealTicketMarketFragment; @@ -34,22 +35,15 @@ export const useOrderMarginValidation = ({ market, estMargin }: Props) => { ) : toBigNum('0', assetDecimals); const margin = toBigNum(estMargin?.margin || 0, assetDecimals); - const { id, symbol, decimals } = - market.tradableInstrument.instrument.product.settlementAsset; - const balanceString = balance.toString(); - const marginString = margin.toString(); + const asset = market.tradableInstrument.instrument.product.settlementAsset; + const memoizedValue = useMemo(() => { return { - balance: balanceString, - margin: marginString, - id, - symbol, - decimals, + balance, + margin, + asset, }; - }, [balanceString, marginString, id, symbol, decimals]); + }, [balance, margin, asset]); - if (balance.isZero() || balance.isLessThan(margin)) { - return memoizedValue; - } - return false; + return memoizedValue; }; diff --git a/libs/deal-ticket/src/components/deal-ticket-validation/use-persisted-order.ts b/libs/deal-ticket/src/hooks/use-persisted-order.ts similarity index 100% rename from libs/deal-ticket/src/components/deal-ticket-validation/use-persisted-order.ts rename to libs/deal-ticket/src/hooks/use-persisted-order.ts diff --git a/libs/deal-ticket/src/index.ts b/libs/deal-ticket/src/index.ts index f76fd6f16..4d6bc47fb 100644 --- a/libs/deal-ticket/src/index.ts +++ b/libs/deal-ticket/src/index.ts @@ -1,2 +1,4 @@ export * from './components'; export * from './hooks'; +export * from './utils'; +export * from './constants'; diff --git a/libs/deal-ticket/src/components/deal-ticket-validation/get-default-order.ts b/libs/deal-ticket/src/utils/get-default-order.ts similarity index 100% rename from libs/deal-ticket/src/components/deal-ticket-validation/get-default-order.ts rename to libs/deal-ticket/src/utils/get-default-order.ts diff --git a/libs/deal-ticket/src/utils/index.ts b/libs/deal-ticket/src/utils/index.ts new file mode 100644 index 000000000..1095489a5 --- /dev/null +++ b/libs/deal-ticket/src/utils/index.ts @@ -0,0 +1,8 @@ +export * from './get-default-order'; +export * from './is-market-in-auction'; +export * from './validate-amount'; +export * from './validate-expiration'; +export * from './validate-market-state'; +export * from './validate-market-trading-mode'; +export * from './validate-time-in-force'; +export * from './validate-type'; diff --git a/libs/deal-ticket/src/utils/is-market-in-auction.ts b/libs/deal-ticket/src/utils/is-market-in-auction.ts new file mode 100644 index 000000000..37253af4b --- /dev/null +++ b/libs/deal-ticket/src/utils/is-market-in-auction.ts @@ -0,0 +1,10 @@ +import { Schema } from '@vegaprotocol/types'; +import type { DealTicketMarketFragment } from '../components'; + +export const isMarketInAuction = (market: DealTicketMarketFragment) => { + return [ + Schema.MarketTradingMode.TRADING_MODE_BATCH_AUCTION, + Schema.MarketTradingMode.TRADING_MODE_MONITORING_AUCTION, + Schema.MarketTradingMode.TRADING_MODE_OPENING_AUCTION, + ].includes(market.tradingMode); +}; diff --git a/libs/deal-ticket/src/utils/validate-amount.ts b/libs/deal-ticket/src/utils/validate-amount.ts new file mode 100644 index 000000000..10991f078 --- /dev/null +++ b/libs/deal-ticket/src/utils/validate-amount.ts @@ -0,0 +1,16 @@ +import { t } from '@vegaprotocol/react-helpers'; + +export const validateAmount = (step: number, field: string) => { + const [, stepDecimals = ''] = String(step).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`); + } + return t(`${field} accepts up to ${stepDecimals.length} decimal places`); + } + return true; + }; +}; diff --git a/libs/deal-ticket/src/components/deal-ticket-validation/validate-expiration.ts b/libs/deal-ticket/src/utils/validate-expiration.ts similarity index 67% rename from libs/deal-ticket/src/components/deal-ticket-validation/validate-expiration.ts rename to libs/deal-ticket/src/utils/validate-expiration.ts index 42c331768..3797792f3 100644 --- a/libs/deal-ticket/src/components/deal-ticket-validation/validate-expiration.ts +++ b/libs/deal-ticket/src/utils/validate-expiration.ts @@ -1,14 +1,13 @@ +import { t } from '@vegaprotocol/react-helpers'; import type { Validate } from 'react-hook-form'; -export const ERROR_EXPIRATION_IN_THE_PAST = 'ERROR_EXPIRATION_IN_THE_PAST'; - export const validateExpiration: Validate = ( value?: string ) => { const now = new Date(); const valueAsDate = value ? new Date(value) : now; if (now > valueAsDate) { - return ERROR_EXPIRATION_IN_THE_PAST; + return t('The expiry date that you have entered appears to be in the past'); } return true; }; diff --git a/libs/deal-ticket/src/utils/validate-market-state.ts b/libs/deal-ticket/src/utils/validate-market-state.ts new file mode 100644 index 000000000..c376955fa --- /dev/null +++ b/libs/deal-ticket/src/utils/validate-market-state.ts @@ -0,0 +1,42 @@ +import { t } from '@vegaprotocol/react-helpers'; +import { MarketStateMapping, Schema } from '@vegaprotocol/types'; + +export const validateMarketState = (state: Schema.MarketState) => { + if ( + [ + Schema.MarketState.STATE_SETTLED, + Schema.MarketState.STATE_REJECTED, + Schema.MarketState.STATE_TRADING_TERMINATED, + Schema.MarketState.STATE_CANCELLED, + Schema.MarketState.STATE_CLOSED, + ].includes(state) + ) { + return t( + `This market is ${marketTranslations(state)} and not accepting orders` + ); + } + + if ( + [ + Schema.MarketState.STATE_PROPOSED, + Schema.MarketState.STATE_PENDING, + ].includes(state) + ) { + return t( + `This market is ${marketTranslations( + state + )} and only accepting liquidity commitment orders` + ); + } + + return true; +}; + +const marketTranslations = (marketState: Schema.MarketState) => { + switch (marketState) { + case Schema.MarketState.STATE_TRADING_TERMINATED: + return t('terminated'); + default: + return t(MarketStateMapping[marketState]).toLowerCase(); + } +}; diff --git a/libs/deal-ticket/src/utils/validate-market-trading-mode.ts b/libs/deal-ticket/src/utils/validate-market-trading-mode.ts new file mode 100644 index 000000000..1ad78b882 --- /dev/null +++ b/libs/deal-ticket/src/utils/validate-market-trading-mode.ts @@ -0,0 +1,12 @@ +import { t } from '@vegaprotocol/react-helpers'; +import { Schema } from '@vegaprotocol/types'; + +export const validateMarketTradingMode = ( + tradingMode: Schema.MarketTradingMode +) => { + if (tradingMode === Schema.MarketTradingMode.TRADING_MODE_NO_TRADING) { + return t('Trading terminated'); + } + + return true; +}; diff --git a/libs/deal-ticket/src/utils/validate-time-in-force.ts b/libs/deal-ticket/src/utils/validate-time-in-force.ts new file mode 100644 index 000000000..cc5ff62b0 --- /dev/null +++ b/libs/deal-ticket/src/utils/validate-time-in-force.ts @@ -0,0 +1,38 @@ +import { Schema } from '@vegaprotocol/types'; +import type { DealTicketMarketFragment } from '../components'; +import { MarketModeValidationType } from '../constants'; +import { isMarketInAuction } from './is-market-in-auction'; + +export const validateTimeInForce = (market: DealTicketMarketFragment) => { + return (value: Schema.OrderTimeInForce) => { + const isMonitoringAuction = + market.tradingMode === + Schema.MarketTradingMode.TRADING_MODE_MONITORING_AUCTION; + const isPriceTrigger = + market.data?.trigger === Schema.AuctionTrigger.AUCTION_TRIGGER_PRICE; + const isLiquidityTrigger = + market.data?.trigger === Schema.AuctionTrigger.AUCTION_TRIGGER_LIQUIDITY; + + if (isMarketInAuction(market)) { + if ( + [ + Schema.OrderTimeInForce.TIME_IN_FORCE_FOK, + Schema.OrderTimeInForce.TIME_IN_FORCE_IOC, + Schema.OrderTimeInForce.TIME_IN_FORCE_GFN, + ].includes(value) + ) { + if (isMonitoringAuction && isLiquidityTrigger) { + return MarketModeValidationType.LiquidityMonitoringAuction; + } + + if (isMonitoringAuction && isPriceTrigger) { + return MarketModeValidationType.PriceMonitoringAuction; + } + + return MarketModeValidationType.Auction; + } + } + + return true; + }; +}; diff --git a/libs/deal-ticket/src/utils/validate-type.ts b/libs/deal-ticket/src/utils/validate-type.ts new file mode 100644 index 000000000..5118660bd --- /dev/null +++ b/libs/deal-ticket/src/utils/validate-type.ts @@ -0,0 +1,31 @@ +import { Schema } from '@vegaprotocol/types'; +import type { DealTicketMarketFragment } from '../components'; +import { MarketModeValidationType } from '../constants'; +import { isMarketInAuction } from './is-market-in-auction'; + +export const validateType = (market: DealTicketMarketFragment) => { + return (value: Schema.OrderType) => { + if (isMarketInAuction(market) && value === Schema.OrderType.TYPE_MARKET) { + const isMonitoringAuction = + market.tradingMode === + Schema.MarketTradingMode.TRADING_MODE_MONITORING_AUCTION; + const isPriceTrigger = + market.data?.trigger === Schema.AuctionTrigger.AUCTION_TRIGGER_PRICE; + const isLiquidityTrigger = + market.data?.trigger === + Schema.AuctionTrigger.AUCTION_TRIGGER_LIQUIDITY; + + if (isMonitoringAuction && isPriceTrigger) { + return MarketModeValidationType.PriceMonitoringAuction; + } + + if (isMonitoringAuction && isLiquidityTrigger) { + return MarketModeValidationType.LiquidityMonitoringAuction; + } + + return MarketModeValidationType.Auction; + } + + return true; + }; +}; diff --git a/libs/deposits/src/lib/deposit-limits.tsx b/libs/deposits/src/lib/deposit-limits.tsx index c026fe1ee..2977a784c 100644 --- a/libs/deposits/src/lib/deposit-limits.tsx +++ b/libs/deposits/src/lib/deposit-limits.tsx @@ -49,7 +49,7 @@ export const DepositLimits = ({ return ( {limits.map(({ key, label, rawValue, value }) => ( - +
{label}
{ const effectiveClassName = classNames( - 'text-sm flex items-center', + 'text-sm flex items-center first-letter:uppercase', 'mt-2', { 'border-danger': intent === 'danger',