feat(trading): add stop orders table and form (#4265)

Co-authored-by: Dariusz Majcherczyk <dariusz.majcherczyk@gmail.com>
This commit is contained in:
Bartłomiej Głownia 2023-08-02 12:28:33 +02:00 committed by GitHub
parent 1dd97a2bce
commit 375d447fa8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
66 changed files with 2780 additions and 411 deletions

View File

@ -100,7 +100,6 @@ describe('markets proposed table', { tags: '@smoke' }, () => {
'VEGA_TOKEN_URL' 'VEGA_TOKEN_URL'
)}/proposals/e9ec6d5c46a7e7bcabf9ba7a893fa5a5eeeec08b731f06f7a6eb7bf0e605b829` )}/proposals/e9ec6d5c46a7e7bcabf9ba7a893fa5a5eeeec08b731f06f7a6eb7bf0e605b829`
); );
cy.getByTestId('proposal-actions-content').click();
}); });
// 6001-MARK-060 // 6001-MARK-060
@ -214,11 +213,12 @@ describe('no markets proposed', { tags: '@smoke', testIsolation: true }, () => {
aliasGQLQuery(req, 'ProposalsList', proposal); aliasGQLQuery(req, 'ProposalsList', proposal);
}); });
cy.mockSubscription(); cy.mockSubscription();
cy.visit('/#/markets/all');
cy.get('[data-testid="Proposed markets"]').click();
}); });
it('can see no markets message', () => { it('can see no markets message', () => {
cy.visit('/#/markets/all');
cy.get('[data-testid="Proposed markets"]').click();
// 6001-MARK-061 // 6001-MARK-061
cy.getByTestId('tab-proposed-markets').should('contain.text', 'No markets'); cy.getByTestId('tab-proposed-markets').should('contain.text', 'No markets');
}); });

View File

@ -28,16 +28,16 @@ describe('deal ticket basics', { tags: '@smoke' }, () => {
it('must be able to select order direction - long/short', function () { it('must be able to select order direction - long/short', function () {
// 7002-SORD-004 // 7002-SORD-004
cy.getByTestId(toggleShort).click().children('input').should('be.checked'); cy.getByTestId(toggleShort).click().next('input').should('be.checked');
cy.getByTestId(toggleLong).click().children('input').should('be.checked'); cy.getByTestId(toggleLong).click().next('input').should('be.checked');
}); });
it('must be able to select order type - limit/market', function () { it('must be able to select order type - limit/market', function () {
// 7002-SORD-005 // 7002-SORD-005
// 7002-SORD-006 // 7002-SORD-006
// 7002-SORD-007 // 7002-SORD-007
cy.getByTestId(toggleLimit).click().children('input').should('be.checked'); cy.getByTestId(toggleLimit).click().next('input').should('be.checked');
cy.getByTestId(toggleMarket).click().children('input').should('be.checked'); cy.getByTestId(toggleMarket).click().next('input').should('be.checked');
}); });
it('order connect vega wallet button should connect', () => { it('order connect vega wallet button should connect', () => {
@ -51,7 +51,7 @@ describe('deal ticket basics', { tags: '@smoke' }, () => {
.click(); .click();
cy.wait('@walletReq'); cy.wait('@walletReq');
cy.getByTestId(placeOrderBtn).should('be.visible'); cy.getByTestId(placeOrderBtn).should('be.visible');
cy.getByTestId(toggleLimit).children('input').should('be.checked'); cy.getByTestId(toggleLimit).next('input').should('be.checked');
cy.getByTestId(orderPriceField).should('have.value', '101'); cy.getByTestId(orderPriceField).should('have.value', '101');
}); });
}); });

View File

