feat(trading): add stop orders table and form (#4265)
Co-authored-by: Dariusz Majcherczyk <dariusz.majcherczyk@gmail.com>
This commit is contained in:
parent
1dd97a2bce
commit
375d447fa8
@ -100,7 +100,6 @@ describe('markets proposed table', { tags: '@smoke' }, () => {
|
||||
'VEGA_TOKEN_URL'
|
||||
)}/proposals/e9ec6d5c46a7e7bcabf9ba7a893fa5a5eeeec08b731f06f7a6eb7bf0e605b829`
|
||||
);
|
||||
cy.getByTestId('proposal-actions-content').click();
|
||||
});
|
||||
|
||||
// 6001-MARK-060
|
||||
@ -214,11 +213,12 @@ describe('no markets proposed', { tags: '@smoke', testIsolation: true }, () => {
|
||||
aliasGQLQuery(req, 'ProposalsList', proposal);
|
||||
});
|
||||
cy.mockSubscription();
|
||||
cy.visit('/#/markets/all');
|
||||
cy.get('[data-testid="Proposed markets"]').click();
|
||||
});
|
||||
|
||||
it('can see no markets message', () => {
|
||||
cy.visit('/#/markets/all');
|
||||
cy.get('[data-testid="Proposed markets"]').click();
|
||||
|
||||
// 6001-MARK-061
|
||||
cy.getByTestId('tab-proposed-markets').should('contain.text', 'No markets');
|
||||
});
|
||||
|
@ -28,16 +28,16 @@ describe('deal ticket basics', { tags: '@smoke' }, () => {
|
||||
|
||||
it('must be able to select order direction - long/short', function () {
|
||||
// 7002-SORD-004
|
||||
cy.getByTestId(toggleShort).click().children('input').should('be.checked');
|
||||
cy.getByTestId(toggleLong).click().children('input').should('be.checked');
|
||||
cy.getByTestId(toggleShort).click().next('input').should('be.checked');
|
||||
cy.getByTestId(toggleLong).click().next('input').should('be.checked');
|
||||
});
|
||||
|
||||
it('must be able to select order type - limit/market', function () {
|
||||
// 7002-SORD-005
|
||||
// 7002-SORD-006
|
||||
// 7002-SORD-007
|
||||
cy.getByTestId(toggleLimit).click().children('input').should('be.checked');
|
||||
cy.getByTestId(toggleMarket).click().children('input').should('be.checked');
|
||||
cy.getByTestId(toggleLimit).click().next('input').should('be.checked');
|
||||
cy.getByTestId(toggleMarket).click().next('input').should('be.checked');
|
||||
});
|
||||
|
||||
it('order connect vega wallet button should connect', () => {
|
||||
@ -51,7 +51,7 @@ describe('deal ticket basics', { tags: '@smoke' }, () => {
|
||||
.click();
|
||||
cy.wait('@walletReq');
|
||||
cy.getByTestId(placeOrderBtn).should('be.visible');
|
||||
cy.getByTestId(toggleLimit).children('input').should('be.checked');
|
||||
cy.getByTestId(toggleLimit).next('input').should('be.checked');
|
||||
cy.getByTestId(orderPriceField).should('have.value', '101');
|
||||
});
|
||||
});
|
||||
|
@ -35,6 +35,8 @@ describe('transfer fees', { tags: '@regression', testIsolation: true }, () => {
|
||||
cy.setVegaWallet();
|
||||
|
||||
cy.visit('/');
|
||||
cy.getByTestId(manageVegaWallet).click();
|
||||
cy.getByTestId(walletTransfer).click();
|
||||
|
||||
cy.wait('@Assets');
|
||||
cy.wait('@Accounts');
|
||||
@ -57,7 +59,6 @@ describe('transfer fees', { tags: '@regression', testIsolation: true }, () => {
|
||||
// 1003-TRAN-019
|
||||
cy.getByTestId(transferForm);
|
||||
cy.contains('Enter manually').click();
|
||||
|
||||
cy.getByTestId(transferForm)
|
||||
.find(toAddressField)
|
||||
.type('7f9cf07d3a9905b1a61a1069f7a758855da428bc0f4a97de87f48644bfc25535');
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { OrderType } from '@vegaprotocol/types';
|
||||
import type { OrderSubmission } from '@vegaprotocol/wallet';
|
||||
|
||||
const orderSizeField = 'order-size';
|
||||
@ -9,7 +10,9 @@ export const createOrder = (order: OrderSubmission): void => {
|
||||
cy.log('Placing order', order);
|
||||
const { type, side, size, price, timeInForce, expiresAt } = order;
|
||||
|
||||
cy.getByTestId(`order-type-${type}`).click();
|
||||
cy.getByTestId(
|
||||
`order-type-${type === OrderType.TYPE_LIMIT ? 'Limit' : 'Market'}`
|
||||
).click();
|
||||
cy.getByTestId(`order-side-${side}`).click();
|
||||
cy.getByTestId(orderSizeField).clear().type(size);
|
||||
if (price) {
|
||||
|
@ -6,8 +6,8 @@ export const orderTIFDropDown = 'order-tif';
|
||||
export const placeOrderBtn = 'place-order';
|
||||
export const toggleShort = 'order-side-SIDE_SELL';
|
||||
export const toggleLong = 'order-side-SIDE_BUY';
|
||||
export const toggleLimit = 'order-type-TYPE_LIMIT';
|
||||
export const toggleMarket = 'order-type-TYPE_MARKET';
|
||||
export const toggleLimit = 'order-type-Limit';
|
||||
export const toggleMarket = 'order-type-Market';
|
||||
|
||||
export const TIFlist = Object.values(Schema.OrderTimeInForce).map((value) => {
|
||||
return {
|
||||
|
@ -16,6 +16,6 @@ NX_WALLETCONNECT_PROJECT_ID=fe8091dc35738863e509fc4947525c72
|
||||
|
||||
# Cosmic elevator flags
|
||||
NX_SUCCESSOR_MARKETS=true
|
||||
# NX_STOP_ORDERS
|
||||
NX_STOP_ORDERS=true
|
||||
# NX_ICEBERG_ORDERS
|
||||
# NX_PRODUCT_PERPETUALS
|
@ -18,6 +18,6 @@ NX_ETH_WALLET_MNEMONIC="ozone access unlock valid olympic save include omit supp
|
||||
|
||||
# Cosmic elevator flags
|
||||
NX_SUCCESSOR_MARKETS=false
|
||||
# NX_STOP_ORDERS
|
||||
NX_STOP_ORDERS=false
|
||||
# NX_ICEBERG_ORDERS
|
||||
# NX_PRODUCT_PERPETUALS
|
@ -16,6 +16,6 @@ NX_VEGA_INCIDENT_URL=https://blog.vega.xyz/tagged/vega-incident-reports
|
||||
|
||||
# Cosmic elevator flags
|
||||
NX_SUCCESSOR_MARKETS=true
|
||||
# NX_STOP_ORDERS
|
||||
NX_STOP_ORDERS=true
|
||||
# NX_ICEBERG_ORDERS
|
||||
# NX_PRODUCT_PERPETUALS
|
@ -18,6 +18,6 @@ NX_APP_VERSION=v0.20.21-core-0.71.6
|
||||
|
||||
# Cosmic elevator flags
|
||||
NX_SUCCESSOR_MARKETS=false
|
||||
# NX_STOP_ORDERS
|
||||
NX_STOP_ORDERS=false
|
||||
# NX_ICEBERG_ORDERS
|
||||
# NX_PRODUCT_PERPETUALS
|
@ -18,6 +18,6 @@ NX_APP_VERSION=v0.20.19-core-0.71.6
|
||||
|
||||
# Cosmic elevator flags
|
||||
NX_SUCCESSOR_MARKETS=false
|
||||
# NX_STOP_ORDERS
|
||||
NX_STOP_ORDERS=false
|
||||
# NX_ICEBERG_ORDERS
|
||||
# NX_PRODUCT_PERPETUALS
|
@ -16,6 +16,6 @@ NX_VEGA_INCIDENT_URL=https://blog.vega.xyz/tagged/vega-incident-reports
|
||||
|
||||
# Cosmic elevator flags
|
||||
NX_SUCCESSOR_MARKETS=true
|
||||
# NX_STOP_ORDERS
|
||||
NX_STOP_ORDERS=true
|
||||
# NX_ICEBERG_ORDERS
|
||||
# NX_PRODUCT_PERPETUALS
|
@ -17,6 +17,6 @@ NX_VEGA_CONSOLE_URL=https://console.fairground.wtf
|
||||
|
||||
# Cosmic elevator flags
|
||||
NX_SUCCESSOR_MARKETS=true
|
||||
# NX_STOP_ORDERS
|
||||
NX_STOP_ORDERS=true
|
||||
# NX_ICEBERG_ORDERS
|
||||
# NX_PRODUCT_PERPETUALS
|
@ -17,6 +17,6 @@ NX_VEGA_CONSOLE_URL=https://trading.validators-testnet.vega.rocks
|
||||
|
||||
# Cosmic elevator flags
|
||||
NX_SUCCESSOR_MARKETS=false
|
||||
# NX_STOP_ORDERS
|
||||
NX_STOP_ORDERS=false
|
||||
# NX_ICEBERG_ORDERS
|
||||
# NX_PRODUCT_PERPETUALS
|
@ -130,6 +130,13 @@ const MainGrid = memo(
|
||||
<TradingViews.orders.component marketId={marketId} />
|
||||
</VegaWalletContainer>
|
||||
</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')}>
|
||||
<VegaWalletContainer>
|
||||
<TradingViews.fills.component
|
||||
|
@ -12,6 +12,7 @@ import { AccountsContainer } from '../../components/accounts-container';
|
||||
import { LiquidityContainer } from '../../components/liquidity-container';
|
||||
import type { OrderContainerProps } from '../../components/orders-container';
|
||||
import { OrdersContainer } from '../../components/orders-container';
|
||||
import { StopOrdersContainer } from '../../components/stop-orders-container';
|
||||
|
||||
type MarketDependantView =
|
||||
| typeof CandlesChartContainer
|
||||
@ -74,6 +75,10 @@ export const TradingViews = {
|
||||
label: 'All',
|
||||
component: OrdersContainer,
|
||||
},
|
||||
stopOrders: {
|
||||
label: 'Stop',
|
||||
component: StopOrdersContainer,
|
||||
},
|
||||
collateral: { label: 'Collateral', component: AccountsContainer },
|
||||
fills: { label: 'Fills', component: FillsContainer },
|
||||
};
|
||||
|
@ -1,10 +1,14 @@
|
||||
import { OrderbookManager } from '@vegaprotocol/market-depth';
|
||||
import { useCreateOrderStore } from '@vegaprotocol/orders';
|
||||
import { ViewType, useSidebar } from '../sidebar';
|
||||
import { useStopOrderFormValues } from '@vegaprotocol/deal-ticket';
|
||||
|
||||
export const OrderbookContainer = ({ marketId }: { marketId: string }) => {
|
||||
const useOrderStoreRef = useCreateOrderStore();
|
||||
const updateOrder = useOrderStoreRef((store) => store.update);
|
||||
const updateStoredFormValues = useStopOrderFormValues(
|
||||
(state) => state.update
|
||||
);
|
||||
const setView = useSidebar((store) => store.setView);
|
||||
return (
|
||||
<OrderbookManager
|
||||
@ -12,9 +16,11 @@ export const OrderbookContainer = ({ marketId }: { marketId: string }) => {
|
||||
onClick={({ price, size }) => {
|
||||
if (price) {
|
||||
updateOrder(marketId, { price });
|
||||
updateStoredFormValues(marketId, { price });
|
||||
}
|
||||
if (size) {
|
||||
updateOrder(marketId, { size });
|
||||
updateStoredFormValues(marketId, { size });
|
||||
}
|
||||
setView({ type: ViewType.Order });
|
||||
}}
|
||||
|
1
apps/trading/components/stop-orders-container/index.ts
Normal file
1
apps/trading/components/stop-orders-container/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './stop-orders-container';
|
@ -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',
|
||||
})
|
||||
);
|
@ -23,7 +23,7 @@ export const useAccountBalance = (assetId?: string) => {
|
||||
},
|
||||
[assetId]
|
||||
);
|
||||
useDataProvider({
|
||||
const { loading, error } = useDataProvider({
|
||||
dataProvider: accountsDataProvider,
|
||||
variables,
|
||||
skip: !pubKey || !assetId,
|
||||
@ -34,7 +34,9 @@ export const useAccountBalance = (assetId?: string) => {
|
||||
() => ({
|
||||
accountBalance: pubKey ? accountBalance : '',
|
||||
accountDecimals: pubKey ? accountDecimals : null,
|
||||
loading,
|
||||
error,
|
||||
}),
|
||||
[accountBalance, accountDecimals, pubKey]
|
||||
[accountBalance, accountDecimals, pubKey, loading, error]
|
||||
);
|
||||
};
|
||||
|
@ -22,7 +22,7 @@ export const useMarketAccountBalance = (marketId: string) => {
|
||||
},
|
||||
[marketId]
|
||||
);
|
||||
useDataProvider({
|
||||
const { loading, error } = useDataProvider({
|
||||
dataProvider: accountsDataProvider,
|
||||
variables: { partyId: pubKey || '' },
|
||||
skip: !pubKey || !marketId,
|
||||
@ -33,7 +33,9 @@ export const useMarketAccountBalance = (marketId: string) => {
|
||||
() => ({
|
||||
accountBalance: pubKey ? accountBalance : '',
|
||||
accountDecimals: pubKey ? accountDecimals : null,
|
||||
loading,
|
||||
error,
|
||||
}),
|
||||
[accountBalance, accountDecimals, pubKey]
|
||||
[accountBalance, accountDecimals, pubKey, loading, error]
|
||||
);
|
||||
};
|
||||
|
@ -3,7 +3,7 @@ import { useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
|
||||
interface MarketNameCellProps {
|
||||
value?: string;
|
||||
value?: string | null;
|
||||
data?: { id?: string; marketId?: string; market?: { id: string } };
|
||||
idPath?: string;
|
||||
onMarketClick?: (marketId: string, metaKey?: boolean) => void;
|
||||
|
@ -1,5 +1,5 @@
|
||||
import type { Control } from 'react-hook-form';
|
||||
import type { Market, MarketData } from '@vegaprotocol/markets';
|
||||
import type { Market, StaticMarketData } from '@vegaprotocol/markets';
|
||||
import { DealTicketMarketAmount } from './deal-ticket-market-amount';
|
||||
import { DealTicketLimitAmount } from './deal-ticket-limit-amount';
|
||||
import * as Schema from '@vegaprotocol/types';
|
||||
@ -9,7 +9,8 @@ import type { OrderFormFields } from '../../hooks/use-order-form';
|
||||
export interface DealTicketAmountProps {
|
||||
control: Control<OrderFormFields>;
|
||||
orderType: Schema.OrderType;
|
||||
marketData: MarketData;
|
||||
marketData: StaticMarketData;
|
||||
marketPrice?: string;
|
||||
market: Market;
|
||||
sizeError?: string;
|
||||
priceError?: string;
|
||||
@ -21,11 +22,18 @@ export interface DealTicketAmountProps {
|
||||
export const DealTicketAmount = ({
|
||||
orderType,
|
||||
marketData,
|
||||
marketPrice,
|
||||
...props
|
||||
}: DealTicketAmountProps) => {
|
||||
switch (orderType) {
|
||||
case Schema.OrderType.TYPE_MARKET:
|
||||
return <DealTicketMarketAmount {...props} marketData={marketData} />;
|
||||
return (
|
||||
<DealTicketMarketAmount
|
||||
{...props}
|
||||
marketData={marketData}
|
||||
marketPrice={marketPrice}
|
||||
/>
|
||||
);
|
||||
case Schema.OrderType.TYPE_LIMIT:
|
||||
return <DealTicketLimitAmount {...props} />;
|
||||
default: {
|
||||
|
@ -4,9 +4,10 @@ import classNames from 'classnames';
|
||||
|
||||
interface Props {
|
||||
side: Side;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const DealTicketButton = ({ side }: Props) => {
|
||||
export const DealTicketButton = ({ side, label }: Props) => {
|
||||
const buttonClasses = classNames(
|
||||
'px-10 py-2 uppercase rounded-md text-white w-full',
|
||||
{
|
||||
@ -17,7 +18,7 @@ export const DealTicketButton = ({ side }: Props) => {
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<button type="submit" data-testid="place-order" className={buttonClasses}>
|
||||
{t('Place order')}
|
||||
{label || t('Place order')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,11 +1,20 @@
|
||||
import { useVegaTransactionStore } from '@vegaprotocol/wallet';
|
||||
import {
|
||||
DealTicketType,
|
||||
useDealTicketTypeStore,
|
||||
} from '../../hooks/use-type-store';
|
||||
import { StopOrder } from './deal-ticket-stop-order';
|
||||
import {
|
||||
useStaticMarketData,
|
||||
useMarket,
|
||||
useMarketPrice,
|
||||
} from '@vegaprotocol/markets';
|
||||
import { AsyncRenderer, Splash } from '@vegaprotocol/ui-toolkit';
|
||||
import { t } from '@vegaprotocol/i18n';
|
||||
import { useThrottledDataProvider } from '@vegaprotocol/data-provider';
|
||||
import { useVegaTransactionStore } from '@vegaprotocol/wallet';
|
||||
import { useMarket, marketDataProvider } from '@vegaprotocol/markets';
|
||||
import { DealTicket } from './deal-ticket';
|
||||
import { FLAGS } from '@vegaprotocol/environment';
|
||||
|
||||
export interface DealTicketContainerProps {
|
||||
interface DealTicketContainerProps {
|
||||
marketId: string;
|
||||
onMarketClick?: (marketId: string, metaKey?: boolean) => void;
|
||||
onClickCollateral?: () => void;
|
||||
@ -14,10 +23,9 @@ export interface DealTicketContainerProps {
|
||||
|
||||
export const DealTicketContainer = ({
|
||||
marketId,
|
||||
onMarketClick,
|
||||
onClickCollateral,
|
||||
onDeposit,
|
||||
...props
|
||||
}: DealTicketContainerProps) => {
|
||||
const type = useDealTicketTypeStore((state) => state.type[marketId]);
|
||||
const {
|
||||
data: market,
|
||||
error: marketError,
|
||||
@ -29,15 +37,9 @@ export const DealTicketContainer = ({
|
||||
error: marketDataError,
|
||||
loading: marketDataLoading,
|
||||
reload,
|
||||
} = useThrottledDataProvider(
|
||||
{
|
||||
dataProvider: marketDataProvider,
|
||||
variables: { marketId },
|
||||
},
|
||||
1000
|
||||
);
|
||||
} = useStaticMarketData(marketId);
|
||||
const { data: marketPrice } = useMarketPrice(market?.id);
|
||||
const create = useVegaTransactionStore((state) => state.create);
|
||||
|
||||
return (
|
||||
<AsyncRenderer
|
||||
data={market && marketData}
|
||||
@ -46,14 +48,23 @@ export const DealTicketContainer = ({
|
||||
reload={reload}
|
||||
>
|
||||
{market && marketData ? (
|
||||
<DealTicket
|
||||
market={market}
|
||||
marketData={marketData}
|
||||
submit={(orderSubmission) => create({ orderSubmission })}
|
||||
onClickCollateral={onClickCollateral}
|
||||
onMarketClick={onMarketClick}
|
||||
onDeposit={onDeposit}
|
||||
/>
|
||||
FLAGS.STOP_ORDERS &&
|
||||
(type === DealTicketType.StopLimit ||
|
||||
type === DealTicketType.StopMarket) ? (
|
||||
<StopOrder
|
||||
market={market}
|
||||
marketPrice={marketPrice}
|
||||
submit={(stopOrdersSubmission) => create({ stopOrdersSubmission })}
|
||||
/>
|
||||
) : (
|
||||
<DealTicket
|
||||
{...props}
|
||||
market={market}
|
||||
marketPrice={marketPrice}
|
||||
marketData={marketData}
|
||||
submit={(orderSubmission) => create({ orderSubmission })}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Splash>
|
||||
<p>{t('Could not load market')}</p>
|
||||
|
@ -4,11 +4,11 @@ import classnames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
import { t } from '@vegaprotocol/i18n';
|
||||
import { FeesBreakdown } from '@vegaprotocol/markets';
|
||||
import type { OrderSubmissionBody } from '@vegaprotocol/wallet';
|
||||
import { useVegaWallet } from '@vegaprotocol/wallet';
|
||||
|
||||
import type { Market } from '@vegaprotocol/markets';
|
||||
import type { EstimatePositionQuery } from '@vegaprotocol/positions';
|
||||
import type { EstimateFeesQuery } from '../../hooks/__generated__/EstimateOrder';
|
||||
import { AccountBreakdownDialog } from '@vegaprotocol/accounts';
|
||||
|
||||
import { formatRange, formatValue } from '@vegaprotocol/utils';
|
||||
@ -24,6 +24,7 @@ import {
|
||||
EST_TOTAL_MARGIN_TOOLTIP_TEXT,
|
||||
MARGIN_ACCOUNT_TOOLTIP_TEXT,
|
||||
} from '../../constants';
|
||||
import { useEstimateFees } from '../../hooks';
|
||||
|
||||
const emptyValue = '-';
|
||||
|
||||
@ -76,26 +77,82 @@ export const DealTicketFeeDetail = ({
|
||||
};
|
||||
|
||||
export interface DealTicketFeeDetailsProps {
|
||||
assetSymbol: string;
|
||||
order: OrderSubmissionBody['orderSubmission'];
|
||||
market: Market;
|
||||
notionalSize: string | null;
|
||||
}
|
||||
|
||||
export const DealTicketFeeDetails = ({
|
||||
assetSymbol,
|
||||
order,
|
||||
market,
|
||||
notionalSize,
|
||||
}: DealTicketFeeDetailsProps) => {
|
||||
const feeEstimate = useEstimateFees(order);
|
||||
const { settlementAsset: asset } =
|
||||
market.tradableInstrument.instrument.product;
|
||||
const { decimals: assetDecimals, quantum } = asset;
|
||||
const marketDecimals = market.decimalPlaces;
|
||||
const quoteName = market.tradableInstrument.instrument.product.quoteName;
|
||||
|
||||
return (
|
||||
<>
|
||||
<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;
|
||||
marginAccountBalance?: string;
|
||||
market: Market;
|
||||
onMarketClick?: (marketId: string, metaKey?: boolean) => void;
|
||||
assetSymbol: string;
|
||||
notionalSize: string | null;
|
||||
feeEstimate: EstimateFeesQuery['estimateFees'] | undefined;
|
||||
positionEstimate: EstimatePositionQuery['estimatePosition'];
|
||||
}
|
||||
|
||||
export const DealTicketFeeDetails = ({
|
||||
export const DealTicketMarginDetails = ({
|
||||
marginAccountBalance,
|
||||
generalAccountBalance,
|
||||
assetSymbol,
|
||||
feeEstimate,
|
||||
market,
|
||||
onMarketClick,
|
||||
notionalSize,
|
||||
positionEstimate,
|
||||
}: DealTicketFeeDetailsProps) => {
|
||||
}: DealTicketMarginDetailsProps) => {
|
||||
const [breakdownDialog, setBreakdownDialog] = useState(false);
|
||||
const { pubKey: partyId } = useVegaWallet();
|
||||
const { data: currentMargins } = useDataProvider({
|
||||
@ -110,7 +167,6 @@ export const DealTicketFeeDetails = ({
|
||||
const { settlementAsset: asset } =
|
||||
market.tradableInstrument.instrument.product;
|
||||
const { decimals: assetDecimals, quantum } = asset;
|
||||
const marketDecimals = market.decimalPlaces;
|
||||
let marginRequiredBestCase: string | undefined = undefined;
|
||||
let marginRequiredWorstCase: string | undefined = undefined;
|
||||
if (marginEstimate) {
|
||||
@ -251,41 +307,7 @@ export const DealTicketFeeDetails = ({
|
||||
const quoteName = market.tradableInstrument.instrument.product.quoteName;
|
||||
|
||||
return (
|
||||
<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
|
||||
label={t('Margin required')}
|
||||
value={formatRange(
|
||||
@ -351,6 +373,6 @@ export const DealTicketFeeDetails = ({
|
||||
onClose={onAccountBreakdownDialogClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -44,12 +44,12 @@ export const DealTicketLimitAmount = ({
|
||||
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-1">
|
||||
<FormGroup
|
||||
label={t('Size')}
|
||||
labelFor="input-order-size-limit"
|
||||
className="!mb-1"
|
||||
className="!mb-0"
|
||||
>
|
||||
<Controller
|
||||
name="size"
|
||||
@ -78,16 +78,13 @@ export const DealTicketLimitAmount = ({
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
<div className="flex-0 items-center">
|
||||
<div className="flex"> </div>
|
||||
<div className="flex">@</div>
|
||||
</div>
|
||||
<div className="pt-7 leading-10">@</div>
|
||||
<div className="flex-1">
|
||||
<FormGroup
|
||||
labelFor="input-price-quote"
|
||||
label={t(`Price (${quoteName})`)}
|
||||
labelAlign="right"
|
||||
className="!mb-1"
|
||||
className="!mb-0"
|
||||
>
|
||||
<Controller
|
||||
name="price"
|
||||
|
@ -5,10 +5,10 @@ import {
|
||||
} from '@vegaprotocol/utils';
|
||||
import { t } from '@vegaprotocol/i18n';
|
||||
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 { getMarketPrice } from '../../utils/get-price';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import classNames from 'classnames';
|
||||
|
||||
export type DealTicketMarketAmountProps = Omit<
|
||||
DealTicketAmountProps,
|
||||
@ -19,37 +19,26 @@ export const DealTicketMarketAmount = ({
|
||||
control,
|
||||
market,
|
||||
marketData,
|
||||
marketPrice,
|
||||
sizeError,
|
||||
update,
|
||||
size,
|
||||
}: DealTicketMarketAmountProps) => {
|
||||
const quoteName = market.tradableInstrument.instrument.product.quoteName;
|
||||
const sizeStep = toDecimal(market?.positionDecimalPlaces);
|
||||
const price = getMarketPrice(marketData);
|
||||
const price = marketPrice;
|
||||
|
||||
const priceFormatted = price
|
||||
? addDecimalsFormatNumber(price, market.decimalPlaces)
|
||||
: undefined;
|
||||
|
||||
const inAuction = isMarketInAuction(marketData.marketTradingMode);
|
||||
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<div className="flex items-end gap-4 mb-2">
|
||||
<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 items-start gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="mb-2 text-sm">{t('Size')}</div>
|
||||
<Controller
|
||||
name="size"
|
||||
control={control}
|
||||
@ -76,15 +65,29 @@ export const DealTicketMarketAmount = ({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>@</div>
|
||||
<div className="flex-1 text-sm text-right" data-testid="last-price">
|
||||
{priceFormatted && quoteName ? (
|
||||
<>
|
||||
~{priceFormatted} {quoteName}
|
||||
</>
|
||||
) : (
|
||||
'-'
|
||||
<div className="pt-7 leading-10">@</div>
|
||||
<div className="flex-1 text-sm text-right">
|
||||
{inAuction && (
|
||||
<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 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>
|
||||
{sizeError && (
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
@ -22,8 +22,12 @@ import { OrdersDocument } from '@vegaprotocol/orders';
|
||||
jest.mock('zustand');
|
||||
jest.mock('./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 market = generateMarket();
|
||||
const marketData = generateMarketData();
|
||||
@ -36,6 +40,7 @@ function generateJsx(mocks: MockedResponse[] = []) {
|
||||
<DealTicket
|
||||
market={market}
|
||||
marketData={marketData}
|
||||
marketPrice={marketPrice}
|
||||
submit={submit}
|
||||
onDeposit={jest.fn()}
|
||||
/>
|
||||
@ -114,30 +119,22 @@ describe('DealTicket', () => {
|
||||
});
|
||||
|
||||
it('should display ticket defaults', () => {
|
||||
const { container } = render(generateJsx());
|
||||
render(generateJsx());
|
||||
|
||||
// place order button should always be enabled
|
||||
expect(screen.getByTestId('place-order')).toBeEnabled();
|
||||
|
||||
// Assert defaults are used
|
||||
expect(
|
||||
screen.getByTestId(`order-type-${Schema.OrderType.TYPE_MARKET}`)
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId(`order-type-${Schema.OrderType.TYPE_LIMIT}`)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('order-type-Market')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('order-type-Limit')).toBeInTheDocument();
|
||||
|
||||
const oderTypeLimitToggle = container.querySelector(
|
||||
`[data-testid="order-type-${Schema.OrderType.TYPE_LIMIT}"] input[type="radio"]`
|
||||
expect(screen.getByTestId('order-type-Limit').dataset.state).toEqual(
|
||||
'checked'
|
||||
);
|
||||
expect(oderTypeLimitToggle).toBeChecked();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('order-side-SIDE_BUY')?.querySelector('input')
|
||||
).toBeChecked();
|
||||
expect(
|
||||
screen.queryByTestId('order-side-SIDE_SELL')?.querySelector('input')
|
||||
).not.toBeChecked();
|
||||
expect(screen.getByTestId('order-side-SIDE_BUY').dataset.state).toEqual(
|
||||
'checked'
|
||||
);
|
||||
expect(screen.getByTestId('order-size')).toHaveDisplayValue('0');
|
||||
expect(screen.getByTestId('order-tif')).toHaveValue(
|
||||
Schema.OrderTimeInForce.TIME_IN_FORCE_GTC
|
||||
@ -147,12 +144,12 @@ describe('DealTicket', () => {
|
||||
it('should display last price for market type order', () => {
|
||||
render(generateJsx());
|
||||
act(() => {
|
||||
screen.getByTestId(`order-type-${Schema.OrderType.TYPE_MARKET}`).click();
|
||||
screen.getByTestId('order-type-Market').click();
|
||||
});
|
||||
// Assert last price is shown
|
||||
expect(screen.getByTestId('last-price')).toHaveTextContent(
|
||||
// eslint-disable-next-line
|
||||
`~${addDecimal(marketData.markPrice, market.decimalPlaces)} ${
|
||||
`~${addDecimal(marketPrice, market.decimalPlaces)} ${
|
||||
market.tradableInstrument.instrument.product.quoteName
|
||||
}`
|
||||
);
|
||||
@ -178,17 +175,12 @@ describe('DealTicket', () => {
|
||||
render(generateJsx());
|
||||
|
||||
// Assert correct defaults are used from store
|
||||
expect(
|
||||
screen
|
||||
.getByTestId(`order-type-${Schema.OrderType.TYPE_LIMIT}`)
|
||||
.querySelector('input')
|
||||
).toBeChecked();
|
||||
expect(
|
||||
screen.queryByTestId('order-side-SIDE_SELL')?.querySelector('input')
|
||||
).toBeChecked();
|
||||
expect(
|
||||
screen.queryByTestId('order-side-SIDE_BUY')?.querySelector('input')
|
||||
).not.toBeChecked();
|
||||
expect(screen.getByTestId('order-type-Limit').dataset.state).toEqual(
|
||||
'checked'
|
||||
);
|
||||
expect(screen.getByTestId('order-side-SIDE_SELL').dataset.state).toEqual(
|
||||
'checked'
|
||||
);
|
||||
expect(screen.getByTestId('order-size')).toHaveDisplayValue(
|
||||
expectedOrder.size
|
||||
);
|
||||
@ -221,17 +213,12 @@ describe('DealTicket', () => {
|
||||
render(generateJsx());
|
||||
|
||||
// Assert correct defaults are used from store
|
||||
expect(
|
||||
screen
|
||||
.getByTestId(`order-type-${Schema.OrderType.TYPE_LIMIT}`)
|
||||
.querySelector('input')
|
||||
).toBeChecked();
|
||||
expect(
|
||||
screen.queryByTestId('order-side-SIDE_SELL')?.querySelector('input')
|
||||
).toBeChecked();
|
||||
expect(
|
||||
screen.queryByTestId('order-side-SIDE_BUY')?.querySelector('input')
|
||||
).not.toBeChecked();
|
||||
expect(screen.getByTestId('order-type-Limit').dataset.state).toEqual(
|
||||
'checked'
|
||||
);
|
||||
expect(screen.getByTestId('order-side-SIDE_SELL').dataset.state).toEqual(
|
||||
'checked'
|
||||
);
|
||||
expect(screen.getByTestId('order-size')).toHaveDisplayValue(
|
||||
expectedOrder.size
|
||||
);
|
||||
@ -269,17 +256,12 @@ describe('DealTicket', () => {
|
||||
render(generateJsx());
|
||||
|
||||
// Assert correct defaults are used from store
|
||||
expect(
|
||||
screen
|
||||
.getByTestId(`order-type-${Schema.OrderType.TYPE_LIMIT}`)
|
||||
.querySelector('input')
|
||||
).toBeChecked();
|
||||
expect(
|
||||
screen.queryByTestId('order-side-SIDE_SELL')?.querySelector('input')
|
||||
).toBeChecked();
|
||||
expect(
|
||||
screen.queryByTestId('order-side-SIDE_BUY')?.querySelector('input')
|
||||
).not.toBeChecked();
|
||||
expect(screen.getByTestId('order-type-Limit').dataset.state).toEqual(
|
||||
'checked'
|
||||
);
|
||||
expect(screen.getByTestId('order-side-SIDE_SELL').dataset.state).toEqual(
|
||||
'checked'
|
||||
);
|
||||
expect(screen.getByTestId('order-size')).toHaveDisplayValue(
|
||||
expectedOrder.size
|
||||
);
|
||||
@ -322,17 +304,12 @@ describe('DealTicket', () => {
|
||||
render(generateJsx());
|
||||
|
||||
// Assert correct defaults are used from store
|
||||
expect(
|
||||
screen
|
||||
.getByTestId(`order-type-${Schema.OrderType.TYPE_LIMIT}`)
|
||||
.querySelector('input')
|
||||
).toBeChecked();
|
||||
expect(
|
||||
screen.queryByTestId('order-side-SIDE_SELL')?.querySelector('input')
|
||||
).toBeChecked();
|
||||
expect(
|
||||
screen.queryByTestId('order-side-SIDE_BUY')?.querySelector('input')
|
||||
).not.toBeChecked();
|
||||
expect(screen.getByTestId('order-type-Limit').dataset.state).toEqual(
|
||||
'checked'
|
||||
);
|
||||
expect(screen.getByTestId('order-side-SIDE_SELL').dataset.state).toEqual(
|
||||
'checked'
|
||||
);
|
||||
expect(screen.getByTestId('order-size')).toHaveDisplayValue(
|
||||
expectedOrder.size
|
||||
);
|
||||
@ -371,17 +348,12 @@ describe('DealTicket', () => {
|
||||
render(generateJsx());
|
||||
|
||||
// Assert correct defaults are used from store
|
||||
expect(
|
||||
screen
|
||||
.getByTestId(`order-type-${Schema.OrderType.TYPE_LIMIT}`)
|
||||
.querySelector('input')
|
||||
).toBeChecked();
|
||||
expect(
|
||||
screen.queryByTestId('order-side-SIDE_SELL')?.querySelector('input')
|
||||
).toBeChecked();
|
||||
expect(
|
||||
screen.queryByTestId('order-side-SIDE_BUY')?.querySelector('input')
|
||||
).not.toBeChecked();
|
||||
expect(screen.getByTestId('order-type-Limit').dataset.state).toEqual(
|
||||
'checked'
|
||||
);
|
||||
expect(screen.getByTestId('order-side-SIDE_SELL').dataset.state).toEqual(
|
||||
'checked'
|
||||
);
|
||||
expect(screen.getByTestId('order-size')).toHaveDisplayValue(
|
||||
expectedOrder.size
|
||||
);
|
||||
@ -402,7 +374,7 @@ describe('DealTicket', () => {
|
||||
render(generateJsx());
|
||||
|
||||
act(() => {
|
||||
screen.getByTestId(`order-type-${Schema.OrderType.TYPE_MARKET}`).click();
|
||||
screen.getByTestId('order-type-Market').click();
|
||||
});
|
||||
|
||||
// Only FOK and IOC should be present for type market order
|
||||
@ -427,7 +399,7 @@ describe('DealTicket', () => {
|
||||
);
|
||||
|
||||
// Switch to type limit order -> all TIF options should be shown
|
||||
await userEvent.click(screen.getByTestId('order-type-TYPE_LIMIT'));
|
||||
await userEvent.click(screen.getByTestId('order-type-Limit'));
|
||||
expect(screen.getByTestId('order-tif').children).toHaveLength(
|
||||
Object.keys(Schema.OrderTimeInForce).length
|
||||
);
|
||||
@ -447,7 +419,7 @@ describe('DealTicket', () => {
|
||||
);
|
||||
|
||||
// Switch back to type market order -> FOK should be preserved from previous selection
|
||||
await userEvent.click(screen.getByTestId('order-type-TYPE_MARKET'));
|
||||
await userEvent.click(screen.getByTestId('order-type-Market'));
|
||||
expect(screen.getByTestId('order-tif')).toHaveValue(
|
||||
Schema.OrderTimeInForce.TIME_IN_FORCE_FOK
|
||||
);
|
||||
@ -462,7 +434,7 @@ describe('DealTicket', () => {
|
||||
);
|
||||
|
||||
// Switch back type limit order -> GTT should be preserved
|
||||
await userEvent.click(screen.getByTestId('order-type-TYPE_LIMIT'));
|
||||
await userEvent.click(screen.getByTestId('order-type-Limit'));
|
||||
expect(screen.getByTestId('order-tif')).toHaveValue(
|
||||
Schema.OrderTimeInForce.TIME_IN_FORCE_GTT
|
||||
);
|
||||
@ -477,7 +449,7 @@ describe('DealTicket', () => {
|
||||
);
|
||||
|
||||
// Switch to type market order -> IOC should be preserved
|
||||
await userEvent.click(screen.getByTestId('order-type-TYPE_MARKET'));
|
||||
await userEvent.click(screen.getByTestId('order-type-Market'));
|
||||
expect(screen.getByTestId('order-tif')).toHaveValue(
|
||||
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
|
||||
);
|
||||
@ -487,9 +459,9 @@ describe('DealTicket', () => {
|
||||
render(generateJsx());
|
||||
|
||||
// BUY is selected by default
|
||||
expect(
|
||||
screen.getByTestId('order-side-SIDE_BUY')?.querySelector('input')
|
||||
).toBeChecked();
|
||||
expect(screen.getByTestId('order-side-SIDE_BUY').dataset.state).toEqual(
|
||||
'checked'
|
||||
);
|
||||
|
||||
await userEvent.type(screen.getByTestId('order-size'), '200');
|
||||
|
||||
@ -504,7 +476,7 @@ describe('DealTicket', () => {
|
||||
);
|
||||
|
||||
// Switch to limit order
|
||||
await userEvent.click(screen.getByTestId('order-type-TYPE_LIMIT'));
|
||||
await userEvent.click(screen.getByTestId('order-type-Limit'));
|
||||
|
||||
// Check all TIF options shown
|
||||
expect(screen.getByTestId('order-tif').children).toHaveLength(
|
||||
|
@ -4,7 +4,10 @@ import { memo, useCallback, useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { DealTicketAmount } from './deal-ticket-amount';
|
||||
import { DealTicketButton } from './deal-ticket-button';
|
||||
import { DealTicketFeeDetails } from './deal-ticket-fee-details';
|
||||
import {
|
||||
DealTicketFeeDetails,
|
||||
DealTicketMarginDetails,
|
||||
} from './deal-ticket-fee-details';
|
||||
import { ExpirySelector } from './expiry-selector';
|
||||
import { SideSelector } from './side-selector';
|
||||
import { TimeInForceSelector } from './time-in-force-selector';
|
||||
@ -30,8 +33,7 @@ import {
|
||||
} from '@vegaprotocol/positions';
|
||||
import { toBigNum, removeDecimal } from '@vegaprotocol/utils';
|
||||
import { activeOrdersProvider } from '@vegaprotocol/orders';
|
||||
import { useEstimateFees } from '../../hooks/use-estimate-fees';
|
||||
import { getDerivedPrice } from '../../utils/get-price';
|
||||
import { getDerivedPrice } from '@vegaprotocol/markets';
|
||||
import type { OrderInfo } from '@vegaprotocol/types';
|
||||
|
||||
import {
|
||||
@ -43,7 +45,11 @@ import {
|
||||
} from '../../utils';
|
||||
import { ZeroBalanceError } from '../deal-ticket-validation/zero-balance-error';
|
||||
import { SummaryValidationType } from '../../constants';
|
||||
import type { Market, MarketData } from '@vegaprotocol/markets';
|
||||
import type {
|
||||
Market,
|
||||
MarketData,
|
||||
StaticMarketData,
|
||||
} from '@vegaprotocol/markets';
|
||||
import { MarginWarning } from '../deal-ticket-validation/margin-warning';
|
||||
import {
|
||||
useMarketAccountBalance,
|
||||
@ -53,26 +59,59 @@ import {
|
||||
import { OrderTimeInForce, OrderType } from '@vegaprotocol/types';
|
||||
import { useOrderForm } from '../../hooks/use-order-form';
|
||||
import { useDataProvider } from '@vegaprotocol/data-provider';
|
||||
import {
|
||||
DealTicketType,
|
||||
useDealTicketTypeStore,
|
||||
} from '../../hooks/use-type-store';
|
||||
import { useStopOrderFormValues } from '../../hooks/use-stop-order-form-values';
|
||||
import { DealTicketSizeIceberg } from './deal-ticket-size-iceberg';
|
||||
import noop from 'lodash/noop';
|
||||
|
||||
export const REDUCE_ONLY_TOOLTIP =
|
||||
'"Reduce only" will ensure that this order will not increase the size of an open position. When the order is matched, it will only trade enough volume to bring your open volume towards 0 but never change the direction of your position. If applied to a limit order that is not instantly filled, the order will be stopped.';
|
||||
|
||||
export interface DealTicketProps {
|
||||
market: Market;
|
||||
marketData: MarketData;
|
||||
marketData: StaticMarketData;
|
||||
marketPrice?: string | null;
|
||||
onMarketClick?: (marketId: string, metaKey?: boolean) => void;
|
||||
submit: (order: OrderSubmission) => void;
|
||||
onClickCollateral?: () => void;
|
||||
onDeposit: (assetId: string) => void;
|
||||
}
|
||||
|
||||
export const useNotionalSize = (
|
||||
price: string | null | undefined,
|
||||
size: string | undefined,
|
||||
decimalPlaces: number,
|
||||
positionDecimalPlaces: number
|
||||
) =>
|
||||
useMemo(() => {
|
||||
if (price && size) {
|
||||
return removeDecimal(
|
||||
toBigNum(size, positionDecimalPlaces).multipliedBy(
|
||||
toBigNum(price, decimalPlaces)
|
||||
),
|
||||
decimalPlaces
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}, [price, size, decimalPlaces, positionDecimalPlaces]);
|
||||
|
||||
export const DealTicket = ({
|
||||
market,
|
||||
onMarketClick,
|
||||
marketData,
|
||||
marketPrice,
|
||||
submit,
|
||||
onClickCollateral,
|
||||
onDeposit,
|
||||
}: DealTicketProps) => {
|
||||
const { pubKey, isReadOnly } = useVegaWallet();
|
||||
const setDealTicketType = useDealTicketTypeStore((state) => state.set);
|
||||
const updateStopOrderFormValues = useStopOrderFormValues(
|
||||
(state) => state.update
|
||||
);
|
||||
// store last used tif for market so that when changing OrderType the previous TIF
|
||||
// selection for that type is used when switching back
|
||||
|
||||
@ -95,11 +134,15 @@ export const DealTicket = ({
|
||||
|
||||
const asset = market.tradableInstrument.instrument.product.settlementAsset;
|
||||
|
||||
const { accountBalance: marginAccountBalance } = useMarketAccountBalance(
|
||||
market.id
|
||||
);
|
||||
const {
|
||||
accountBalance: marginAccountBalance,
|
||||
loading: loadingMarginAccountBalance,
|
||||
} = useMarketAccountBalance(market.id);
|
||||
|
||||
const { accountBalance: generalAccountBalance } = useAccountBalance(asset.id);
|
||||
const {
|
||||
accountBalance: generalAccountBalance,
|
||||
loading: loadingGeneralAccountBalance,
|
||||
} = useAccountBalance(asset.id);
|
||||
|
||||
const balance = (
|
||||
BigInt(marginAccountBalance) + BigInt(generalAccountBalance)
|
||||
@ -116,30 +159,20 @@ export const DealTicket = ({
|
||||
);
|
||||
|
||||
const price = useMemo(() => {
|
||||
return normalizedOrder && getDerivedPrice(normalizedOrder, marketData);
|
||||
}, [normalizedOrder, marketData]);
|
||||
return (
|
||||
normalizedOrder &&
|
||||
marketPrice &&
|
||||
getDerivedPrice(normalizedOrder, marketPrice)
|
||||
);
|
||||
}, [normalizedOrder, marketPrice]);
|
||||
|
||||
const notionalSize = useMemo(() => {
|
||||
if (price && normalizedOrder?.size) {
|
||||
return removeDecimal(
|
||||
toBigNum(
|
||||
normalizedOrder.size,
|
||||
market.positionDecimalPlaces
|
||||
).multipliedBy(toBigNum(price, market.decimalPlaces)),
|
||||
market.decimalPlaces
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}, [
|
||||
const notionalSize = useNotionalSize(
|
||||
price,
|
||||
normalizedOrder?.size,
|
||||
market.decimalPlaces,
|
||||
market.positionDecimalPlaces,
|
||||
]);
|
||||
|
||||
const feeEstimate = useEstimateFees(
|
||||
normalizedOrder && { ...normalizedOrder, price }
|
||||
market.positionDecimalPlaces
|
||||
);
|
||||
|
||||
const { data: activeOrders } = useDataProvider({
|
||||
dataProvider: activeOrdersProvider,
|
||||
variables: { partyId: pubKey || '', marketId: market.id },
|
||||
@ -197,7 +230,10 @@ export const DealTicket = ({
|
||||
|
||||
const hasNoBalance =
|
||||
!BigInt(generalAccountBalance) && !BigInt(marginAccountBalance);
|
||||
if (hasNoBalance) {
|
||||
if (
|
||||
hasNoBalance &&
|
||||
!(loadingMarginAccountBalance || loadingGeneralAccountBalance)
|
||||
) {
|
||||
setError('summary', {
|
||||
message: SummaryValidationType.NoCollateral,
|
||||
type: SummaryValidationType.NoCollateral,
|
||||
@ -221,6 +257,8 @@ export const DealTicket = ({
|
||||
marketTradingMode,
|
||||
generalAccountBalance,
|
||||
marginAccountBalance,
|
||||
loadingMarginAccountBalance,
|
||||
loadingGeneralAccountBalance,
|
||||
pubKey,
|
||||
setError,
|
||||
clearErrors,
|
||||
@ -265,11 +303,13 @@ export const DealTicket = ({
|
||||
);
|
||||
|
||||
// if an order doesn't exist one will be created by the store immediately
|
||||
if (!order || !normalizedOrder) return null;
|
||||
if (!order || !normalizedOrder) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={isReadOnly ? undefined : handleSubmit(onSubmit)}
|
||||
onSubmit={isReadOnly ? noop : handleSubmit(onSubmit)}
|
||||
noValidate
|
||||
data-testid="deal-ticket-form"
|
||||
>
|
||||
@ -284,9 +324,29 @@ export const DealTicket = ({
|
||||
}}
|
||||
render={() => (
|
||||
<TypeSelector
|
||||
value={order.type}
|
||||
onSelect={(type) => {
|
||||
if (type === OrderType.TYPE_NETWORK) return;
|
||||
value={
|
||||
order.type === OrderType.TYPE_LIMIT
|
||||
? DealTicketType.Limit
|
||||
: DealTicketType.Market
|
||||
}
|
||||
onValueChange={(dealTicketType) => {
|
||||
setDealTicketType(market.id, dealTicketType);
|
||||
if (
|
||||
dealTicketType !== DealTicketType.Limit &&
|
||||
dealTicketType !== DealTicketType.Market
|
||||
) {
|
||||
updateStopOrderFormValues(market.id, {
|
||||
type:
|
||||
dealTicketType === DealTicketType.StopLimit
|
||||
? OrderType.TYPE_LIMIT
|
||||
: OrderType.TYPE_MARKET,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const type =
|
||||
dealTicketType === DealTicketType.Limit
|
||||
? OrderType.TYPE_LIMIT
|
||||
: OrderType.TYPE_MARKET;
|
||||
update({
|
||||
type,
|
||||
// when changing type also update the TIF to what was last used of new type
|
||||
@ -333,7 +393,7 @@ export const DealTicket = ({
|
||||
render={() => (
|
||||
<SideSelector
|
||||
value={order.side}
|
||||
onSelect={(side) => {
|
||||
onValueChange={(side) => {
|
||||
update({ side });
|
||||
}}
|
||||
/>
|
||||
@ -344,6 +404,7 @@ export const DealTicket = ({
|
||||
orderType={order.type}
|
||||
market={market}
|
||||
marketData={marketData}
|
||||
marketPrice={marketPrice || undefined}
|
||||
sizeError={errors.size?.message}
|
||||
priceError={errors.price?.message}
|
||||
update={update}
|
||||
@ -467,9 +528,7 @@ export const DealTicket = ({
|
||||
? t(
|
||||
'"Reduce only" can be used only with non-persistent orders, such as "Fill or Kill" or "Immediate or Cancel".'
|
||||
)
|
||||
: t(
|
||||
'"Reduce only" will ensure that this order will not increase the size of an open position. When the order is matched, it will only trade enough volume to bring your open volume towards 0 but never change the direction of your position. If applied to a limit order that is not instantly filled, the order will be stopped.'
|
||||
)}
|
||||
: t(REDUCE_ONLY_TOOLTIP)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
@ -541,10 +600,16 @@ export const DealTicket = ({
|
||||
/>
|
||||
<DealTicketButton side={order.side} />
|
||||
<DealTicketFeeDetails
|
||||
onMarketClick={onMarketClick}
|
||||
feeEstimate={feeEstimate}
|
||||
order={
|
||||
normalizedOrder && { ...normalizedOrder, price: price || undefined }
|
||||
}
|
||||
notionalSize={notionalSize}
|
||||
assetSymbol={assetSymbol}
|
||||
market={market}
|
||||
/>
|
||||
<DealTicketMarginDetails
|
||||
onMarketClick={onMarketClick}
|
||||
assetSymbol={assetSymbol}
|
||||
marginAccountBalance={marginAccountBalance}
|
||||
generalAccountBalance={generalAccountBalance}
|
||||
positionEstimate={positionEstimate?.estimatePosition}
|
||||
@ -569,6 +634,55 @@ interface SummaryMessageProps {
|
||||
onClickCollateral?: () => 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(
|
||||
({
|
||||
errorMessage,
|
||||
@ -583,46 +697,16 @@ const SummaryMessage = memo(
|
||||
}: SummaryMessageProps) => {
|
||||
// Specific error UI for if balance is so we can
|
||||
// render a deposit dialog
|
||||
const assetSymbol = asset.symbol;
|
||||
const openVegaWalletDialog = useVegaWalletDialogStore(
|
||||
(store) => store.openVegaWalletDialog
|
||||
);
|
||||
if (isReadOnly) {
|
||||
if (isReadOnly || !pubKey) {
|
||||
return (
|
||||
<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>
|
||||
<NoWalletWarning
|
||||
isReadOnly={isReadOnly}
|
||||
asset={asset}
|
||||
pubKey={pubKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (errorMessage === SummaryValidationType.NoCollateral) {
|
||||
return (
|
||||
<div className="mb-2">
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { FormGroup, Input, InputError } from '@vegaprotocol/ui-toolkit';
|
||||
import { formatForInput } from '@vegaprotocol/utils';
|
||||
import { t } from '@vegaprotocol/i18n';
|
||||
import { useRef } from 'react';
|
||||
|
||||
interface ExpirySelectorProps {
|
||||
value?: string;
|
||||
@ -13,7 +14,8 @@ export const ExpirySelector = ({
|
||||
onSelect,
|
||||
errorMessage,
|
||||
}: ExpirySelectorProps) => {
|
||||
const date = value ? new Date(value) : new Date();
|
||||
const now = useRef(new Date());
|
||||
const date = value ? new Date(value) : now.current;
|
||||
const dateFormatted = formatForInput(date);
|
||||
const minDate = formatForInput(date);
|
||||
return (
|
||||
|
@ -3,6 +3,8 @@ export * from './deal-ticket-container';
|
||||
export * from './deal-ticket-limit-amount';
|
||||
export * from './deal-ticket-market-amount';
|
||||
export * from './deal-ticket';
|
||||
export * from './deal-ticket-stop-order';
|
||||
export * from './deal-ticket-container';
|
||||
export * from './expiry-selector';
|
||||
export * from './side-selector';
|
||||
export * from './time-in-force-selector';
|
||||
|
@ -1,46 +1,41 @@
|
||||
import { FormGroup } from '@vegaprotocol/ui-toolkit';
|
||||
import { Toggle } from '@vegaprotocol/ui-toolkit';
|
||||
import { t } from '@vegaprotocol/i18n';
|
||||
import * as Schema from '@vegaprotocol/types';
|
||||
import * as RadioGroup from '@radix-ui/react-radio-group';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface SideSelectorProps {
|
||||
value: Schema.Side;
|
||||
onSelect: (side: Schema.Side) => void;
|
||||
onValueChange: (side: Schema.Side) => void;
|
||||
}
|
||||
|
||||
export const SideSelector = ({ value, onSelect }: SideSelectorProps) => {
|
||||
const toggles = [
|
||||
{ label: t('Long'), value: Schema.Side.SIDE_BUY },
|
||||
{ label: t('Short'), value: Schema.Side.SIDE_SELL },
|
||||
];
|
||||
|
||||
const toggleType = (e: Schema.Side) => {
|
||||
switch (e) {
|
||||
case Schema.Side.SIDE_BUY:
|
||||
return 'buy';
|
||||
case Schema.Side.SIDE_SELL:
|
||||
return 'sell';
|
||||
default:
|
||||
return 'primary';
|
||||
}
|
||||
};
|
||||
const toggles = [
|
||||
{ label: t('Long'), value: Schema.Side.SIDE_BUY },
|
||||
{ label: t('Short'), value: Schema.Side.SIDE_SELL },
|
||||
];
|
||||
|
||||
export const SideSelector = (props: SideSelectorProps) => {
|
||||
return (
|
||||
<FormGroup
|
||||
label={t('Direction')}
|
||||
labelFor="order-side-toggle"
|
||||
compact={true}
|
||||
<RadioGroup.Root
|
||||
name="order-side"
|
||||
className="mb-2 flex h-10 leading-10"
|
||||
{...props}
|
||||
>
|
||||
<Toggle
|
||||
id="order-side-toggle"
|
||||
name="order-side"
|
||||
toggles={toggles}
|
||||
checkedValue={value}
|
||||
type={toggleType(value)}
|
||||
onChange={(e) => {
|
||||
onSelect(e.target.value as Schema.Side);
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
{toggles.map(({ label, value }) => (
|
||||
<RadioGroup.Item value={value} key={value} id={`side-${value}`} asChild>
|
||||
<button
|
||||
className="flex-1 relative font-alpha text-sm"
|
||||
data-testid={`order-side-${value}`}
|
||||
>
|
||||
{label}
|
||||
<RadioGroup.Indicator
|
||||
className={classNames('absolute bottom-0 left-0 right-0 h-0.5', {
|
||||
'bg-market-red': props.value === Schema.Side.SIDE_SELL,
|
||||
'bg-market-green-550': props.value === Schema.Side.SIDE_BUY,
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
</RadioGroup.Item>
|
||||
))}
|
||||
</RadioGroup.Root>
|
||||
);
|
||||
};
|
||||
|
@ -1,32 +1,127 @@
|
||||
import {
|
||||
FormGroup,
|
||||
Icon,
|
||||
InputError,
|
||||
SimpleGrid,
|
||||
Tooltip,
|
||||
TradingDropdown,
|
||||
TradingDropdownContent,
|
||||
TradingDropdownItemIndicator,
|
||||
TradingDropdownPortal,
|
||||
TradingDropdownRadioGroup,
|
||||
TradingDropdownRadioItem,
|
||||
TradingDropdownTrigger,
|
||||
} from '@vegaprotocol/ui-toolkit';
|
||||
import { t } from '@vegaprotocol/i18n';
|
||||
import * as Schema from '@vegaprotocol/types';
|
||||
import { Toggle } from '@vegaprotocol/ui-toolkit';
|
||||
import type { Market, MarketData } from '@vegaprotocol/markets';
|
||||
import type { Market, StaticMarketData } from '@vegaprotocol/markets';
|
||||
import { compileGridData } from '../trading-mode-tooltip';
|
||||
import { MarketModeValidationType } from '../../constants';
|
||||
import { DealTicketType } from '../../hooks/use-type-store';
|
||||
import * as RadioGroup from '@radix-ui/react-radio-group';
|
||||
import classNames from 'classnames';
|
||||
import { FLAGS } from '@vegaprotocol/environment';
|
||||
|
||||
interface TypeSelectorProps {
|
||||
value: Schema.OrderType;
|
||||
onSelect: (type: Schema.OrderType) => void;
|
||||
value: DealTicketType;
|
||||
onValueChange: (type: DealTicketType) => void;
|
||||
market: Market;
|
||||
marketData: MarketData;
|
||||
marketData: StaticMarketData;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
const toggles = [
|
||||
{ label: t('Limit'), value: Schema.OrderType.TYPE_LIMIT },
|
||||
{ label: t('Market'), value: Schema.OrderType.TYPE_MARKET },
|
||||
{ label: t('Limit'), value: DealTicketType.Limit },
|
||||
{ label: t('Market'), value: DealTicketType.Market },
|
||||
];
|
||||
const options = [
|
||||
{ label: t('Stop Limit'), value: DealTicketType.StopLimit },
|
||||
{ label: t('Stop Market'), value: DealTicketType.StopMarket },
|
||||
];
|
||||
|
||||
export const TypeToggle = ({
|
||||
value,
|
||||
onValueChange,
|
||||
}: Pick<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 = ({
|
||||
value,
|
||||
onSelect,
|
||||
onValueChange,
|
||||
market,
|
||||
marketData,
|
||||
errorMessage,
|
||||
@ -74,19 +169,18 @@ export const TypeSelector = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<FormGroup label={t('Order type')} labelFor="order-type" compact={true}>
|
||||
<Toggle
|
||||
id="order-type"
|
||||
name="order-type"
|
||||
toggles={toggles}
|
||||
checkedValue={value}
|
||||
onChange={(e) => onSelect(e.target.value as Schema.OrderType)}
|
||||
<>
|
||||
<TypeToggle
|
||||
onValueChange={(value) => {
|
||||
onValueChange(value as DealTicketType);
|
||||
}}
|
||||
value={value}
|
||||
/>
|
||||
{errorMessage && (
|
||||
<InputError testId="deal-ticket-error-message-type">
|
||||
{renderError(errorMessage as MarketModeValidationType)}
|
||||
</InputError>
|
||||
)}
|
||||
</FormGroup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -1,2 +1,4 @@
|
||||
export * from './__generated__/EstimateOrder';
|
||||
export * from './use-estimate-fees';
|
||||
export * from './use-type-store';
|
||||
export * from './use-stop-order-form-values';
|
||||
|
62
libs/deal-ticket/src/hooks/use-stop-order-form-values.ts
Normal file
62
libs/deal-ticket/src/hooks/use-stop-order-form-values.ts
Normal 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',
|
||||
}
|
||||
)
|
||||
);
|
28
libs/deal-ticket/src/hooks/use-type-store.ts
Normal file
28
libs/deal-ticket/src/hooks/use-type-store.ts
Normal 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',
|
||||
}
|
||||
)
|
||||
);
|
@ -1,4 +1,4 @@
|
||||
import type { Market, MarketData } from '@vegaprotocol/markets';
|
||||
import type { Market, StaticMarketData } from '@vegaprotocol/markets';
|
||||
import * as Schema from '@vegaprotocol/types';
|
||||
import merge from 'lodash/merge';
|
||||
import type { PartialDeep } from 'type-fest';
|
||||
@ -85,33 +85,15 @@ export function generateMarket(override?: PartialDeep<Market>): Market {
|
||||
}
|
||||
|
||||
export function generateMarketData(
|
||||
override?: PartialDeep<MarketData>
|
||||
): MarketData {
|
||||
const defaultMarketData: MarketData = {
|
||||
__typename: 'MarketData',
|
||||
market: {
|
||||
id: 'market-id',
|
||||
__typename: 'Market',
|
||||
},
|
||||
override?: PartialDeep<StaticMarketData>
|
||||
): StaticMarketData {
|
||||
const defaultMarketData: StaticMarketData = {
|
||||
auctionEnd: '2022-06-21T17:18:43.484055236Z',
|
||||
auctionStart: '2022-06-21T17:18:43.484055236Z',
|
||||
bestBidPrice: '0',
|
||||
bestBidVolume: '0',
|
||||
bestOfferPrice: '0',
|
||||
bestOfferVolume: '0',
|
||||
bestStaticBidPrice: '0',
|
||||
bestStaticBidVolume: '0',
|
||||
bestStaticOfferPrice: '0',
|
||||
bestStaticOfferVolume: '0',
|
||||
indicativePrice: '100',
|
||||
indicativeVolume: '10',
|
||||
marketState: Schema.MarketState.STATE_ACTIVE,
|
||||
marketTradingMode: Schema.MarketTradingMode.TRADING_MODE_CONTINUOUS,
|
||||
marketValueProxy: '',
|
||||
markPrice: '200',
|
||||
midPrice: '0',
|
||||
openInterest: '',
|
||||
staticMidPrice: '0',
|
||||
suppliedStake: '1000',
|
||||
targetStake: '1000000',
|
||||
trigger: Schema.AuctionTrigger.AUCTION_TRIGGER_BATCH,
|
||||
|
@ -1,5 +1,4 @@
|
||||
export * from './get-default-order';
|
||||
export * from './is-market-in-auction';
|
||||
export * from './validate-expiration';
|
||||
export * from './validate-market-state';
|
||||
export * from './validate-market-trading-mode';
|
||||
|
@ -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;
|
||||
};
|
@ -1,6 +1,6 @@
|
||||
import * as Schema from '@vegaprotocol/types';
|
||||
import { MarketModeValidationType } from '../constants';
|
||||
import { isMarketInAuction } from './is-market-in-auction';
|
||||
import { isMarketInAuction } from '@vegaprotocol/markets';
|
||||
|
||||
export const validateTimeInForce = (
|
||||
marketTradingMode: Schema.MarketTradingMode,
|
||||
|
@ -1,6 +1,6 @@
|
||||
import * as Schema from '@vegaprotocol/types';
|
||||
import { MarketModeValidationType } from '../constants';
|
||||
import { isMarketInAuction } from './is-market-in-auction';
|
||||
import { isMarketInAuction } from '@vegaprotocol/markets';
|
||||
|
||||
export const validateType = (
|
||||
marketTradingMode: Schema.MarketTradingMode,
|
||||
|
@ -1,6 +1,6 @@
|
||||
import * as Schema from '@vegaprotocol/types';
|
||||
import { isMarketInAuction } from './is-market-in-auction';
|
||||
import type { MarketData } from '@vegaprotocol/markets';
|
||||
import type { MarketData } from './market-data-provider';
|
||||
|
||||
/**
|
||||
* Get the market price based on market mode (auction or not auction)
|
||||
@ -33,19 +33,16 @@ export const getDerivedPrice = (
|
||||
type: Schema.OrderType;
|
||||
price?: string | undefined;
|
||||
},
|
||||
marketData: MarketData
|
||||
marketPrice: string
|
||||
) => {
|
||||
// If order type is market we should use either the mark price
|
||||
// or the uncrossing price. If order type is limit use the price
|
||||
// the user has input
|
||||
|
||||
// Use the market price if order is a market order
|
||||
let price;
|
||||
if (order.type === Schema.OrderType.TYPE_LIMIT && order.price) {
|
||||
price = order.price;
|
||||
} else {
|
||||
price = getMarketPrice(marketData);
|
||||
}
|
||||
|
||||
const price =
|
||||
order.type === Schema.OrderType.TYPE_LIMIT && order.price
|
||||
? order.price
|
||||
: marketPrice;
|
||||
return price === '0' ? undefined : price;
|
||||
};
|
@ -6,6 +6,8 @@ export * from './hooks';
|
||||
export * from './market-utils';
|
||||
export { marketCandlesProvider } from './market-candles-provider';
|
||||
export type { Candle } from './market-candles-provider';
|
||||
export * from './get-price';
|
||||
export * from './is-market-in-auction';
|
||||
export * from './market-data-provider';
|
||||
export * from './markets-candles-provider';
|
||||
export * from './markets-data-provider';
|
||||
|
@ -15,6 +15,7 @@ import type {
|
||||
MarketDataUpdateFieldsFragment,
|
||||
MarketDataQueryVariables,
|
||||
} from './__generated__/market-data';
|
||||
import { getMarketPrice } from './get-price';
|
||||
|
||||
export type MarketData = MarketDataFieldsFragment;
|
||||
|
||||
@ -58,6 +59,21 @@ export const markPriceProvider = makeDerivedDataProvider<
|
||||
MarketDataQueryVariables
|
||||
>([marketDataProvider], ([marketData]) => (marketData as MarketData).markPrice);
|
||||
|
||||
export const marketPriceProvider = makeDerivedDataProvider<
|
||||
string | undefined,
|
||||
never,
|
||||
MarketDataQueryVariables
|
||||
>([marketDataProvider], ([marketData]) =>
|
||||
getMarketPrice(marketData as MarketData)
|
||||
);
|
||||
|
||||
export const useMarketPrice = (marketId?: string, skip?: boolean) =>
|
||||
useDataProvider({
|
||||
dataProvider: marketPriceProvider,
|
||||
variables: { marketId: marketId || '' },
|
||||
skip: skip || !marketId,
|
||||
});
|
||||
|
||||
export type StaticMarketData = Pick<
|
||||
MarketData,
|
||||
| 'marketTradingMode'
|
||||
|
@ -1,4 +1,5 @@
|
||||
export * from './order-data-provider';
|
||||
export * from './order-list';
|
||||
export * from './order-list-manager';
|
||||
export * from './stop-orders-manager';
|
||||
export * from './mocks/generate-orders';
|
||||
|
113
libs/orders/src/lib/components/mocks/generate-stop-orders.ts
Normal file
113
libs/orders/src/lib/components/mocks/generate-stop-orders.ts
Normal 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);
|
||||
};
|
@ -99,3 +99,54 @@ subscription OrdersUpdate($partyId: ID!, $marketIds: [ID!]) {
|
||||
...OrderUpdateFields
|
||||
}
|
||||
}
|
||||
|
||||
fragment OrderSubmissionFields on OrderSubmission {
|
||||
marketId
|
||||
price
|
||||
size
|
||||
side
|
||||
timeInForce
|
||||
expiresAt
|
||||
type
|
||||
reference
|
||||
peggedOrder {
|
||||
reference
|
||||
offset
|
||||
}
|
||||
postOnly
|
||||
reduceOnly
|
||||
}
|
||||
|
||||
fragment StopOrderFields on StopOrder {
|
||||
id
|
||||
ocoLinkId
|
||||
expiresAt
|
||||
expiryStrategy
|
||||
triggerDirection
|
||||
status
|
||||
createdAt
|
||||
updatedAt
|
||||
partyId
|
||||
marketId
|
||||
trigger {
|
||||
... on StopOrderPrice {
|
||||
price
|
||||
}
|
||||
... on StopOrderTrailingPercentOffset {
|
||||
trailingPercentOffset
|
||||
}
|
||||
}
|
||||
submission {
|
||||
...OrderSubmissionFields
|
||||
}
|
||||
}
|
||||
|
||||
query StopOrders($partyId: ID!) {
|
||||
stopOrders(filter: { parties: [$partyId] }) {
|
||||
edges {
|
||||
node {
|
||||
...StopOrderFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -32,6 +32,17 @@ export type OrdersUpdateSubscriptionVariables = Types.Exact<{
|
||||
|
||||
export type OrdersUpdateSubscription = { __typename?: 'Subscription', orders?: Array<{ __typename?: 'OrderUpdate', id: string, marketId: string, type?: Types.OrderType | null, side: Types.Side, size: string, status: Types.OrderStatus, rejectionReason?: Types.OrderRejectionReason | null, price: string, timeInForce: Types.OrderTimeInForce, remaining: string, expiresAt?: any | null, createdAt: any, updatedAt?: any | null, liquidityProvisionId?: string | null, peggedOrder?: { __typename: 'PeggedOrder', reference: Types.PeggedReference, offset: string } | null, icebergOrder?: { __typename: 'IcebergOrder', peakSize: string, minimumVisibleSize: string, reservedRemaining: string } | null }> | null };
|
||||
|
||||
export type OrderSubmissionFieldsFragment = { __typename?: 'OrderSubmission', marketId: string, price: string, size: string, side: Types.Side, timeInForce: Types.OrderTimeInForce, expiresAt: any, type: Types.OrderType, reference?: string | null, postOnly?: boolean | null, reduceOnly?: boolean | null, peggedOrder?: { __typename?: 'PeggedOrder', reference: Types.PeggedReference, offset: string } | null};
|
||||
|
||||
export type StopOrderFieldsFragment = { __typename?: 'StopOrder', id: string, ocoLinkId?: string | null, expiresAt?: any | null, expiryStrategy?: Types.StopOrderExpiryStrategy | null, triggerDirection: Types.StopOrderTriggerDirection, status: Types.StopOrderStatus, createdAt: any, updatedAt?: any | null, partyId: string, marketId: string, trigger?: { __typename?: 'StopOrderPrice', price: string } | { __typename?: 'StopOrderTrailingPercentOffset', trailingPercentOffset: string } | null, submission: { __typename?: 'OrderSubmission', marketId: string, price: string, size: string, side: Types.Side, timeInForce: Types.OrderTimeInForce, expiresAt: any, type: Types.OrderType, reference?: string | null, postOnly?: boolean | null, reduceOnly?: boolean | null, peggedOrder?: { __typename?: 'PeggedOrder', reference: Types.PeggedReference, offset: string } | null } };
|
||||
|
||||
export type StopOrdersQueryVariables = Types.Exact<{
|
||||
partyId: Types.Scalars['ID'];
|
||||
}>;
|
||||
|
||||
|
||||
export type StopOrdersQuery = { __typename?: 'Query', stopOrders?: { __typename?: 'StopOrderConnection', edges?: Array<{ __typename?: 'StopOrderEdge', node?: { __typename?: 'StopOrder', id: string, ocoLinkId?: string | null, expiresAt?: any | null, expiryStrategy?: Types.StopOrderExpiryStrategy | null, triggerDirection: Types.StopOrderTriggerDirection, status: Types.StopOrderStatus, createdAt: any, updatedAt?: any | null, partyId: string, marketId: string, trigger?: { __typename?: 'StopOrderPrice', price: string } | { __typename?: 'StopOrderTrailingPercentOffset', trailingPercentOffset: string } | null, submission: { __typename?: 'OrderSubmission', marketId: string, price: string, size: string, side: Types.Side, timeInForce: Types.OrderTimeInForce, expiresAt: any, type: Types.OrderType, reference?: string | null, postOnly?: boolean | null, reduceOnly?: boolean | null, peggedOrder?: { __typename?: 'PeggedOrder', reference: Types.PeggedReference, offset: string } | null } } | null }> | null } | null };
|
||||
|
||||
export const OrderFieldsFragmentDoc = gql`
|
||||
fragment OrderFields on Order {
|
||||
id
|
||||
@ -96,6 +107,49 @@ export const OrderUpdateFieldsFragmentDoc = gql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const OrderSubmissionFieldsFragmentDoc = gql`
|
||||
fragment OrderSubmissionFields on OrderSubmission {
|
||||
marketId
|
||||
price
|
||||
size
|
||||
side
|
||||
timeInForce
|
||||
expiresAt
|
||||
type
|
||||
reference
|
||||
peggedOrder {
|
||||
reference
|
||||
offset
|
||||
}
|
||||
postOnly
|
||||
reduceOnly
|
||||
}
|
||||
`;
|
||||
export const StopOrderFieldsFragmentDoc = gql`
|
||||
fragment StopOrderFields on StopOrder {
|
||||
id
|
||||
ocoLinkId
|
||||
expiresAt
|
||||
expiryStrategy
|
||||
triggerDirection
|
||||
status
|
||||
createdAt
|
||||
updatedAt
|
||||
partyId
|
||||
marketId
|
||||
trigger {
|
||||
... on StopOrderPrice {
|
||||
price
|
||||
}
|
||||
... on StopOrderTrailingPercentOffset {
|
||||
trailingPercentOffset
|
||||
}
|
||||
}
|
||||
submission {
|
||||
...OrderSubmissionFields
|
||||
}
|
||||
}
|
||||
${OrderSubmissionFieldsFragmentDoc}`;
|
||||
export const OrderByIdDocument = gql`
|
||||
query OrderById($orderId: ID!) {
|
||||
orderByID(id: $orderId) {
|
||||
@ -216,4 +270,43 @@ export function useOrdersUpdateSubscription(baseOptions: Apollo.SubscriptionHook
|
||||
return Apollo.useSubscription<OrdersUpdateSubscription, OrdersUpdateSubscriptionVariables>(OrdersUpdateDocument, options);
|
||||
}
|
||||
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>;
|
@ -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
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
@ -0,0 +1 @@
|
||||
export * from './stop-orders-manager';
|
@ -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();
|
||||
});
|
||||
});
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
@ -111,13 +111,13 @@ export const useOrder = (marketId: string) => {
|
||||
[marketId, _update]
|
||||
);
|
||||
|
||||
// add new order to store if it doesnt exist, but don't
|
||||
// add new order to store if it doesn't exist, but don't
|
||||
// persist until user has edited
|
||||
useEffect(() => {
|
||||
if (!order) {
|
||||
update(
|
||||
getDefaultOrder(marketId),
|
||||
false // dont persist the order
|
||||
false // don't persist the order
|
||||
);
|
||||
}
|
||||
}, [order, marketId, update]);
|
||||
|
@ -23,6 +23,7 @@ import type {
|
||||
VoteValue,
|
||||
WithdrawalStatus,
|
||||
DispatchMetric,
|
||||
StopOrderStatus,
|
||||
} from './__generated__/types';
|
||||
|
||||
export const AccountTypeMapping: {
|
||||
@ -234,6 +235,21 @@ export const OrderStatusMapping: {
|
||||
STATUS_STOPPED: 'Stopped',
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop order statuses, these determine several states for an stop order that cannot be expressed with other fields in StopOrder.
|
||||
*/
|
||||
export const StopOrderStatusMapping: {
|
||||
[T in StopOrderStatus]: string;
|
||||
} = {
|
||||
STATUS_CANCELLED: 'Cancelled',
|
||||
STATUS_EXPIRED: 'Expired',
|
||||
STATUS_PENDING: 'Pending',
|
||||
STATUS_REJECTED: 'Rejected',
|
||||
STATUS_STOPPED: 'Stopped',
|
||||
STATUS_TRIGGERED: 'Triggered',
|
||||
STATUS_UNSPECIFIED: 'Unspecified',
|
||||
};
|
||||
|
||||
/**
|
||||
* Valid order types, these determine what happens when an order is added to the book
|
||||
*/
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { forwardRef } from 'react';
|
||||
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
@ -9,31 +10,43 @@ export interface RadioGroupProps {
|
||||
value?: string;
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
onChange?: (value: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const RadioGroup = ({
|
||||
children,
|
||||
name,
|
||||
value,
|
||||
orientation = 'vertical',
|
||||
onChange,
|
||||
}: RadioGroupProps) => {
|
||||
const groupClasses = classNames('flex text-sm', {
|
||||
'flex-col gap-2': orientation === 'vertical',
|
||||
'flex-row gap-4': orientation === 'horizontal',
|
||||
});
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
name={name}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
orientation={orientation}
|
||||
className={groupClasses}
|
||||
>
|
||||
{children}
|
||||
</RadioGroupPrimitive.Root>
|
||||
);
|
||||
};
|
||||
export const RadioGroup = forwardRef<HTMLDivElement, RadioGroupProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
name,
|
||||
value,
|
||||
orientation = 'vertical',
|
||||
onChange,
|
||||
className,
|
||||
}: RadioGroupProps,
|
||||
ref
|
||||
) => {
|
||||
const groupClasses = classNames(
|
||||
'flex text-sm',
|
||||
{
|
||||
'flex-col gap-2': orientation === 'vertical',
|
||||
'flex-row gap-4': orientation === 'horizontal',
|
||||
},
|
||||
className
|
||||
);
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
ref={ref}
|
||||
name={name}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
orientation={orientation}
|
||||
className={groupClasses}
|
||||
>
|
||||
{children}
|
||||
</RadioGroupPrimitive.Root>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
interface RadioProps {
|
||||
id: string;
|
||||
|
@ -7,7 +7,7 @@ import classNames from 'classnames';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import { Children, isValidElement, useState } from 'react';
|
||||
export interface TabsProps extends TabsPrimitive.TabsProps {
|
||||
children: ReactElement<TabProps>[];
|
||||
children: (ReactElement<TabProps> | null)[];
|
||||
}
|
||||
|
||||
export const Tabs = ({
|
||||
@ -17,11 +17,11 @@ export const Tabs = ({
|
||||
onValueChange,
|
||||
...props
|
||||
}: TabsProps) => {
|
||||
const [activeTab, setActiveTab] = useState<string>(() => {
|
||||
const [activeTab, setActiveTab] = useState<string | undefined>(() => {
|
||||
if (defaultValue) {
|
||||
return defaultValue;
|
||||
}
|
||||
return children[0].props.id;
|
||||
return children.find((v) => v)?.props.id;
|
||||
});
|
||||
|
||||
return (
|
||||
@ -112,7 +112,10 @@ export const LocalStoragePersistTabs = ({
|
||||
children={children}
|
||||
value={getValidItem(
|
||||
value,
|
||||
Children.map(children, (child) => child.props.id),
|
||||
Children.map(
|
||||
children.filter((c): c is ReactElement<TabProps> => c !== null),
|
||||
(child) => child.props.id
|
||||
),
|
||||
undefined
|
||||
)}
|
||||
onValueChange={onValueChange}
|
||||
|
@ -75,7 +75,7 @@ export const TradingDropdownContent = forwardRef<
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={forwardedRef}
|
||||
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',
|
||||
'p-2 rounded z-20 text-default'
|
||||
)}
|
||||
|
@ -3,8 +3,8 @@ import { t } from '@vegaprotocol/i18n';
|
||||
export const validateAmount = (step: number | string, field: string) => {
|
||||
const [, stepDecimals = ''] = String(step).split('.');
|
||||
|
||||
return (value: string) => {
|
||||
const [, valueDecimals = ''] = value.split('.');
|
||||
return (value?: string) => {
|
||||
const [, valueDecimals = ''] = (value || '').split('.');
|
||||
if (stepDecimals.length < valueDecimals.length) {
|
||||
if (stepDecimals === '') {
|
||||
return t(`${field} must be whole numbers for this market`);
|
||||
|
@ -79,6 +79,32 @@ export interface OrderAmendmentBody {
|
||||
orderAmendment: OrderAmendment;
|
||||
}
|
||||
|
||||
export interface StopOrderSetup {
|
||||
orderSubmission: OrderSubmission;
|
||||
expiresAt?: string;
|
||||
expiryStrategy?: Schema.StopOrderExpiryStrategy;
|
||||
price?: string;
|
||||
trailingPercentOffset?: string;
|
||||
}
|
||||
|
||||
export interface StopOrdersSubmission {
|
||||
risesAbove?: StopOrderSetup;
|
||||
fallsBelow?: StopOrderSetup;
|
||||
}
|
||||
|
||||
export interface StopOrdersCancellation {
|
||||
stopOrderId?: string;
|
||||
marketId?: string;
|
||||
}
|
||||
|
||||
export interface StopOrdersSubmissionBody {
|
||||
stopOrdersSubmission: StopOrdersSubmission;
|
||||
}
|
||||
|
||||
export interface StopOrdersCancellationBody {
|
||||
stopOrdersCancellation: StopOrdersCancellation;
|
||||
}
|
||||
|
||||
export interface VoteSubmissionBody {
|
||||
voteSubmission: {
|
||||
value: Schema.VoteValue;
|
||||
@ -357,6 +383,8 @@ export interface TransferBody {
|
||||
}
|
||||
|
||||
export type Transaction =
|
||||
| StopOrdersSubmissionBody
|
||||
| StopOrdersCancellationBody
|
||||
| OrderSubmissionBody
|
||||
| OrderCancellationBody
|
||||
| WithdrawSubmissionBody
|
||||
@ -381,6 +409,16 @@ export const isOrderCancellationTransaction = (
|
||||
transaction: Transaction
|
||||
): transaction is OrderCancellationBody => 'orderCancellation' in transaction;
|
||||
|
||||
export const isStopOrdersSubmissionTransaction = (
|
||||
transaction: Transaction
|
||||
): transaction is StopOrdersSubmissionBody =>
|
||||
'stopOrdersSubmission' in transaction;
|
||||
|
||||
export const isStopOrdersCancellationTransaction = (
|
||||
transaction: Transaction
|
||||
): transaction is StopOrdersCancellationBody =>
|
||||
'stopOrdersCancellation' in transaction;
|
||||
|
||||
export const isOrderAmendmentTransaction = (
|
||||
transaction: Transaction
|
||||
): transaction is OrderAmendmentBody => 'orderAmendment' in transaction;
|
||||
|
@ -7,6 +7,8 @@ import {
|
||||
isOrderAmendmentTransaction,
|
||||
isBatchMarketInstructionsTransaction,
|
||||
isTransferTransaction,
|
||||
isStopOrdersSubmissionTransaction,
|
||||
isStopOrdersCancellationTransaction,
|
||||
} from './connectors';
|
||||
import { determineId } from './utils';
|
||||
|
||||
@ -196,9 +198,16 @@ export const useVegaTransactionStore = create<VegaTransactionStore>()(
|
||||
isOrderCancellationTransaction(transaction.body) &&
|
||||
!transaction.body.orderCancellation.orderId;
|
||||
const isConfirmedTransfer = isTransferTransaction(transaction.body);
|
||||
const isConfirmedStopOrderCancellation =
|
||||
isStopOrdersCancellationTransaction(transaction.body);
|
||||
const isConfirmedStopOrderSubmission =
|
||||
isStopOrdersSubmissionTransaction(transaction.body);
|
||||
|
||||
if (
|
||||
(isConfirmedOrderCancellation || isConfirmedTransfer) &&
|
||||
(isConfirmedOrderCancellation ||
|
||||
isConfirmedTransfer ||
|
||||
isConfirmedStopOrderCancellation ||
|
||||
isConfirmedStopOrderSubmission) &&
|
||||
!transactionResult.error &&
|
||||
transactionResult.status
|
||||
) {
|
||||
|
@ -22,6 +22,8 @@ import {
|
||||
isWithdrawTransaction,
|
||||
useVegaTransactionStore,
|
||||
VegaTxStatus,
|
||||
isStopOrdersSubmissionTransaction,
|
||||
isStopOrdersCancellationTransaction,
|
||||
} from '@vegaprotocol/wallet';
|
||||
import type { Toast, ToastContent } from '@vegaprotocol/ui-toolkit';
|
||||
import { ToastHeading } from '@vegaprotocol/ui-toolkit';
|
||||
@ -85,6 +87,8 @@ const isTransactionTypeSupported = (tx: VegaStoredTxState) => {
|
||||
const withdraw = isWithdrawTransaction(tx.body);
|
||||
const submitOrder = isOrderSubmissionTransaction(tx.body);
|
||||
const cancelOrder = isOrderCancellationTransaction(tx.body);
|
||||
const submitStopOrder = isStopOrdersSubmissionTransaction(tx.body);
|
||||
const cancelStopOrder = isStopOrdersCancellationTransaction(tx.body);
|
||||
const editOrder = isOrderAmendmentTransaction(tx.body);
|
||||
const batchMarketInstructions = isBatchMarketInstructionsTransaction(tx.body);
|
||||
const transfer = isTransferTransaction(tx.body);
|
||||
@ -92,6 +96,8 @@ const isTransactionTypeSupported = (tx: VegaStoredTxState) => {
|
||||
withdraw ||
|
||||
submitOrder ||
|
||||
cancelOrder ||
|
||||
submitStopOrder ||
|
||||
cancelStopOrder ||
|
||||
editOrder ||
|
||||
batchMarketInstructions ||
|
||||
transfer
|
||||
|
Loading…
Reference in New Issue
Block a user