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

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

View File

@ -100,7 +100,6 @@ describe('markets proposed table', { tags: '@smoke' }, () => {
'VEGA_TOKEN_URL'
)}/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');
});

View File

@ -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');
});
});

View File

@ -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');

View File

@ -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) {

View File

@ -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 {

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 },
};

View File

@ -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 });
}}

View File

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

View File

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

View File

@ -23,7 +23,7 @@ export const useAccountBalance = (assetId?: string) => {
},
[assetId]
);
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]
);
};

View File

@ -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]
);
};

View File

@ -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;

View File

@ -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: {

View File

@ -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>
);

View File

@ -1,11 +1,20 @@
import { useVegaTransactionStore } from '@vegaprotocol/wallet';
import {
DealTicketType,
useDealTicketTypeStore,
} from '../../hooks/use-type-store';
import { StopOrder } from './deal-ticket-stop-order';
import {
useStaticMarketData,
useMarket,
useMarketPrice,
} from '@vegaprotocol/markets';
import { AsyncRenderer, Splash } from '@vegaprotocol/ui-toolkit';
import { 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>

View File

@ -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>
</>
);
};

View File

@ -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">&nbsp;</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"

View File

@ -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 && (

View File

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

View File

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

View File

@ -22,8 +22,12 @@ import { OrdersDocument } from '@vegaprotocol/orders';
jest.mock('zustand');
jest.mock('./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(

View File

@ -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">

View File

@ -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 (

View File

@ -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';

View File

@ -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>
);
};

View File

@ -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>
</>
);
};

View File

@ -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';

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import type { Market, MarketData } from '@vegaprotocol/markets';
import type { Market, StaticMarketData } from '@vegaprotocol/markets';
import * as Schema from '@vegaprotocol/types';
import 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,

View File

@ -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';

View File

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

View File

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

View File

@ -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,

View File

@ -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;
};

View File

@ -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';

View File

@ -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'

View File

@ -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';

View File

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

View File

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

View File

@ -32,6 +32,17 @@ export type OrdersUpdateSubscriptionVariables = Types.Exact<{
export type OrdersUpdateSubscription = { __typename?: 'Subscription', orders?: Array<{ __typename?: 'OrderUpdate', id: string, marketId: string, type?: Types.OrderType | null, side: Types.Side, size: string, status: Types.OrderStatus, rejectionReason?: Types.OrderRejectionReason | null, price: string, timeInForce: Types.OrderTimeInForce, remaining: string, expiresAt?: any | null, createdAt: any, updatedAt?: any | null, liquidityProvisionId?: string | null, peggedOrder?: { __typename: 'PeggedOrder', reference: Types.PeggedReference, offset: string } | null, icebergOrder?: { __typename: 'IcebergOrder', peakSize: string, minimumVisibleSize: string, reservedRemaining: string } | null }> | null };
export type 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) {
@ -217,3 +271,42 @@ export function useOrdersUpdateSubscription(baseOptions: Apollo.SubscriptionHook
}
export type OrdersUpdateSubscriptionHookResult = ReturnType<typeof useOrdersUpdateSubscription>;
export type OrdersUpdateSubscriptionResult = Apollo.SubscriptionResult<OrdersUpdateSubscription>;
export const StopOrdersDocument = gql`
query StopOrders($partyId: ID!) {
stopOrders(filter: {parties: [$partyId]}) {
edges {
node {
...StopOrderFields
}
}
}
}
${StopOrderFieldsFragmentDoc}`;
/**
* __useStopOrdersQuery__
*
* To run a query within a React component, call `useStopOrdersQuery` and pass it any options that fit your needs.
* When your component renders, `useStopOrdersQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useStopOrdersQuery({
* variables: {
* partyId: // value for 'partyId'
* },
* });
*/
export function useStopOrdersQuery(baseOptions: Apollo.QueryHookOptions<StopOrdersQuery, StopOrdersQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<StopOrdersQuery, StopOrdersQueryVariables>(StopOrdersDocument, options);
}
export function useStopOrdersLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<StopOrdersQuery, StopOrdersQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<StopOrdersQuery, StopOrdersQueryVariables>(StopOrdersDocument, options);
}
export type StopOrdersQueryHookResult = ReturnType<typeof useStopOrdersQuery>;
export type StopOrdersLazyQueryHookResult = ReturnType<typeof useStopOrdersLazyQuery>;
export type StopOrdersQueryResult = Apollo.QueryResult<StopOrdersQuery, StopOrdersQueryVariables>;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -111,13 +111,13 @@ export const useOrder = (marketId: string) => {
[marketId, _update]
);
// 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]);

View File

@ -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
*/

View File

@ -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;

View File

@ -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}

View File

@ -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'
)}

View File

@ -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`);

View File

@ -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;

View File

@ -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
) {

View File

@ -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