diff --git a/apps/trading-e2e/src/integration/closed-markets.cy.ts b/apps/trading-e2e/src/integration/closed-markets.cy.ts deleted file mode 100644 index f0861de49..000000000 --- a/apps/trading-e2e/src/integration/closed-markets.cy.ts +++ /dev/null @@ -1,475 +0,0 @@ -import { aliasGQLQuery } from '@vegaprotocol/cypress'; -import type { DataSourceDefinition } from '@vegaprotocol/types'; -import { - MarketState, - MarketStateMapping, - PropertyKeyType, -} from '@vegaprotocol/types'; -import { addDays, subDays } from 'date-fns'; -import { - chainIdQuery, - statisticsQuery, - createDataConnection, - oracleSpecDataConnectionQuery, - createMarketFragment, - marketsQuery, - marketsDataQuery, - createMarketsDataFragment, - assetQuery, - networkParamsQuery, - nodeGuardQuery, -} from '@vegaprotocol/mock'; -import { - addDecimalsFormatNumber, - getDateTimeFormat, -} from '@vegaprotocol/utils'; - -describe('Closed markets', { tags: '@smoke' }, () => { - const settlementDataProperty = 'settlement-data-property'; - const settlementDataPropertyKey = { - __typename: 'PropertyKey' as const, - name: settlementDataProperty, - type: PropertyKeyType.TYPE_INTEGER, - numberDecimalPlaces: 2, - }; - const settlementDataSourceData: DataSourceDefinition = { - sourceType: { - sourceType: { - filters: [ - { - __typename: 'Filter', - key: settlementDataPropertyKey, - }, - ], - }, - }, - }; - const rowSelector = - '[data-testid="tab-closed-markets"] .ag-center-cols-container .ag-row'; - - const assetsResult = assetQuery(); - // @ts-ignore asset definitely exists - const settlementAsset = assetsResult.assetsConnection.edges[0].node; - - const settledMarket = createMarketFragment({ - id: '0', - state: MarketState.STATE_SETTLED, - marketTimestamps: { - open: subDays(new Date(), 10).toISOString(), - close: subDays(new Date(), 4).toISOString(), - }, - tradableInstrument: { - instrument: { - product: { - dataSourceSpecBinding: { - settlementDataProperty, - }, - dataSourceSpecForTradingTermination: { - id: 'market-1-trading-termination-oracle-id', - }, - dataSourceSpecForSettlementData: { - id: 'market-1-settlement-data-oracle-id', - data: settlementDataSourceData, - }, - settlementAsset, - }, - }, - }, - }); - - const terminatedMarket = createMarketFragment({ - id: '1', - state: MarketState.STATE_TRADING_TERMINATED, - marketTimestamps: { - open: subDays(new Date(), 10).toISOString(), - close: null, // market - }, - tradableInstrument: { - instrument: { - metadata: { - tags: [ - `settlement-expiry-date:${addDays(new Date(), 4).toISOString()}`, - ], - }, - product: { - dataSourceSpecBinding: { - settlementDataProperty, - }, - dataSourceSpecForSettlementData: { - id: 'market-1-settlement-data-oracle-id', - data: settlementDataSourceData, - }, - }, - }, - }, - }); - - const delayedSettledMarket = createMarketFragment({ - id: '2', - state: MarketState.STATE_TRADING_TERMINATED, - marketTimestamps: { - open: subDays(new Date(), 10).toISOString(), - close: null, // market - }, - tradableInstrument: { - instrument: { - metadata: { - tags: [ - `settlement-expiry-date:${subDays(new Date(), 2).toISOString()}`, - ], - }, - product: { - dataSourceSpecBinding: { - settlementDataProperty, - }, - dataSourceSpecForSettlementData: { - id: 'market-1-settlement-data-oracle-id', - data: settlementDataSourceData, - }, - }, - }, - }, - }); - - const unknownMarket = createMarketFragment({ - id: '3', - state: MarketState.STATE_SETTLED, - }); - - const closedMarketsResult = [ - { - node: settledMarket, - }, - { - node: terminatedMarket, - }, - { - node: delayedSettledMarket, - }, - { node: unknownMarket }, - { - node: createMarketFragment({ id: '4', state: MarketState.STATE_PENDING }), - }, - { - node: createMarketFragment({ id: '5', state: MarketState.STATE_ACTIVE }), - }, - ]; - - const settledMarketData = createMarketsDataFragment({ - market: { - id: settledMarket.id, - }, - bestBidPrice: '1000', - bestOfferPrice: '2000', - markPrice: '1500', - }); - - const closedMarketsDataResult = [ - { - node: { - data: settledMarketData, - }, - }, - { - node: { - data: createMarketsDataFragment({ - market: { - id: terminatedMarket.id, - }, - }), - }, - }, - { - node: { - data: createMarketsDataFragment({ - market: { - id: delayedSettledMarket.id, - }, - }), - }, - }, - { - node: { - data: createMarketsDataFragment({ - market: { - id: unknownMarket.id, - }, - }), - }, - }, - ]; - - const specDataConnection = createDataConnection(); - - before(() => { - cy.setOnBoardingViewed(); - cy.mockGQL((req) => { - aliasGQLQuery(req, 'ChainId', chainIdQuery()); - aliasGQLQuery(req, 'Statistics', statisticsQuery()); - aliasGQLQuery(req, 'NodeGuard', nodeGuardQuery()); - aliasGQLQuery(req, 'NetworkParams', networkParamsQuery()); - aliasGQLQuery( - req, - 'Markets', - marketsQuery({ - marketsConnection: { - edges: closedMarketsResult, - }, - }) - ); - aliasGQLQuery( - req, - 'MarketsData', - marketsDataQuery({ - marketsConnection: { - edges: closedMarketsDataResult, - }, - }) - ); - aliasGQLQuery( - req, - 'OracleSpecDataConnection', - oracleSpecDataConnectionQuery() - ); - }); - - cy.mockSubscription(); - - cy.visit('/#/markets/all'); - cy.get('[data-testid="Closed markets"]').click(); - }); - - it('renders a settled market', () => { - const expectedMarkets = closedMarketsResult.filter((edge) => { - return [ - MarketState.STATE_SETTLED, - MarketState.STATE_TRADING_TERMINATED, - ].includes(edge.node.state); - }); - const product = settledMarket.tradableInstrument.instrument.product; - - // rows should be filtered to only include settled/terminated markets - cy.get(rowSelector).should('have.length', expectedMarkets.length); - - // check each column in the first row renders correctly - // 6001-MARK-001 - cy.get(rowSelector) - .first() - .find('[col-id="code"]') - .find('[data-testid="market-code"]') - .should('have.text', settledMarket.tradableInstrument.instrument.code); - - // 6001-MARK-071 - cy.get(rowSelector) - .first() - .find('[title="Future"]') - .should('have.text', 'Futr'); - - // 6001-MARK-002 - cy.get(rowSelector) - .first() - .find('[col-id="name"]') - .should('have.text', settledMarket.tradableInstrument.instrument.name); - - // 6001-MARK-003 - cy.get(rowSelector) - .first() - .find('[col-id="state"]') - .should('have.text', MarketStateMapping[settledMarket.state]); - - // 6001-MARK-004 - // 6001-MARK-005 - // 6001-MARK-009 - // 6001-MARK-008 - // 6001-MARK-010 - cy.get(rowSelector) - .first() - .find('[col-id="settlementDate"]') - .find('[data-testid="link"]') - .should(($el) => { - const href = $el.attr('href'); - expect(href).to.match( - new RegExp( - `/oracles/${product.dataSourceSpecForTradingTermination.id}` - ) - ); - }) - .should('have.text', '4 days ago') - .should( - 'have.attr', - 'title', - getDateTimeFormat().format( - new Date(settledMarket.marketTimestamps.close) - ) - ); - - // 6001-MARK-011 - cy.get(rowSelector) - .first() - .find('[col-id="bestBidPrice"]') - .should( - 'have.text', - addDecimalsFormatNumber( - settledMarketData.bestBidPrice, - settledMarket.decimalPlaces - ) - ); - - // 6001-MARK-012 - cy.get(rowSelector) - .first() - .find('[col-id="bestOfferPrice"]') - .should( - 'have.text', - addDecimalsFormatNumber( - settledMarketData.bestOfferPrice, - settledMarket.decimalPlaces - ) - ); - - // 6001-MARK-013 - cy.get(rowSelector).first().find('[col-id="markPrice"]').should( - 'have.text', - - addDecimalsFormatNumber( - settledMarketData.markPrice, - settledMarket.decimalPlaces - ) - ); - - // 6001-MARK-014 - // 6001-MARK-015 - // 6001-MARK-016 - cy.get(rowSelector) - .first() - .find('[col-id="settlementDataOracleId"]') - .find('[data-testid="link"]') - .should(($el) => { - const href = $el.attr('href'); - expect(href).to.match( - new RegExp(`/oracles/${product.dataSourceSpecForSettlementData.id}`) - ); - }) - .should( - 'have.text', - addDecimalsFormatNumber( - // @ts-ignore cannot deep un-partial - specDataConnection.externalData.data.data[0].value, - settlementDataPropertyKey.numberDecimalPlaces - ) - ); - - // 6001-MARK-018 - cy.get(rowSelector) - .first() - .find('[col-id="settlementAsset"]') - .should('have.text', product.settlementAsset.symbol); - - // 6001-MARK-020 - cy.get('.ag-pinned-right-cols-container') - .find('[col-id="market-actions"]') - .first() - .find('button svg') - .should('exist'); - if (Cypress.env('NX_SUCCESSOR_MARKETS')) { - cy.get(rowSelector) - .find('[col-id="successorMarket"]') - .first() - .should('have.text', '-'); - } - }); - - // test market list for market in terminated state - it('renders a terminated market', () => { - cy.get(rowSelector) - .eq(1) - .find('[col-id="state"]') - .should('have.text', MarketStateMapping[terminatedMarket.state]); - - // 6001-MARK-006 - // 6001-MARK-007 - cy.get(rowSelector) - .eq(1) - .find('[col-id="settlementDate"]') - .find('[data-testid="link"]') - .should('have.text', 'Expected in 4 days'); - }); - - it('renders a terminated market which was expected to have settled', () => { - cy.get(rowSelector) - .eq(2) - .find('[col-id="settlementDate"]') - .should('have.class', 'text-danger') - .find('[data-testid="link"]') - .should('have.text', 'Expected 2 days ago'); - }); - - it('renders terminated market which doesnt have settlement date metadata', () => { - cy.get(rowSelector) - .eq(3) - .find('[col-id="settlementDate"]') - .find('[data-testid="link"]') - .should('have.text', 'Unknown'); - }); - - it('can open asset detail dialog', () => { - cy.mockGQL((req) => { - aliasGQLQuery(req, 'Asset', assetsResult); - }); - - cy.get(rowSelector) - .first() - .find('[col-id="settlementAsset"]') - .find('button') - .click(); - - // 6001-MARK-019 - cy.get('[data-testid="dialog-title"]').should( - 'have.text', - `Asset details - ${settlementAsset.symbol}` - ); - - cy.get('[data-testid="dialog-close"]').click(); - }); - - it('can open row actions', () => { - cy.get('.ag-pinned-right-cols-container') - .find('[col-id="market-actions"]') - .first() - .find('button') - .click(); - - const dropdownContent = '[data-testid="market-actions-content"]'; - const dropdownContentItem = '[role="menuitem"]'; - cy.get(dropdownContent) - .find(dropdownContentItem) - .eq(0) - // Cannot click the copy button as it falls back to window.prompt, blocking the test. - .should('have.text', 'Copy Market ID'); - - cy.get(dropdownContent) - .find(dropdownContentItem) - .eq(1) - .find('a') - .then(($el) => { - const href = $el.attr('href'); - expect(/\/markets\/0/.test(href || '')).to.equal(true); - }) - .should('have.text', 'View on Explorer'); - }); -}); - -describe('no closed markets', { tags: '@smoke', testIsolation: true }, () => { - before(() => { - cy.mockTradingPage(); - cy.mockSubscription(); - cy.setOnBoardingViewed(); - cy.visit('/#/markets/all'); - cy.get('[data-testid="Closed markets"]').click(); - }); - - it('can see no markets message', () => { - // 6001-MARK-034 - cy.getByTestId('tab-closed-markets').should('contain.text', 'No markets'); - }); -}); diff --git a/apps/trading-e2e/src/integration/trading-deal-ticket-order.cy.ts b/apps/trading-e2e/src/integration/trading-deal-ticket-order.cy.ts index 9b83ed530..fe82aee3e 100644 --- a/apps/trading-e2e/src/integration/trading-deal-ticket-order.cy.ts +++ b/apps/trading-e2e/src/integration/trading-deal-ticket-order.cy.ts @@ -33,9 +33,7 @@ describe('deal ticker order validation', { tags: '@smoke' }, () => { it('must see the price unit', function () { // 7002-SORD-018 - cy.getByTestId(orderPriceField) - .siblings('label') - .should('have.text', 'Price (DAI)'); + cy.getByTestId(orderPriceField).next().should('have.text', 'DAI'); }); it('must see warning when placing an order with expiry date in past', () => { 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 deleted file mode 100644 index 39070688c..000000000 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-button.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { t } from '@vegaprotocol/i18n'; -import { Side } from '@vegaprotocol/types'; -import classNames from 'classnames'; - -interface Props { - side: Side; - label?: string; -} - -export const DealTicketButton = ({ side, label }: Props) => { - const buttonClasses = classNames( - 'px-10 py-2 uppercase rounded-md text-white w-full', - { - 'bg-market-red': side === Side.SIDE_SELL, - 'bg-market-green-550': side === Side.SIDE_BUY, - } - ); - return ( -
- -
- ); -}; 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 7df3041f3..182ab55fa 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 @@ -1,7 +1,4 @@ import { useCallback, useState } from 'react'; -import { Tooltip } from '@vegaprotocol/ui-toolkit'; -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'; @@ -16,7 +13,6 @@ import { marketMarginDataProvider } from '@vegaprotocol/accounts'; import { useDataProvider } from '@vegaprotocol/data-provider'; import { - NOTIONAL_SIZE_TOOLTIP_TEXT, MARGIN_DIFF_TOOLTIP_TEXT, DEDUCTION_FROM_COLLATERAL_TOOLTIP_TEXT, TOTAL_MARGIN_AVAILABLE, @@ -25,114 +21,54 @@ import { MARGIN_ACCOUNT_TOOLTIP_TEXT, } from '../../constants'; import { useEstimateFees } from '../../hooks'; +import { KeyValue } from './key-value'; const emptyValue = '-'; -export interface DealTicketFeeDetailPros { - label: string; - value?: string | null | undefined; - symbol: string; - indent?: boolean | undefined; - labelDescription?: ReactNode; - formattedValue?: string; - onClick?: () => void; -} - -export const DealTicketFeeDetail = ({ - label, - value, - labelDescription, - symbol, - indent, - onClick, - formattedValue, -}: DealTicketFeeDetailPros) => { - const displayValue = `${formattedValue ?? '-'} ${symbol || ''}`; - const valueElement = onClick ? ( - - ) : ( -
{displayValue}
- ); - return ( -
- -
{label}
-
- - {valueElement} - -
- ); -}; - 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} - /> - + + + {t( + `An estimate of the most you would be expected to pay in fees, in the market's settlement asset ${assetSymbol}.` + )} + + + + } + symbol={assetSymbol} + /> ); }; @@ -209,7 +145,7 @@ export const DealTicketMarginDetails = ({ BigInt(marginAccountBalance); deductionFromCollateral = ( - ); projectedMargin = ( - - - {deductionFromCollateral} - {projectedMargin} - { if (peakSizeError) { return ( - + {peakSizeError} ); @@ -44,7 +44,7 @@ export const DealTicketSizeIceberg = ({ const renderMinimumSizeError = () => { if (minimumVisibleSizeError) { return ( - + {minimumVisibleSizeError} ); diff --git a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-stop-order.spec.tsx b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-stop-order.spec.tsx index c389b0541..ce1762733 100644 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-stop-order.spec.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-stop-order.spec.tsx @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { VegaWalletContext } from '@vegaprotocol/wallet'; -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, 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'; @@ -12,6 +12,7 @@ import { useDealTicketFormValues, } from '../../hooks/use-form-values'; import type { FeatureFlags } from '@vegaprotocol/environment'; +import { formatForInput } from '@vegaprotocol/utils'; jest.mock('zustand'); jest.mock('./deal-ticket-fee-details', () => ({ @@ -57,7 +58,7 @@ const orderSideBuy = 'order-side-SIDE_BUY'; const orderSideSell = 'order-side-SIDE_SELL'; const triggerDirectionRisesAbove = 'triggerDirection-risesAbove'; -// const triggerDirectionFallsBelow = 'triggerDirection-fallsBelow'; +const triggerDirectionFallsBelow = 'triggerDirection-fallsBelow'; const expiryStrategySubmit = 'expiryStrategy-submit'; const expiryStrategyCancel = 'expiryStrategy-cancel'; @@ -65,6 +66,7 @@ const expiryStrategyCancel = 'expiryStrategy-cancel'; const triggerTypePrice = 'triggerType-price'; const triggerTypeTrailingPercentOffset = 'triggerType-trailingPercentOffset'; +const oco = 'oco'; const expire = 'expire'; const datePicker = 'date-picker-field'; const timeInForce = 'order-tif'; @@ -76,6 +78,8 @@ const triggerPriceWarningMessage = 'stop-order-warning-message-trigger-price'; const triggerTrailingPercentOffsetErrorMessage = 'stop-order-error-message-trigger-trailing-percent-offset'; +const ocoPostfix = (id: string, postfix = true) => (postfix ? `${id}-oco` : id); + describe('StopOrder', () => { beforeEach(() => { localStorage.clear(); @@ -107,6 +111,7 @@ describe('StopOrder', () => { 'checked' ); expect(screen.getByTestId(expire).dataset.state).toEqual('unchecked'); + expect(screen.getByTestId(oco).dataset.state).toEqual('unchecked'); await userEvent.click(screen.getByTestId(expire)); await waitFor(() => { expect(screen.getByTestId(expiryStrategySubmit).dataset.state).toEqual( @@ -115,6 +120,32 @@ describe('StopOrder', () => { }); }); + it('calculate notional for market limit', async () => { + render(generateJsx()); + await userEvent.type(screen.getByTestId(sizeInput), '10'); + await userEvent.type(screen.getByTestId(priceInput), '10'); + expect(screen.getByTestId('deal-ticket-fee-notional')).toHaveTextContent( + 'Notional100.00 BTC' + ); + }); + + it('calculates notional for limit order', async () => { + render(generateJsx()); + await userEvent.click(screen.getByTestId(orderTypeTrigger)); + await userEvent.click(screen.getByTestId(orderTypeMarket)); + await userEvent.type(screen.getByTestId(sizeInput), '10'); + // price trigger is selected but it's empty, calculate base on size and marketPrice prop + expect(screen.getByTestId('deal-ticket-fee-notional')).toHaveTextContent( + 'Notional20.00 BTC' + ); + + await userEvent.type(screen.getByTestId(triggerPriceInput), '3'); + // calculate base on size and price trigger + expect(screen.getByTestId('deal-ticket-fee-notional')).toHaveTextContent( + 'Notional30.00 BTC' + ); + }); + it('should use local storage state for initial values', async () => { const values: Partial = { type: Schema.OrderType.TYPE_LIMIT, @@ -125,6 +156,11 @@ describe('StopOrder', () => { expire: true, expiryStrategy: Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_CANCELS, expiresAt: '2023-07-27T16:43:27.000', + oco: true, + ocoType: Schema.OrderType.TYPE_LIMIT, + ocoSize: '0.2', + ocoPrice: '300.23', + ocoTimeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_FOK, }; useDealTicketFormValues.setState({ @@ -143,10 +179,22 @@ describe('StopOrder', () => { expect(screen.getByTestId(sizeInput)).toHaveDisplayValue( values.size as string ); - expect(screen.getByTestId('order-tif')).toHaveValue(values.timeInForce); + expect(screen.getByTestId(timeInForce)).toHaveValue(values.timeInForce); expect(screen.getByTestId(priceInput)).toHaveDisplayValue( values.price as string ); + + expect(screen.getByTestId(ocoPostfix(sizeInput))).toHaveDisplayValue( + values.ocoSize as string + ); + expect(screen.getByTestId(ocoPostfix(timeInForce))).toHaveValue( + values.ocoTimeInForce + ); + expect(screen.getByTestId(ocoPostfix(priceInput))).toHaveDisplayValue( + values.ocoPrice as string + ); + expect(screen.getByTestId('ocoTypeLimit').dataset.state).toEqual('checked'); + expect(screen.getByTestId(expire).dataset.state).toEqual('checked'); expect(screen.getByTestId(expiryStrategyCancel).dataset.state).toEqual( 'checked' @@ -154,6 +202,9 @@ describe('StopOrder', () => { expect(screen.getByTestId(datePicker)).toHaveDisplayValue( values.expiresAt as string ); + + await userEvent.click(screen.getByTestId(orderTypeMarket)); + expect(screen.getByTestId(oco).dataset.state).toEqual('unchecked'); }); it('does not submit if no wallet connected', async () => { @@ -174,145 +225,239 @@ describe('StopOrder', () => { expect(submit).toBeCalled(); }); - it('validates size field', async () => { + it.each([ + { fieldName: 'size', ocoValue: false }, + { fieldName: 'ocoSize', ocoValue: true }, + ])('validates $fieldName field', async ({ ocoValue }) => { render(generateJsx()); - + if (ocoValue) { + await userEvent.click(screen.getByTestId(oco)); + } await userEvent.click(screen.getByTestId(submitButton)); - + const getByTestId = (id: string) => + screen.getByTestId(ocoPostfix(id, ocoValue)); + const queryByTestId = (id: string) => + screen.queryByTestId(ocoPostfix(id, ocoValue)); // default value should be invalid - expect(screen.getByTestId(sizeErrorMessage)).toBeInTheDocument(); + expect(getByTestId(sizeErrorMessage)).toBeInTheDocument(); // to small value should be invalid - await userEvent.type(screen.getByTestId(sizeInput), '0.01'); - expect(screen.getByTestId(sizeErrorMessage)).toBeInTheDocument(); + await userEvent.type(getByTestId(sizeInput), '0.01'); + expect(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(); + await userEvent.clear(getByTestId(sizeInput)); + await userEvent.type(getByTestId(sizeInput), '0.1'); + expect(queryByTestId(sizeErrorMessage)).toBeNull(); }); - it('validates price field', async () => { + it.each([ + { fieldName: 'price', ocoValue: false }, + { fieldName: 'ocoPrice', ocoValue: true }, + ])('validates $fieldName field', async ({ ocoValue }) => { render(generateJsx()); - + if (ocoValue) { + await userEvent.click(screen.getByTestId(oco)); + } 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(); + + const getByTestId = (id: string) => + screen.getByTestId(ocoPostfix(id, ocoValue)); + const queryByTestId = (id: string) => + screen.queryByTestId(ocoPostfix(id, ocoValue)); + + expect(getByTestId(priceErrorMessage)).toBeInTheDocument(); + await userEvent.type(getByTestId(priceInput), '0.001'); + expect(getByTestId(priceErrorMessage)).toBeInTheDocument(); // switch to market order type error should disappear await userEvent.click(screen.getByTestId(orderTypeTrigger)); await userEvent.click(screen.getByTestId(orderTypeMarket)); await userEvent.click(screen.getByTestId(submitButton)); - expect(screen.queryByTestId(priceErrorMessage)).toBeNull(); + expect(queryByTestId(priceErrorMessage)).toBeNull(); // switch back to limit type await userEvent.click(screen.getByTestId(orderTypeTrigger)); await userEvent.click(screen.getByTestId(orderTypeLimit)); await userEvent.click(screen.getByTestId(submitButton)); - expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument(); + expect(getByTestId(priceErrorMessage)).toBeInTheDocument(); // to small value should be invalid - await userEvent.type(screen.getByTestId(priceInput), '0.001'); - expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument(); + await userEvent.type(getByTestId(priceInput), '0.001'); + expect(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(); + await userEvent.clear(getByTestId(priceInput)); + await userEvent.type(getByTestId(priceInput), '0.01'); + expect(queryByTestId(priceErrorMessage)).toBeNull(); }); - it('validates trigger price field', async () => { + it.each([ + { fieldName: 'triggerPrice', ocoValue: false }, + { fieldName: 'ocoTriggerPrice', ocoValue: true }, + ])('validates $fieldName field', async ({ ocoValue }) => { render(generateJsx()); + if (ocoValue) { + await userEvent.click(screen.getByTestId(oco)); + await userEvent.click(screen.getByTestId(triggerDirectionFallsBelow)); + } await userEvent.click(screen.getByTestId(submitButton)); - expect(screen.getByTestId(triggerPriceErrorMessage)).toBeInTheDocument(); + const getByTestId = (id: string) => + screen.getByTestId(ocoPostfix(id, ocoValue)); + const queryByTestId = (id: string) => + screen.queryByTestId(ocoPostfix(id, ocoValue)); + expect(getByTestId(triggerPriceErrorMessage)).toBeInTheDocument(); // switch to trailing percentage offset trigger type - await userEvent.click(screen.getByTestId(triggerTypeTrailingPercentOffset)); - expect(screen.queryByTestId(triggerPriceErrorMessage)).toBeNull(); + await userEvent.click(getByTestId(triggerTypeTrailingPercentOffset)); + expect(queryByTestId(triggerPriceErrorMessage)).toBeNull(); // switch back to price trigger type - await userEvent.click(screen.getByTestId(triggerTypePrice)); - expect(screen.getByTestId(triggerPriceErrorMessage)).toBeInTheDocument(); + await userEvent.click(getByTestId(triggerTypePrice)); + expect(getByTestId(triggerPriceErrorMessage)).toBeInTheDocument(); // to small value should be invalid - await userEvent.type(screen.getByTestId(triggerPriceInput), '0.001'); - expect(screen.getByTestId(triggerPriceErrorMessage)).toBeInTheDocument(); + await userEvent.type(getByTestId(triggerPriceInput), '0.001'); + expect(getByTestId(triggerPriceErrorMessage)).toBeInTheDocument(); // clear and fill using value causing immediate trigger - await userEvent.clear(screen.getByTestId(triggerPriceInput)); - await userEvent.type(screen.getByTestId(triggerPriceInput), '0.01'); - expect(screen.queryByTestId(triggerPriceErrorMessage)).toBeNull(); - expect( - screen.queryByTestId(triggerPriceWarningMessage) - ).toBeInTheDocument(); + await userEvent.clear(getByTestId(triggerPriceInput)); + await userEvent.type(getByTestId(triggerPriceInput), '0.01'); + expect(queryByTestId(triggerPriceErrorMessage)).toBeNull(); + expect(queryByTestId(triggerPriceWarningMessage)).toBeInTheDocument(); // change to correct value - await userEvent.type(screen.getByTestId(triggerPriceInput), '2'); - expect(screen.queryByTestId(triggerPriceWarningMessage)).toBeNull(); + await userEvent.type(getByTestId(triggerPriceInput), '2'); + expect(queryByTestId(triggerPriceWarningMessage)).toBeNull(); }); - it('validates trigger trailing percentage offset field', async () => { + it.each([ + { fieldName: 'trailingPercentageOffset', ocoValue: false }, + { fieldName: 'ocoTrailingPercentageOffset', ocoValue: true }, + ])('validates $fieldName field', async ({ ocoValue }) => { render(generateJsx()); + if (ocoValue) { + await userEvent.click(screen.getByTestId(oco)); + } + await userEvent.click(screen.getByTestId(submitButton)); + const getByTestId = (id: string) => + screen.getByTestId(ocoPostfix(id, ocoValue)); + const queryByTestId = (id: string) => + screen.queryByTestId(ocoPostfix(id, ocoValue)); // should not show error with default form values - await userEvent.click(screen.getByTestId(submitButton)); - expect( - screen.queryByTestId(triggerTrailingPercentOffsetErrorMessage) - ).toBeNull(); + expect(queryByTestId(triggerTrailingPercentOffsetErrorMessage)).toBeNull(); // switch to trailing percentage offset trigger type - await userEvent.click(screen.getByTestId(triggerTypeTrailingPercentOffset)); + await userEvent.click(getByTestId(triggerTypeTrailingPercentOffset)); expect( - screen.getByTestId(triggerTrailingPercentOffsetErrorMessage) + getByTestId(triggerTrailingPercentOffsetErrorMessage) ).toBeInTheDocument(); // to small value should be invalid await userEvent.type( - screen.getByTestId(triggerTrailingPercentOffsetInput), + getByTestId(triggerTrailingPercentOffsetInput), '0.09' ); expect( - screen.getByTestId(triggerTrailingPercentOffsetErrorMessage) + 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(); + await userEvent.clear(getByTestId(triggerTrailingPercentOffsetInput)); + await userEvent.type(getByTestId(triggerTrailingPercentOffsetInput), '0.1'); + expect(queryByTestId(triggerTrailingPercentOffsetErrorMessage)).toBeNull(); // to big value should be invalid - await userEvent.clear( - screen.getByTestId(triggerTrailingPercentOffsetInput) - ); + await userEvent.clear(getByTestId(triggerTrailingPercentOffsetInput)); await userEvent.type( - screen.getByTestId(triggerTrailingPercentOffsetInput), + getByTestId(triggerTrailingPercentOffsetInput), '99.91' ); expect( - screen.getByTestId(triggerTrailingPercentOffsetErrorMessage) + getByTestId(triggerTrailingPercentOffsetErrorMessage) ).toBeInTheDocument(); // clear and fill using valid value - await userEvent.clear( - screen.getByTestId(triggerTrailingPercentOffsetInput) - ); + await userEvent.clear(getByTestId(triggerTrailingPercentOffsetInput)); await userEvent.type( - screen.getByTestId(triggerTrailingPercentOffsetInput), + getByTestId(triggerTrailingPercentOffsetInput), '99.9' ); + expect(queryByTestId(triggerTrailingPercentOffsetErrorMessage)).toBeNull(); + }); + + it('sync oco trigger', async () => { + render(generateJsx()); + await userEvent.click(screen.getByTestId(oco)); expect( - screen.queryByTestId(triggerTrailingPercentOffsetErrorMessage) - ).toBeNull(); + screen.getByTestId(triggerDirectionRisesAbove).dataset.state + ).toEqual('checked'); + expect( + screen.getByTestId(ocoPostfix(triggerDirectionFallsBelow)).dataset.state + ).toEqual('checked'); + await userEvent.click(screen.getByTestId(triggerDirectionFallsBelow)); + expect( + screen.getByTestId(triggerDirectionRisesAbove).dataset.state + ).toEqual('unchecked'); + expect( + screen.getByTestId(ocoPostfix(triggerDirectionFallsBelow)).dataset.state + ).toEqual('unchecked'); + await userEvent.click( + screen.getByTestId(ocoPostfix(triggerDirectionFallsBelow)) + ); + expect( + screen.getByTestId(triggerDirectionRisesAbove).dataset.state + ).toEqual('checked'); + expect( + screen.getByTestId(ocoPostfix(triggerDirectionFallsBelow)).dataset.state + ).toEqual('checked'); + }); + + it('disables submit expiry strategy when OCO selected', async () => { + render(generateJsx()); + await userEvent.click(screen.getByTestId(expire)); + await userEvent.click(screen.getByTestId(expiryStrategySubmit)); + await userEvent.click(screen.getByTestId(oco)); + expect(screen.getByTestId(expiryStrategySubmit).dataset.state).toEqual( + 'unchecked' + ); + expect(screen.getByTestId(expiryStrategySubmit)).toBeDisabled(); + await userEvent.click(screen.getByTestId(oco)); + await userEvent.click(screen.getByTestId(expiryStrategySubmit)); + expect(screen.getByTestId(expiryStrategySubmit).dataset.state).toEqual( + 'checked' + ); + expect(screen.getByTestId(expiryStrategySubmit)).not.toBeDisabled(); + }); + + it('sets expiry time/date to now if expiry is changed to checked', async () => { + const now = Math.round(Date.now() / 1000) * 1000; + render(generateJsx()); + jest.spyOn(global.Date, 'now').mockImplementationOnce(() => now); + await userEvent.click(screen.getByTestId(expire)); + + // expiry time/date was empty it should be set to now + expect( + new Date(screen.getByTestId(datePicker).value).getTime() + ).toEqual(now); + + // set to the value in the past (now - 1s) + fireEvent.change(screen.getByTestId(datePicker), { + target: { value: formatForInput(new Date(now - 1000)) }, + }); + expect( + new Date( + screen.getByTestId(datePicker).value + ).getTime() + 1000 + ).toEqual(now); + + // switch expiry off and on + await userEvent.click(screen.getByTestId(expire)); + await userEvent.click(screen.getByTestId(expire)); + // expiry time/date was in the past it should be set to now + expect( + new Date(screen.getByTestId(datePicker).value).getTime() + ).toEqual(now); }); }); diff --git a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-stop-order.tsx b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-stop-order.tsx index b18e72e4d..cf9368dad 100644 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-stop-order.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-stop-order.tsx @@ -1,8 +1,12 @@ import { useRef, useCallback, useEffect } from 'react'; import { useVegaWallet } from '@vegaprotocol/wallet'; -import type { StopOrdersSubmission } from '@vegaprotocol/wallet'; +import type { + OrderSubmissionBody, + StopOrdersSubmission, +} from '@vegaprotocol/wallet'; import { formatForInput, + formatValue, removeDecimal, toDecimal, validateAmount, @@ -19,6 +23,9 @@ import { TradingInputError as InputError, TradingSelect as Select, Tooltip, + TradingButton as Button, + Pill, + Intent, } from '@vegaprotocol/ui-toolkit'; import { getDerivedPrice } from '@vegaprotocol/markets'; import type { Market } from '@vegaprotocol/markets'; @@ -31,6 +38,7 @@ import { REDUCE_ONLY_TOOLTIP, stopSubmit, getNotionalSize, + getAssetUnit, } from './deal-ticket'; import { TypeToggle } from './type-selector'; import { @@ -41,9 +49,10 @@ import { } from '../../hooks/use-form-values'; import type { StopOrderFormValues } from '../../hooks/use-form-values'; import { mapFormValuesToStopOrdersSubmission } from '../../utils/map-form-values-to-submission'; -import { DealTicketButton } from './deal-ticket-button'; import { DealTicketFeeDetails } from './deal-ticket-fee-details'; import { validateExpiration } from '../../utils'; +import { NOTIONAL_SIZE_TOOLTIP_TEXT } from '../../constants'; +import { KeyValue } from './key-value'; export interface StopOrderProps { market: Market; @@ -78,7 +87,7 @@ const Trigger = ({ control, watch, priceStep, - assetSymbol, + quoteName, oco, marketPrice, decimalPlaces, @@ -86,7 +95,7 @@ const Trigger = ({ control: Control; watch: UseFormWatch; priceStep: string; - assetSymbol: string; + quoteName: string; oco?: boolean; marketPrice?: string | null; decimalPlaces: number; @@ -181,7 +190,7 @@ const Trigger = ({ data-testid={`triggerPrice${oco ? '-oco' : ''}`} type="number" step={priceStep} - appendElement={assetSymbol} + appendElement={{quoteName}} value={value || ''} hasError={!!fieldState.error} {...props} @@ -249,7 +258,7 @@ const Trigger = ({ %} data-testid={`triggerTrailingPercentOffset${ oco ? '-oco' : '' }`} @@ -311,10 +320,14 @@ const Size = ({ control, sizeStep, oco, + isLimitType, + assetUnit, }: { control: Control; sizeStep: string; oco?: boolean; + isLimitType: boolean; + assetUnit?: string; }) => { return ( +
e.currentTarget.blur()} + appendElement={assetUnit && {assetUnit}} data-testid={id} value={value || ''} hasError={!!fieldState.error} @@ -394,12 +408,8 @@ const Price = ({ const { value, ...props } = field; const id = `order-price${oco ? '-oco' : ''}`; return ( -
- +
+ e.currentTarget.blur()} value={value || ''} hasError={!!fieldState.error} + appendElement={{quoteName}} {...props} /> @@ -434,17 +445,17 @@ const TimeInForce = ({ oco?: boolean; }) => ( { - const id = `select-time-in-force${oco ? '-oco' : ''}`; + const id = `order-tif${oco ? '-oco' : ''}`; return (
{assetUnit}} step={sizeStep} min={sizeStep} data-testid="order-size" @@ -411,7 +425,7 @@ export const DealTicket = ({
)} /> - {type === Schema.OrderType.TYPE_LIMIT && ( + {isLimitType && ( ( -
+
{quoteName}} className="w-full" type="number" step={priceStep} @@ -449,6 +464,22 @@ export const DealTicket = ({ )} /> )} +
+ + +
)} /> - {type === Schema.OrderType.TYPE_LIMIT && + {isLimitType && timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_GTT && (
- {type === Schema.OrderType.TYPE_LIMIT && ( + {isLimitType && ( <>
- - + void; +} + +export const KeyValue = ({ + label, + value, + labelDescription, + symbol, + indent, + onClick, + formattedValue, +}: KeyValuePros) => { + const displayValue = `${formattedValue ?? '-'} ${symbol || ''}`; + const valueElement = onClick ? ( + + ) : ( +
{displayValue}
+ ); + return ( +
+ +
{label}
+
+ + {valueElement} + +
+ ); +}; diff --git a/libs/ui-toolkit/src/components/trading-input/input.tsx b/libs/ui-toolkit/src/components/trading-input/input.tsx index 7bef3d2cc..20345f08a 100644 --- a/libs/ui-toolkit/src/components/trading-input/input.tsx +++ b/libs/ui-toolkit/src/components/trading-input/input.tsx @@ -80,13 +80,14 @@ const getAffixElement = ({ appendIconName, appendIconDescription, }: Pick) => { - const position = prependIconName || prependElement ? 'pre' : 'post'; - const className = classNames( - ['fill-black dark:fill-white', 'absolute', 'z-10'], + 'absolute z-10 top-0 bottom-0 flex items-center', { - 'left-3': position === 'pre', - 'right-3': position === 'post', + 'fill-black dark:fill-white': prependIconName || appendIconName, + 'left-3': prependIconName, + 'right-3': appendIconName, + 'left-1': prependElement, + 'right-1': appendElement, } ); @@ -161,7 +162,7 @@ export const TradingInput = forwardRef( if (element) { return ( -
+
{hasPrepended && element} {input} {hasAppended && element}