@ -35,6 +35,8 @@ describe('transfer fees', { tags: '@regression', testIsolation: true }, () => {
cy.setVegaWallet(); cy.setVegaWallet();
cy.visit('/'); cy.visit('/');
cy.getByTestId(manageVegaWallet).click();
cy.getByTestId(walletTransfer).click();
cy.wait('@Assets'); cy.wait('@Assets');
cy.wait('@Accounts'); cy.wait('@Accounts');
@ -57,7 +59,6 @@ describe('transfer fees', { tags: '@regression', testIsolation: true }, () => {
// 1003-TRAN-019 // 1003-TRAN-019
cy.getByTestId(transferForm); cy.getByTestId(transferForm);
cy.contains('Enter manually').click(); cy.contains('Enter manually').click();
cy.getByTestId(transferForm) cy.getByTestId(transferForm)
.find(toAddressField) .find(toAddressField)
.type('7f9cf07d3a9905b1a61a1069f7a758855da428bc0f4a97de87f48644bfc25535'); .type('7f9cf07d3a9905b1a61a1069f7a758855da428bc0f4a97de87f48644bfc25535');

View File

@ -1,3 +1,4 @@
import { OrderType } from '@vegaprotocol/types';
import type { OrderSubmission } from '@vegaprotocol/wallet'; import type { OrderSubmission } from '@vegaprotocol/wallet';
const orderSizeField = 'order-size'; const orderSizeField = 'order-size';
@ -9,7 +10,9 @@ export const createOrder = (order: OrderSubmission): void => {
cy.log('Placing order', order); cy.log('Placing order', order);
const { type, side, size, price, timeInForce, expiresAt } = order; const { type, side, size, price, timeInForce, expiresAt } = order;
cy.getByTestId(`order-type-${type}`).click(); cy.getByTestId(
`order-type-${type === OrderType.TYPE_LIMIT ? 'Limit' : 'Market'}`
).click();
cy.getByTestId(`order-side-${side}`).click(); cy.getByTestId(`order-side-${side}`).click();
cy.getByTestId(orderSizeField).clear().type(size); cy.getByTestId(orderSizeField).clear().type(size);
if (price) { if (price) {

View File

@ -6,8 +6,8 @@ export const orderTIFDropDown = 'order-tif';
export const placeOrderBtn = 'place-order'; export const placeOrderBtn = 'place-order';
export const toggleShort = 'order-side-SIDE_SELL'; export const toggleShort = 'order-side-SIDE_SELL';
export const toggleLong = 'order-side-SIDE_BUY'; export const toggleLong = 'order-side-SIDE_BUY';
export const toggleLimit = 'order-type-TYPE_LIMIT'; export const toggleLimit = 'order-type-Limit';
export const toggleMarket = 'order-type-TYPE_MARKET'; export const toggleMarket = 'order-type-Market';
export const TIFlist = Object.values(Schema.OrderTimeInForce).map((value) => { export const TIFlist = Object.values(Schema.OrderTimeInForce).map((value) => {
return { return {

View File

@ -16,6 +16,6 @@ NX_WALLETCONNECT_PROJECT_ID=fe8091dc35738863e509fc4947525c72
# Cosmic elevator flags # Cosmic elevator flags
NX_SUCCESSOR_MARKETS=true NX_SUCCESSOR_MARKETS=true
# NX_STOP_ORDERS NX_STOP_ORDERS=true
# NX_ICEBERG_ORDERS # NX_ICEBERG_ORDERS
# NX_PRODUCT_PERPETUALS # NX_PRODUCT_PERPETUALS

View File

@ -18,6 +18,6 @@ NX_ETH_WALLET_MNEMONIC="ozone access unlock valid olympic save include omit supp
# Cosmic elevator flags # Cosmic elevator flags
NX_SUCCESSOR_MARKETS=false NX_SUCCESSOR_MARKETS=false
# NX_STOP_ORDERS NX_STOP_ORDERS=false
# NX_ICEBERG_ORDERS # NX_ICEBERG_ORDERS
# NX_PRODUCT_PERPETUALS # NX_PRODUCT_PERPETUALS

View File

@ -16,6 +16,6 @@ NX_VEGA_INCIDENT_URL=https://blog.vega.xyz/tagged/vega-incident-reports
# Cosmic elevator flags # Cosmic elevator flags
NX_SUCCESSOR_MARKETS=true NX_SUCCESSOR_MARKETS=true
# NX_STOP_ORDERS NX_STOP_ORDERS=true
# NX_ICEBERG_ORDERS # NX_ICEBERG_ORDERS
# NX_PRODUCT_PERPETUALS # NX_PRODUCT_PERPETUALS

View File

@ -18,6 +18,6 @@ NX_APP_VERSION=v0.20.21-core-0.71.6
# Cosmic elevator flags # Cosmic elevator flags
NX_SUCCESSOR_MARKETS=false NX_SUCCESSOR_MARKETS=false
# NX_STOP_ORDERS NX_STOP_ORDERS=false
# NX_ICEBERG_ORDERS # NX_ICEBERG_ORDERS
# NX_PRODUCT_PERPETUALS # NX_PRODUCT_PERPETUALS

View File

@ -18,6 +18,6 @@ NX_APP_VERSION=v0.20.19-core-0.71.6
# Cosmic elevator flags # Cosmic elevator flags
NX_SUCCESSOR_MARKETS=false NX_SUCCESSOR_MARKETS=false
# NX_STOP_ORDERS NX_STOP_ORDERS=false
# NX_ICEBERG_ORDERS # NX_ICEBERG_ORDERS
# NX_PRODUCT_PERPETUALS # NX_PRODUCT_PERPETUALS

View File

@ -16,6 +16,6 @@ NX_VEGA_INCIDENT_URL=https://blog.vega.xyz/tagged/vega-incident-reports
# Cosmic elevator flags # Cosmic elevator flags
NX_SUCCESSOR_MARKETS=true NX_SUCCESSOR_MARKETS=true
# NX_STOP_ORDERS NX_STOP_ORDERS=true
# NX_ICEBERG_ORDERS # NX_ICEBERG_ORDERS
# NX_PRODUCT_PERPETUALS # NX_PRODUCT_PERPETUALS

View File

@ -17,6 +17,6 @@ NX_VEGA_CONSOLE_URL=https://console.fairground.wtf
# Cosmic elevator flags # Cosmic elevator flags
NX_SUCCESSOR_MARKETS=true NX_SUCCESSOR_MARKETS=true
# NX_STOP_ORDERS NX_STOP_ORDERS=true
# NX_ICEBERG_ORDERS # NX_ICEBERG_ORDERS
# NX_PRODUCT_PERPETUALS # NX_PRODUCT_PERPETUALS

View File

@ -17,6 +17,6 @@ NX_VEGA_CONSOLE_URL=https://trading.validators-testnet.vega.rocks
# Cosmic elevator flags # Cosmic elevator flags
NX_SUCCESSOR_MARKETS=false NX_SUCCESSOR_MARKETS=false
# NX_STOP_ORDERS NX_STOP_ORDERS=false
# NX_ICEBERG_ORDERS # NX_ICEBERG_ORDERS
# NX_PRODUCT_PERPETUALS # NX_PRODUCT_PERPETUALS

View File

@ -130,6 +130,13 @@ const MainGrid = memo(
<TradingViews.orders.component marketId={marketId} /> <TradingViews.orders.component marketId={marketId} />
</VegaWalletContainer> </VegaWalletContainer>
</Tab> </Tab>
{FLAGS.STOP_ORDERS ? (
<Tab id="stop-orders" name={t('Stop orders')}>
<VegaWalletContainer>
<TradingViews.stopOrders.component />
</VegaWalletContainer>
</Tab>
) : null}
<Tab id="fills" name={t('Fills')}> <Tab id="fills" name={t('Fills')}>
<VegaWalletContainer> <VegaWalletContainer>
<TradingViews.fills.component <TradingViews.fills.component

View File

@ -12,6 +12,7 @@ import { AccountsContainer } from '../../components/accounts-container';
import { LiquidityContainer } from '../../components/liquidity-container'; import { LiquidityContainer } from '../../components/liquidity-container';
import type { OrderContainerProps } from '../../components/orders-container'; import type { OrderContainerProps } from '../../components/orders-container';
import { OrdersContainer } from '../../components/orders-container'; import { OrdersContainer } from '../../components/orders-container';
import { StopOrdersContainer } from '../../components/stop-orders-container';
type MarketDependantView = type MarketDependantView =
| typeof CandlesChartContainer | typeof CandlesChartContainer
@ -74,6 +75,10 @@ export const TradingViews = {
label: 'All', label: 'All',
component: OrdersContainer, component: OrdersContainer,
}, },
stopOrders: {
label: 'Stop',
component: StopOrdersContainer,
},
collateral: { label: 'Collateral', component: AccountsContainer }, collateral: { label: 'Collateral', component: AccountsContainer },
fills: { label: 'Fills', component: FillsContainer }, fills: { label: 'Fills', component: FillsContainer },
}; };

View File

@ -1,10 +1,14 @@
import { OrderbookManager } from '@vegaprotocol/market-depth'; import { OrderbookManager } from '@vegaprotocol/market-depth';
import { useCreateOrderStore } from '@vegaprotocol/orders'; import { useCreateOrderStore } from '@vegaprotocol/orders';
import { ViewType, useSidebar } from '../sidebar'; import { ViewType, useSidebar } from '../sidebar';
import { useStopOrderFormValues } from '@vegaprotocol/deal-ticket';
export const OrderbookContainer = ({ marketId }: { marketId: string }) => { export const OrderbookContainer = ({ marketId }: { marketId: string }) => {
const useOrderStoreRef = useCreateOrderStore(); const useOrderStoreRef = useCreateOrderStore();
const updateOrder = useOrderStoreRef((store) => store.update); const updateOrder = useOrderStoreRef((store) => store.update);
const updateStoredFormValues = useStopOrderFormValues(
(state) => state.update
);
const setView = useSidebar((store) => store.setView); const setView = useSidebar((store) => store.setView);
return ( return (
<OrderbookManager <OrderbookManager
@ -12,9 +16,11 @@ export const OrderbookContainer = ({ marketId }: { marketId: string }) => {
onClick={({ price, size }) => { onClick={({ price, size }) => {
if (price) { if (price) {
updateOrder(marketId, { price }); updateOrder(marketId, { price });
updateStoredFormValues(marketId, { price });
} }
if (size) { if (size) {
updateOrder(marketId, { size }); updateOrder(marketId, { size });
updateStoredFormValues(marketId, { size });
} }
setView({ type: ViewType.Order }); setView({ type: ViewType.Order });
}} }}

View File

@ -0,0 +1 @@
export * from './stop-orders-container';

View File

@ -0,0 +1,41 @@
import { useDataGridEvents } from '@vegaprotocol/datagrid';
import { t } from '@vegaprotocol/i18n';
import { StopOrdersManager } from '@vegaprotocol/orders';
import { Splash } from '@vegaprotocol/ui-toolkit';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { useMarketClickHandler } from '../../lib/hooks/use-market-click-handler';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { DataGridSlice } from '../../stores/datagrid-store-slice';
import { createDataGridSlice } from '../../stores/datagrid-store-slice';
export const StopOrdersContainer = () => {
const { pubKey, isReadOnly } = useVegaWallet();
const onMarketClick = useMarketClickHandler(true);
const gridStore = useStopOrdersStore((store) => store.gridStore);
const updateGridStore = useStopOrdersStore((store) => store.updateGridStore);
const gridStoreCallbacks = useDataGridEvents(gridStore, (colState) => {
updateGridStore(colState);
});
if (!pubKey) {
return <Splash>{t('Please connect Vega wallet')}</Splash>;
}
return (
<StopOrdersManager
partyId={pubKey}
onMarketClick={onMarketClick}
isReadOnly={isReadOnly}
gridProps={gridStoreCallbacks}
/>
);
};
const useStopOrdersStore = create<DataGridSlice>()(
persist(createDataGridSlice, {
name: 'vega_fills_store',
})
);

View File

@ -23,7 +23,7 @@ export const useAccountBalance = (assetId?: string) => {
}, },
[assetId] [assetId]
); );
useDataProvider({ const { loading, error } = useDataProvider({
dataProvider: accountsDataProvider, dataProvider: accountsDataProvider,
variables, variables,
skip: !pubKey || !assetId, skip: !pubKey || !assetId,
@ -34,7 +34,9 @@ export const useAccountBalance = (assetId?: string) => {
() => ({ () => ({
accountBalance: pubKey ? accountBalance : '', accountBalance: pubKey ? accountBalance : '',
accountDecimals: pubKey ? accountDecimals : null, accountDecimals: pubKey ? accountDecimals : null,
loading,
error,
}), }),
[accountBalance, accountDecimals, pubKey] [accountBalance, accountDecimals, pubKey, loading, error]
); );
}; };

View File

@ -22,7 +22,7 @@ export const useMarketAccountBalance = (marketId: string) => {
}, },
[marketId] [marketId]
); );
useDataProvider({ const { loading, error } = useDataProvider({
dataProvider: accountsDataProvider, dataProvider: accountsDataProvider,
variables: { partyId: pubKey || '' }, variables: { partyId: pubKey || '' },
skip: !pubKey || !marketId, skip: !pubKey || !marketId,
@ -33,7 +33,9 @@ export const useMarketAccountBalance = (marketId: string) => {
() => ({ () => ({
accountBalance: pubKey ? accountBalance : '', accountBalance: pubKey ? accountBalance : '',
accountDecimals: pubKey ? accountDecimals : null, accountDecimals: pubKey ? accountDecimals : null,
loading,
error,
}), }),
[accountBalance, accountDecimals, pubKey] [accountBalance, accountDecimals, pubKey, loading, error]
); );
}; };

View File

@ -3,7 +3,7 @@ import { useCallback } from 'react';
import get from 'lodash/get'; import get from 'lodash/get';
interface MarketNameCellProps { interface MarketNameCellProps {
value?: string; value?: string | null;
data?: { id?: string; marketId?: string; market?: { id: string } }; data?: { id?: string; marketId?: string; market?: { id: string } };
idPath?: string; idPath?: string;
onMarketClick?: (marketId: string, metaKey?: boolean) => void; onMarketClick?: (marketId: string, metaKey?: boolean) => void;

View File

@ -1,5 +1,5 @@
import type { Control } from 'react-hook-form'; import type { Control } from 'react-hook-form';
import type { Market, MarketData } from '@vegaprotocol/markets'; import type { Market, StaticMarketData } from '@vegaprotocol/markets';
import { DealTicketMarketAmount } from './deal-ticket-market-amount'; import { DealTicketMarketAmount } from './deal-ticket-market-amount';
import { DealTicketLimitAmount } from './deal-ticket-limit-amount'; import { DealTicketLimitAmount } from './deal-ticket-limit-amount';
import * as Schema from '@vegaprotocol/types'; import * as Schema from '@vegaprotocol/types';
@ -9,7 +9,8 @@ import type { OrderFormFields } from '../../hooks/use-order-form';
export interface DealTicketAmountProps { export interface DealTicketAmountProps {
control: Control<OrderFormFields>; control: Control<OrderFormFields>;
orderType: Schema.OrderType; orderType: Schema.OrderType;
marketData: MarketData; marketData: StaticMarketData;
marketPrice?: string;
market: Market; market: Market;
sizeError?: string; sizeError?: string;
priceError?: string; priceError?: string;
@ -21,11 +22,18 @@ export interface DealTicketAmountProps {
export const DealTicketAmount = ({ export const DealTicketAmount = ({
orderType, orderType,
marketData, marketData,
marketPrice,
...props ...props
}: DealTicketAmountProps) => { }: DealTicketAmountProps) => {
switch (orderType) { switch (orderType) {
case Schema.OrderType.TYPE_MARKET: case Schema.OrderType.TYPE_MARKET:
return <DealTicketMarketAmount {...props} marketData={marketData} />; return (
<DealTicketMarketAmount
{...props}
marketData={marketData}
marketPrice={marketPrice}
/>
);
case Schema.OrderType.TYPE_LIMIT: case Schema.OrderType.TYPE_LIMIT:
return <DealTicketLimitAmount {...props} />; return <DealTicketLimitAmount {...props} />;
default: { default: {

View File

@ -4,9 +4,10 @@ import classNames from 'classnames';
interface Props { interface Props {
side: Side; side: Side;
label?: string;
} }
export const DealTicketButton = ({ side }: Props) => { export const DealTicketButton = ({ side, label }: Props) => {
const buttonClasses = classNames( const buttonClasses = classNames(
'px-10 py-2 uppercase rounded-md text-white w-full', 'px-10 py-2 uppercase rounded-md text-white w-full',
{ {
@ -17,7 +18,7 @@ export const DealTicketButton = ({ side }: Props) => {
return ( return (
<div className="mb-2"> <div className="mb-2">
<button type="submit" data-testid="place-order" className={buttonClasses}> <button type="submit" data-testid="place-order" className={buttonClasses}>
{t('Place order')} {label || t('Place order')}
</button> </button>
</div> </div>
); );

View File

@ -1,11 +1,20 @@
import { useVegaTransactionStore } from '@vegaprotocol/wallet';
import {
DealTicketType,
useDealTicketTypeStore,
} from '../../hooks/use-type-store';
import { StopOrder } from './deal-ticket-stop-order';
import {
useStaticMarketData,
useMarket,
useMarketPrice,
} from '@vegaprotocol/markets';
import { AsyncRenderer, Splash } from '@vegaprotocol/ui-toolkit'; import { AsyncRenderer, Splash } from '@vegaprotocol/ui-toolkit';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { useThrottledDataProvider } from '@vegaprotocol/data-provider';
import { useVegaTransactionStore } from '@vegaprotocol/wallet';
import { useMarket, marketDataProvider } from '@vegaprotocol/markets';
import { DealTicket } from './deal-ticket'; import { DealTicket } from './deal-ticket';
import { FLAGS } from '@vegaprotocol/environment';
export interface DealTicketContainerProps { interface DealTicketContainerProps {
marketId: string; marketId: string;
onMarketClick?: (marketId: string, metaKey?: boolean) => void; onMarketClick?: (marketId: string, metaKey?: boolean) => void;
onClickCollateral?: () => void; onClickCollateral?: () => void;
@ -14,10 +23,9 @@ export interface DealTicketContainerProps {
export const DealTicketContainer = ({ export const DealTicketContainer = ({
marketId, marketId,
onMarketClick, ...props
onClickCollateral,
onDeposit,
}: DealTicketContainerProps) => { }: DealTicketContainerProps) => {
const type = useDealTicketTypeStore((state) => state.type[marketId]);
const { const {
data: market, data: market,
error: marketError, error: marketError,
@ -29,15 +37,9 @@ export const DealTicketContainer = ({
error: marketDataError, error: marketDataError,
loading: marketDataLoading, loading: marketDataLoading,
reload, reload,
} = useThrottledDataProvider( } = useStaticMarketData(marketId);
{ const { data: marketPrice } = useMarketPrice(market?.id);
dataProvider: marketDataProvider,
variables: { marketId },
},
1000
);
const create = useVegaTransactionStore((state) => state.create); const create = useVegaTransactionStore((state) => state.create);
return ( return (
<AsyncRenderer <AsyncRenderer
data={market && marketData} data={market && marketData}
@ -46,14 +48,23 @@ export const DealTicketContainer = ({
reload={reload} reload={reload}
> >
{market && marketData ? ( {market && marketData ? (
<DealTicket FLAGS.STOP_ORDERS &&
market={market} (type === DealTicketType.StopLimit ||
marketData={marketData} type === DealTicketType.StopMarket) ? (
submit={(orderSubmission) => create({ orderSubmission })} <StopOrder
onClickCollateral={onClickCollateral} market={market}
onMarketClick={onMarketClick} marketPrice={marketPrice}
onDeposit={onDeposit} submit={(stopOrdersSubmission) => create({ stopOrdersSubmission })}
/> />
) : (
<DealTicket
{...props}
market={market}
marketPrice={marketPrice}
marketData={marketData}
submit={(orderSubmission) => create({ orderSubmission })}
/>
)
) : ( ) : (
<Splash> <Splash>
<p>{t('Could not load market')}</p> <p>{t('Could not load market')}</p>

View File

@ -4,11 +4,11 @@ import classnames from 'classnames';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { FeesBreakdown } from '@vegaprotocol/markets'; import { FeesBreakdown } from '@vegaprotocol/markets';
import type { OrderSubmissionBody } from '@vegaprotocol/wallet';
import { useVegaWallet } from '@vegaprotocol/wallet'; import { useVegaWallet } from '@vegaprotocol/wallet';
import type { Market } from '@vegaprotocol/markets'; import type { Market } from '@vegaprotocol/markets';
import type { EstimatePositionQuery } from '@vegaprotocol/positions'; import type { EstimatePositionQuery } from '@vegaprotocol/positions';
import type { EstimateFeesQuery } from '../../hooks/__generated__/EstimateOrder';
import { AccountBreakdownDialog } from '@vegaprotocol/accounts'; import { AccountBreakdownDialog } from '@vegaprotocol/accounts';
import { formatRange, formatValue } from '@vegaprotocol/utils'; import { formatRange, formatValue } from '@vegaprotocol/utils';
@ -24,6 +24,7 @@ import {
EST_TOTAL_MARGIN_TOOLTIP_TEXT, EST_TOTAL_MARGIN_TOOLTIP_TEXT,
MARGIN_ACCOUNT_TOOLTIP_TEXT, MARGIN_ACCOUNT_TOOLTIP_TEXT,
} from '../../constants'; } from '../../constants';
import { useEstimateFees } from '../../hooks';
const emptyValue = '-'; const emptyValue = '-';
@ -76,26 +77,82 @@ export const DealTicketFeeDetail = ({
}; };
export interface DealTicketFeeDetailsProps { 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 (
<>
<DealTicketFeeDetail
label={t('Notional')}
value={formatValue(notionalSize, marketDecimals)}
formattedValue={formatValue(notionalSize, marketDecimals)}
symbol={quoteName}
labelDescription={NOTIONAL_SIZE_TOOLTIP_TEXT(quoteName)}
/>
<DealTicketFeeDetail
label={t('Fees')}
value={
feeEstimate?.totalFeeAmount &&
`~${formatValue(feeEstimate?.totalFeeAmount, assetDecimals)}`
}
formattedValue={
feeEstimate?.totalFeeAmount &&
`~${formatValue(feeEstimate?.totalFeeAmount, assetDecimals, quantum)}`
}
labelDescription={
<>
<span>
{t(
`An estimate of the most you would be expected to pay in fees, in the market's settlement asset ${assetSymbol}.`
)}
</span>
<FeesBreakdown
fees={feeEstimate?.fees}
feeFactors={market.fees.factors}
symbol={assetSymbol}
decimals={assetDecimals}
/>
</>
}
symbol={assetSymbol}
/>
</>
);
};
export interface DealTicketMarginDetailsProps {
generalAccountBalance?: string; generalAccountBalance?: string;
marginAccountBalance?: string; marginAccountBalance?: string;
market: Market; market: Market;
onMarketClick?: (marketId: string, metaKey?: boolean) => void; onMarketClick?: (marketId: string, metaKey?: boolean) => void;
assetSymbol: string; assetSymbol: string;
notionalSize: string | null;
feeEstimate: EstimateFeesQuery['estimateFees'] | undefined;
positionEstimate: EstimatePositionQuery['estimatePosition']; positionEstimate: EstimatePositionQuery['estimatePosition'];
} }
export const DealTicketFeeDetails = ({ export const DealTicketMarginDetails = ({
marginAccountBalance, marginAccountBalance,
generalAccountBalance, generalAccountBalance,
assetSymbol, assetSymbol,
feeEstimate,
market, market,
onMarketClick, onMarketClick,
notionalSize,
positionEstimate, positionEstimate,
}: DealTicketFeeDetailsProps) => { }: DealTicketMarginDetailsProps) => {
const [breakdownDialog, setBreakdownDialog] = useState(false); const [breakdownDialog, setBreakdownDialog] = useState(false);
const { pubKey: partyId } = useVegaWallet(); const { pubKey: partyId } = useVegaWallet();
const { data: currentMargins } = useDataProvider({ const { data: currentMargins } = useDataProvider({
@ -110,7 +167,6 @@ export const DealTicketFeeDetails = ({
const { settlementAsset: asset } = const { settlementAsset: asset } =
market.tradableInstrument.instrument.product; market.tradableInstrument.instrument.product;
const { decimals: assetDecimals, quantum } = asset; const { decimals: assetDecimals, quantum } = asset;
const marketDecimals = market.decimalPlaces;
let marginRequiredBestCase: string | undefined = undefined; let marginRequiredBestCase: string | undefined = undefined;
let marginRequiredWorstCase: string | undefined = undefined; let marginRequiredWorstCase: string | undefined = undefined;
if (marginEstimate) { if (marginEstimate) {
@ -251,41 +307,7 @@ export const DealTicketFeeDetails = ({
const quoteName = market.tradableInstrument.instrument.product.quoteName; const quoteName = market.tradableInstrument.instrument.product.quoteName;
return ( return (
<div> <>
<DealTicketFeeDetail
label={t('Notional')}
value={formatValue(notionalSize, marketDecimals)}
formattedValue={formatValue(notionalSize, marketDecimals)}
symbol={quoteName}
labelDescription={NOTIONAL_SIZE_TOOLTIP_TEXT(quoteName)}
/>
<DealTicketFeeDetail
label={t('Fees')}
value={
feeEstimate?.totalFeeAmount &&
`~${formatValue(feeEstimate?.totalFeeAmount, assetDecimals)}`
}
formattedValue={
feeEstimate?.totalFeeAmount &&
`~${formatValue(feeEstimate?.totalFeeAmount, assetDecimals, quantum)}`
}
labelDescription={
<>
<span>
{t(
`An estimate of the most you would be expected to pay in fees, in the market's settlement asset ${assetSymbol}.`
)}
</span>
<FeesBreakdown
fees={feeEstimate?.fees}
feeFactors={market.fees.factors}
symbol={assetSymbol}
decimals={assetDecimals}
/>
</>
}
symbol={assetSymbol}
/>
<DealTicketFeeDetail <DealTicketFeeDetail
label={t('Margin required')} label={t('Margin required')}
value={formatRange( value={formatRange(
@ -351,6 +373,6 @@ export const DealTicketFeeDetails = ({
onClose={onAccountBreakdownDialogClose} onClose={onAccountBreakdownDialogClose}
/> />
)} )}
</div> </>
); );
}; };

View File

@ -44,12 +44,12 @@ export const DealTicketLimitAmount = ({
return ( return (
<div className="mb-2"> <div className="mb-2">
<div className="flex items-center gap-4"> <div className="flex items-start gap-4">
<div className="flex-1"> <div className="flex-1">
<FormGroup <FormGroup
label={t('Size')} label={t('Size')}
labelFor="input-order-size-limit" labelFor="input-order-size-limit"
className="!mb-1" className="!mb-0"
> >
<Controller <Controller
name="size" name="size"
@ -78,16 +78,13 @@ export const DealTicketLimitAmount = ({
/> />
</FormGroup> </FormGroup>
</div> </div>
<div className="flex-0 items-center"> <div className="pt-7 leading-10">@</div>
<div className="flex">&nbsp;</div>
<div className="flex">@</div>
</div>
<div className="flex-1"> <div className="flex-1">
<FormGroup <FormGroup
labelFor="input-price-quote" labelFor="input-price-quote"
label={t(`Price (${quoteName})`)} label={t(`Price (${quoteName})`)}
labelAlign="right" labelAlign="right"
className="!mb-1" className="!mb-0"
> >
<Controller <Controller
name="price" name="price"

View File

@ -5,10 +5,10 @@ import {
} from '@vegaprotocol/utils'; } from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { Input, InputError, Tooltip } from '@vegaprotocol/ui-toolkit'; import { Input, InputError, Tooltip } from '@vegaprotocol/ui-toolkit';
import { isMarketInAuction } from '../../utils'; import { isMarketInAuction } from '@vegaprotocol/markets';
import type { DealTicketAmountProps } from './deal-ticket-amount'; import type { DealTicketAmountProps } from './deal-ticket-amount';
import { getMarketPrice } from '../../utils/get-price';
import { Controller } from 'react-hook-form'; import { Controller } from 'react-hook-form';
import classNames from 'classnames';
export type DealTicketMarketAmountProps = Omit< export type DealTicketMarketAmountProps = Omit<
DealTicketAmountProps, DealTicketAmountProps,
@ -19,37 +19,26 @@ export const DealTicketMarketAmount = ({
control, control,
market, market,
marketData, marketData,
marketPrice,
sizeError, sizeError,
update, update,
size, size,
}: DealTicketMarketAmountProps) => { }: DealTicketMarketAmountProps) => {
const quoteName = market.tradableInstrument.instrument.product.quoteName; const quoteName = market.tradableInstrument.instrument.product.quoteName;
const sizeStep = toDecimal(market?.positionDecimalPlaces); const sizeStep = toDecimal(market?.positionDecimalPlaces);
const price = getMarketPrice(marketData); const price = marketPrice;
const priceFormatted = price const priceFormatted = price
? addDecimalsFormatNumber(price, market.decimalPlaces) ? addDecimalsFormatNumber(price, market.decimalPlaces)
: undefined; : undefined;
const inAuction = isMarketInAuction(marketData.marketTradingMode);
return ( return (
<div className="mb-2"> <div className="mb-2">
<div className="flex items-end gap-4 mb-2"> <div className="flex items-start gap-4">
<div className="flex-1 text-sm">{t('Size')}</div>
<div />
<div className="flex-2 text-sm text-right">
{isMarketInAuction(marketData.marketTradingMode) && (
<Tooltip
description={t(
'This market is in auction. The uncrossing price is an indication of what the price is expected to be when the auction ends.'
)}
>
<div>{t(`Indicative price`)}</div>
</Tooltip>
)}
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex-1"> <div className="flex-1">
<div className="mb-2 text-sm">{t('Size')}</div>
<Controller <Controller
name="size" name="size"
control={control} control={control}
@ -76,15 +65,29 @@ export const DealTicketMarketAmount = ({
)} )}
/> />
</div> </div>
<div>@</div> <div className="pt-7 leading-10">@</div>
<div className="flex-1 text-sm text-right" data-testid="last-price"> <div className="flex-1 text-sm text-right">
{priceFormatted && quoteName ? ( {inAuction && (
<> <Tooltip
~{priceFormatted} {quoteName} description={t(
</> 'This market is in auction. The uncrossing price is an indication of what the price is expected to be when the auction ends.'
) : ( )}
'-' >
<div className="mb-2">{t(`Indicative price`)}</div>
</Tooltip>
)} )}
<div
data-testid="last-price"
className={classNames('leading-10', { 'pt-7': !inAuction })}
>
{priceFormatted && quoteName ? (
<>
~{priceFormatted} {quoteName}
</>
) : (
'-'
)}
</div>
</div> </div>
</div> </div>
{sizeError && ( {sizeError && (

View File

@ -0,0 +1,314 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { VegaWalletContext } from '@vegaprotocol/wallet';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { generateMarket } from '../../test-helpers';
import { StopOrder } from './deal-ticket-stop-order';
import * as Schema from '@vegaprotocol/types';
import { MockedProvider } from '@apollo/client/testing';
import type { StopOrderFormValues } from '../../hooks/use-stop-order-form-values';
import { useStopOrderFormValues } from '../../hooks/use-stop-order-form-values';
import type { FeatureFlags } from '@vegaprotocol/environment';
jest.mock('zustand');
jest.mock('./deal-ticket-fee-details', () => ({
DealTicketFeeDetails: () => <div data-testid="deal-ticket-fee-details" />,
}));
jest.mock('@vegaprotocol/environment', () => {
const actual = jest.requireActual('@vegaprotocol/environment');
return {
...actual,
FLAGS: {
...actual.FLAGS,
STOP_ORDERS: true,
} as FeatureFlags,
};
});
const marketPrice = '200';
const market = generateMarket();
const submit = jest.fn();
function generateJsx(pubKey: string | null = 'pubKey', isReadOnly = false) {
return (
<MockedProvider>
<VegaWalletContext.Provider value={{ pubKey, isReadOnly } as any}>
<StopOrder market={market} marketPrice={marketPrice} submit={submit} />
</VegaWalletContext.Provider>
</MockedProvider>
);
}
const submitButton = 'place-order';
const sizeInput = 'order-size';
const priceInput = 'order-price';
const triggerPriceInput = 'triggerPrice';
const triggerTrailingPercentOffsetInput = 'triggerTrailingPercentOffset';
const orderTypeTrigger = 'order-type-Stop';
const orderTypeLimit = 'order-type-StopLimit';
const orderTypeMarket = 'order-type-StopMarket';
const orderSideBuy = 'order-side-SIDE_BUY';
const orderSideSell = 'order-side-SIDE_SELL';
const triggerDirectionRisesAbove = 'triggerDirection-risesAbove';
// const triggerDirectionFallsBelow = 'triggerDirection-fallsBelow';
const expiryStrategySubmit = 'expiryStrategy-submit';
const expiryStrategyCancel = 'expiryStrategy-cancel';
const triggerTypePrice = 'triggerType-price';
const triggerTypeTrailingPercentOffset = 'triggerType-trailingPercentOffset';
const expire = 'expire';
const datePicker = 'date-picker-field';
const timeInForce = 'order-tif';
const sizeErrorMessage = 'stop-order-error-message-size';
const priceErrorMessage = 'stop-order-error-message-price';
const triggerPriceErrorMessage = 'stop-order-error-message-trigger-price';
const triggerTrailingPercentOffsetErrorMessage =
'stop-order-error-message-trigger-trailing-percent-offset';
describe('StopOrder', () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
jest.clearAllMocks();
});
it('should display ticket defaults', async () => {
render(generateJsx());
// place order button should always be enabled
expect(screen.getByTestId(submitButton)).toBeEnabled();
// Assert defaults are used
await userEvent.click(screen.getByTestId(orderTypeTrigger));
expect(screen.getByTestId(orderTypeLimit).dataset.state).toEqual('checked');
await userEvent.click(screen.getByTestId(orderTypeLimit));
expect(screen.getByTestId(orderSideBuy).dataset.state).toEqual('checked');
expect(screen.getByTestId(sizeInput)).toHaveDisplayValue('0');
expect(screen.getByTestId(timeInForce)).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_FOK
);
expect(
screen.getByTestId(triggerDirectionRisesAbove).dataset.state
).toEqual('checked');
expect(screen.getByTestId(triggerTypePrice).dataset.state).toEqual(
'checked'
);
expect(screen.getByTestId(expire).dataset.state).toEqual('unchecked');
await userEvent.click(screen.getByTestId(expire));
await waitFor(() => {
expect(screen.getByTestId(expiryStrategySubmit).dataset.state).toEqual(
'checked'
);
});
});
it('should display trigger price as price for market type order', async () => {
render(generateJsx());
await userEvent.click(screen.getByTestId(orderTypeTrigger));
await userEvent.click(screen.getByTestId(orderTypeMarket));
await userEvent.type(screen.getByTestId(triggerPriceInput), '10');
expect(screen.getByTestId('price')).toHaveTextContent('10.0');
});
it('should use local storage state for initial values', async () => {
const values: Partial<StopOrderFormValues> = {
type: Schema.OrderType.TYPE_LIMIT,
side: Schema.Side.SIDE_SELL,
size: '0.1',
price: '300.22',
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC,
expire: true,
expiryStrategy: Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_CANCELS,
expiresAt: '2023-07-27T16:43:27.000',
};
useStopOrderFormValues.setState({
formValues: {
[market.id]: values,
},
});
render(generateJsx());
// Assert correct defaults are used from store
await userEvent.click(screen.getByTestId(orderTypeTrigger));
expect(screen.queryByTestId(orderTypeLimit)).toBeChecked();
expect(screen.getByTestId(orderSideSell).dataset.state).toEqual('checked');
expect(screen.getByTestId(sizeInput)).toHaveDisplayValue(
values.size as string
);
expect(screen.getByTestId('order-tif')).toHaveValue(values.timeInForce);
expect(screen.getByTestId(priceInput)).toHaveDisplayValue(
values.price as string
);
expect(screen.getByTestId(expire).dataset.state).toEqual('checked');
expect(screen.getByTestId(expiryStrategyCancel).dataset.state).toEqual(
'checked'
);
expect(screen.getByTestId(datePicker)).toHaveDisplayValue(
values.expiresAt as string
);
});
it('shows no wallet warning and do not submit if no wallet connected', async () => {
render(generateJsx(null));
await userEvent.type(screen.getByTestId(sizeInput), '1');
await userEvent.type(screen.getByTestId(priceInput), '1');
await userEvent.type(screen.getByTestId(triggerPriceInput), '1');
await userEvent.click(screen.getByTestId(submitButton));
expect(submit).not.toBeCalled();
expect(
screen.getByTestId('deal-ticket-connect-wallet')
).toBeInTheDocument();
});
it('calls submit if form is valid', async () => {
render(generateJsx());
await userEvent.type(screen.getByTestId(sizeInput), '1');
await userEvent.type(screen.getByTestId(priceInput), '1');
await userEvent.type(screen.getByTestId(triggerPriceInput), '1');
await userEvent.click(screen.getByTestId(submitButton));
expect(submit).toBeCalled();
});
it('validates size field', async () => {
render(generateJsx());
await userEvent.click(screen.getByTestId(submitButton));
// default value should be invalid
expect(screen.getByTestId(sizeErrorMessage)).toBeInTheDocument();
// to small value should be invalid
await userEvent.type(screen.getByTestId(sizeInput), '0.01');
expect(screen.getByTestId(sizeErrorMessage)).toBeInTheDocument();
// clear and fill using valid value
await userEvent.clear(screen.getByTestId(sizeInput));
await userEvent.type(screen.getByTestId(sizeInput), '0.1');
expect(screen.queryByTestId(sizeErrorMessage)).toBeNull();
});
it('validates price field', async () => {
render(generateJsx());
await userEvent.click(screen.getByTestId(submitButton));
// price error message should not show if size has error
expect(screen.queryByTestId(priceErrorMessage)).toBeNull();
await userEvent.type(screen.getByTestId(sizeInput), '0.1');
expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument();
await userEvent.type(screen.getByTestId(priceInput), '0.001');
expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument();
// switch to market order type error should disappear
await userEvent.click(screen.getByTestId(orderTypeTrigger));
await userEvent.click(screen.getByTestId(orderTypeMarket));
expect(screen.queryByTestId(priceErrorMessage)).toBeNull();
// switch back to limit type
await userEvent.click(screen.getByTestId(orderTypeTrigger));
await userEvent.click(screen.getByTestId(orderTypeLimit));
expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument();
// to small value should be invalid
await userEvent.type(screen.getByTestId(priceInput), '0.001');
expect(screen.getByTestId(priceErrorMessage)).toBeInTheDocument();
// clear and fill using valid value
await userEvent.clear(screen.getByTestId(priceInput));
await userEvent.type(screen.getByTestId(priceInput), '0.01');
expect(screen.queryByTestId(priceErrorMessage)).toBeNull();
});
it('validates trigger price field', async () => {
render(generateJsx());
await userEvent.click(screen.getByTestId(submitButton));
expect(screen.getByTestId(triggerPriceErrorMessage)).toBeInTheDocument();
// switch to trailing percentage offset trigger type
await userEvent.click(screen.getByTestId(triggerTypeTrailingPercentOffset));
expect(screen.queryByTestId(triggerPriceErrorMessage)).toBeNull();
// switch back to price trigger type
await userEvent.click(screen.getByTestId(triggerTypePrice));
expect(screen.getByTestId(triggerPriceErrorMessage)).toBeInTheDocument();
// to small value should be invalid
await userEvent.type(screen.getByTestId(triggerPriceInput), '0.001');
expect(screen.getByTestId(triggerPriceErrorMessage)).toBeInTheDocument();
// clear and fill using valid value
await userEvent.clear(screen.getByTestId(triggerPriceInput));
await userEvent.type(screen.getByTestId(triggerPriceInput), '0.01');
expect(screen.queryByTestId(triggerPriceErrorMessage)).toBeNull();
});
it('validates trigger trailing percentage offset field', async () => {
render(generateJsx());
// should not show error with default form values
await userEvent.click(screen.getByTestId(submitButton));
expect(
screen.queryByTestId(triggerTrailingPercentOffsetErrorMessage)
).toBeNull();
// switch to trailing percentage offset trigger type
await userEvent.click(screen.getByTestId(triggerTypeTrailingPercentOffset));
expect(
screen.getByTestId(triggerTrailingPercentOffsetErrorMessage)
).toBeInTheDocument();
// to small value should be invalid
await userEvent.type(
screen.getByTestId(triggerTrailingPercentOffsetInput),
'0.09'
);
expect(
screen.getByTestId(triggerTrailingPercentOffsetErrorMessage)
).toBeInTheDocument();
// clear and fill using valid value
await userEvent.clear(
screen.getByTestId(triggerTrailingPercentOffsetInput)
);
await userEvent.type(
screen.getByTestId(triggerTrailingPercentOffsetInput),
'0.1'
);
expect(
screen.queryByTestId(triggerTrailingPercentOffsetErrorMessage)
).toBeNull();
// to big value should be invalid
await userEvent.clear(
screen.getByTestId(triggerTrailingPercentOffsetInput)
);
await userEvent.type(
screen.getByTestId(triggerTrailingPercentOffsetInput),
'99.91'
);
expect(
screen.getByTestId(triggerTrailingPercentOffsetErrorMessage)
).toBeInTheDocument();
// clear and fill using valid value
await userEvent.clear(
screen.getByTestId(triggerTrailingPercentOffsetInput)
);
await userEvent.type(
screen.getByTestId(triggerTrailingPercentOffsetInput),
'99.9'
);
expect(
screen.queryByTestId(triggerTrailingPercentOffsetErrorMessage)
).toBeNull();
});
});

View File

@ -0,0 +1,590 @@
import type { FormEventHandler } from 'react';
import { useRef, useCallback, useEffect } from 'react';
import { useVegaWallet } from '@vegaprotocol/wallet';
import type { StopOrdersSubmission } from '@vegaprotocol/wallet';
import {
formatNumber,
removeDecimal,
toDecimal,
validateAmount,
} from '@vegaprotocol/utils';
import { useForm, Controller } from 'react-hook-form';
import * as Schema from '@vegaprotocol/types';
import {
Radio,
RadioGroup,
Input,
Checkbox,
FormGroup,
InputError,
Select,
Tooltip,
} from '@vegaprotocol/ui-toolkit';
import { getDerivedPrice, type Market } from '@vegaprotocol/markets';
import { t } from '@vegaprotocol/i18n';
import { ExpirySelector } from './expiry-selector';
import { SideSelector } from './side-selector';
import { timeInForceLabel, useOrder } from '@vegaprotocol/orders';
import {
NoWalletWarning,
REDUCE_ONLY_TOOLTIP,
useNotionalSize,
} from './deal-ticket';
import { TypeToggle } from './type-selector';
import {
useStopOrderFormValues,
type StopOrderFormValues,
} from '../../hooks/use-stop-order-form-values';
import {
DealTicketType,
useDealTicketTypeStore,
} from '../../hooks/use-type-store';
import { mapFormValuesToStopOrdersSubmission } from '../../utils/map-form-values-to-stop-order-submission';
import { DealTicketButton } from './deal-ticket-button';
import { DealTicketFeeDetails } from './deal-ticket-fee-details';
import { validateExpiration } from '../../utils';
export interface StopOrderProps {
market: Market;
marketPrice?: string | null;
submit: (order: StopOrdersSubmission) => void;
}
const defaultValues: Partial<StopOrderFormValues> = {
type: Schema.OrderType.TYPE_LIMIT,
side: Schema.Side.SIDE_BUY,
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_FOK,
triggerType: 'price',
triggerDirection:
Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_RISES_ABOVE,
expiryStrategy: Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT,
size: '0',
};
const stopSubmit: FormEventHandler = (e) => e.preventDefault();
export const StopOrder = ({ market, marketPrice, submit }: StopOrderProps) => {
const { pubKey, isReadOnly } = useVegaWallet();
const setDealTicketType = useDealTicketTypeStore((state) => state.set);
const [, updateOrder] = useOrder(market.id);
const updateStoredFormValues = useStopOrderFormValues(
(state) => state.update
);
const storedFormValues = useStopOrderFormValues(
(state) => state.formValues[market.id]
);
const { handleSubmit, setValue, watch, control, formState } =
useForm<StopOrderFormValues>({
defaultValues: { ...defaultValues, ...storedFormValues },
});
const { errors } = formState;
const lastSubmitTime = useRef(0);
const onSubmit = useCallback(
(data: StopOrderFormValues) => {
const now = new Date().getTime();
if (lastSubmitTime.current && now - lastSubmitTime.current < 1000) {
return;
}
submit(
mapFormValuesToStopOrdersSubmission(
data,
market.id,
market.decimalPlaces,
market.positionDecimalPlaces
)
);
lastSubmitTime.current = now;
},
[market.id, market.decimalPlaces, market.positionDecimalPlaces, submit]
);
const side = watch('side');
const expire = watch('expire');
const triggerType = watch('triggerType');
const triggerPrice = watch('triggerPrice');
const timeInForce = watch('timeInForce');
const type = watch('type');
const rawPrice = watch('price');
const rawSize = watch('size');
if (storedFormValues?.size && rawSize !== storedFormValues?.size) {
setValue('size', storedFormValues.size);
}
if (storedFormValues?.price && rawPrice !== storedFormValues?.price) {
setValue('price', storedFormValues.price);
}
const isPriceTrigger = triggerType === 'price';
const size = removeDecimal(rawSize, market.positionDecimalPlaces);
const price =
marketPrice &&
getDerivedPrice(
{
type,
price: rawPrice && removeDecimal(rawPrice, market.decimalPlaces),
},
type === Schema.OrderType.TYPE_MARKET && isPriceTrigger && triggerPrice
? removeDecimal(triggerPrice, market.decimalPlaces)
: marketPrice
);
const notionalSize = useNotionalSize(
price,
size,
market.decimalPlaces,
market.positionDecimalPlaces
);
useEffect(() => {
const subscription = watch((value, { name, type }) => {
updateStoredFormValues(market.id, value);
});
return () => subscription.unsubscribe();
}, [watch, market.id, updateStoredFormValues]);
const { quoteName, settlementAsset: asset } =
market.tradableInstrument.instrument.product;
const sizeStep = toDecimal(market?.positionDecimalPlaces);
const priceStep = toDecimal(market?.decimalPlaces);
const trailingPercentOffsetStep = '0.1';
const priceFormatted =
isPriceTrigger && triggerPrice
? formatNumber(triggerPrice, market.decimalPlaces)
: undefined;
return (
<form
onSubmit={isReadOnly || !pubKey ? stopSubmit : handleSubmit(onSubmit)}
noValidate
>
<Controller
name="type"
control={control}
render={({ field }) => {
const { value } = field;
return (
<TypeToggle
value={
value === Schema.OrderType.TYPE_LIMIT
? DealTicketType.StopLimit
: DealTicketType.StopMarket
}
onValueChange={(value) => {
const type = value as DealTicketType;
setDealTicketType(market.id, type);
if (
type === DealTicketType.Limit ||
type === DealTicketType.Market
) {
updateOrder({
type:
type === DealTicketType.Limit
? Schema.OrderType.TYPE_LIMIT
: Schema.OrderType.TYPE_MARKET,
});
return;
}
setValue(
'type',
type === DealTicketType.StopLimit
? Schema.OrderType.TYPE_LIMIT
: Schema.OrderType.TYPE_MARKET
);
}}
/>
);
}}
/>
{errors.type && (
<InputError testId="stop-order-error-message-type">
{errors.type.message}
</InputError>
)}
<Controller
name="side"
control={control}
render={({ field }) => (
<SideSelector value={field.value} onValueChange={field.onChange} />
)}
/>
<FormGroup label={t('Trigger')} compact={true} labelFor="">
<Controller
name="triggerDirection"
control={control}
render={({ field }) => {
const { onChange, value } = field;
return (
<RadioGroup
name="triggerDirection"
onChange={onChange}
value={value}
orientation="horizontal"
className="mb-2"
>
<Radio
value={
Schema.StopOrderTriggerDirection
.TRIGGER_DIRECTION_RISES_ABOVE
}
id="triggerDirection-risesAbove"
label={'Rises above'}
/>
<Radio
value={
Schema.StopOrderTriggerDirection
.TRIGGER_DIRECTION_FALLS_BELOW
}
id="triggerDirection-fallsBelow"
label={'Falls below'}
/>
</RadioGroup>
);
}}
/>
{isPriceTrigger && (
<div className="mb-2">
<Controller
name="triggerPrice"
rules={{
required: t('You need provide a price'),
min: {
value: priceStep,
message: t('Price cannot be lower than ' + priceStep),
},
validate: validateAmount(priceStep, 'Price'),
}}
control={control}
render={({ field }) => {
const { value, ...props } = field;
return (
<div className="mb-2">
<Input
data-testid="triggerPrice"
type="number"
step={priceStep}
appendElement={asset.symbol}
value={value || ''}
{...props}
/>
</div>
);
}}
/>
{errors.triggerPrice && (
<InputError testId="stop-order-error-message-trigger-price">
{errors.triggerPrice.message}
</InputError>
)}
</div>
)}
{!isPriceTrigger && (
<div className="mb-2">
<Controller
name="triggerTrailingPercentOffset"
control={control}
rules={{
required: t('You need provide a trailing percent offset'),
min: {
value: trailingPercentOffsetStep,
message: t(
'Trailing percent offset cannot be lower than ' +
trailingPercentOffsetStep
),
},
max: {
value: '99.9',
message: t(
'Trailing percent offset cannot be higher than 99.9'
),
},
validate: validateAmount(
trailingPercentOffsetStep,
'Trailing percentage offset'
),
}}
render={({ field }) => {
const { value, ...props } = field;
return (
<div className="mb-2">
<Input
type="number"
step={trailingPercentOffsetStep}
appendElement="%"
data-testid="triggerTrailingPercentOffset"
value={value || ''}
{...props}
/>
</div>
);
}}
/>
{errors.triggerTrailingPercentOffset && (
<InputError testId="stop-order-error-message-trigger-trailing-percent-offset">
{errors.triggerTrailingPercentOffset.message}
</InputError>
)}
</div>
)}
<Controller
name="triggerType"
control={control}
rules={{ deps: ['triggerTrailingPercentOffset', 'triggerPrice'] }}
render={({ field }) => {
const { onChange, value } = field;
return (
<RadioGroup
onChange={onChange}
value={value}
orientation="horizontal"
>
<Radio value="price" id="triggerType-price" label={'Price'} />
<Radio
value="trailingPercentOffset"
id="triggerType-trailingPercentOffset"
label={'Trailing Percent Offset'}
/>
</RadioGroup>
);
}}
/>
</FormGroup>
<div className="mb-2">
<div className="flex items-start gap-4">
<FormGroup
labelFor="input-price-quote"
label={t(`Size`)}
className="!mb-0 flex-1"
>
<Controller
name="size"
control={control}
rules={{
required: t('You need to provide a size'),
min: {
value: sizeStep,
message: t('Size cannot be lower than ' + sizeStep),
},
validate: validateAmount(sizeStep, 'Size'),
}}
render={({ field }) => {
const { value, ...props } = field;
return (
<Input
id="order-size"
className="w-full"
type="number"
step={sizeStep}
min={sizeStep}
onWheel={(e) => e.currentTarget.blur()}
data-testid="order-size"
value={value || ''}
{...props}
/>
);
}}
/>
</FormGroup>
<div className="pt-7 leading-10">@</div>
<div className="flex-1">
{type === Schema.OrderType.TYPE_LIMIT ? (
<FormGroup
labelFor="input-price-quote"
label={t(`Price (${quoteName})`)}
labelAlign="right"
className="!mb-0"
>
<Controller
name="price"
control={control}
rules={{
deps: 'type',
required: t('You need provide a price'),
min: {
value: priceStep,
message: t('Price cannot be lower than ' + priceStep),
},
validate: validateAmount(priceStep, 'Price'),
}}
render={({ field }) => {
const { value, ...props } = field;
return (
<Input
id="input-price-quote"
className="w-full"
type="number"
step={priceStep}
data-testid="order-price"
onWheel={(e) => e.currentTarget.blur()}
value={value || ''}
{...props}
/>
);
}}
/>
</FormGroup>
) : (
<div
className="text-sm text-right pt-7 leading-10"
data-testid="price"
>
{priceFormatted && quoteName
? `~${priceFormatted} ${quoteName}`
: '-'}
</div>
)}
</div>
</div>
{errors.size && (
<InputError testId="stop-order-error-message-size">
{errors.size.message}
</InputError>
)}
{!errors.size &&
errors.price &&
type === Schema.OrderType.TYPE_LIMIT && (
<InputError testId="stop-order-error-message-price">
{errors.price.message}
</InputError>
)}
</div>
<div className="mb-2">
<FormGroup
label={t('Time in force')}
labelFor="select-time-in-force"
compact={true}
>
<Controller
name="timeInForce"
control={control}
render={({ field }) => (
<Select
id="select-time-in-force"
className="w-full"
data-testid="order-tif"
{...field}
>
<option
key={Schema.OrderTimeInForce.TIME_IN_FORCE_IOC}
value={Schema.OrderTimeInForce.TIME_IN_FORCE_IOC}
>
{timeInForceLabel(Schema.OrderTimeInForce.TIME_IN_FORCE_IOC)}
</option>
<option
key={Schema.OrderTimeInForce.TIME_IN_FORCE_FOK}
value={Schema.OrderTimeInForce.TIME_IN_FORCE_FOK}
>
{timeInForceLabel(Schema.OrderTimeInForce.TIME_IN_FORCE_FOK)}
</option>
</Select>
)}
/>
</FormGroup>
{errors.timeInForce && (
<InputError testId="stop-error-message-tif">
{errors.timeInForce.message}
</InputError>
)}
</div>
<div className="flex gap-2 pb-2 justify-end">
<Checkbox
name="reduce-only"
checked={true}
disabled={true}
label={
<Tooltip description={<span>{t(REDUCE_ONLY_TOOLTIP)}</span>}>
<span className="text-xs">{t('Reduce only')}</span>
</Tooltip>
}
/>
</div>
<div className="mb-2">
<Controller
name="expire"
control={control}
render={({ field }) => {
const { onChange: onCheckedChange, value } = field;
return (
<Checkbox
onCheckedChange={onCheckedChange}
checked={value}
name="expire"
label={'Expire'}
/>
);
}}
/>
</div>
{expire && (
<>
<FormGroup
label={t('Strategy')}
labelFor="expiryStrategy"
compact={true}
>
<Controller
name="expiryStrategy"
control={control}
render={({ field }) => {
return (
<RadioGroup orientation="horizontal" {...field}>
<Radio
value={
Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT
}
id="expiryStrategy-submit"
label={'Submit'}
/>
<Radio
value={
Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_CANCELS
}
id="expiryStrategy-cancel"
label={'Cancel'}
/>
</RadioGroup>
);
}}
/>
</FormGroup>
<div className="mb-2">
<Controller
name="expiresAt"
control={control}
rules={{
validate: validateExpiration,
}}
render={({ field }) => {
const { value, onChange: onSelect } = field;
return (
<ExpirySelector
value={value}
onSelect={onSelect}
errorMessage={errors.expiresAt?.message}
/>
);
}}
/>
</div>
</>
)}
<NoWalletWarning pubKey={pubKey} isReadOnly={isReadOnly} asset={asset} />
<DealTicketButton side={side} label={t('Submit Stop Order')} />
<DealTicketFeeDetails
order={{
marketId: market.id,
price: price || undefined,
side,
size,
timeInForce,
type,
}}
notionalSize={notionalSize}
assetSymbol={asset.symbol}
market={market}
/>
</form>
);
};

View File

@ -22,8 +22,12 @@ import { OrdersDocument } from '@vegaprotocol/orders';
jest.mock('zustand'); jest.mock('zustand');
jest.mock('./deal-ticket-fee-details', () => ({ jest.mock('./deal-ticket-fee-details', () => ({
DealTicketFeeDetails: () => <div data-testid="deal-ticket-fee-details" />, DealTicketFeeDetails: () => <div data-testid="deal-ticket-fee-details" />,
DealTicketMarginDetails: () => (
<div data-testid="deal-ticket-margin-details" />
),
})); }));
const marketPrice = '200';
const pubKey = 'pubKey'; const pubKey = 'pubKey';
const market = generateMarket(); const market = generateMarket();
const marketData = generateMarketData(); const marketData = generateMarketData();
@ -36,6 +40,7 @@ function generateJsx(mocks: MockedResponse[] = []) {
<DealTicket <DealTicket
market={market} market={market}
marketData={marketData} marketData={marketData}
marketPrice={marketPrice}
submit={submit} submit={submit}
onDeposit={jest.fn()} onDeposit={jest.fn()}
/> />
@ -114,30 +119,22 @@ describe('DealTicket', () => {
}); });
it('should display ticket defaults', () => { it('should display ticket defaults', () => {
const { container } = render(generateJsx()); render(generateJsx());
// place order button should always be enabled // place order button should always be enabled
expect(screen.getByTestId('place-order')).toBeEnabled(); expect(screen.getByTestId('place-order')).toBeEnabled();
// Assert defaults are used // Assert defaults are used
expect( expect(screen.getByTestId('order-type-Market')).toBeInTheDocument();
screen.getByTestId(`order-type-${Schema.OrderType.TYPE_MARKET}`) expect(screen.getByTestId('order-type-Limit')).toBeInTheDocument();
).toBeInTheDocument();
expect(
screen.getByTestId(`order-type-${Schema.OrderType.TYPE_LIMIT}`)
).toBeInTheDocument();
const oderTypeLimitToggle = container.querySelector( expect(screen.getByTestId('order-type-Limit').dataset.state).toEqual(
`[data-testid="order-type-${Schema.OrderType.TYPE_LIMIT}"] input[type="radio"]` 'checked'
); );
expect(oderTypeLimitToggle).toBeChecked();
expect( expect(screen.getByTestId('order-side-SIDE_BUY').dataset.state).toEqual(
screen.queryByTestId('order-side-SIDE_BUY')?.querySelector('input') 'checked'
).toBeChecked(); );
expect(
screen.queryByTestId('order-side-SIDE_SELL')?.querySelector('input')
).not.toBeChecked();
expect(screen.getByTestId('order-size')).toHaveDisplayValue('0'); expect(screen.getByTestId('order-size')).toHaveDisplayValue('0');
expect(screen.getByTestId('order-tif')).toHaveValue( expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_GTC Schema.OrderTimeInForce.TIME_IN_FORCE_GTC
@ -147,12 +144,12 @@ describe('DealTicket', () => {
it('should display last price for market type order', () => { it('should display last price for market type order', () => {
render(generateJsx()); render(generateJsx());
act(() => { act(() => {
screen.getByTestId(`order-type-${Schema.OrderType.TYPE_MARKET}`).click(); screen.getByTestId('order-type-Market').click();
}); });
// Assert last price is shown // Assert last price is shown
expect(screen.getByTestId('last-price')).toHaveTextContent( expect(screen.getByTestId('last-price')).toHaveTextContent(
// eslint-disable-next-line // eslint-disable-next-line
`~${addDecimal(marketData.markPrice, market.decimalPlaces)} ${ `~${addDecimal(marketPrice, market.decimalPlaces)} ${
market.tradableInstrument.instrument.product.quoteName market.tradableInstrument.instrument.product.quoteName
}` }`
); );
@ -178,17 +175,12 @@ describe('DealTicket', () => {
render(generateJsx()); render(generateJsx());
// Assert correct defaults are used from store // Assert correct defaults are used from store
expect( expect(screen.getByTestId('order-type-Limit').dataset.state).toEqual(
screen 'checked'
.getByTestId(`order-type-${Schema.OrderType.TYPE_LIMIT}`) );
.querySelector('input') expect(screen.getByTestId('order-side-SIDE_SELL').dataset.state).toEqual(
).toBeChecked(); 'checked'
expect( );
screen.queryByTestId('order-side-SIDE_SELL')?.querySelector('input')
).toBeChecked();
expect(
screen.queryByTestId('order-side-SIDE_BUY')?.querySelector('input')
).not.toBeChecked();
expect(screen.getByTestId('order-size')).toHaveDisplayValue( expect(screen.getByTestId('order-size')).toHaveDisplayValue(
expectedOrder.size expectedOrder.size
); );
@ -221,17 +213,12 @@ describe('DealTicket', () => {
render(generateJsx()); render(generateJsx());
// Assert correct defaults are used from store // Assert correct defaults are used from store
expect( expect(screen.getByTestId('order-type-Limit').dataset.state).toEqual(
screen 'checked'
.getByTestId(`order-type-${Schema.OrderType.TYPE_LIMIT}`) );
.querySelector('input') expect(screen.getByTestId('order-side-SIDE_SELL').dataset.state).toEqual(
).toBeChecked(); 'checked'
expect( );
screen.queryByTestId('order-side-SIDE_SELL')?.querySelector('input')
).toBeChecked();
expect(
screen.queryByTestId('order-side-SIDE_BUY')?.querySelector('input')
).not.toBeChecked();
expect(screen.getByTestId('order-size')).toHaveDisplayValue( expect(screen.getByTestId('order-size')).toHaveDisplayValue(
expectedOrder.size expectedOrder.size
); );
@ -269,17 +256,12 @@ describe('DealTicket', () => {
render(generateJsx()); render(generateJsx());
// Assert correct defaults are used from store // Assert correct defaults are used from store
expect( expect(screen.getByTestId('order-type-Limit').dataset.state).toEqual(
screen 'checked'
.getByTestId(`order-type-${Schema.OrderType.TYPE_LIMIT}`) );
.querySelector('input') expect(screen.getByTestId('order-side-SIDE_SELL').dataset.state).toEqual(
).toBeChecked(); 'checked'
expect( );
screen.queryByTestId('order-side-SIDE_SELL')?.querySelector('input')
).toBeChecked();
expect(
screen.queryByTestId('order-side-SIDE_BUY')?.querySelector('input')
).not.toBeChecked();
expect(screen.getByTestId('order-size')).toHaveDisplayValue( expect(screen.getByTestId('order-size')).toHaveDisplayValue(
expectedOrder.size expectedOrder.size
); );
@ -322,17 +304,12 @@ describe('DealTicket', () => {
render(generateJsx()); render(generateJsx());
// Assert correct defaults are used from store // Assert correct defaults are used from store
expect( expect(screen.getByTestId('order-type-Limit').dataset.state).toEqual(
screen 'checked'
.getByTestId(`order-type-${Schema.OrderType.TYPE_LIMIT}`) );
.querySelector('input') expect(screen.getByTestId('order-side-SIDE_SELL').dataset.state).toEqual(
).toBeChecked(); 'checked'
expect( );
screen.queryByTestId('order-side-SIDE_SELL')?.querySelector('input')
).toBeChecked();
expect(
screen.queryByTestId('order-side-SIDE_BUY')?.querySelector('input')
).not.toBeChecked();
expect(screen.getByTestId('order-size')).toHaveDisplayValue( expect(screen.getByTestId('order-size')).toHaveDisplayValue(
expectedOrder.size expectedOrder.size
); );
@ -371,17 +348,12 @@ describe('DealTicket', () => {
render(generateJsx()); render(generateJsx());
// Assert correct defaults are used from store // Assert correct defaults are used from store
expect( expect(screen.getByTestId('order-type-Limit').dataset.state).toEqual(
screen 'checked'
.getByTestId(`order-type-${Schema.OrderType.TYPE_LIMIT}`) );
.querySelector('input') expect(screen.getByTestId('order-side-SIDE_SELL').dataset.state).toEqual(
).toBeChecked(); 'checked'
expect( );
screen.queryByTestId('order-side-SIDE_SELL')?.querySelector('input')
).toBeChecked();
expect(
screen.queryByTestId('order-side-SIDE_BUY')?.querySelector('input')
).not.toBeChecked();
expect(screen.getByTestId('order-size')).toHaveDisplayValue( expect(screen.getByTestId('order-size')).toHaveDisplayValue(
expectedOrder.size expectedOrder.size
); );
@ -402,7 +374,7 @@ describe('DealTicket', () => {
render(generateJsx()); render(generateJsx());
act(() => { act(() => {
screen.getByTestId(`order-type-${Schema.OrderType.TYPE_MARKET}`).click(); screen.getByTestId('order-type-Market').click();
}); });
// Only FOK and IOC should be present for type market order // Only FOK and IOC should be present for type market order
@ -427,7 +399,7 @@ describe('DealTicket', () => {
); );
// Switch to type limit order -> all TIF options should be shown // Switch to type limit order -> all TIF options should be shown
await userEvent.click(screen.getByTestId('order-type-TYPE_LIMIT')); await userEvent.click(screen.getByTestId('order-type-Limit'));
expect(screen.getByTestId('order-tif').children).toHaveLength( expect(screen.getByTestId('order-tif').children).toHaveLength(
Object.keys(Schema.OrderTimeInForce).length Object.keys(Schema.OrderTimeInForce).length
); );
@ -447,7 +419,7 @@ describe('DealTicket', () => {
); );
// Switch back to type market order -> FOK should be preserved from previous selection // Switch back to type market order -> FOK should be preserved from previous selection
await userEvent.click(screen.getByTestId('order-type-TYPE_MARKET')); await userEvent.click(screen.getByTestId('order-type-Market'));
expect(screen.getByTestId('order-tif')).toHaveValue( expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_FOK Schema.OrderTimeInForce.TIME_IN_FORCE_FOK
); );
@ -462,7 +434,7 @@ describe('DealTicket', () => {
); );
// Switch back type limit order -> GTT should be preserved // Switch back type limit order -> GTT should be preserved
await userEvent.click(screen.getByTestId('order-type-TYPE_LIMIT')); await userEvent.click(screen.getByTestId('order-type-Limit'));
expect(screen.getByTestId('order-tif')).toHaveValue( expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_GTT Schema.OrderTimeInForce.TIME_IN_FORCE_GTT
); );
@ -477,7 +449,7 @@ describe('DealTicket', () => {
); );
// Switch to type market order -> IOC should be preserved // Switch to type market order -> IOC should be preserved
await userEvent.click(screen.getByTestId('order-type-TYPE_MARKET')); await userEvent.click(screen.getByTestId('order-type-Market'));
expect(screen.getByTestId('order-tif')).toHaveValue( expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
); );
@ -487,9 +459,9 @@ describe('DealTicket', () => {
render(generateJsx()); render(generateJsx());
// BUY is selected by default // BUY is selected by default
expect( expect(screen.getByTestId('order-side-SIDE_BUY').dataset.state).toEqual(
screen.getByTestId('order-side-SIDE_BUY')?.querySelector('input') 'checked'
).toBeChecked(); );
await userEvent.type(screen.getByTestId('order-size'), '200'); await userEvent.type(screen.getByTestId('order-size'), '200');
@ -504,7 +476,7 @@ describe('DealTicket', () => {
); );
// Switch to limit order // Switch to limit order
await userEvent.click(screen.getByTestId('order-type-TYPE_LIMIT')); await userEvent.click(screen.getByTestId('order-type-Limit'));
// Check all TIF options shown // Check all TIF options shown
expect(screen.getByTestId('order-tif').children).toHaveLength( expect(screen.getByTestId('order-tif').children).toHaveLength(

View File

@ -4,7 +4,10 @@ import { memo, useCallback, useEffect, useState, useRef, useMemo } from 'react';
import { Controller } from 'react-hook-form'; import { Controller } from 'react-hook-form';
import { DealTicketAmount } from './deal-ticket-amount'; import { DealTicketAmount } from './deal-ticket-amount';
import { DealTicketButton } from './deal-ticket-button'; import { DealTicketButton } from './deal-ticket-button';
import { DealTicketFeeDetails } from './deal-ticket-fee-details'; import {
DealTicketFeeDetails,
DealTicketMarginDetails,
} from './deal-ticket-fee-details';
import { ExpirySelector } from './expiry-selector'; import { ExpirySelector } from './expiry-selector';
import { SideSelector } from './side-selector'; import { SideSelector } from './side-selector';
import { TimeInForceSelector } from './time-in-force-selector'; import { TimeInForceSelector } from './time-in-force-selector';
@ -30,8 +33,7 @@ import {
} from '@vegaprotocol/positions'; } from '@vegaprotocol/positions';
import { toBigNum, removeDecimal } from '@vegaprotocol/utils'; import { toBigNum, removeDecimal } from '@vegaprotocol/utils';
import { activeOrdersProvider } from '@vegaprotocol/orders'; import { activeOrdersProvider } from '@vegaprotocol/orders';
import { useEstimateFees } from '../../hooks/use-estimate-fees'; import { getDerivedPrice } from '@vegaprotocol/markets';
import { getDerivedPrice } from '../../utils/get-price';
import type { OrderInfo } from '@vegaprotocol/types'; import type { OrderInfo } from '@vegaprotocol/types';
import { import {
@ -43,7 +45,11 @@ import {
} from '../../utils'; } from '../../utils';
import { ZeroBalanceError } from '../deal-ticket-validation/zero-balance-error'; import { ZeroBalanceError } from '../deal-ticket-validation/zero-balance-error';
import { SummaryValidationType } from '../../constants'; import { SummaryValidationType } from '../../constants';
import type { Market, MarketData } from '@vegaprotocol/markets'; import type {
Market,
MarketData,
StaticMarketData,
} from '@vegaprotocol/markets';
import { MarginWarning } from '../deal-ticket-validation/margin-warning'; import { MarginWarning } from '../deal-ticket-validation/margin-warning';
import { import {
useMarketAccountBalance, useMarketAccountBalance,
@ -53,26 +59,59 @@ import {
import { OrderTimeInForce, OrderType } from '@vegaprotocol/types'; import { OrderTimeInForce, OrderType } from '@vegaprotocol/types';
import { useOrderForm } from '../../hooks/use-order-form'; import { useOrderForm } from '../../hooks/use-order-form';
import { useDataProvider } from '@vegaprotocol/data-provider'; import { useDataProvider } from '@vegaprotocol/data-provider';
import {
DealTicketType,
useDealTicketTypeStore,
} from '../../hooks/use-type-store';
import { useStopOrderFormValues } from '../../hooks/use-stop-order-form-values';
import { DealTicketSizeIceberg } from './deal-ticket-size-iceberg'; import { DealTicketSizeIceberg } from './deal-ticket-size-iceberg';
import noop from 'lodash/noop';
export const REDUCE_ONLY_TOOLTIP =
'"Reduce only" will ensure that this order will not increase the size of an open position. When the order is matched, it will only trade enough volume to bring your open volume towards 0 but never change the direction of your position. If applied to a limit order that is not instantly filled, the order will be stopped.';
export interface DealTicketProps { export interface DealTicketProps {
market: Market; market: Market;
marketData: MarketData; marketData: StaticMarketData;
marketPrice?: string | null;
onMarketClick?: (marketId: string, metaKey?: boolean) => void; onMarketClick?: (marketId: string, metaKey?: boolean) => void;
submit: (order: OrderSubmission) => void; submit: (order: OrderSubmission) => void;
onClickCollateral?: () => void; onClickCollateral?: () => void;
onDeposit: (assetId: string) => void; onDeposit: (assetId: string) => void;
} }
export const useNotionalSize = (
price: string | null | undefined,
size: string | undefined,
decimalPlaces: number,
positionDecimalPlaces: number
) =>
useMemo(() => {
if (price && size) {
return removeDecimal(
toBigNum(size, positionDecimalPlaces).multipliedBy(
toBigNum(price, decimalPlaces)
),
decimalPlaces
);
}
return null;
}, [price, size, decimalPlaces, positionDecimalPlaces]);
export const DealTicket = ({ export const DealTicket = ({
market, market,
onMarketClick, onMarketClick,
marketData, marketData,
marketPrice,
submit, submit,
onClickCollateral, onClickCollateral,
onDeposit, onDeposit,
}: DealTicketProps) => { }: DealTicketProps) => {
const { pubKey, isReadOnly } = useVegaWallet(); const { pubKey, isReadOnly } = useVegaWallet();
const setDealTicketType = useDealTicketTypeStore((state) => state.set);
const updateStopOrderFormValues = useStopOrderFormValues(
(state) => state.update
);
// store last used tif for market so that when changing OrderType the previous TIF // store last used tif for market so that when changing OrderType the previous TIF
// selection for that type is used when switching back // selection for that type is used when switching back
@ -95,11 +134,15 @@ export const DealTicket = ({
const asset = market.tradableInstrument.instrument.product.settlementAsset; const asset = market.tradableInstrument.instrument.product.settlementAsset;
const { accountBalance: marginAccountBalance } = useMarketAccountBalance( const {
market.id accountBalance: marginAccountBalance,
); loading: loadingMarginAccountBalance,
} = useMarketAccountBalance(market.id);
const { accountBalance: generalAccountBalance } = useAccountBalance(asset.id); const {
accountBalance: generalAccountBalance,
loading: loadingGeneralAccountBalance,
} = useAccountBalance(asset.id);
const balance = ( const balance = (
BigInt(marginAccountBalance) + BigInt(generalAccountBalance) BigInt(marginAccountBalance) + BigInt(generalAccountBalance)
@ -116,30 +159,20 @@ export const DealTicket = ({
); );
const price = useMemo(() => { const price = useMemo(() => {
return normalizedOrder && getDerivedPrice(normalizedOrder, marketData); return (
}, [normalizedOrder, marketData]); normalizedOrder &&
marketPrice &&
getDerivedPrice(normalizedOrder, marketPrice)
);
}, [normalizedOrder, marketPrice]);
const notionalSize = useMemo(() => { const notionalSize = useNotionalSize(
if (price && normalizedOrder?.size) {
return removeDecimal(
toBigNum(
normalizedOrder.size,
market.positionDecimalPlaces
).multipliedBy(toBigNum(price, market.decimalPlaces)),
market.decimalPlaces
);
}
return null;
}, [
price, price,
normalizedOrder?.size, normalizedOrder?.size,
market.decimalPlaces, market.decimalPlaces,
market.positionDecimalPlaces, market.positionDecimalPlaces
]);
const feeEstimate = useEstimateFees(
normalizedOrder && { ...normalizedOrder, price }
); );
const { data: activeOrders } = useDataProvider({ const { data: activeOrders } = useDataProvider({
dataProvider: activeOrdersProvider, dataProvider: activeOrdersProvider,
variables: { partyId: pubKey || '', marketId: market.id }, variables: { partyId: pubKey || '', marketId: market.id },
@ -197,7 +230,10 @@ export const DealTicket = ({
const hasNoBalance = const hasNoBalance =
!BigInt(generalAccountBalance) && !BigInt(marginAccountBalance); !BigInt(generalAccountBalance) && !BigInt(marginAccountBalance);
if (hasNoBalance) { if (
hasNoBalance &&
!(loadingMarginAccountBalance || loadingGeneralAccountBalance)
) {
setError('summary', { setError('summary', {
message: SummaryValidationType.NoCollateral, message: SummaryValidationType.NoCollateral,
type: SummaryValidationType.NoCollateral, type: SummaryValidationType.NoCollateral,
@ -221,6 +257,8 @@ export const DealTicket = ({
marketTradingMode, marketTradingMode,
generalAccountBalance, generalAccountBalance,
marginAccountBalance, marginAccountBalance,
loadingMarginAccountBalance,
loadingGeneralAccountBalance,
pubKey, pubKey,
setError, setError,
clearErrors, clearErrors,
@ -265,11 +303,13 @@ export const DealTicket = ({
); );
// if an order doesn't exist one will be created by the store immediately // if an order doesn't exist one will be created by the store immediately
if (!order || !normalizedOrder) return null; if (!order || !normalizedOrder) {
return null;
}
return ( return (
<form <form
onSubmit={isReadOnly ? undefined : handleSubmit(onSubmit)} onSubmit={isReadOnly ? noop : handleSubmit(onSubmit)}
noValidate noValidate
data-testid="deal-ticket-form" data-testid="deal-ticket-form"
> >
@ -284,9 +324,29 @@ export const DealTicket = ({
}} }}
render={() => ( render={() => (
<TypeSelector <TypeSelector
value={order.type} value={
onSelect={(type) => { order.type === OrderType.TYPE_LIMIT
if (type === OrderType.TYPE_NETWORK) return; ? DealTicketType.Limit
: DealTicketType.Market
}
onValueChange={(dealTicketType) => {
setDealTicketType(market.id, dealTicketType);
if (
dealTicketType !== DealTicketType.Limit &&
dealTicketType !== DealTicketType.Market
) {
updateStopOrderFormValues(market.id, {
type:
dealTicketType === DealTicketType.StopLimit
? OrderType.TYPE_LIMIT
: OrderType.TYPE_MARKET,
});
return;
}
const type =
dealTicketType === DealTicketType.Limit
? OrderType.TYPE_LIMIT
: OrderType.TYPE_MARKET;
update({ update({
type, type,
// when changing type also update the TIF to what was last used of new type // when changing type also update the TIF to what was last used of new type
@ -333,7 +393,7 @@ export const DealTicket = ({
render={() => ( render={() => (
<SideSelector <SideSelector
value={order.side} value={order.side}
onSelect={(side) => { onValueChange={(side) => {
update({ side }); update({ side });
}} }}
/> />
@ -344,6 +404,7 @@ export const DealTicket = ({
orderType={order.type} orderType={order.type}
market={market} market={market}
marketData={marketData} marketData={marketData}
marketPrice={marketPrice || undefined}
sizeError={errors.size?.message} sizeError={errors.size?.message}
priceError={errors.price?.message} priceError={errors.price?.message}
update={update} update={update}
@ -467,9 +528,7 @@ export const DealTicket = ({
? t( ? t(
'"Reduce only" can be used only with non-persistent orders, such as "Fill or Kill" or "Immediate or Cancel".' '"Reduce only" can be used only with non-persistent orders, such as "Fill or Kill" or "Immediate or Cancel".'
) )
: t( : t(REDUCE_ONLY_TOOLTIP)}
'"Reduce only" will ensure that this order will not increase the size of an open position. When the order is matched, it will only trade enough volume to bring your open volume towards 0 but never change the direction of your position. If applied to a limit order that is not instantly filled, the order will be stopped.'
)}
</span> </span>
} }
> >
@ -541,10 +600,16 @@ export const DealTicket = ({
/> />
<DealTicketButton side={order.side} /> <DealTicketButton side={order.side} />
<DealTicketFeeDetails <DealTicketFeeDetails
onMarketClick={onMarketClick} order={
feeEstimate={feeEstimate} normalizedOrder && { ...normalizedOrder, price: price || undefined }
}
notionalSize={notionalSize} notionalSize={notionalSize}
assetSymbol={assetSymbol} assetSymbol={assetSymbol}
market={market}
/>
<DealTicketMarginDetails
onMarketClick={onMarketClick}
assetSymbol={assetSymbol}
marginAccountBalance={marginAccountBalance} marginAccountBalance={marginAccountBalance}
generalAccountBalance={generalAccountBalance} generalAccountBalance={generalAccountBalance}
positionEstimate={positionEstimate?.estimatePosition} positionEstimate={positionEstimate?.estimatePosition}
@ -569,6 +634,55 @@ interface SummaryMessageProps {
onClickCollateral?: () => void; onClickCollateral?: () => void;
onDeposit: (assetId: string) => void; onDeposit: (assetId: string) => void;
} }
export const NoWalletWarning = ({
isReadOnly,
pubKey,
asset,
}: Pick<SummaryMessageProps, 'isReadOnly' | 'pubKey' | 'asset'>) => {
const assetSymbol = asset.symbol;
const openVegaWalletDialog = useVegaWalletDialogStore(
(store) => store.openVegaWalletDialog
);
if (isReadOnly) {
return (
<div className="mb-2">
<InputError testId="deal-ticket-error-message-summary">
{
'You need to connect your own wallet to start trading on this market'
}
</InputError>
</div>
);
}
if (!pubKey) {
return (
<div className="mb-2">
<Notification
testId={'deal-ticket-connect-wallet'}
intent={Intent.Warning}
message={
<p className="text-sm pb-2">
You need a{' '}
<ExternalLink href="https://vega.xyz/wallet">
Vega wallet
</ExternalLink>{' '}
with {assetSymbol} to start trading in this market.
</p>
}
buttonProps={{
text: t('Connect wallet'),
action: openVegaWalletDialog,
dataTestId: 'order-connect-wallet',
size: 'small',
}}
/>
</div>
);
}
return null;
};
const SummaryMessage = memo( const SummaryMessage = memo(
({ ({
errorMessage, errorMessage,
@ -583,46 +697,16 @@ const SummaryMessage = memo(
}: SummaryMessageProps) => { }: SummaryMessageProps) => {
// Specific error UI for if balance is so we can // Specific error UI for if balance is so we can
// render a deposit dialog // render a deposit dialog
const assetSymbol = asset.symbol; if (isReadOnly || !pubKey) {
const openVegaWalletDialog = useVegaWalletDialogStore(
(store) => store.openVegaWalletDialog
);
if (isReadOnly) {
return ( return (
<div className="mb-2"> <NoWalletWarning
<InputError testId="deal-ticket-error-message-summary"> isReadOnly={isReadOnly}
{ asset={asset}
'You need to connect your own wallet to start trading on this market' pubKey={pubKey}
} />
</InputError>
</div>
);
}
if (!pubKey) {
return (
<div className="mb-2">
<Notification
testId={'deal-ticket-connect-wallet'}
intent={Intent.Warning}
message={
<p className="text-sm pb-2">
You need a{' '}
<ExternalLink href="https://vega.xyz/wallet">
Vega wallet
</ExternalLink>{' '}
with {assetSymbol} to start trading in this market.
</p>
}
buttonProps={{
text: t('Connect wallet'),
action: openVegaWalletDialog,
dataTestId: 'order-connect-wallet',
size: 'small',
}}
/>
</div>
); );
} }
if (errorMessage === SummaryValidationType.NoCollateral) { if (errorMessage === SummaryValidationType.NoCollateral) {
return ( return (
<div className="mb-2"> <div className="mb-2">

View File

@ -1,6 +1,7 @@
import { FormGroup, Input, InputError } from '@vegaprotocol/ui-toolkit'; import { FormGroup, Input, InputError } from '@vegaprotocol/ui-toolkit';
import { formatForInput } from '@vegaprotocol/utils'; import { formatForInput } from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { useRef } from 'react';
interface ExpirySelectorProps { interface ExpirySelectorProps {
value?: string; value?: string;
@ -13,7 +14,8 @@ export const ExpirySelector = ({
onSelect, onSelect,
errorMessage, errorMessage,
}: ExpirySelectorProps) => { }: ExpirySelectorProps) => {
const date = value ? new Date(value) : new Date(); const now = useRef(new Date());
const date = value ? new Date(value) : now.current;
const dateFormatted = formatForInput(date); const dateFormatted = formatForInput(date);
const minDate = formatForInput(date); const minDate = formatForInput(date);
return ( return (

View File

@ -3,6 +3,8 @@ export * from './deal-ticket-container';
export * from './deal-ticket-limit-amount'; export * from './deal-ticket-limit-amount';
export * from './deal-ticket-market-amount'; export * from './deal-ticket-market-amount';
export * from './deal-ticket'; export * from './deal-ticket';
export * from './deal-ticket-stop-order';
export * from './deal-ticket-container';
export * from './expiry-selector'; export * from './expiry-selector';
export * from './side-selector'; export * from './side-selector';
export * from './time-in-force-selector'; export * from './time-in-force-selector';

View File

@ -1,46 +1,41 @@
import { FormGroup } from '@vegaprotocol/ui-toolkit';
import { Toggle } from '@vegaprotocol/ui-toolkit';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import * as Schema from '@vegaprotocol/types'; import * as Schema from '@vegaprotocol/types';
import * as RadioGroup from '@radix-ui/react-radio-group';
import classNames from 'classnames';
interface SideSelectorProps { interface SideSelectorProps {
value: Schema.Side; value: Schema.Side;
onSelect: (side: Schema.Side) => void; onValueChange: (side: Schema.Side) => void;
} }
export const SideSelector = ({ value, onSelect }: SideSelectorProps) => { const toggles = [
const toggles = [ { label: t('Long'), value: Schema.Side.SIDE_BUY },
{ label: t('Long'), value: Schema.Side.SIDE_BUY }, { label: t('Short'), value: Schema.Side.SIDE_SELL },
{ label: t('Short'), value: Schema.Side.SIDE_SELL }, ];
];
const toggleType = (e: Schema.Side) => {
switch (e) {
case Schema.Side.SIDE_BUY:
return 'buy';
case Schema.Side.SIDE_SELL:
return 'sell';
default:
return 'primary';
}
};
export const SideSelector = (props: SideSelectorProps) => {
return ( return (
<FormGroup <RadioGroup.Root
label={t('Direction')} name="order-side"
labelFor="order-side-toggle" className="mb-2 flex h-10 leading-10"
compact={true} {...props}
> >
<Toggle {toggles.map(({ label, value }) => (
id="order-side-toggle" <RadioGroup.Item value={value} key={value} id={`side-${value}`} asChild>
name="order-side" <button
toggles={toggles} className="flex-1 relative font-alpha text-sm"
checkedValue={value} data-testid={`order-side-${value}`}
type={toggleType(value)} >
onChange={(e) => { {label}
onSelect(e.target.value as Schema.Side); <RadioGroup.Indicator
}} className={classNames('absolute bottom-0 left-0 right-0 h-0.5', {
/> 'bg-market-red': props.value === Schema.Side.SIDE_SELL,
</FormGroup> 'bg-market-green-550': props.value === Schema.Side.SIDE_BUY,
})}
/>
</button>
</RadioGroup.Item>
))}
</RadioGroup.Root>
); );
}; };

View File

@ -1,32 +1,127 @@
import { import {
FormGroup, Icon,
InputError, InputError,
SimpleGrid, SimpleGrid,
Tooltip, Tooltip,
TradingDropdown,
TradingDropdownContent,
TradingDropdownItemIndicator,
TradingDropdownPortal,
TradingDropdownRadioGroup,
TradingDropdownRadioItem,
TradingDropdownTrigger,
} from '@vegaprotocol/ui-toolkit'; } from '@vegaprotocol/ui-toolkit';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import * as Schema from '@vegaprotocol/types'; import type { Market, StaticMarketData } from '@vegaprotocol/markets';
import { Toggle } from '@vegaprotocol/ui-toolkit';
import type { Market, MarketData } from '@vegaprotocol/markets';
import { compileGridData } from '../trading-mode-tooltip'; import { compileGridData } from '../trading-mode-tooltip';
import { MarketModeValidationType } from '../../constants'; import { MarketModeValidationType } from '../../constants';
import { DealTicketType } from '../../hooks/use-type-store';
import * as RadioGroup from '@radix-ui/react-radio-group';
import classNames from 'classnames';
import { FLAGS } from '@vegaprotocol/environment';
interface TypeSelectorProps { interface TypeSelectorProps {
value: Schema.OrderType; value: DealTicketType;
onSelect: (type: Schema.OrderType) => void; onValueChange: (type: DealTicketType) => void;
market: Market; market: Market;
marketData: MarketData; marketData: StaticMarketData;
errorMessage?: string; errorMessage?: string;
} }
const toggles = [ const toggles = [
{ label: t('Limit'), value: Schema.OrderType.TYPE_LIMIT }, { label: t('Limit'), value: DealTicketType.Limit },
{ label: t('Market'), value: Schema.OrderType.TYPE_MARKET }, { label: t('Market'), value: DealTicketType.Market },
]; ];
const options = [
{ label: t('Stop Limit'), value: DealTicketType.StopLimit },
{ label: t('Stop Market'), value: DealTicketType.StopMarket },
];
export const TypeToggle = ({
value,
onValueChange,
}: Pick<TypeSelectorProps, 'onValueChange' | 'value'>) => {
const selectedOption = options.find((t) => t.value === value);
return (
<RadioGroup.Root
name="order-type"
className={classNames('mb-2 grid h-8 leading-8 font-alpha text-sm', {
'grid-cols-3': FLAGS.STOP_ORDERS,
'grid-cols-2': !FLAGS.STOP_ORDERS,
})}
value={value}
onValueChange={onValueChange}
>
{toggles.map(({ label, value: itemValue }) => (
<RadioGroup.Item
value={itemValue}
key={itemValue}
id={`order-type-${itemValue}`}
data-testid={`order-type-${itemValue}`}
asChild
>
<button
className={classNames('rounded', {
'bg-vega-clight-500 dark:bg-vega-cdark-500': value === itemValue,
})}
>
{label}
</button>
</RadioGroup.Item>
))}
{FLAGS.STOP_ORDERS && (
<TradingDropdown
trigger={
<TradingDropdownTrigger
data-testid="order-type-Stop"
className={classNames(
'rounded px-3 flex flex-nowrap items-center justify-center',
{
'bg-vega-clight-500 dark:bg-vega-cdark-500': selectedOption,
}
)}
>
<button>
<span className="text-ellipsis whitespace-nowrap shrink overflow-hidden">
{t(selectedOption ? selectedOption.label : 'Stop')}
</span>
<Icon name="chevron-down" className="ml-1" />
</button>
</TradingDropdownTrigger>
}
>
<TradingDropdownPortal>
<TradingDropdownContent>
<TradingDropdownRadioGroup
onValueChange={(value) =>
onValueChange(value as DealTicketType)
}
value={value}
>
{options.map(({ label, value: itemValue }) => (
<TradingDropdownRadioItem
key={itemValue}
value={itemValue}
textValue={itemValue}
id={`order-type-${itemValue}`}
data-testid={`order-type-${itemValue}`}
>
{t(label)}
<TradingDropdownItemIndicator />
</TradingDropdownRadioItem>
))}
</TradingDropdownRadioGroup>
</TradingDropdownContent>
</TradingDropdownPortal>
</TradingDropdown>
)}
</RadioGroup.Root>
);
};
export const TypeSelector = ({ export const TypeSelector = ({
value, value,
onSelect, onValueChange,
market, market,
marketData, marketData,
errorMessage, errorMessage,
@ -74,19 +169,18 @@ export const TypeSelector = ({
}; };
return ( return (
<FormGroup label={t('Order type')} labelFor="order-type" compact={true}> <>
<Toggle <TypeToggle
id="order-type" onValueChange={(value) => {
name="order-type" onValueChange(value as DealTicketType);
toggles={toggles} }}
checkedValue={value} value={value}
onChange={(e) => onSelect(e.target.value as Schema.OrderType)}
/> />
{errorMessage && ( {errorMessage && (
<InputError testId="deal-ticket-error-message-type"> <InputError testId="deal-ticket-error-message-type">
{renderError(errorMessage as MarketModeValidationType)} {renderError(errorMessage as MarketModeValidationType)}
</InputError> </InputError>
)} )}
</FormGroup> </>
); );
}; };

View File

@ -1,2 +1,4 @@
export * from './__generated__/EstimateOrder'; export * from './__generated__/EstimateOrder';
export * from './use-estimate-fees'; export * from './use-estimate-fees';
export * from './use-type-store';
export * from './use-stop-order-form-values';

View File

@ -0,0 +1,62 @@
import { create } from 'zustand';
import { persist, subscribeWithSelector } from 'zustand/middleware';
import type { OrderTimeInForce, Side, OrderType } from '@vegaprotocol/types';
import type * as Schema from '@vegaprotocol/types';
export interface StopOrderFormValues {
side: Side;
triggerDirection: Schema.StopOrderTriggerDirection;
triggerType: 'price' | 'trailingPercentOffset';
triggerPrice: string;
triggerTrailingPercentOffset: string;
type: OrderType;
size: string;
timeInForce: OrderTimeInForce;
price?: string;
expire: boolean;
expiryStrategy?: Schema.StopOrderExpiryStrategy;
expiresAt?: string;
}
type StopOrderFormValuesMap = {
[marketId: string]: Partial<StopOrderFormValues> | undefined;
};
type Update = (
marketId: string,
formValues: Partial<StopOrderFormValues>,
persist?: boolean
) => void;
interface Store {
formValues: StopOrderFormValuesMap;
update: Update;
}
export const useStopOrderFormValues = create<Store>()(
persist(
subscribeWithSelector((set) => ({
formValues: {},
update: (marketId, formValues, persist = true) => {
set((state) => {
return {
formValues: {
...state.formValues,
[marketId]: {
...state.formValues[marketId],
...formValues,
},
},
};
});
},
})),
{
name: 'vega_stop_order_store',
}
)
);

View File

@ -0,0 +1,28 @@
import { create } from 'zustand';
import { persist, subscribeWithSelector } from 'zustand/middleware';
export enum DealTicketType {
Limit = 'Limit',
Market = 'Market',
StopLimit = 'StopLimit',
StopMarket = 'StopMarket',
}
export const useDealTicketTypeStore = create<{
set: (marketId: string, type: DealTicketType) => void;
type: Record<string, DealTicketType>;
}>()(
persist(
subscribeWithSelector((set) => ({
type: {},
set: (marketId: string, type: DealTicketType) =>
set((state) => ({
...state,
type: { ...state.type, [marketId]: type },
})),
})),
{
name: 'deal_ticket_type',
}
)
);

View File

@ -1,4 +1,4 @@
import type { Market, MarketData } from '@vegaprotocol/markets'; import type { Market, StaticMarketData } from '@vegaprotocol/markets';
import * as Schema from '@vegaprotocol/types'; import * as Schema from '@vegaprotocol/types';
import merge from 'lodash/merge'; import merge from 'lodash/merge';
import type { PartialDeep } from 'type-fest'; import type { PartialDeep } from 'type-fest';
@ -85,33 +85,15 @@ export function generateMarket(override?: PartialDeep<Market>): Market {
} }
export function generateMarketData( export function generateMarketData(
override?: PartialDeep<MarketData> override?: PartialDeep<StaticMarketData>
): MarketData { ): StaticMarketData {
const defaultMarketData: MarketData = { const defaultMarketData: StaticMarketData = {
__typename: 'MarketData',
market: {
id: 'market-id',
__typename: 'Market',
},
auctionEnd: '2022-06-21T17:18:43.484055236Z', auctionEnd: '2022-06-21T17:18:43.484055236Z',
auctionStart: '2022-06-21T17:18:43.484055236Z', auctionStart: '2022-06-21T17:18:43.484055236Z',
bestBidPrice: '0',
bestBidVolume: '0',
bestOfferPrice: '0',
bestOfferVolume: '0',
bestStaticBidPrice: '0',
bestStaticBidVolume: '0',
bestStaticOfferPrice: '0',
bestStaticOfferVolume: '0',
indicativePrice: '100', indicativePrice: '100',
indicativeVolume: '10', indicativeVolume: '10',
marketState: Schema.MarketState.STATE_ACTIVE, marketState: Schema.MarketState.STATE_ACTIVE,
marketTradingMode: Schema.MarketTradingMode.TRADING_MODE_CONTINUOUS, marketTradingMode: Schema.MarketTradingMode.TRADING_MODE_CONTINUOUS,
marketValueProxy: '',
markPrice: '200',
midPrice: '0',
openInterest: '',
staticMidPrice: '0',
suppliedStake: '1000', suppliedStake: '1000',
targetStake: '1000000', targetStake: '1000000',
trigger: Schema.AuctionTrigger.AUCTION_TRIGGER_BATCH, trigger: Schema.AuctionTrigger.AUCTION_TRIGGER_BATCH,

View File

@ -1,5 +1,4 @@
export * from './get-default-order'; export * from './get-default-order';
export * from './is-market-in-auction';
export * from './validate-expiration'; export * from './validate-expiration';
export * from './validate-market-state'; export * from './validate-market-state';
export * from './validate-market-trading-mode'; export * from './validate-market-trading-mode';

View File

@ -0,0 +1,71 @@
import type {
StopOrderSetup,
StopOrdersSubmission,
} from '@vegaprotocol/wallet';
import { normalizeOrderSubmission } from '@vegaprotocol/wallet';
import type { StopOrderFormValues } from '../hooks/use-stop-order-form-values';
import * as Schema from '@vegaprotocol/types';
import { removeDecimal, toNanoSeconds } from '@vegaprotocol/utils';
export const mapFormValuesToStopOrdersSubmission = (
data: StopOrderFormValues,
marketId: string,
decimalPlaces: number,
positionDecimalPlaces: number
): StopOrdersSubmission => {
const submission: StopOrdersSubmission = {};
const stopOrderSetup: StopOrderSetup = {
orderSubmission: normalizeOrderSubmission(
{
marketId,
type: data.type,
side: data.side,
size: data.size,
timeInForce: data.timeInForce,
price: data.price,
reduceOnly: true,
},
decimalPlaces,
positionDecimalPlaces
),
};
if (data.triggerType === 'price') {
stopOrderSetup.price = removeDecimal(data.triggerPrice, decimalPlaces);
} else if (data.triggerType === 'trailingPercentOffset') {
stopOrderSetup.trailingPercentOffset = (
Number(data.triggerTrailingPercentOffset) / 100
).toFixed(3);
}
if (data.expire) {
stopOrderSetup.expiresAt = data.expiresAt && toNanoSeconds(data.expiresAt);
if (
data.expiryStrategy ===
Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_CANCELS
) {
stopOrderSetup.expiryStrategy =
Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_CANCELS;
} else if (
data.expiryStrategy ===
Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT
) {
stopOrderSetup.expiryStrategy =
Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT;
}
}
if (
data.triggerDirection ===
Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_RISES_ABOVE
) {
submission.risesAbove = stopOrderSetup;
}
if (
data.triggerDirection ===
Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_FALLS_BELOW
) {
submission.fallsBelow = stopOrderSetup;
}
return submission;
};

View File

@ -1,6 +1,6 @@
import * as Schema from '@vegaprotocol/types'; import * as Schema from '@vegaprotocol/types';
import { MarketModeValidationType } from '../constants'; import { MarketModeValidationType } from '../constants';
import { isMarketInAuction } from './is-market-in-auction'; import { isMarketInAuction } from '@vegaprotocol/markets';
export const validateTimeInForce = ( export const validateTimeInForce = (
marketTradingMode: Schema.MarketTradingMode, marketTradingMode: Schema.MarketTradingMode,

View File

@ -1,6 +1,6 @@
import * as Schema from '@vegaprotocol/types'; import * as Schema from '@vegaprotocol/types';
import { MarketModeValidationType } from '../constants'; import { MarketModeValidationType } from '../constants';
import { isMarketInAuction } from './is-market-in-auction'; import { isMarketInAuction } from '@vegaprotocol/markets';
export const validateType = ( export const validateType = (
marketTradingMode: Schema.MarketTradingMode, marketTradingMode: Schema.MarketTradingMode,

View File

@ -1,6 +1,6 @@
import * as Schema from '@vegaprotocol/types'; import * as Schema from '@vegaprotocol/types';
import { isMarketInAuction } from './is-market-in-auction'; import { isMarketInAuction } from './is-market-in-auction';
import type { MarketData } from '@vegaprotocol/markets'; import type { MarketData } from './market-data-provider';
/** /**
* Get the market price based on market mode (auction or not auction) * Get the market price based on market mode (auction or not auction)
@ -33,19 +33,16 @@ export const getDerivedPrice = (
type: Schema.OrderType; type: Schema.OrderType;
price?: string | undefined; price?: string | undefined;
}, },
marketData: MarketData marketPrice: string
) => { ) => {
// If order type is market we should use either the mark price // If order type is market we should use either the mark price
// or the uncrossing price. If order type is limit use the price // or the uncrossing price. If order type is limit use the price
// the user has input // the user has input
// Use the market price if order is a market order // Use the market price if order is a market order
let price; const price =
if (order.type === Schema.OrderType.TYPE_LIMIT && order.price) { order.type === Schema.OrderType.TYPE_LIMIT && order.price
price = order.price; ? order.price
} else { : marketPrice;
price = getMarketPrice(marketData);
}
return price === '0' ? undefined : price; return price === '0' ? undefined : price;
}; };

View File

@ -6,6 +6,8 @@ export * from './hooks';
export * from './market-utils'; export * from './market-utils';
export { marketCandlesProvider } from './market-candles-provider'; export { marketCandlesProvider } from './market-candles-provider';
export type { Candle } from './market-candles-provider'; export type { Candle } from './market-candles-provider';
export * from './get-price';
export * from './is-market-in-auction';
export * from './market-data-provider'; export * from './market-data-provider';
export * from './markets-candles-provider'; export * from './markets-candles-provider';
export * from './markets-data-provider'; export * from './markets-data-provider';

View File

@ -15,6 +15,7 @@ import type {
MarketDataUpdateFieldsFragment, MarketDataUpdateFieldsFragment,
MarketDataQueryVariables, MarketDataQueryVariables,
} from './__generated__/market-data'; } from './__generated__/market-data';
import { getMarketPrice } from './get-price';
export type MarketData = MarketDataFieldsFragment; export type MarketData = MarketDataFieldsFragment;
@ -58,6 +59,21 @@ export const markPriceProvider = makeDerivedDataProvider<
MarketDataQueryVariables MarketDataQueryVariables
>([marketDataProvider], ([marketData]) => (marketData as MarketData).markPrice); >([marketDataProvider], ([marketData]) => (marketData as MarketData).markPrice);
export const marketPriceProvider = makeDerivedDataProvider<
string | undefined,
never,
MarketDataQueryVariables
>([marketDataProvider], ([marketData]) =>
getMarketPrice(marketData as MarketData)
);
export const useMarketPrice = (marketId?: string, skip?: boolean) =>
useDataProvider({
dataProvider: marketPriceProvider,
variables: { marketId: marketId || '' },
skip: skip || !marketId,
});
export type StaticMarketData = Pick< export type StaticMarketData = Pick<
MarketData, MarketData,
| 'marketTradingMode' | 'marketTradingMode'

View File

@ -1,4 +1,5 @@
export * from './order-data-provider'; export * from './order-data-provider';
export * from './order-list'; export * from './order-list';
export * from './order-list-manager'; export * from './order-list-manager';
export * from './stop-orders-manager';
export * from './mocks/generate-orders'; export * from './mocks/generate-orders';

View File

@ -0,0 +1,113 @@
import merge from 'lodash/merge';
import * as Schema from '@vegaprotocol/types';
import type { StopOrder } from '../order-data-provider/stop-orders-data-provider';
import type { PartialDeep } from 'type-fest';
export const generateStopOrder = (
partialStopOrder?: PartialDeep<StopOrder>
) => {
const stopOrder: StopOrder = {
__typename: 'StopOrder',
id: 'stop-order-id',
marketId: 'market-id',
partyId: 'party-id',
triggerDirection:
Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_RISES_ABOVE,
trigger: {
trailingPercentOffset: '5',
},
ocoLinkId: undefined,
market: {
__typename: 'Market',
id: 'market-id',
decimalPlaces: 1,
fees: {
__typename: 'Fees',
factors: {
__typename: 'FeeFactors',
infrastructureFee: '0.1',
liquidityFee: '0.1',
makerFee: '0.1',
},
},
marketTimestamps: {
__typename: 'MarketTimestamps',
close: '',
open: '',
},
positionDecimalPlaces: 2,
state: Schema.MarketState.STATE_ACTIVE,
tradableInstrument: {
__typename: 'TradableInstrument',
instrument: {
__typename: 'Instrument',
code: 'XYZ',
id: 'XYZ',
metadata: {
__typename: 'InstrumentMetadata',
tags: ['xyz asset'],
},
name: 'XYZ intrument',
product: {
__typename: 'Future',
quoteName: '',
settlementAsset: {
__typename: 'Asset',
id: 'asset-id',
decimals: 1,
symbol: 'XYZ',
name: 'XYZ',
quantum: '1',
},
dataSourceSpecForTradingTermination: {
__typename: 'DataSourceSpec',
id: 'oracleId',
data: {
__typename: 'DataSourceDefinition',
sourceType: {
__typename: 'DataSourceDefinitionExternal',
sourceType: {
__typename: 'DataSourceSpecConfiguration',
},
},
},
},
dataSourceSpecForSettlementData: {
__typename: 'DataSourceSpec',
id: 'oracleId',
data: {
__typename: 'DataSourceDefinition',
sourceType: {
__typename: 'DataSourceDefinitionExternal',
sourceType: {
__typename: 'DataSourceSpecConfiguration',
},
},
},
},
dataSourceSpecBinding: {
__typename: 'DataSourceSpecToFutureBinding',
tradingTerminationProperty: 'trading-termination-property',
settlementDataProperty: 'settlement-data-property',
},
},
},
},
tradingMode: Schema.MarketTradingMode.TRADING_MODE_CONTINUOUS,
},
submission: {
marketId: 'market-id',
size: '10',
type: Schema.OrderType.TYPE_MARKET,
side: Schema.Side.SIDE_BUY,
price: '',
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC,
expiresAt: null,
},
status: Schema.StopOrderStatus.STATUS_PENDING,
createdAt: new Date().toISOString(),
updatedAt: null,
expiresAt: null,
};
return merge(stopOrder, partialStopOrder);
};

View File

@ -99,3 +99,54 @@ subscription OrdersUpdate($partyId: ID!, $marketIds: [ID!]) {
...OrderUpdateFields ...OrderUpdateFields
} }
} }
fragment OrderSubmissionFields on OrderSubmission {
marketId
price
size
side
timeInForce
expiresAt
type
reference
peggedOrder {
reference
offset
}
postOnly
reduceOnly
}
fragment StopOrderFields on StopOrder {
id
ocoLinkId
expiresAt
expiryStrategy
triggerDirection
status
createdAt
updatedAt
partyId
marketId
trigger {
... on StopOrderPrice {
price
}
... on StopOrderTrailingPercentOffset {
trailingPercentOffset
}
}
submission {
...OrderSubmissionFields
}
}
query StopOrders($partyId: ID!) {
stopOrders(filter: { parties: [$partyId] }) {
edges {
node {
...StopOrderFields
}
}
}
}

View File

@ -32,6 +32,17 @@ export type OrdersUpdateSubscriptionVariables = Types.Exact<{
export type OrdersUpdateSubscription = { __typename?: 'Subscription', orders?: Array<{ __typename?: 'OrderUpdate', id: string, marketId: string, type?: Types.OrderType | null, side: Types.Side, size: string, status: Types.OrderStatus, rejectionReason?: Types.OrderRejectionReason | null, price: string, timeInForce: Types.OrderTimeInForce, remaining: string, expiresAt?: any | null, createdAt: any, updatedAt?: any | null, liquidityProvisionId?: string | null, peggedOrder?: { __typename: 'PeggedOrder', reference: Types.PeggedReference, offset: string } | null, icebergOrder?: { __typename: 'IcebergOrder', peakSize: string, minimumVisibleSize: string, reservedRemaining: string } | null }> | null }; export type OrdersUpdateSubscription = { __typename?: 'Subscription', orders?: Array<{ __typename?: 'OrderUpdate', id: string, marketId: string, type?: Types.OrderType | null, side: Types.Side, size: string, status: Types.OrderStatus, rejectionReason?: Types.OrderRejectionReason | null, price: string, timeInForce: Types.OrderTimeInForce, remaining: string, expiresAt?: any | null, createdAt: any, updatedAt?: any | null, liquidityProvisionId?: string | null, peggedOrder?: { __typename: 'PeggedOrder', reference: Types.PeggedReference, offset: string } | null, icebergOrder?: { __typename: 'IcebergOrder', peakSize: string, minimumVisibleSize: string, reservedRemaining: string } | null }> | null };
export type OrderSubmissionFieldsFragment = { __typename?: 'OrderSubmission', marketId: string, price: string, size: string, side: Types.Side, timeInForce: Types.OrderTimeInForce, expiresAt: any, type: Types.OrderType, reference?: string | null, postOnly?: boolean | null, reduceOnly?: boolean | null, peggedOrder?: { __typename?: 'PeggedOrder', reference: Types.PeggedReference, offset: string } | null};
export type StopOrderFieldsFragment = { __typename?: 'StopOrder', id: string, ocoLinkId?: string | null, expiresAt?: any | null, expiryStrategy?: Types.StopOrderExpiryStrategy | null, triggerDirection: Types.StopOrderTriggerDirection, status: Types.StopOrderStatus, createdAt: any, updatedAt?: any | null, partyId: string, marketId: string, trigger?: { __typename?: 'StopOrderPrice', price: string } | { __typename?: 'StopOrderTrailingPercentOffset', trailingPercentOffset: string } | null, submission: { __typename?: 'OrderSubmission', marketId: string, price: string, size: string, side: Types.Side, timeInForce: Types.OrderTimeInForce, expiresAt: any, type: Types.OrderType, reference?: string | null, postOnly?: boolean | null, reduceOnly?: boolean | null, peggedOrder?: { __typename?: 'PeggedOrder', reference: Types.PeggedReference, offset: string } | null } };
export type StopOrdersQueryVariables = Types.Exact<{
partyId: Types.Scalars['ID'];
}>;
export type StopOrdersQuery = { __typename?: 'Query', stopOrders?: { __typename?: 'StopOrderConnection', edges?: Array<{ __typename?: 'StopOrderEdge', node?: { __typename?: 'StopOrder', id: string, ocoLinkId?: string | null, expiresAt?: any | null, expiryStrategy?: Types.StopOrderExpiryStrategy | null, triggerDirection: Types.StopOrderTriggerDirection, status: Types.StopOrderStatus, createdAt: any, updatedAt?: any | null, partyId: string, marketId: string, trigger?: { __typename?: 'StopOrderPrice', price: string } | { __typename?: 'StopOrderTrailingPercentOffset', trailingPercentOffset: string } | null, submission: { __typename?: 'OrderSubmission', marketId: string, price: string, size: string, side: Types.Side, timeInForce: Types.OrderTimeInForce, expiresAt: any, type: Types.OrderType, reference?: string | null, postOnly?: boolean | null, reduceOnly?: boolean | null, peggedOrder?: { __typename?: 'PeggedOrder', reference: Types.PeggedReference, offset: string } | null } } | null }> | null } | null };
export const OrderFieldsFragmentDoc = gql` export const OrderFieldsFragmentDoc = gql`
fragment OrderFields on Order { fragment OrderFields on Order {
id id
@ -96,6 +107,49 @@ export const OrderUpdateFieldsFragmentDoc = gql`
} }
} }
`; `;
export const OrderSubmissionFieldsFragmentDoc = gql`
fragment OrderSubmissionFields on OrderSubmission {
marketId
price
size
side
timeInForce
expiresAt
type
reference
peggedOrder {
reference
offset
}
postOnly
reduceOnly
}
`;
export const StopOrderFieldsFragmentDoc = gql`
fragment StopOrderFields on StopOrder {
id
ocoLinkId
expiresAt
expiryStrategy
triggerDirection
status
createdAt
updatedAt
partyId
marketId
trigger {
... on StopOrderPrice {
price
}
... on StopOrderTrailingPercentOffset {
trailingPercentOffset
}
}
submission {
...OrderSubmissionFields
}
}
${OrderSubmissionFieldsFragmentDoc}`;
export const OrderByIdDocument = gql` export const OrderByIdDocument = gql`
query OrderById($orderId: ID!) { query OrderById($orderId: ID!) {
orderByID(id: $orderId) { orderByID(id: $orderId) {
@ -216,4 +270,43 @@ export function useOrdersUpdateSubscription(baseOptions: Apollo.SubscriptionHook
return Apollo.useSubscription<OrdersUpdateSubscription, OrdersUpdateSubscriptionVariables>(OrdersUpdateDocument, options); return Apollo.useSubscription<OrdersUpdateSubscription, OrdersUpdateSubscriptionVariables>(OrdersUpdateDocument, options);
} }
export type OrdersUpdateSubscriptionHookResult = ReturnType<typeof useOrdersUpdateSubscription>; export type OrdersUpdateSubscriptionHookResult = ReturnType<typeof useOrdersUpdateSubscription>;
export type OrdersUpdateSubscriptionResult = Apollo.SubscriptionResult<OrdersUpdateSubscription>; export type OrdersUpdateSubscriptionResult = Apollo.SubscriptionResult<OrdersUpdateSubscription>;
export const StopOrdersDocument = gql`
query StopOrders($partyId: ID!) {
stopOrders(filter: {parties: [$partyId]}) {
edges {
node {
...StopOrderFields
}
}
}
}
${StopOrderFieldsFragmentDoc}`;
/**
* __useStopOrdersQuery__
*
* To run a query within a React component, call `useStopOrdersQuery` and pass it any options that fit your needs.
* When your component renders, `useStopOrdersQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useStopOrdersQuery({
* variables: {
* partyId: // value for 'partyId'
* },
* });
*/
export function useStopOrdersQuery(baseOptions: Apollo.QueryHookOptions<StopOrdersQuery, StopOrdersQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<StopOrdersQuery, StopOrdersQueryVariables>(StopOrdersDocument, options);
}
export function useStopOrdersLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<StopOrdersQuery, StopOrdersQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<StopOrdersQuery, StopOrdersQueryVariables>(StopOrdersDocument, options);
}
export type StopOrdersQueryHookResult = ReturnType<typeof useStopOrdersQuery>;
export type StopOrdersLazyQueryHookResult = ReturnType<typeof useStopOrdersLazyQuery>;
export type StopOrdersQueryResult = Apollo.QueryResult<StopOrdersQuery, StopOrdersQueryVariables>;

View File

@ -0,0 +1,57 @@
import {
makeDataProvider,
makeDerivedDataProvider,
} from '@vegaprotocol/data-provider';
import type { Market } from '@vegaprotocol/markets';
import { marketsMapProvider } from '@vegaprotocol/markets';
import type {
StopOrderFieldsFragment,
StopOrdersQuery,
StopOrdersQueryVariables,
} from './__generated__/Orders';
import { StopOrdersDocument } from './__generated__/Orders';
export type StopOrder = StopOrderFieldsFragment & {
market: Market;
};
const getData = (
responseData: StopOrdersQuery | null
): StopOrderFieldsFragment[] =>
responseData?.stopOrders?.edges
?.map((edge) => edge.node)
.filter((node): node is StopOrderFieldsFragment => !!node) || [];
export const stopOrdersProvider = makeDataProvider<
StopOrdersQuery,
ReturnType<typeof getData>,
never,
never,
StopOrdersQueryVariables
>({
query: StopOrdersDocument,
getData,
});
export const stopOrdersWithMarketProvider = makeDerivedDataProvider<
StopOrder[],
never,
StopOrdersQueryVariables
>(
[
stopOrdersProvider,
(callback, client) => marketsMapProvider(callback, client, undefined),
],
(partsData): StopOrder[] => {
return ((partsData[0] as ReturnType<typeof getData>) || []).map(
(stopOrder) => {
return {
...stopOrder,
market: (partsData[1] as Record<string, Market>)[
stopOrder.submission.marketId
],
};
}
);
}
);

View File

@ -0,0 +1 @@
export * from './stop-orders-manager';

View File

@ -0,0 +1,43 @@
import { render, screen, act } from '@testing-library/react';
import { StopOrdersManager } from './stop-orders-manager';
import * as useDataProviderHook from '@vegaprotocol/data-provider';
import type { StopOrder } from '../order-data-provider/stop-orders-data-provider';
import * as stopOrdersTableMock from '../stop-orders-table/stop-orders-table';
import { forwardRef } from 'react';
import type { VegaWalletContextShape } from '@vegaprotocol/wallet';
import { VegaWalletContext } from '@vegaprotocol/wallet';
import { MockedProvider } from '@apollo/client/testing';
// @ts-ignore StopOrdersTable is read only but we need to override with the forwardRef to
// avoid warnings about padding refs
stopOrdersTableMock.StopOrdersTable = forwardRef(() => (
<div>StopOrdersTable</div>
));
const generateJsx = () => {
const pubKey = '0x123';
return (
<MockedProvider>
<VegaWalletContext.Provider value={{ pubKey } as VegaWalletContextShape}>
<StopOrdersManager partyId={pubKey} isReadOnly={false} />
</VegaWalletContext.Provider>
</MockedProvider>
);
};
describe('StopOrdersManager', () => {
it('should render the stop orders table if data provided', async () => {
jest.spyOn(useDataProviderHook, 'useDataProvider').mockReturnValue({
data: [{ id: '1' } as StopOrder],
loading: false,
error: undefined,
flush: jest.fn(),
reload: jest.fn(),
load: jest.fn(),
});
await act(async () => {
render(generateJsx());
});
expect(await screen.findByText('StopOrdersTable')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,66 @@
import { t } from '@vegaprotocol/i18n';
import { useCallback, useEffect } from 'react';
import { StopOrdersTable } from '../stop-orders-table/stop-orders-table';
import type { useDataGridEvents } from '@vegaprotocol/datagrid';
import { useVegaTransactionStore } from '@vegaprotocol/wallet';
import type { StopOrder } from '../order-data-provider/stop-orders-data-provider';
import { useDataProvider } from '@vegaprotocol/data-provider';
import { stopOrdersWithMarketProvider } from '../order-data-provider/stop-orders-data-provider';
export interface StopOrdersManagerProps {
partyId: string;
onMarketClick?: (marketId: string, metaKey?: boolean) => void;
isReadOnly: boolean;
gridProps?: ReturnType<typeof useDataGridEvents>;
}
const POLLING_TIME = 2000;
export const StopOrdersManager = ({
partyId,
onMarketClick,
isReadOnly,
gridProps,
}: StopOrdersManagerProps) => {
const create = useVegaTransactionStore((state) => state.create);
const variables = { partyId };
const { data, error, reload } = useDataProvider({
dataProvider: stopOrdersWithMarketProvider,
variables,
});
useEffect(() => {
const interval = setInterval(() => {
reload();
}, POLLING_TIME);
return () => {
clearInterval(interval);
};
}, [reload]);
const cancel = useCallback(
(order: StopOrder) => {
if (!order.submission.marketId) return;
create({
stopOrdersCancellation: {
stopOrderId: order.id,
marketId: order.submission.marketId,
},
});
},
[create]
);
return (
<StopOrdersTable
rowData={data}
onCancel={cancel}
onMarketClick={onMarketClick}
isReadOnly={isReadOnly}
suppressAutoSize
overlayNoRowsTemplate={error ? error.message : t('No stop orders')}
{...gridProps}
/>
);
};

View File

@ -0,0 +1,237 @@
import { act, render, screen } from '@testing-library/react';
import * as Schema from '@vegaprotocol/types';
import type { PartialDeep } from 'type-fest';
import type { VegaWalletContextShape } from '@vegaprotocol/wallet';
import { VegaWalletContext } from '@vegaprotocol/wallet';
import { MockedProvider } from '@apollo/client/testing';
import {
StopOrdersTable,
type StopOrdersTableProps,
} from './stop-orders-table';
import { generateStopOrder } from '../mocks/generate-stop-orders';
// Mock theme switcher to get around inconsistent mocking of zustand
// stores
jest.mock('@vegaprotocol/react-helpers', () => ({
...jest.requireActual('@vegaprotocol/react-helpers'),
useThemeSwitcher: () => ({
theme: 'light',
}),
}));
jest.mock('@vegaprotocol/utils', () => ({
...jest.requireActual('@vegaprotocol/utils'),
getDateTimeFormat: jest.fn(() => ({
format: (date: Date) => date.toISOString(),
})),
}));
const defaultProps: StopOrdersTableProps = {
rowData: [],
onCancel: jest.fn(),
isReadOnly: false,
};
const generateJsx = (
props: Partial<StopOrdersTableProps> = defaultProps,
context: PartialDeep<VegaWalletContextShape> = { pubKey: '0x123' }
) => {
return (
<MockedProvider>
<VegaWalletContext.Provider value={context as VegaWalletContextShape}>
<StopOrdersTable {...defaultProps} {...props} />
</VegaWalletContext.Provider>
</MockedProvider>
);
};
const rowData = [
generateStopOrder({
id: 'stop-order-1',
trigger: { price: '80', __typename: 'StopOrderPrice' },
triggerDirection:
Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_FALLS_BELOW,
expiresAt: '2023-08-26T08:31:22Z',
expiryStrategy: Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT,
submission: {
size: '100',
side: Schema.Side.SIDE_BUY,
type: Schema.OrderType.TYPE_LIMIT,
price: '120',
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_FOK,
},
status: Schema.StopOrderStatus.STATUS_CANCELLED,
}),
generateStopOrder({
id: 'stop-order-2',
trigger: { price: '90', __typename: 'StopOrderPrice' },
triggerDirection:
Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_RISES_ABOVE,
expiresAt: '2023-08-26T08:31:22Z',
expiryStrategy: Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_CANCELS,
submission: {
size: '110',
side: Schema.Side.SIDE_SELL,
type: Schema.OrderType.TYPE_MARKET,
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC,
},
status: Schema.StopOrderStatus.STATUS_EXPIRED,
}),
generateStopOrder({
id: 'stop-order-3',
trigger: {
trailingPercentOffset: '0.1',
__typename: 'StopOrderTrailingPercentOffset',
},
triggerDirection:
Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_FALLS_BELOW,
status: Schema.StopOrderStatus.STATUS_PENDING,
}),
generateStopOrder({
id: 'stop-order-4',
trigger: {
trailingPercentOffset: '0.2',
__typename: 'StopOrderTrailingPercentOffset',
},
triggerDirection:
Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_RISES_ABOVE,
status: Schema.StopOrderStatus.STATUS_REJECTED,
}),
generateStopOrder({
id: 'stop-order-5',
status: Schema.StopOrderStatus.STATUS_STOPPED,
}),
generateStopOrder({
id: 'stop-order-6',
status: Schema.StopOrderStatus.STATUS_TRIGGERED,
}),
];
describe('StopOrdersTable', () => {
it('should render correct columns', async () => {
await act(async () => {
render(generateJsx({ rowData }));
});
const expectedHeaders = [
'Market',
'Trigger',
'Expires At',
'Size',
'Submission Type',
'Status',
'Submission Price',
'Submission Time In Force',
'Updated At',
'', // no cell header for edit/cancel
];
const headers = screen.getAllByRole('columnheader');
expect(headers).toHaveLength(expectedHeaders.length);
expect(headers.map((h) => h.textContent?.trim())).toEqual(expectedHeaders);
});
it('formats trigger column', async () => {
await act(async () => {
render(generateJsx({ rowData }));
});
const grid = screen.getByRole('treegrid');
const cells = grid.querySelectorAll('.ag-body [col-id="trigger"]');
const expectedValues: string[] = [
'Mark < 8.0',
'Mark > 9.0',
'Mark +10.0%',
'Mark -20.0%',
];
expectedValues.forEach((expectedValue, i) =>
expect(cells[i]).toHaveTextContent(expectedValue)
);
});
it('formats expires at column', async () => {
await act(async () => {
render(generateJsx({ rowData }));
});
const grid = screen.getByRole('treegrid');
const cells = grid.querySelectorAll('.ag-body [col-id="expiresAt"]');
const expectedValues: string[] = [
'Submit 2023-08-26T08:31:22.000Z',
'Cancels 2023-08-26T08:31:22.000Z',
];
expectedValues.forEach((expectedValue, i) =>
expect(cells[i]).toHaveTextContent(expectedValue)
);
});
it('formats size column', async () => {
await act(async () => {
render(generateJsx({ rowData }));
});
const grid = screen.getByRole('treegrid');
const cells = grid.querySelectorAll('.ag-body [col-id="submission.size"]');
const expectedValues: string[] = ['+1.00', '-1.10'];
expectedValues.forEach((expectedValue, i) =>
expect(cells[i]).toHaveTextContent(expectedValue)
);
});
it('formats type column', async () => {
await act(async () => {
render(generateJsx({ rowData }));
});
const grid = screen.getByRole('treegrid');
const cells = grid.querySelectorAll('.ag-body [col-id="submission.type"]');
const expectedValues: string[] = ['Limit', 'Market'];
expectedValues.forEach((expectedValue, i) =>
expect(cells[i]).toHaveTextContent(expectedValue)
);
});
it('formats status column', async () => {
await act(async () => {
render(generateJsx({ rowData }));
});
const grid = screen.getByRole('treegrid');
const cells = grid.querySelectorAll('.ag-body [col-id="status"]');
const expectedValues: string[] = [
'Cancelled',
'Expired',
'Pending',
'Rejected',
'Stopped',
'Triggered',
];
expectedValues.forEach((expectedValue, i) =>
expect(cells[i]).toHaveTextContent(expectedValue)
);
});
it('formats price column', async () => {
await act(async () => {
render(generateJsx({ rowData }));
});
const grid = screen.getByRole('treegrid');
const cells = grid.querySelectorAll('.ag-body [col-id="submission.price"]');
const expectedValues: string[] = ['12.0', '-'];
expectedValues.forEach((expectedValue, i) =>
expect(cells[i]).toHaveTextContent(expectedValue)
);
});
it('shows cancel button only for pending stop orders', async () => {
await act(async () => {
render(generateJsx({ rowData }));
});
const cancelButtons = screen.getAllByTestId('cancel');
expect(cancelButtons).toHaveLength(1);
cancelButtons.forEach((cancelButton) => {
const id = cancelButton.closest('[role="row"]')?.getAttribute('row-id');
expect(rowData.find((row) => row.id === id)?.status).toEqual(
Schema.StopOrderStatus.STATUS_PENDING
);
});
});
});

View File

@ -0,0 +1,300 @@
import {
addDecimalsFormatNumber,
getDateTimeFormat,
isNumeric,
toBigNum,
} from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n';
import * as Schema from '@vegaprotocol/types';
import { ButtonLink } from '@vegaprotocol/ui-toolkit';
import type { ForwardedRef } from 'react';
import { memo, forwardRef, useMemo } from 'react';
import {
AgGridLazy as AgGrid,
SetFilter,
DateRangeFilter,
negativeClassNames,
positiveClassNames,
MarketNameCell,
COL_DEFS,
} from '@vegaprotocol/datagrid';
import type {
TypedDataAgGrid,
VegaICellRendererParams,
VegaValueFormatterParams,
VegaValueGetterParams,
} from '@vegaprotocol/datagrid';
import type { AgGridReact } from 'ag-grid-react';
import type { StopOrder } from '../order-data-provider/stop-orders-data-provider';
import type { ColDef } from 'ag-grid-community';
export type StopOrdersTableProps = TypedDataAgGrid<StopOrder> & {
onCancel: (order: StopOrder) => void;
onMarketClick?: (marketId: string, metaKey?: boolean) => void;
isReadOnly: boolean;
};
export const StopOrdersTable = memo<
StopOrdersTableProps & { ref?: ForwardedRef<AgGridReact> }
>(
forwardRef<AgGridReact, StopOrdersTableProps>(
({ onCancel, onMarketClick, ...props }, ref) => {
const showAllActions = !props.isReadOnly;
const columnDefs: ColDef[] = useMemo(
() => [
{
headerName: t('Market'),
field: 'market.tradableInstrument.instrument.code',
cellRenderer: 'MarketNameCell',
cellRendererParams: { idPath: 'market.id', onMarketClick },
minWidth: 150,
},
{
headerName: t('Trigger'),
field: 'trigger',
cellClass: 'font-mono text-right',
type: 'rightAligned',
sortable: false,
valueFormatter: ({
data,
value,
}: VegaValueFormatterParams<StopOrder, 'trigger'>): string => {
if (data && value?.__typename === 'StopOrderPrice') {
return `${t('Mark')} ${
data?.triggerDirection ===
Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_FALLS_BELOW
? '<'
: '>'
} ${addDecimalsFormatNumber(
value.price,
data.market.decimalPlaces
)}`;
}
if (
data &&
value?.__typename === 'StopOrderTrailingPercentOffset'
) {
return `${t('Mark')} ${
data?.triggerDirection ===
Schema.StopOrderTriggerDirection.TRIGGER_DIRECTION_FALLS_BELOW
? '+'
: '-'
}${(Number(value.trailingPercentOffset) * 100).toFixed(1)}%`;
}
return '-';
},
minWidth: 100,
},
{
field: 'expiresAt',
valueFormatter: ({
value,
data,
}: VegaValueFormatterParams<StopOrder, 'expiresAt'>) => {
if (
data &&
value &&
data?.expiryStrategy !==
Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_UNSPECIFIED
) {
const expiresAt = getDateTimeFormat().format(new Date(value));
const expiryStrategy =
data.expiryStrategy ===
Schema.StopOrderExpiryStrategy.EXPIRY_STRATEGY_SUBMIT
? t('Submit')
: t('Cancels');
return `${expiryStrategy} ${expiresAt}`;
}
return '';
},
minWidth: 150,
},
{
headerName: t('Size'),
field: 'submission.size',
cellClass: 'font-mono text-right',
type: 'rightAligned',
cellClassRules: {
[positiveClassNames]: ({ data }: { data: StopOrder }) =>
data?.submission.size === Schema.Side.SIDE_BUY,
[negativeClassNames]: ({ data }: { data: StopOrder }) =>
data?.submission.size === Schema.Side.SIDE_SELL,
},
valueGetter: ({ data }: VegaValueGetterParams<StopOrder>) => {
return data?.submission.size && data.market
? toBigNum(
data.submission.size,
data.market.positionDecimalPlaces ?? 0
)
.multipliedBy(
data.submission.side === Schema.Side.SIDE_SELL ? -1 : 1
)
.toNumber()
: undefined;
},
valueFormatter: ({
data,
}: VegaValueFormatterParams<StopOrder, 'size'>) => {
if (!data) {
return '';
}
if (!data?.market || !isNumeric(data.submission.size)) {
return '-';
}
const prefix = data
? data.submission.side === Schema.Side.SIDE_BUY
? '+'
: '-'
: '';
return (
prefix +
addDecimalsFormatNumber(
data.submission.size,
data.market.positionDecimalPlaces
)
);
},
minWidth: 80,
},
{
field: 'submission.type',
filter: SetFilter,
filterParams: {
set: Schema.OrderTypeMapping,
},
cellRenderer: ({
value,
}: VegaICellRendererParams<StopOrder, 'submission.type'>) =>
value ? Schema.OrderTypeMapping[value] : '',
minWidth: 80,
},
{
field: 'status',
filter: SetFilter,
filterParams: {
set: Schema.StopOrderStatusMapping,
},
valueFormatter: ({
value,
}: VegaValueFormatterParams<StopOrder, 'status'>) => {
return value ? Schema.StopOrderStatusMapping[value] : '';
},
cellRenderer: ({
valueFormatted,
data,
}: {
valueFormatted: string;
data: StopOrder;
}) => (
<span data-testid={`order-status-${data?.id}`}>
{valueFormatted}
</span>
),
minWidth: 100,
},
{
field: 'submission.price',
type: 'rightAligned',
cellClass: 'font-mono text-right',
valueFormatter: ({
value,
data,
}: VegaValueFormatterParams<StopOrder, 'submission.price'>) => {
if (!data) {
return '';
}
if (
!data?.market ||
data.submission.type === Schema.OrderType.TYPE_MARKET ||
!isNumeric(value)
) {
return '-';
}
return addDecimalsFormatNumber(value, data.market.decimalPlaces);
},
minWidth: 100,
},
{
field: 'submission.timeInForce',
filter: SetFilter,
filterParams: {
set: Schema.OrderTimeInForceMapping,
},
valueFormatter: ({
value,
}: VegaValueFormatterParams<
StopOrder,
'submission.timeInForce'
>) => {
return value ? Schema.OrderTimeInForceCode[value] : '';
},
minWidth: 150,
},
{
field: 'updatedAt',
filter: DateRangeFilter,
valueGetter: ({ data }: VegaValueGetterParams<StopOrder>) =>
data?.updatedAt || data?.createdAt,
cellRenderer: ({
data,
}: VegaICellRendererParams<StopOrder, 'createdAt'>) => {
if (!data) {
return undefined;
}
const value = data.updatedAt || data.createdAt;
return (
<span data-value={value}>
{value ? getDateTimeFormat().format(new Date(value)) : '-'}
</span>
);
},
minWidth: 150,
},
{
colId: 'actions',
...COL_DEFS.actions,
minWidth: showAllActions ? 120 : COL_DEFS.actions.minWidth,
maxWidth: showAllActions ? 120 : COL_DEFS.actions.minWidth,
cellRenderer: ({ data }: { data?: StopOrder }) => {
if (!data) return null;
return (
<div className="flex gap-2 items-center justify-end">
{data.status === Schema.StopOrderStatus.STATUS_PENDING &&
!props.isReadOnly && (
<ButtonLink
data-testid="cancel"
onClick={() => onCancel(data)}
>
{t('Cancel')}
</ButtonLink>
)}
</div>
);
},
},
],
[onCancel, onMarketClick, props.isReadOnly, showAllActions]
);
return (
<AgGrid
ref={ref}
defaultColDef={{
resizable: true,
sortable: true,
filterParams: { buttons: ['reset'] },
}}
columnDefs={columnDefs}
style={{
width: '100%',
height: '100%',
}}
getRowId={({ data }) => data.id}
components={{ MarketNameCell }}
{...props}
/>
);
}
)
);

View File

@ -111,13 +111,13 @@ export const useOrder = (marketId: string) => {
[marketId, _update] [marketId, _update]
); );
// add new order to store if it doesnt exist, but don't // add new order to store if it doesn't exist, but don't
// persist until user has edited // persist until user has edited
useEffect(() => { useEffect(() => {
if (!order) { if (!order) {
update( update(
getDefaultOrder(marketId), getDefaultOrder(marketId),
false // dont persist the order false // don't persist the order
); );
} }
}, [order, marketId, update]); }, [order, marketId, update]);

View File

@ -23,6 +23,7 @@ import type {
VoteValue, VoteValue,
WithdrawalStatus, WithdrawalStatus,
DispatchMetric, DispatchMetric,
StopOrderStatus,
} from './__generated__/types'; } from './__generated__/types';
export const AccountTypeMapping: { export const AccountTypeMapping: {
@ -234,6 +235,21 @@ export const OrderStatusMapping: {
STATUS_STOPPED: 'Stopped', STATUS_STOPPED: 'Stopped',
}; };
/**
* Stop order statuses, these determine several states for an stop order that cannot be expressed with other fields in StopOrder.
*/
export const StopOrderStatusMapping: {
[T in StopOrderStatus]: string;
} = {
STATUS_CANCELLED: 'Cancelled',
STATUS_EXPIRED: 'Expired',
STATUS_PENDING: 'Pending',
STATUS_REJECTED: 'Rejected',
STATUS_STOPPED: 'Stopped',
STATUS_TRIGGERED: 'Triggered',
STATUS_UNSPECIFIED: 'Unspecified',
};
/** /**
* Valid order types, these determine what happens when an order is added to the book * Valid order types, these determine what happens when an order is added to the book
*/ */

View File

@ -1,3 +1,4 @@
import { forwardRef } from 'react';
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'; import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
import classNames from 'classnames'; import classNames from 'classnames';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
@ -9,31 +10,43 @@ export interface RadioGroupProps {
value?: string; value?: string;
orientation?: 'horizontal' | 'vertical'; orientation?: 'horizontal' | 'vertical';
onChange?: (value: string) => void; onChange?: (value: string) => void;
className?: string;
} }
export const RadioGroup = ({ export const RadioGroup = forwardRef<HTMLDivElement, RadioGroupProps>(
children, (
name, {
value, children,
orientation = 'vertical', name,
onChange, value,
}: RadioGroupProps) => { orientation = 'vertical',
const groupClasses = classNames('flex text-sm', { onChange,
'flex-col gap-2': orientation === 'vertical', className,
'flex-row gap-4': orientation === 'horizontal', }: RadioGroupProps,
}); ref
return ( ) => {
<RadioGroupPrimitive.Root const groupClasses = classNames(
name={name} 'flex text-sm',
value={value} {
onValueChange={onChange} 'flex-col gap-2': orientation === 'vertical',
orientation={orientation} 'flex-row gap-4': orientation === 'horizontal',
className={groupClasses} },
> className
{children} );
</RadioGroupPrimitive.Root> return (
); <RadioGroupPrimitive.Root
}; ref={ref}
name={name}
value={value}
onValueChange={onChange}
orientation={orientation}
className={groupClasses}
>
{children}
</RadioGroupPrimitive.Root>
);
}
);
interface RadioProps { interface RadioProps {
id: string; id: string;

View File

@ -7,7 +7,7 @@ import classNames from 'classnames';
import type { ReactElement, ReactNode } from 'react'; import type { ReactElement, ReactNode } from 'react';
import { Children, isValidElement, useState } from 'react'; import { Children, isValidElement, useState } from 'react';
export interface TabsProps extends TabsPrimitive.TabsProps { export interface TabsProps extends TabsPrimitive.TabsProps {
children: ReactElement<TabProps>[]; children: (ReactElement<TabProps> | null)[];
} }
export const Tabs = ({ export const Tabs = ({
@ -17,11 +17,11 @@ export const Tabs = ({
onValueChange, onValueChange,
...props ...props
}: TabsProps) => { }: TabsProps) => {
const [activeTab, setActiveTab] = useState<string>(() => { const [activeTab, setActiveTab] = useState<string | undefined>(() => {
if (defaultValue) { if (defaultValue) {
return defaultValue; return defaultValue;
} }
return children[0].props.id; return children.find((v) => v)?.props.id;
}); });
return ( return (
@ -112,7 +112,10 @@ export const LocalStoragePersistTabs = ({
children={children} children={children}
value={getValidItem( value={getValidItem(
value, value,
Children.map(children, (child) => child.props.id), Children.map(
children.filter((c): c is ReactElement<TabProps> => c !== null),
(child) => child.props.id
),
undefined undefined
)} )}
onValueChange={onValueChange} onValueChange={onValueChange}

View File

@ -75,7 +75,7 @@ export const TradingDropdownContent = forwardRef<
<DropdownMenuPrimitive.Content <DropdownMenuPrimitive.Content
ref={forwardedRef} ref={forwardedRef}
className={classNames( className={classNames(
'min-w-[290px] bg-vega-clight-700 dark:bg-vega-cdark-700', 'bg-vega-clight-700 dark:bg-vega-cdark-700',
'border border-vega-clight-500 dark:border-vega-cdark-500', 'border border-vega-clight-500 dark:border-vega-cdark-500',
'p-2 rounded z-20 text-default' 'p-2 rounded z-20 text-default'
)} )}

View File

@ -3,8 +3,8 @@ import { t } from '@vegaprotocol/i18n';
export const validateAmount = (step: number | string, field: string) => { export const validateAmount = (step: number | string, field: string) => {
const [, stepDecimals = ''] = String(step).split('.'); const [, stepDecimals = ''] = String(step).split('.');
return (value: string) => { return (value?: string) => {
const [, valueDecimals = ''] = value.split('.'); const [, valueDecimals = ''] = (value || '').split('.');
if (stepDecimals.length < valueDecimals.length) { if (stepDecimals.length < valueDecimals.length) {
if (stepDecimals === '') { if (stepDecimals === '') {
return t(`${field} must be whole numbers for this market`); return t(`${field} must be whole numbers for this market`);

View File

@ -79,6 +79,32 @@ export interface OrderAmendmentBody {
orderAmendment: OrderAmendment; orderAmendment: OrderAmendment;
} }
export interface StopOrderSetup {
orderSubmission: OrderSubmission;
expiresAt?: string;
expiryStrategy?: Schema.StopOrderExpiryStrategy;
price?: string;
trailingPercentOffset?: string;
}
export interface StopOrdersSubmission {
risesAbove?: StopOrderSetup;
fallsBelow?: StopOrderSetup;
}
export interface StopOrdersCancellation {
stopOrderId?: string;
marketId?: string;
}
export interface StopOrdersSubmissionBody {
stopOrdersSubmission: StopOrdersSubmission;
}
export interface StopOrdersCancellationBody {
stopOrdersCancellation: StopOrdersCancellation;
}
export interface VoteSubmissionBody { export interface VoteSubmissionBody {
voteSubmission: { voteSubmission: {
value: Schema.VoteValue; value: Schema.VoteValue;
@ -357,6 +383,8 @@ export interface TransferBody {
} }
export type Transaction = export type Transaction =
| StopOrdersSubmissionBody
| StopOrdersCancellationBody
| OrderSubmissionBody | OrderSubmissionBody
| OrderCancellationBody | OrderCancellationBody
| WithdrawSubmissionBody | WithdrawSubmissionBody
@ -381,6 +409,16 @@ export const isOrderCancellationTransaction = (
transaction: Transaction transaction: Transaction
): transaction is OrderCancellationBody => 'orderCancellation' in transaction; ): transaction is OrderCancellationBody => 'orderCancellation' in transaction;
export const isStopOrdersSubmissionTransaction = (
transaction: Transaction
): transaction is StopOrdersSubmissionBody =>
'stopOrdersSubmission' in transaction;
export const isStopOrdersCancellationTransaction = (
transaction: Transaction
): transaction is StopOrdersCancellationBody =>
'stopOrdersCancellation' in transaction;
export const isOrderAmendmentTransaction = ( export const isOrderAmendmentTransaction = (
transaction: Transaction transaction: Transaction
): transaction is OrderAmendmentBody => 'orderAmendment' in transaction; ): transaction is OrderAmendmentBody => 'orderAmendment' in transaction;

View File

@ -7,6 +7,8 @@ import {
isOrderAmendmentTransaction, isOrderAmendmentTransaction,
isBatchMarketInstructionsTransaction, isBatchMarketInstructionsTransaction,
isTransferTransaction, isTransferTransaction,
isStopOrdersSubmissionTransaction,
isStopOrdersCancellationTransaction,
} from './connectors'; } from './connectors';
import { determineId } from './utils'; import { determineId } from './utils';
@ -196,9 +198,16 @@ export const useVegaTransactionStore = create<VegaTransactionStore>()(
isOrderCancellationTransaction(transaction.body) && isOrderCancellationTransaction(transaction.body) &&
!transaction.body.orderCancellation.orderId; !transaction.body.orderCancellation.orderId;
const isConfirmedTransfer = isTransferTransaction(transaction.body); const isConfirmedTransfer = isTransferTransaction(transaction.body);
const isConfirmedStopOrderCancellation =
isStopOrdersCancellationTransaction(transaction.body);
const isConfirmedStopOrderSubmission =
isStopOrdersSubmissionTransaction(transaction.body);
if ( if (
(isConfirmedOrderCancellation || isConfirmedTransfer) && (isConfirmedOrderCancellation ||
isConfirmedTransfer ||
isConfirmedStopOrderCancellation ||
isConfirmedStopOrderSubmission) &&
!transactionResult.error && !transactionResult.error &&
transactionResult.status transactionResult.status
) { ) {

View File

@ -22,6 +22,8 @@ import {
isWithdrawTransaction, isWithdrawTransaction,
useVegaTransactionStore, useVegaTransactionStore,
VegaTxStatus, VegaTxStatus,
isStopOrdersSubmissionTransaction,
isStopOrdersCancellationTransaction,
} from '@vegaprotocol/wallet'; } from '@vegaprotocol/wallet';
import type { Toast, ToastContent } from '@vegaprotocol/ui-toolkit'; import type { Toast, ToastContent } from '@vegaprotocol/ui-toolkit';
import { ToastHeading } from '@vegaprotocol/ui-toolkit'; import { ToastHeading } from '@vegaprotocol/ui-toolkit';
@ -85,6 +87,8 @@ const isTransactionTypeSupported = (tx: VegaStoredTxState) => {
const withdraw = isWithdrawTransaction(tx.body); const withdraw = isWithdrawTransaction(tx.body);
const submitOrder = isOrderSubmissionTransaction(tx.body); const submitOrder = isOrderSubmissionTransaction(tx.body);
const cancelOrder = isOrderCancellationTransaction(tx.body); const cancelOrder = isOrderCancellationTransaction(tx.body);
const submitStopOrder = isStopOrdersSubmissionTransaction(tx.body);
const cancelStopOrder = isStopOrdersCancellationTransaction(tx.body);
const editOrder = isOrderAmendmentTransaction(tx.body); const editOrder = isOrderAmendmentTransaction(tx.body);
const batchMarketInstructions = isBatchMarketInstructionsTransaction(tx.body); const batchMarketInstructions = isBatchMarketInstructionsTransaction(tx.body);
const transfer = isTransferTransaction(tx.body); const transfer = isTransferTransaction(tx.body);
@ -92,6 +96,8 @@ const isTransactionTypeSupported = (tx: VegaStoredTxState) => {
withdraw || withdraw ||
submitOrder || submitOrder ||
cancelOrder || cancelOrder ||
submitStopOrder ||
cancelStopOrder ||
editOrder || editOrder ||
batchMarketInstructions || batchMarketInstructions ||
transfer transfer