Fix/Order dialog state (#850)

* feat: remove dialog state handling from dialog and split out edit dialog

* feat: add complete state to use-vega-transaction, fix cancel dialog

* feat: add custom dialog content for order submission

* feat: handle custom title and custom intent

* feat: separate components, make order dialog wrapper more generic

* feat: remove dialog wrapper and add icon to dialog

* chore: remove other dialog wrappers and use icon and title props on main dialog

* chore: adjust default color of dialog text

* fix: tests for tx dialog and vega tx hook

* fix: order edit and cancel hook tests

* chore: add edit dialog to stories

* fix: e2e test for deal ticket

* feat: return dialog from hook

* refactor: add use-order-event hook to dedupe bus event logic

* refactor: add custom title and intent to order submit dialog

* chore: remove console logs

* fix: type error due to component being named idalog

* chore: add helper function for converting nanoseconds

* chore: remove capitalization text transform to dialog titles

* chore: remove unused import

* feat: handle titles and intents for cancel and edit

* chore: remove unused var
This commit is contained in:
Matthew Russell 2022-07-26 14:35:30 +01:00 committed by GitHub
parent 19a79afcc9
commit a5f9ed90e8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 1021 additions and 1137 deletions

View File

@ -28,13 +28,6 @@ const mockTx = {
}; };
describe('deal ticket orders', () => { describe('deal ticket orders', () => {
const orderSizeField = 'order-size';
const orderPriceField = 'order-price';
const orderTIFDropDown = 'order-tif';
const placeOrderBtn = 'place-order';
const orderStatusHeader = 'order-status-header';
const orderTransactionHash = 'tx-block-explorer';
before(() => { before(() => {
cy.mockGQL((req) => { cy.mockGQL((req) => {
mockTradingPage(req, MarketState.Active); mockTradingPage(req, MarketState.Active);
@ -107,7 +100,15 @@ describe('deal ticket orders', () => {
}); });
const testOrder = (order: Order, expected?: Partial<Order>) => { const testOrder = (order: Order, expected?: Partial<Order>) => {
const orderSizeField = 'order-size';
const orderPriceField = 'order-price';
const orderTIFDropDown = 'order-tif';
const placeOrderBtn = 'place-order';
const dialogTitle = 'dialog-title';
const orderTransactionHash = 'tx-block-explorer';
const { type, side, size, price, timeInForce, expiresAt } = order; const { type, side, size, price, timeInForce, expiresAt } = order;
cy.get(`[name="order-type"][value="${type}"`).click({ force: true }); // force as input is hidden and displayed as a button cy.get(`[name="order-type"][value="${type}"`).click({ force: true }); // force as input is hidden and displayed as a button
cy.get(`[name="order-side"][value="${side}"`).click({ force: true }); cy.get(`[name="order-side"][value="${side}"`).click({ force: true });
cy.getByTestId(orderSizeField).clear().type(size); cy.getByTestId(orderSizeField).clear().type(size);
@ -139,7 +140,7 @@ describe('deal ticket orders', () => {
...expectedOrder, ...expectedOrder,
}, },
}); });
cy.getByTestId(orderStatusHeader).should( cy.getByTestId(dialogTitle).should(
'have.text', 'have.text',
'Awaiting network confirmation' 'Awaiting network confirmation'
); );

View File

@ -1,10 +1,11 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useState } from 'react'; import { VegaTxStatus } from '@vegaprotocol/wallet';
import { VegaTransactionDialog, VegaTxStatus } from '@vegaprotocol/wallet';
import { DealTicket } from './deal-ticket'; import { DealTicket } from './deal-ticket';
import type { DealTicketQuery_market } from './__generated__/DealTicketQuery'; import type { DealTicketQuery_market } from './__generated__/DealTicketQuery';
import { useOrderSubmit } from '@vegaprotocol/orders'; import { useOrderSubmit, OrderFeedback } from '@vegaprotocol/orders';
import { OrderStatus } from '@vegaprotocol/types'; import { OrderStatus } from '@vegaprotocol/types';
import { Icon, Intent } from '@vegaprotocol/ui-toolkit';
import { t } from '@vegaprotocol/react-helpers';
export interface DealTicketManagerProps { export interface DealTicketManagerProps {
market: DealTicketQuery_market; market: DealTicketQuery_market;
@ -15,28 +16,15 @@ export const DealTicketManager = ({
market, market,
children, children,
}: DealTicketManagerProps) => { }: DealTicketManagerProps) => {
const [orderDialogOpen, setOrderDialogOpen] = useState(false); const { submit, transaction, finalizedOrder, TransactionDialog } =
const { submit, transaction, finalizedOrder, reset } = useOrderSubmit(market); useOrderSubmit(market);
const getDialogTitle = (status?: string) => {
switch (status) {
case OrderStatus.Active:
return 'Order submitted';
case OrderStatus.Filled:
return 'Order filled';
case OrderStatus.PartiallyFilled:
return 'Order partially filled';
case OrderStatus.Parked:
return 'Order parked';
default:
return 'Submission failed';
}
};
return ( return (
<> <>
{children || ( {children || (
<DealTicket <DealTicket
market={market} market={market}
submit={submit} submit={(order) => submit(order)}
transactionStatus={ transactionStatus={
transaction.status === VegaTxStatus.Requested || transaction.status === VegaTxStatus.Requested ||
transaction.status === VegaTxStatus.Pending transaction.status === VegaTxStatus.Pending
@ -45,15 +33,68 @@ export const DealTicketManager = ({
} }
/> />
)} )}
<VegaTransactionDialog <TransactionDialog
key={`submit-order-dialog-${transaction.txHash}`}
orderDialogOpen={orderDialogOpen}
setOrderDialogOpen={setOrderDialogOpen}
finalizedOrder={finalizedOrder}
transaction={transaction}
reset={reset}
title={getDialogTitle(finalizedOrder?.status)} title={getDialogTitle(finalizedOrder?.status)}
/> intent={getDialogIntent(finalizedOrder?.status)}
icon={getDialogIcon(finalizedOrder?.status)}
>
<OrderFeedback transaction={transaction} order={finalizedOrder} />
</TransactionDialog>
</> </>
); );
}; };
const getDialogTitle = (status?: OrderStatus): string | undefined => {
if (!status) {
return;
}
switch (status) {
case OrderStatus.Active:
return t('Order submitted');
case OrderStatus.Filled:
return t('Order filled');
case OrderStatus.PartiallyFilled:
return t('Order partially filled');
case OrderStatus.Parked:
return t('Order parked');
default:
return t('Submission failed');
}
};
const getDialogIntent = (status?: OrderStatus): Intent | undefined => {
if (!status) {
return;
}
switch (status) {
case OrderStatus.Parked:
case OrderStatus.Expired:
return Intent.Warning;
case OrderStatus.Rejected:
case OrderStatus.Stopped:
case OrderStatus.Cancelled:
return Intent.Danger;
default:
return;
}
};
const getDialogIcon = (status?: OrderStatus): ReactNode | undefined => {
if (!status) {
return;
}
switch (status) {
case OrderStatus.Parked:
case OrderStatus.Expired:
return <Icon name="warning-sign" size={20} />;
case OrderStatus.Rejected:
case OrderStatus.Stopped:
case OrderStatus.Cancelled:
return <Icon name="error" size={20} />;
default:
return;
}
};

View File

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

View File

@ -6,42 +6,42 @@ import {
Side, Side,
} from '@vegaprotocol/types'; } from '@vegaprotocol/types';
import type { Orders_party_ordersConnection_edges_node } from '../'; import type { Orders_party_ordersConnection_edges_node } from '../';
import type { PartialDeep } from 'type-fest';
export const generateOrder = ( export const generateOrder = (
partialOrder: Partial<Orders_party_ordersConnection_edges_node> partialOrder?: PartialDeep<Orders_party_ordersConnection_edges_node>
) => ) => {
merge( const order: Orders_party_ordersConnection_edges_node = {
{ __typename: 'Order',
__typename: 'Order', id: 'order-id2',
id: 'order-id2', market: {
market: { __typename: 'Market',
__typename: 'Market', id: 'market-id',
id: 'market-id', name: 'market-name',
name: 'market-name', decimalPlaces: 2,
decimalPlaces: 2, positionDecimalPlaces: 2,
positionDecimalPlaces: 2, tradableInstrument: {
tradableInstrument: { __typename: 'TradableInstrument',
__typename: 'TradableInstrument', instrument: {
instrument: { __typename: 'Instrument',
__typename: 'Instrument', code: 'instrument-code',
code: 'instrument-code',
},
}, },
}, },
size: '10', },
type: OrderType.Market, size: '10',
status: OrderStatus.Active, type: OrderType.Market,
side: Side.Buy, status: OrderStatus.Active,
remaining: '5', side: Side.Buy,
price: '', remaining: '5',
timeInForce: OrderTimeInForce.IOC, price: '',
createdAt: new Date().toISOString(), timeInForce: OrderTimeInForce.IOC,
updatedAt: null, createdAt: new Date().toISOString(),
expiresAt: null, updatedAt: null,
rejectionReason: null, expiresAt: null,
} as Orders_party_ordersConnection_edges_node, rejectionReason: null,
partialOrder };
); return merge(order, partialOrder);
};
export const limitOrder = generateOrder({ export const limitOrder = generateOrder({
id: 'limit-order', id: 'limit-order',

View File

@ -0,0 +1 @@
export * from './order-feedback';

View File

@ -0,0 +1,87 @@
import { render, screen } from '@testing-library/react';
import { formatLabel } from '@vegaprotocol/react-helpers';
import {
OrderRejectionReason,
OrderStatus,
OrderType,
Side,
} from '@vegaprotocol/types';
import { VegaTxStatus } from '@vegaprotocol/wallet';
import { generateOrder } from '../mocks/generate-orders';
import type { OrderFeedbackProps } from './order-feedback';
import { OrderFeedback } from './order-feedback';
jest.mock('@vegaprotocol/environment', () => ({
useEnvironment: () => ({
VEGA_EXPLORER_URL: 'https://test.explorer.vega.network',
}),
}));
describe('OrderFeedback', () => {
let props: OrderFeedbackProps;
beforeEach(() => {
props = {
transaction: {
status: VegaTxStatus.Complete,
error: null,
txHash: 'tx-hash',
signature: null,
},
order: null,
};
});
it('renders null if no order provided', () => {
const { container } = render(<OrderFeedback {...props} />);
expect(container).toBeEmptyDOMElement();
});
it('renders error reason', () => {
const orderFields = {
status: OrderStatus.Rejected,
rejectionReason: OrderRejectionReason.OrderAmendFailure,
};
const order = generateOrder(orderFields);
render(<OrderFeedback {...props} order={order} />);
expect(screen.getByTestId('error-reason')).toHaveTextContent(
`Reason: ${formatLabel(orderFields.rejectionReason)}`
);
});
it('should render order details when order is placed successfully', () => {
const order = generateOrder({
type: OrderType.Limit,
price: '100',
size: '200',
side: Side.Buy,
market: {
decimalPlaces: 2,
positionDecimalPlaces: 0,
},
});
render(<OrderFeedback {...props} order={order} />);
expect(screen.getByTestId('order-confirmed')).toBeInTheDocument();
expect(screen.getByTestId('tx-block-explorer')).toHaveTextContent(
// eslint-disable-next-line
props.transaction.txHash!
);
expect(screen.getByTestId('tx-block-explorer')).toHaveTextContent(
// eslint-disable-next-line
props.transaction.txHash!
);
expect(screen.getByText('Market').nextElementSibling).toHaveTextContent(
// eslint-disable-next-line
order.market!.name
);
expect(screen.getByText('Status').nextElementSibling).toHaveTextContent(
order.status
);
expect(screen.getByText('Price').nextElementSibling).toHaveTextContent(
'1.00'
);
expect(screen.getByText('Amount').nextElementSibling).toHaveTextContent(
`+ 200`
);
});
});

View File

@ -0,0 +1,116 @@
import { useEnvironment } from '@vegaprotocol/environment';
import type { OrderEvent_busEvents_event_Order } from '../../order-hooks/__generated__';
import {
addDecimalsFormatNumber,
formatLabel,
t,
} from '@vegaprotocol/react-helpers';
import { OrderStatus, OrderType, Side } from '@vegaprotocol/types';
import type { VegaTxState } from '@vegaprotocol/wallet';
export interface OrderFeedbackProps {
transaction: VegaTxState;
order: OrderEvent_busEvents_event_Order | null;
}
export const OrderFeedback = ({ transaction, order }: OrderFeedbackProps) => {
const { VEGA_EXPLORER_URL } = useEnvironment();
const labelClass = 'font-bold';
if (!order) return null;
// Order on network but was rejected
if (order.status === OrderStatus.Rejected) {
return (
<p data-testid="error-reason">
{order.rejectionReason &&
t(`Reason: ${formatLabel(order.rejectionReason)}`)}
</p>
);
}
if (order.status === OrderStatus.Cancelled) {
return (
<div data-testid="order-confirmed">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
{order.market && (
<div>
<p className={labelClass}>{t(`Market`)}</p>
<p>{t(`${order.market.name}`)}</p>
</div>
)}
</div>
<div>
{transaction.txHash && (
<div>
<p className={labelClass}>{t('Transaction')}</p>
<a
className="underline break-words"
data-testid="tx-block-explorer"
href={`${VEGA_EXPLORER_URL}/txs/0x${transaction.txHash}`}
target="_blank"
rel="noreferrer"
>
{transaction.txHash}
</a>
</div>
)}
</div>
</div>
);
}
return (
<div data-testid="order-confirmed">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
{order.market && (
<div>
<p className={labelClass}>{t(`Market`)}</p>
<p>{t(`${order.market.name}`)}</p>
</div>
)}
<div>
<p className={labelClass}>{t(`Status`)}</p>
<p>{t(`${order.status}`)}</p>
</div>
{order.type === OrderType.Limit && order.market && (
<div>
<p className={labelClass}>{t(`Price`)}</p>
<p>
{addDecimalsFormatNumber(order.price, order.market.decimalPlaces)}
</p>
</div>
)}
<div>
<p className={labelClass}>{t(`Amount`)}</p>
<p
className={
order.side === Side.Buy ? 'text-vega-green' : 'text-vega-red'
}
>
{`${order.side === Side.Buy ? '+' : '-'} ${addDecimalsFormatNumber(
order.size,
order.market?.positionDecimalPlaces ?? 0
)}
`}
</p>
</div>
</div>
<div>
{transaction.txHash && (
<div>
<p className={labelClass}>{t('Transaction')}</p>
<a
className="underline break-words"
data-testid="tx-block-explorer"
href={`${VEGA_EXPLORER_URL}/txs/0x${transaction.txHash}`}
target="_blank"
rel="noreferrer"
>
{transaction.txHash}
</a>
</div>
)}
</div>
</div>
);
};

View File

@ -4,16 +4,22 @@ import {
addDecimalsFormatNumber, addDecimalsFormatNumber,
} from '@vegaprotocol/react-helpers'; } from '@vegaprotocol/react-helpers';
import { OrderType } from '@vegaprotocol/types'; import { OrderType } from '@vegaprotocol/types';
import { FormGroup, Input, InputError, Button } from '@vegaprotocol/ui-toolkit'; import {
FormGroup,
Input,
InputError,
Button,
Dialog,
Icon,
} from '@vegaprotocol/ui-toolkit';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import Icon from 'react-syntax-highlighter'; import type { OrderFields } from '../order-data-provider';
import { OrderDialogWrapper } from '@vegaprotocol/wallet';
import type { Order } from '@vegaprotocol/wallet';
interface OrderEditDialogProps { interface OrderEditDialogProps {
title: string; isOpen: boolean;
order: Order | null; onChange: (isOpen: boolean) => void;
edit: (body: Order) => Promise<unknown>; order: OrderFields | null;
onSubmit: (fields: FormFields) => void;
} }
interface FormFields { interface FormFields {
@ -21,9 +27,10 @@ interface FormFields {
} }
export const OrderEditDialog = ({ export const OrderEditDialog = ({
isOpen,
onChange,
order, order,
title, onSubmit,
edit,
}: OrderEditDialogProps) => { }: OrderEditDialogProps) => {
const headerClassName = 'text-h5 font-bold text-black dark:text-white'; const headerClassName = 'text-h5 font-bold text-black dark:text-white';
const { const {
@ -37,9 +44,16 @@ export const OrderEditDialog = ({
: '', : '',
}, },
}); });
if (!order) return null; if (!order) return null;
return ( return (
<OrderDialogWrapper title={title} icon={<Icon name="hand-up" size={20} />}> <Dialog
open={isOpen}
onChange={onChange}
title={t('Edit order')}
icon={<Icon name="edit" />}
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{order.market && ( {order.market && (
<div> <div>
@ -49,7 +63,7 @@ export const OrderEditDialog = ({
)} )}
{order.type === OrderType.Limit && order.market && ( {order.type === OrderType.Limit && order.market && (
<div> <div>
<p className={headerClassName}>{t(`Last price`)}</p> <p className={headerClassName}>{t(`Current price`)}</p>
<p> <p>
{addDecimalsFormatNumber(order.price, order.market.decimalPlaces)} {addDecimalsFormatNumber(order.price, order.market.decimalPlaces)}
</p> </p>
@ -71,15 +85,7 @@ export const OrderEditDialog = ({
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 py-12"> <div className="grid grid-cols-1 md:grid-cols-2 gap-8 py-12">
<form <form onSubmit={handleSubmit(onSubmit)} data-testid="edit-order">
onSubmit={handleSubmit(async (data) => {
await edit({
...order,
price: data.entryPrice,
});
})}
data-testid="edit-order"
>
<FormGroup label={t('Entry price')} labelFor="entryPrice"> <FormGroup label={t('Entry price')} labelFor="entryPrice">
<Input <Input
{...register('entryPrice', { required: t('Required') })} {...register('entryPrice', { required: t('Required') })}
@ -97,6 +103,6 @@ export const OrderEditDialog = ({
</Button> </Button>
</form> </form>
</div> </div>
</OrderDialogWrapper> </Dialog>
); );
}; };

View File

@ -1,10 +1,11 @@
import type { Story, Meta } from '@storybook/react'; import type { Story, Meta } from '@storybook/react';
import { OrderType, OrderStatus, OrderTimeInForce } from '@vegaprotocol/types';
import { OrderList, OrderListTable } from './order-list'; import { OrderList, OrderListTable } from './order-list';
import { useState } from 'react'; import { useState } from 'react';
import type { Order, VegaTxState } from '@vegaprotocol/wallet'; import type { VegaTxState } from '@vegaprotocol/wallet';
import { VegaTransactionDialog, VegaTxStatus } from '@vegaprotocol/wallet'; import { VegaTransactionDialog, VegaTxStatus } from '@vegaprotocol/wallet';
import { generateOrdersArray } from '../mocks'; import { generateOrdersArray } from '../mocks';
import { OrderEditDialog } from './order-edit-dialog';
import type { OrderFields } from '../order-data-provider';
export default { export default {
component: OrderList, component: OrderList,
@ -18,9 +19,6 @@ const Template: Story = (args) => {
<OrderListTable <OrderListTable
rowData={args.data} rowData={args.data}
cancel={cancel} cancel={cancel}
setEditOrderDialogOpen={() => {
return;
}}
setEditOrder={() => { setEditOrder={() => {
return; return;
}} }}
@ -31,47 +29,43 @@ const Template: Story = (args) => {
const Template2: Story = (args) => { const Template2: Story = (args) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [editOrder, setEditOrder] = useState<OrderFields | null>(null);
const cancel = () => { const cancel = () => {
setOpen(!open); setOpen(!open);
return Promise.resolve(); return Promise.resolve();
}; };
const transaction: VegaTxState = { const transaction: VegaTxState = {
status: VegaTxStatus.Default, status: VegaTxStatus.Requested,
error: null, error: null,
txHash: null, txHash: null,
signature: null, signature: null,
dialogOpen: false,
}; };
const finalizedOrder: Order = {
status: OrderStatus.Cancelled,
rejectionReason: null,
size: '10',
price: '1000',
market: { name: 'ETH/DAI (30 Jun 2022)', decimalPlaces: 5 },
type: OrderType.Limit,
timeInForce: OrderTimeInForce.GTC,
};
const reset = () => null;
return ( return (
<> <>
<div style={{ height: 1000 }}> <div style={{ height: 1000 }}>
<OrderListTable <OrderListTable
rowData={args.data} rowData={args.data}
cancel={cancel} cancel={cancel}
setEditOrderDialogOpen={() => { setEditOrder={(order) => {
return; setEditOrder(order);
}}
setEditOrder={() => {
return;
}} }}
/> />
</div> </div>
<VegaTransactionDialog <VegaTransactionDialog
orderDialogOpen={open} isOpen={open}
setOrderDialogOpen={setOpen} onChange={setOpen}
finalizedOrder={finalizedOrder}
transaction={transaction} transaction={transaction}
reset={reset} />
title={'Order cancelled'} <OrderEditDialog
isOpen={Boolean(editOrder)}
onChange={(isOpen) => {
if (!isOpen) setEditOrder(null);
}}
order={editOrder}
onSubmit={(fields) => {
return;
}}
/> />
</> </>
); );

View File

@ -5,7 +5,11 @@ import {
getDateTimeFormat, getDateTimeFormat,
t, t,
} from '@vegaprotocol/react-helpers'; } from '@vegaprotocol/react-helpers';
import { AgGridDynamic as AgGrid, Button } from '@vegaprotocol/ui-toolkit'; import {
AgGridDynamic as AgGrid,
Button,
Intent,
} from '@vegaprotocol/ui-toolkit';
import type { import type {
ICellRendererParams, ICellRendererParams,
ValueFormatterParams, ValueFormatterParams,
@ -21,83 +25,61 @@ import type { Orders_party_ordersConnection_edges_node } from '../';
import BigNumber from 'bignumber.js'; import BigNumber from 'bignumber.js';
import { useOrderCancel } from '../../order-hooks/use-order-cancel'; import { useOrderCancel } from '../../order-hooks/use-order-cancel';
import { VegaTransactionDialog } from '@vegaprotocol/wallet';
import { useOrderEdit } from '../../order-hooks/use-order-edit'; import { useOrderEdit } from '../../order-hooks/use-order-edit';
import { OrderEditDialog } from './order-edit-dialog'; import { OrderEditDialog } from './order-edit-dialog';
import type { OrderFields } from '../order-data-provider/__generated__';
import { OrderFeedback } from '../order-feedback';
type OrderListProps = AgGridReactProps | AgReactUiProps; type OrderListProps = AgGridReactProps | AgReactUiProps;
export const OrderList = forwardRef<AgGridReact, OrderListProps>( export const OrderList = forwardRef<AgGridReact, OrderListProps>(
(props, ref) => { (props, ref) => {
const [cancelOrderDialogOpen, setCancelOrderDialogOpen] = useState(false); const [editOrder, setEditOrder] = useState<OrderFields | null>(null);
const [editOrderDialogOpen, setEditOrderDialogOpen] = useState(false); const orderCancel = useOrderCancel();
const [editOrder, setEditOrder] = const orderEdit = useOrderEdit(editOrder);
useState<Orders_party_ordersConnection_edges_node | null>(null);
const { transaction, updatedOrder, reset, cancel } = useOrderCancel();
const {
transaction: editTransaction,
updatedOrder: editedOrder,
reset: resetEdit,
edit,
} = useOrderEdit();
const getCancelDialogTitle = (status?: string) => {
switch (status) {
case OrderStatus.Cancelled:
return 'Order cancelled';
case OrderStatus.Rejected:
return 'Order rejected';
case OrderStatus.Expired:
return 'Order expired';
default:
return 'Cancellation failed';
}
};
const getEditDialogTitle = () =>
editedOrder
? t(
`Order ${
editOrder?.market?.tradableInstrument.instrument.code ?? ''
} updated`
)
: t(
`Edit ${
editOrder?.market?.tradableInstrument.instrument.code ?? ''
} order`
);
return ( return (
<> <>
<OrderListTable <OrderListTable
{...props} {...props}
cancel={cancel} cancel={(order) => {
if (!order.market) return;
orderCancel.cancel({
orderId: order.id,
marketId: order.market.id,
});
}}
ref={ref} ref={ref}
setEditOrderDialogOpen={setEditOrderDialogOpen}
setEditOrder={setEditOrder} setEditOrder={setEditOrder}
/> />
<VegaTransactionDialog <orderCancel.TransactionDialog
key={`cancel-order-dialog-${transaction.txHash}`} title={getCancelDialogTitle(orderCancel.cancelledOrder?.status)}
orderDialogOpen={cancelOrderDialogOpen} intent={getCancelDialogIntent(orderCancel.cancelledOrder?.status)}
setOrderDialogOpen={setCancelOrderDialogOpen}
transaction={transaction}
reset={reset}
title={getCancelDialogTitle(updatedOrder?.status)}
finalizedOrder={updatedOrder}
/>
<VegaTransactionDialog
key={`edit-order-dialog-${transaction.txHash}`}
orderDialogOpen={editOrderDialogOpen}
setOrderDialogOpen={setEditOrderDialogOpen}
transaction={editTransaction}
reset={resetEdit}
title={getEditDialogTitle()}
finalizedOrder={editedOrder}
> >
<OrderEditDialog <OrderFeedback
title={getEditDialogTitle()} transaction={orderCancel.transaction}
order={editOrder} order={orderCancel.cancelledOrder}
edit={edit}
/> />
</VegaTransactionDialog> </orderCancel.TransactionDialog>
<orderEdit.TransactionDialog
title={getEditDialogTitle(orderEdit.updatedOrder?.status)}
>
<OrderFeedback
transaction={orderEdit.transaction}
order={orderEdit.updatedOrder}
/>
</orderEdit.TransactionDialog>
<OrderEditDialog
isOpen={Boolean(editOrder)}
onChange={(isOpen) => {
if (!isOpen) setEditOrder(null);
}}
order={editOrder}
onSubmit={(fields) => {
setEditOrder(null);
orderEdit.edit({ price: fields.entryPrice });
}}
/>
</> </>
); );
} }
@ -111,15 +93,12 @@ type OrderListTableValueFormatterParams = Omit<
}; };
type OrderListTableProps = (AgGridReactProps | AgReactUiProps) & { type OrderListTableProps = (AgGridReactProps | AgReactUiProps) & {
cancel: (body?: unknown) => Promise<unknown>; cancel: (order: OrderFields) => void;
setEditOrderDialogOpen: (value: boolean) => void; setEditOrder: (order: OrderFields) => void;
setEditOrder: (
order: Orders_party_ordersConnection_edges_node | null
) => void;
}; };
export const OrderListTable = forwardRef<AgGridReact, OrderListTableProps>( export const OrderListTable = forwardRef<AgGridReact, OrderListTableProps>(
({ cancel, setEditOrderDialogOpen, setEditOrder, ...props }, ref) => { ({ cancel, setEditOrder, ...props }, ref) => {
return ( return (
<AgGrid <AgGrid
ref={ref} ref={ref}
@ -260,54 +239,36 @@ export const OrderListTable = forwardRef<AgGridReact, OrderListTableProps>(
<AgGridColumn <AgGridColumn
field="edit" field="edit"
cellRenderer={({ data }: ICellRendererParams) => { cellRenderer={({ data }: ICellRendererParams) => {
if ( if (!data) return null;
![ if (isOrderActive(data.status)) {
OrderStatus.Cancelled,
OrderStatus.Rejected,
OrderStatus.Expired,
OrderStatus.Filled,
OrderStatus.Stopped,
].includes(data.status)
) {
return ( return (
<Button <Button
data-testid="edit" data-testid="edit"
variant="secondary" variant="secondary"
onClick={() => { onClick={() => {
setEditOrderDialogOpen(true);
setEditOrder(data); setEditOrder(data);
}} }}
> >
Edit {t('Edit')}
</Button> </Button>
); );
} }
return null; return null;
}} }}
/> />
<AgGridColumn <AgGridColumn
field="cancel" field="cancel"
cellRenderer={({ data }: ICellRendererParams) => { cellRenderer={({ data }: ICellRendererParams) => {
if ( if (!data) return null;
![ if (isOrderActive(data.status)) {
OrderStatus.Cancelled,
OrderStatus.Rejected,
OrderStatus.Expired,
OrderStatus.Filled,
OrderStatus.Stopped,
].includes(data.status)
) {
return ( return (
<Button <Button data-testid="cancel" onClick={() => cancel(data)}>
data-testid="cancel"
onClick={async () => {
await cancel(data);
}}
>
Cancel Cancel
</Button> </Button>
); );
} }
return null; return null;
}} }}
/> />
@ -315,3 +276,61 @@ export const OrderListTable = forwardRef<AgGridReact, OrderListTableProps>(
); );
} }
); );
/**
* Check if an order is active to determine if it can be edited or cancelled
*/
const isOrderActive = (status: OrderStatus) => {
return ![
OrderStatus.Cancelled,
OrderStatus.Rejected,
OrderStatus.Expired,
OrderStatus.Filled,
OrderStatus.Stopped,
].includes(status);
};
const getEditDialogTitle = (status?: OrderStatus): string | undefined => {
if (!status) {
return;
}
switch (status) {
case OrderStatus.Active:
return t('Order updated');
case OrderStatus.Filled:
return t('Order filled');
case OrderStatus.PartiallyFilled:
return t('Order partially filled');
case OrderStatus.Parked:
return t('Order parked');
default:
return t('Submission failed');
}
};
const getCancelDialogIntent = (status?: OrderStatus): Intent | undefined => {
if (!status) {
return;
}
switch (status) {
case OrderStatus.Cancelled:
return Intent.Success;
default:
return Intent.Danger;
}
};
const getCancelDialogTitle = (status?: OrderStatus): string | undefined => {
if (!status) {
return;
}
switch (status) {
case OrderStatus.Cancelled:
return t('Order cancelled');
default:
return t('Order cancellation failed');
}
};

View File

@ -1,4 +1,3 @@
export * from './components'; export * from './components';
export * from './order-hooks'; export * from './order-hooks';
export * from './utils'; export * from './utils';
export * from './market';

View File

@ -1,13 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { MarketState, MarketTradingMode } from '@vegaprotocol/types';
export interface Market {
__typename?: string;
id: string;
positionDecimalPlaces: number;
state: MarketState;
decimalPlaces: number;
tradingMode: MarketTradingMode;
tradableInstrument?: any;
depth?: any;
}

View File

@ -40,6 +40,12 @@ export interface OrderEvent_busEvents_event_Order_market {
* GBX (pence) 1 4 GBP 0.000001 ( 0.0001p) * GBX (pence) 1 4 GBP 0.000001 ( 0.0001p)
*/ */
decimalPlaces: number; decimalPlaces: number;
/**
* positionDecimalPlaces indicated the number of decimal places that an integer must be shifted in order to get a correct size (uint64).
* i.e. 0 means there are no fractional orders for the market, and order sizes are always whole sizes.
* 2 means sizes given as 10^2 * desired size, e.g. a desired size of 1.23 is represented as 123 in this market.
*/
positionDecimalPlaces: number;
} }
export interface OrderEvent_busEvents_event_Order { export interface OrderEvent_busEvents_event_Order {
@ -76,6 +82,10 @@ export interface OrderEvent_busEvents_event_Order {
* The timeInForce of order (determines how and if it executes, and whether it persists on the book) * The timeInForce of order (determines how and if it executes, and whether it persists on the book)
*/ */
timeInForce: OrderTimeInForce; timeInForce: OrderTimeInForce;
/**
* Expiration time of this order (ISO-8601 RFC3339+Nano formatted date)
*/
expiresAt: string | null;
/** /**
* Whether the order is to buy or sell * Whether the order is to buy or sell
*/ */

View File

@ -20,6 +20,7 @@ export const ORDER_EVENT_SUB = gql`
id id
name name
decimalPlaces decimalPlaces
positionDecimalPlaces
} }
} }
} }

View File

@ -1,7 +1,6 @@
import type { MockedResponse } from '@apollo/client/testing'; import type { MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing'; import { MockedProvider } from '@apollo/client/testing';
import { act, renderHook } from '@testing-library/react-hooks'; import { act, renderHook } from '@testing-library/react-hooks';
import { MarketState, MarketTradingMode, OrderType } from '@vegaprotocol/types';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { VegaTxStatus, VegaWalletContext } from '@vegaprotocol/wallet'; import { VegaTxStatus, VegaWalletContext } from '@vegaprotocol/wallet';
import type { import type {
@ -15,33 +14,6 @@ import type {
} from './__generated__/OrderEvent'; } from './__generated__/OrderEvent';
import { ORDER_EVENT_SUB } from './order-event-query'; import { ORDER_EVENT_SUB } from './order-event-query';
const defaultMarket = {
__typename: 'Market',
id: 'market-id',
decimalPlaces: 2,
positionDecimalPlaces: 1,
tradingMode: MarketTradingMode.Continuous,
state: MarketState.Active,
name: 'market-name',
tradableInstrument: {
__typename: 'TradableInstrument',
instrument: {
__typename: 'Instrument',
product: {
__typename: 'Future',
quoteName: 'quote-name',
},
},
},
depth: {
__typename: 'MarketDepth',
lastTrade: {
__typename: 'Trade',
price: '100',
},
},
};
const defaultWalletContext = { const defaultWalletContext = {
keypair: null, keypair: null,
keypairs: [], keypairs: [],
@ -147,23 +119,15 @@ describe('useOrderCancel', () => {
expect(result.current.transaction.error).toEqual(null); expect(result.current.transaction.error).toEqual(null);
}); });
it('should not sendTx if no keypair', async () => { it('should not sendTx if no keypair', () => {
const mockSendTx = jest.fn(); const mockSendTx = jest.fn();
const order = {
type: OrderType.Market,
size: '10',
price: '1234567.89',
status: '',
rejectionReason: null,
market: defaultMarket,
};
const { result } = setup({ const { result } = setup({
sendTx: mockSendTx, sendTx: mockSendTx,
keypairs: [], keypairs: [],
keypair: null, keypair: null,
}); });
await act(async () => { act(() => {
result.current.cancel(order); result.current.cancel({ orderId: 'order-id', marketId: 'market-id' });
}); });
expect(mockSendTx).not.toHaveBeenCalled(); expect(mockSendTx).not.toHaveBeenCalled();
}); });
@ -173,30 +137,24 @@ describe('useOrderCancel', () => {
const keypair = { const keypair = {
pub: '0x123', pub: '0x123',
} as VegaKeyExtended; } as VegaKeyExtended;
const order = {
type: OrderType.Limit,
size: '10',
price: '1234567.89',
status: '',
rejectionReason: null,
market: defaultMarket,
};
const { result } = setup({ const { result } = setup({
sendTx: mockSendTx, sendTx: mockSendTx,
keypairs: [keypair], keypairs: [keypair],
keypair, keypair,
}); });
await act(async () => { const args = {
result.current.cancel(order); orderId: 'order-id',
marketId: 'market-id',
};
act(() => {
result.current.cancel(args);
}); });
expect(mockSendTx).toHaveBeenCalledWith({ expect(mockSendTx).toHaveBeenCalledWith({
pubKey: keypair.pub, pubKey: keypair.pub,
propagate: true, propagate: true,
orderCancellation: { orderCancellation: args,
marketId: 'market-id',
},
}); });
}); });
}); });

View File

@ -1,116 +1,68 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useState } from 'react';
import { useVegaWallet, useVegaTransaction } from '@vegaprotocol/wallet'; import { useVegaWallet, useVegaTransaction } from '@vegaprotocol/wallet';
import { useApolloClient } from '@apollo/client'; import type { OrderEvent_busEvents_event_Order } from './__generated__/OrderEvent';
import type {
OrderEvent,
OrderEventVariables,
OrderEvent_busEvents_event_Order,
} from './__generated__/OrderEvent';
import { ORDER_EVENT_SUB } from './order-event-query';
import * as Sentry from '@sentry/react'; import * as Sentry from '@sentry/react';
import { OrderStatus } from '@vegaprotocol/types'; import { useOrderEvent } from './use-order-event';
import { determineId } from '@vegaprotocol/react-helpers';
import type { Subscription } from 'zen-observable-ts'; interface CancelOrderArgs {
orderId: string;
marketId: string;
}
export const useOrderCancel = () => { export const useOrderCancel = () => {
const { keypair } = useVegaWallet(); const { keypair } = useVegaWallet();
const { send, transaction, reset: resetTransaction } = useVegaTransaction(); const waitForOrderEvent = useOrderEvent();
const [updatedOrder, setUpdatedOrder] =
useState<OrderEvent_busEvents_event_Order | null>(null);
const client = useApolloClient();
const subRef = useRef<Subscription | null>(null);
useEffect(() => { const [cancelledOrder, setCancelledOrder] =
return () => { useState<OrderEvent_busEvents_event_Order | null>(null);
subRef.current?.unsubscribe();
setUpdatedOrder(null); const {
resetTransaction(); send,
}; transaction,
}, [resetTransaction]); reset: resetTransaction,
setComplete,
TransactionDialog,
} = useVegaTransaction();
const reset = useCallback(() => { const reset = useCallback(() => {
resetTransaction(); resetTransaction();
setUpdatedOrder(null); setCancelledOrder(null);
subRef.current?.unsubscribe();
}, [resetTransaction]); }, [resetTransaction]);
const cancel = useCallback( const cancel = useCallback(
async (order) => { async (args: CancelOrderArgs) => {
if (!keypair) { if (!keypair) {
return; return;
} }
if ( setCancelledOrder(null);
[
OrderStatus.Cancelled,
OrderStatus.Rejected,
OrderStatus.Expired,
OrderStatus.Filled,
OrderStatus.Stopped,
].includes(order.status)
) {
return;
}
setUpdatedOrder(null);
try { try {
const res = await send({ await send({
pubKey: keypair.pub, pubKey: keypair.pub,
propagate: true, propagate: true,
orderCancellation: { orderCancellation: {
orderId: order.id, orderId: args.orderId,
marketId: order.market.id, marketId: args.marketId,
}, },
}); });
if (res?.signature) { waitForOrderEvent(args.orderId, keypair.pub, (cancelledOrder) => {
const resId = order.id ?? determineId(res.signature); setCancelledOrder(cancelledOrder);
setUpdatedOrder(null); setComplete();
});
if (resId) {
// Start a subscription looking for the newly created order
subRef.current = client
.subscribe<OrderEvent, OrderEventVariables>({
query: ORDER_EVENT_SUB,
variables: { partyId: keypair?.pub || '' },
})
.subscribe(({ data }) => {
if (!data?.busEvents?.length) {
return;
}
// No types available for the subscription result
const matchingOrderEvent = data.busEvents.find((e) => {
if (e.event.__typename !== 'Order') {
return false;
}
return e.event.id === resId;
});
if (
matchingOrderEvent &&
matchingOrderEvent.event.__typename === 'Order'
) {
setUpdatedOrder(matchingOrderEvent.event);
subRef.current?.unsubscribe();
}
});
}
}
return res;
} catch (e) { } catch (e) {
Sentry.captureException(e); Sentry.captureException(e);
return; return;
} }
}, },
[client, keypair, send] [keypair, send, setComplete, waitForOrderEvent]
); );
return { return {
transaction, transaction,
updatedOrder, cancelledOrder,
TransactionDialog,
cancel, cancel,
reset, reset,
}; };

View File

@ -3,11 +3,7 @@ import type {
VegaKeyExtended, VegaKeyExtended,
VegaWalletContextShape, VegaWalletContextShape,
} from '@vegaprotocol/wallet'; } from '@vegaprotocol/wallet';
import { import { VegaWalletOrderTimeInForce } from '@vegaprotocol/wallet';
VegaWalletOrderSide,
VegaWalletOrderTimeInForce,
VegaWalletOrderType,
} from '@vegaprotocol/wallet';
import { VegaTxStatus, VegaWalletContext } from '@vegaprotocol/wallet'; import { VegaTxStatus, VegaWalletContext } from '@vegaprotocol/wallet';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useOrderEdit } from './use-order-edit'; import { useOrderEdit } from './use-order-edit';
@ -18,15 +14,8 @@ import type {
import { ORDER_EVENT_SUB } from './order-event-query'; import { ORDER_EVENT_SUB } from './order-event-query';
import type { MockedResponse } from '@apollo/client/testing'; import type { MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing'; import { MockedProvider } from '@apollo/client/testing';
import { import type { OrderFields } from '../components';
MarketTradingMode, import { generateOrder } from '../components';
MarketState,
OrderTimeInForce,
} from '@vegaprotocol/types';
import type {
OrderAmendmentBodyOrderAmendment,
OrderAmendmentBody,
} from '@vegaprotocol/vegawallet-service-api-client';
const defaultWalletContext = { const defaultWalletContext = {
keypair: null, keypair: null,
@ -38,7 +27,7 @@ const defaultWalletContext = {
connector: null, connector: null,
}; };
function setup(context?: Partial<VegaWalletContextShape>) { function setup(order: OrderFields, context?: Partial<VegaWalletContextShape>) {
const mocks: MockedResponse<OrderEvent> = { const mocks: MockedResponse<OrderEvent> = {
request: { request: {
query: ORDER_EVENT_SUB, query: ORDER_EVENT_SUB,
@ -119,86 +108,47 @@ function setup(context?: Partial<VegaWalletContextShape>) {
</VegaWalletContext.Provider> </VegaWalletContext.Provider>
</MockedProvider> </MockedProvider>
); );
return renderHook(() => useOrderEdit(), { wrapper }); return renderHook(() => useOrderEdit(order), { wrapper });
} }
const defaultMarket = {
__typename: 'Market',
id: 'market-id',
decimalPlaces: 2,
positionDecimalPlaces: 1,
tradingMode: MarketTradingMode.Continuous,
state: MarketState.Active,
tradableInstrument: {
__typename: 'TradableInstrument',
instrument: {
__typename: 'Instrument',
product: {
__typename: 'Future',
quoteName: 'quote-name',
},
},
},
depth: {
__typename: 'MarketDepth',
lastTrade: {
__typename: 'Trade',
price: '100',
},
},
};
const order = {
id: 'order-id',
type: VegaWalletOrderType.Limit,
size: '10',
timeInForce: OrderTimeInForce.GTT, // order timeInForce is transformed to wallet timeInForce
side: VegaWalletOrderSide.Buy,
price: '1234567.89',
expiration: new Date('2022-01-01'),
expiresAt: new Date('2022-01-01'),
status: VegaTxStatus.Pending,
rejectionReason: null,
market: {
id: 'market-id',
decimalPlaces: 2,
name: 'ETHDAI',
positionDecimalPlaces: 2,
},
};
describe('useOrderEdit', () => { describe('useOrderEdit', () => {
it('should edit a correctly formatted order', async () => { it('should edit a correctly formatted order', async () => {
const mockSendTx = jest.fn().mockReturnValue(Promise.resolve({})); const mockSendTx = jest.fn().mockReturnValue(Promise.resolve({}));
const keypair = { const keypair = {
pub: '0x123', pub: '0x123',
} as VegaKeyExtended; } as VegaKeyExtended;
const { result } = setup({ const order = generateOrder({
price: '123456789',
market: { decimalPlaces: 2 },
});
const { result } = setup(order, {
sendTx: mockSendTx, sendTx: mockSendTx,
keypairs: [keypair], keypairs: [keypair],
keypair, keypair,
}); });
await act(async () => { act(() => {
result.current.edit(order); result.current.edit({ price: '1234567.89' });
}); });
expect(mockSendTx).toHaveBeenCalledWith({ expect(mockSendTx).toHaveBeenCalledWith({
pubKey: keypair.pub, pubKey: keypair.pub,
propagate: true, propagate: true,
orderAmendment: { orderAmendment: {
orderId: 'order-id', orderId: order.id,
marketId: defaultMarket.id, // Market provided from hook argument // eslint-disable-next-line
timeInForce: VegaWalletOrderTimeInForce.GTT, marketId: order.market!.id,
timeInForce: VegaWalletOrderTimeInForce[order.timeInForce],
price: { value: '123456789' }, // Decimal removed price: { value: '123456789' }, // Decimal removed
sizeDelta: 0, sizeDelta: 0,
expiresAt: { value: order.expiration?.getTime() + '000000' }, // Nanoseconds append expiresAt: undefined,
} as unknown as OrderAmendmentBodyOrderAmendment, },
} as OrderAmendmentBody); });
}); });
it('has the correct default state', () => { it('has the correct default state', () => {
const { result } = setup(); const order = generateOrder();
const { result } = setup(order);
expect(typeof result.current.edit).toEqual('function'); expect(typeof result.current.edit).toEqual('function');
expect(typeof result.current.reset).toEqual('function'); expect(typeof result.current.reset).toEqual('function');
expect(result.current.transaction.status).toEqual(VegaTxStatus.Default); expect(result.current.transaction.status).toEqual(VegaTxStatus.Default);
@ -207,8 +157,9 @@ describe('useOrderEdit', () => {
}); });
it('should not sendTx if no keypair', async () => { it('should not sendTx if no keypair', async () => {
const order = generateOrder();
const mockSendTx = jest.fn(); const mockSendTx = jest.fn();
const { result } = setup({ const { result } = setup(order, {
sendTx: mockSendTx, sendTx: mockSendTx,
keypairs: [], keypairs: [],
keypair: null, keypair: null,

View File

@ -1,50 +1,51 @@
import { useApolloClient } from '@apollo/client'; import { removeDecimal, toNanoSeconds } from '@vegaprotocol/react-helpers';
import { determineId, removeDecimal } from '@vegaprotocol/react-helpers'; import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect, useRef } from 'react'; import {
import type { Order } from '@vegaprotocol/wallet'; useVegaTransaction,
import { VegaWalletOrderTimeInForce } from '@vegaprotocol/wallet'; useVegaWallet,
import { useVegaTransaction, useVegaWallet } from '@vegaprotocol/wallet'; VegaWalletOrderTimeInForce,
import { ORDER_EVENT_SUB } from './order-event-query'; } from '@vegaprotocol/wallet';
import type { Subscription } from 'zen-observable-ts'; import type { OrderEvent_busEvents_event_Order } from './__generated__';
import type {
OrderEvent_busEvents_event_Order,
OrderEvent,
OrderEventVariables,
} from './__generated__';
import * as Sentry from '@sentry/react'; import * as Sentry from '@sentry/react';
import type { OrderFields } from '../components';
import { useOrderEvent } from './use-order-event';
export const useOrderEdit = () => { // Can only edit price for now
export interface EditOrderArgs {
price: string;
}
export const useOrderEdit = (order: OrderFields | null) => {
const { keypair } = useVegaWallet(); const { keypair } = useVegaWallet();
const { send, transaction, reset: resetTransaction } = useVegaTransaction();
const [updatedOrder, setUpdatedOrder] = const [updatedOrder, setUpdatedOrder] =
useState<OrderEvent_busEvents_event_Order | null>(null); useState<OrderEvent_busEvents_event_Order | null>(null);
const client = useApolloClient();
const subRef = useRef<Subscription | null>(null); const {
send,
transaction,
reset: resetTransaction,
setComplete,
TransactionDialog,
} = useVegaTransaction();
const waitForOrderEvent = useOrderEvent();
const reset = useCallback(() => { const reset = useCallback(() => {
resetTransaction(); resetTransaction();
setUpdatedOrder(null); setUpdatedOrder(null);
subRef.current?.unsubscribe();
}, [resetTransaction]);
useEffect(() => {
return () => {
resetTransaction();
setUpdatedOrder(null);
subRef.current?.unsubscribe();
};
}, [resetTransaction]); }, [resetTransaction]);
const edit = useCallback( const edit = useCallback(
async (order: Order) => { async (args: EditOrderArgs) => {
if (!keypair || !order.market || !order.market.id) { if (!keypair || !order || !order.market) {
return; return;
} }
setUpdatedOrder(null); setUpdatedOrder(null);
try { try {
const res = await send({ await send({
pubKey: keypair.pub, pubKey: keypair.pub,
propagate: true, propagate: true,
orderAmendment: { orderAmendment: {
@ -52,70 +53,35 @@ export const useOrderEdit = () => {
marketId: order.market.id, marketId: order.market.id,
// @ts-ignore fix me please! // @ts-ignore fix me please!
price: { price: {
value: removeDecimal(order.price, order.market?.decimalPlaces), value: removeDecimal(args.price, order.market.decimalPlaces),
}, },
timeInForce: VegaWalletOrderTimeInForce[order.timeInForce], timeInForce: VegaWalletOrderTimeInForce[order.timeInForce],
// @ts-ignore fix me please! // @ts-ignore fix me please!
sizeDelta: 0, sizeDelta: 0,
// @ts-ignore fix me please!
expiresAt: order.expiresAt expiresAt: order.expiresAt
? { ? {
value: value: toNanoSeconds(new Date(order.expiresAt)), // Wallet expects timestamp in nanoseconds
// Wallet expects timestamp in nanoseconds,
// we don't have that level of accuracy so just append 6 zeroes
new Date(order.expiresAt).getTime().toString() + '000000',
} }
: undefined, : undefined,
}, },
}); });
if (res?.signature) { waitForOrderEvent(order.id, keypair.pub, (updatedOrder) => {
const resId = order.id ?? determineId(res.signature); setUpdatedOrder(updatedOrder);
setUpdatedOrder(null); setComplete();
});
if (resId) {
// Start a subscription looking for the newly created order
subRef.current = client
.subscribe<OrderEvent, OrderEventVariables>({
query: ORDER_EVENT_SUB,
variables: { partyId: keypair?.pub || '' },
})
.subscribe(({ data }) => {
if (!data?.busEvents?.length) {
return;
}
// No types available for the subscription result
const matchingOrderEvent = data.busEvents.find((e) => {
if (e.event.__typename !== 'Order') {
return false;
}
return e.event.id === resId;
});
if (
matchingOrderEvent &&
matchingOrderEvent.event.__typename === 'Order'
) {
setUpdatedOrder(matchingOrderEvent.event);
subRef.current?.unsubscribe();
}
});
}
}
return res;
} catch (e) { } catch (e) {
Sentry.captureException(e); Sentry.captureException(e);
return; return;
} }
}, },
[client, keypair, send] [keypair, send, order, setComplete, waitForOrderEvent]
); );
return { return {
transaction, transaction,
updatedOrder, updatedOrder,
TransactionDialog,
edit, edit,
reset, reset,
}; };

View File

@ -0,0 +1,59 @@
import { useApolloClient } from '@apollo/client';
import { useCallback, useEffect, useRef } from 'react';
import { ORDER_EVENT_SUB } from './order-event-query';
import type {
OrderEvent,
OrderEventVariables,
OrderEvent_busEvents_event_Order,
} from './__generated__';
import type { Subscription } from 'zen-observable-ts';
export const useOrderEvent = () => {
const client = useApolloClient();
const subRef = useRef<Subscription | null>(null);
const waitForOrderEvent = useCallback(
(
id: string,
partyId: string,
callback: (order: OrderEvent_busEvents_event_Order) => void
) => {
subRef.current = client
.subscribe<OrderEvent, OrderEventVariables>({
query: ORDER_EVENT_SUB,
variables: { partyId },
})
.subscribe(({ data }) => {
if (!data?.busEvents?.length) {
return;
}
// No types available for the subscription result
const matchingOrderEvent = data.busEvents.find((e) => {
if (e.event.__typename !== 'Order') {
return false;
}
return e.event.id === id;
});
if (
matchingOrderEvent &&
matchingOrderEvent.event.__typename === 'Order'
) {
callback(matchingOrderEvent.event);
subRef.current?.unsubscribe();
}
});
},
[client]
);
useEffect(() => {
return () => {
subRef.current?.unsubscribe();
};
}, []);
return waitForOrderEvent;
};

View File

@ -21,6 +21,7 @@ import { ORDER_EVENT_SUB } from './order-event-query';
import type { MockedResponse } from '@apollo/client/testing'; import type { MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing'; import { MockedProvider } from '@apollo/client/testing';
import type { Market } from '../market'; import type { Market } from '../market';
import { toNanoSeconds } from '@vegaprotocol/react-helpers';
const defaultMarket = { const defaultMarket = {
__typename: 'Market', __typename: 'Market',
@ -179,7 +180,9 @@ describe('useOrderSubmit', () => {
side: VegaWalletOrderSide.Buy, side: VegaWalletOrderSide.Buy,
timeInForce: VegaWalletOrderTimeInForce.GTT, timeInForce: VegaWalletOrderTimeInForce.GTT,
price: '123456789', // Decimal removed price: '123456789', // Decimal removed
expiresAt: order.expiration?.getTime() + '000000', // Nanoseconds append expiresAt: order.expiration
? toNanoSeconds(order.expiration)
: undefined,
}, },
}); });
}); });

View File

@ -1,39 +1,52 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useState } from 'react';
import { useApolloClient } from '@apollo/client'; import type { OrderEvent_busEvents_event_Order } from './__generated__';
import type { Order } from '../utils/get-default-order';
import { ORDER_EVENT_SUB } from './order-event-query';
import type { import type {
OrderEvent, VegaWalletOrderTimeInForce,
OrderEventVariables, VegaWalletOrderSide,
OrderEvent_busEvents_event_Order, } from '@vegaprotocol/wallet';
} from './__generated__';
import { VegaWalletOrderType, useVegaWallet } from '@vegaprotocol/wallet'; import { VegaWalletOrderType, useVegaWallet } from '@vegaprotocol/wallet';
import { determineId, removeDecimal } from '@vegaprotocol/react-helpers'; import {
determineId,
removeDecimal,
toNanoSeconds,
} from '@vegaprotocol/react-helpers';
import { useVegaTransaction } from '@vegaprotocol/wallet'; import { useVegaTransaction } from '@vegaprotocol/wallet';
import * as Sentry from '@sentry/react'; import * as Sentry from '@sentry/react';
import type { Market } from '../market'; import { useOrderEvent } from './use-order-event';
import type { Subscription } from 'zen-observable-ts';
export interface Order {
type: VegaWalletOrderType;
size: string;
side: VegaWalletOrderSide;
timeInForce: VegaWalletOrderTimeInForce;
price?: string;
expiration?: Date;
}
export interface Market {
id: string;
decimalPlaces: number;
positionDecimalPlaces: number;
}
export const useOrderSubmit = (market: Market) => { export const useOrderSubmit = (market: Market) => {
const { keypair } = useVegaWallet(); const { keypair } = useVegaWallet();
const { send, transaction, reset: resetTransaction } = useVegaTransaction(); const waitForOrderEvent = useOrderEvent();
const {
send,
transaction,
reset: resetTransaction,
setComplete,
TransactionDialog,
} = useVegaTransaction();
const [finalizedOrder, setFinalizedOrder] = const [finalizedOrder, setFinalizedOrder] =
useState<OrderEvent_busEvents_event_Order | null>(null); useState<OrderEvent_busEvents_event_Order | null>(null);
const client = useApolloClient();
const subRef = useRef<Subscription | null>(null);
const reset = useCallback(() => { const reset = useCallback(() => {
resetTransaction(); resetTransaction();
setFinalizedOrder(null); setFinalizedOrder(null);
subRef.current?.unsubscribe();
}, [resetTransaction]);
useEffect(() => {
return () => {
resetTransaction();
setFinalizedOrder(null);
subRef.current?.unsubscribe();
};
}, [resetTransaction]); }, [resetTransaction]);
const submit = useCallback( const submit = useCallback(
@ -43,6 +56,7 @@ export const useOrderSubmit = (market: Market) => {
} }
setFinalizedOrder(null); setFinalizedOrder(null);
try { try {
const res = await send({ const res = await send({
pubKey: keypair.pub, pubKey: keypair.pub,
@ -58,9 +72,7 @@ export const useOrderSubmit = (market: Market) => {
side: order.side, side: order.side,
timeInForce: order.timeInForce, timeInForce: order.timeInForce,
expiresAt: order.expiration expiresAt: order.expiration
? // Wallet expects timestamp in nanoseconds, we don't have that level of accuracy so ? toNanoSeconds(order.expiration) // Wallet expects timestampe in nanoseconds
// just append 6 zeroes
order.expiration.getTime().toString() + '000000'
: undefined, : undefined,
}, },
}); });
@ -68,34 +80,10 @@ export const useOrderSubmit = (market: Market) => {
if (res?.signature) { if (res?.signature) {
const resId = determineId(res.signature); const resId = determineId(res.signature);
if (resId) { if (resId) {
// Start a subscription looking for the newly created order waitForOrderEvent(resId, keypair.pub, (order) => {
subRef.current = client setFinalizedOrder(order);
.subscribe<OrderEvent, OrderEventVariables>({ setComplete();
query: ORDER_EVENT_SUB, });
variables: { partyId: keypair?.pub || '' },
})
.subscribe(({ data }) => {
if (!data?.busEvents?.length) {
return;
}
// No types available for the subscription result
const matchingOrderEvent = data.busEvents.find((e) => {
if (e.event.__typename !== 'Order') {
return false;
}
return e.event.id === resId;
});
if (
matchingOrderEvent &&
matchingOrderEvent.event.__typename === 'Order'
) {
setFinalizedOrder(matchingOrderEvent.event);
subRef.current?.unsubscribe();
}
});
} }
} }
return res; return res;
@ -104,19 +92,13 @@ export const useOrderSubmit = (market: Market) => {
return; return;
} }
}, },
[ [keypair, send, market, setComplete, waitForOrderEvent]
client,
keypair,
send,
market.id,
market.decimalPlaces,
market.positionDecimalPlaces,
]
); );
return { return {
transaction, transaction,
finalizedOrder, finalizedOrder,
TransactionDialog,
submit, submit,
reset, reset,
}; };

View File

@ -1,19 +1,22 @@
import type { FieldErrors } from 'react-hook-form'; import type { FieldErrors } from 'react-hook-form';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { t } from '@vegaprotocol/react-helpers'; import { t } from '@vegaprotocol/react-helpers';
import type { Order } from '@vegaprotocol/wallet';
import { import {
useVegaWallet, useVegaWallet,
VegaWalletOrderTimeInForce as OrderTimeInForce, VegaWalletOrderTimeInForce as OrderTimeInForce,
VegaWalletOrderType as OrderType, VegaWalletOrderType as OrderType,
} from '@vegaprotocol/wallet'; } from '@vegaprotocol/wallet';
import { MarketState, MarketTradingMode } from '@vegaprotocol/types'; import { MarketState, MarketTradingMode } from '@vegaprotocol/types';
import type { Market } from '../market';
import { ERROR_SIZE_DECIMAL } from '../utils/validate-size'; import { ERROR_SIZE_DECIMAL } from '../utils/validate-size';
import type { Order } from './use-order-submit';
export type ValidationProps = { export type ValidationArgs = {
step: number; step: number;
market: Market; market: {
state: MarketState;
tradingMode: MarketTradingMode;
positionDecimalPlaces: number;
};
orderType: OrderType; orderType: OrderType;
orderTimeInForce: OrderTimeInForce; orderTimeInForce: OrderTimeInForce;
fieldErrors?: FieldErrors<Order>; fieldErrors?: FieldErrors<Order>;
@ -34,7 +37,7 @@ export const useOrderValidation = ({
fieldErrors = {}, fieldErrors = {},
orderType, orderType,
orderTimeInForce, orderTimeInForce,
}: ValidationProps) => { }: ValidationArgs) => {
const { keypair } = useVegaWallet(); const { keypair } = useVegaWallet();
const { message, isDisabled } = useMemo(() => { const { message, isDisabled } = useMemo(() => {

View File

@ -4,38 +4,11 @@ import {
VegaWalletOrderSide, VegaWalletOrderSide,
} from '@vegaprotocol/wallet'; } from '@vegaprotocol/wallet';
import { toDecimal } from '@vegaprotocol/react-helpers'; import { toDecimal } from '@vegaprotocol/react-helpers';
import type { Market } from '../market'; import type { Order, Market } from '../order-hooks';
import type { OrderStatus } from '@vegaprotocol/types';
export type Order =
| {
size: string;
type: VegaWalletOrderType.Market;
timeInForce: VegaWalletOrderTimeInForce;
side: VegaWalletOrderSide;
price?: never;
expiration?: never;
rejectionReason: string | null;
status?: OrderStatus;
market?: Market | null;
}
| {
size: string;
type: VegaWalletOrderType.Limit;
timeInForce: VegaWalletOrderTimeInForce;
side: VegaWalletOrderSide;
price?: string;
expiration?: Date;
rejectionReason: string | null;
status?: OrderStatus;
market?: Market | null;
};
export const getDefaultOrder = (market: Market): Order => ({ export const getDefaultOrder = (market: Market): Order => ({
type: VegaWalletOrderType.Market, type: VegaWalletOrderType.Market,
side: VegaWalletOrderSide.Buy, side: VegaWalletOrderSide.Buy,
timeInForce: VegaWalletOrderTimeInForce.IOC, timeInForce: VegaWalletOrderTimeInForce.IOC,
size: String(toDecimal(market.positionDecimalPlaces)), size: String(toDecimal(market.positionDecimalPlaces)),
rejectionReason: null,
market: null,
}); });

View File

@ -8,4 +8,5 @@ export * from './lib/i18n';
export * from './lib/pagination'; export * from './lib/pagination';
export * from './lib/remove-0x'; export * from './lib/remove-0x';
export * from './lib/storage'; export * from './lib/storage';
export * from './lib/time';
export * from './lib/validate'; export * from './lib/validate';

View File

@ -0,0 +1,3 @@
export const toNanoSeconds = (date: Date) => {
return date.getTime().toString() + '000000';
};

View File

@ -293,6 +293,16 @@ export enum WithdrawalStatus {
Rejected = "Rejected", Rejected = "Rejected",
} }
/**
* Pagination constructs to support cursor based pagination in the API
*/
export interface Pagination {
first?: number | null;
after?: string | null;
last?: number | null;
before?: string | null;
}
//============================================================== //==============================================================
// END Enums and Input Objects // END Enums and Input Objects
//============================================================== //==============================================================

View File

@ -1,3 +1,2 @@
export * from './__generated__'; export * from './__generated__';
export * from './candle'; export * from './candle';
export * from './pagination';

View File

@ -1,6 +0,0 @@
export interface Pagination {
first?: number;
after?: string;
last?: number;
before?: string;
}

View File

@ -10,6 +10,7 @@ interface DialogProps {
open: boolean; open: boolean;
onChange?: (isOpen: boolean) => void; onChange?: (isOpen: boolean) => void;
title?: string; title?: string;
icon?: ReactNode;
intent?: Intent; intent?: Intent;
titleClassNames?: string; titleClassNames?: string;
contentClassNames?: string; contentClassNames?: string;
@ -20,6 +21,7 @@ export function Dialog({
open, open,
onChange, onChange,
title, title,
icon,
intent, intent,
titleClassNames, titleClassNames,
contentClassNames, contentClassNames,
@ -50,15 +52,20 @@ export function Dialog({
className="focus:outline-none focus-visible:outline-none" className="focus:outline-none focus-visible:outline-none"
/> />
</DialogPrimitives.Close> </DialogPrimitives.Close>
{title && ( <div className="flex gap-12 max-w-full">
<h1 {icon && <div className="pt-8 fill-current">{icon}</div>}
className={`text-h5 text-black-95 dark:text-white-95 mt-0 mb-20 ${titleClassNames}`} <div data-testid="dialog-content" className="flex-1">
data-testid="dialog-title" {title && (
> <h1
{title} className={`text-h4 font-bold text-black-95 dark:text-white-95 mt-0 mb-6 ${titleClassNames}`}
</h1> data-testid="dialog-title"
)} >
{children} {title}
</h1>
)}
<div className="text-black-60 dark:text-white-60">{children}</div>
</div>
</div>
</DialogPrimitives.Content> </DialogPrimitives.Content>
</DialogPrimitives.Portal> </DialogPrimitives.Portal>
</DialogPrimitives.Root> </DialogPrimitives.Root>

View File

@ -2,12 +2,8 @@ import { act, renderHook } from '@testing-library/react-hooks';
import type { VegaWalletContextShape } from './context'; import type { VegaWalletContextShape } from './context';
import { VegaWalletContext } from './context'; import { VegaWalletContext } from './context';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { import { useVegaTransaction, VegaTxStatus } from './use-vega-transaction';
initialState, import type { OrderSubmissionBody } from '@vegaprotocol/vegawallet-service-api-client';
useVegaTransaction,
VegaTxStatus,
} from './use-vega-transaction';
import type { OrderSubmission } from './types';
const defaultWalletContext = { const defaultWalletContext = {
keypair: null, keypair: null,
@ -42,7 +38,7 @@ it('If provider returns null status should be default', async () => {
const mockSendTx = jest.fn().mockReturnValue(Promise.resolve(null)); const mockSendTx = jest.fn().mockReturnValue(Promise.resolve(null));
const { result } = setup({ sendTx: mockSendTx }); const { result } = setup({ sendTx: mockSendTx });
await act(async () => { await act(async () => {
result.current.send({} as OrderSubmission); result.current.send({} as OrderSubmissionBody);
}); });
expect(result.current.transaction.status).toEqual(VegaTxStatus.Default); expect(result.current.transaction.status).toEqual(VegaTxStatus.Default);
}); });
@ -54,7 +50,7 @@ it('Handles a single error', async () => {
.mockReturnValue(Promise.resolve({ error: errorMessage })); .mockReturnValue(Promise.resolve({ error: errorMessage }));
const { result } = setup({ sendTx: mockSendTx }); const { result } = setup({ sendTx: mockSendTx });
await act(async () => { await act(async () => {
result.current.send({} as OrderSubmission); result.current.send({} as OrderSubmissionBody);
}); });
expect(result.current.transaction.status).toEqual(VegaTxStatus.Error); expect(result.current.transaction.status).toEqual(VegaTxStatus.Error);
expect(result.current.transaction.error).toEqual({ error: errorMessage }); expect(result.current.transaction.error).toEqual({ error: errorMessage });
@ -69,7 +65,7 @@ it('Handles multiple errors', async () => {
const mockSendTx = jest.fn().mockReturnValue(Promise.resolve(errorObj)); const mockSendTx = jest.fn().mockReturnValue(Promise.resolve(errorObj));
const { result } = setup({ sendTx: mockSendTx }); const { result } = setup({ sendTx: mockSendTx });
await act(async () => { await act(async () => {
result.current.send({} as OrderSubmission); result.current.send({} as OrderSubmissionBody);
}); });
expect(result.current.transaction.status).toEqual(VegaTxStatus.Error); expect(result.current.transaction.status).toEqual(VegaTxStatus.Error);
expect(result.current.transaction.error).toEqual(errorObj); expect(result.current.transaction.error).toEqual(errorObj);
@ -90,7 +86,7 @@ it('Returns the signature if successful', async () => {
const mockSendTx = jest.fn().mockReturnValue(Promise.resolve(successObj)); const mockSendTx = jest.fn().mockReturnValue(Promise.resolve(successObj));
const { result } = setup({ sendTx: mockSendTx }); const { result } = setup({ sendTx: mockSendTx });
await act(async () => { await act(async () => {
result.current.send({} as OrderSubmission); result.current.send({} as OrderSubmissionBody);
}); });
expect(result.current.transaction.status).toEqual(VegaTxStatus.Pending); expect(result.current.transaction.status).toEqual(VegaTxStatus.Pending);
expect(result.current.transaction.txHash).toEqual(successObj.txHash); expect(result.current.transaction.txHash).toEqual(successObj.txHash);
@ -98,14 +94,3 @@ it('Returns the signature if successful', async () => {
successObj.tx.signature.value successObj.tx.signature.value
); );
}); });
it('Resets transaction state if user rejects', async () => {
const mockSendTx = jest
.fn()
.mockReturnValue(Promise.resolve({ error: 'User rejected' }));
const { result } = setup({ sendTx: mockSendTx });
await act(async () => {
result.current.send({} as OrderSubmission);
});
expect(result.current.transaction).toEqual(initialState);
});

View File

@ -1,15 +1,24 @@
import { useCallback, useState } from 'react'; import type { ReactNode } from 'react';
import { useCallback, useMemo, useState } from 'react';
import type { TransactionSubmission } from './wallet-types'; import type { TransactionSubmission } from './wallet-types';
import { useVegaWallet } from './use-vega-wallet'; import { useVegaWallet } from './use-vega-wallet';
import type { SendTxError } from './context'; import type { SendTxError } from './context';
import { VegaTransactionDialog } from './vega-transaction-dialog';
import type { Intent } from '@vegaprotocol/ui-toolkit';
export interface DialogProps {
children?: JSX.Element;
intent?: Intent;
title?: string;
icon?: ReactNode;
}
export enum VegaTxStatus { export enum VegaTxStatus {
Default = 'Default', Default = 'Default',
Requested = 'Requested', Requested = 'Requested',
Pending = 'Pending', Pending = 'Pending',
Error = 'Error', Error = 'Error',
// Note no complete state as we have to use api calls/subs to check if Complete = 'Complete',
// our transaction was completed
} }
export interface VegaTxState { export interface VegaTxState {
@ -17,6 +26,7 @@ export interface VegaTxState {
error: object | null; error: object | null;
txHash: string | null; txHash: string | null;
signature: string | null; signature: string | null;
dialogOpen: boolean;
} }
export const initialState = { export const initialState = {
@ -24,6 +34,7 @@ export const initialState = {
error: null, error: null,
txHash: null, txHash: null,
signature: null, signature: null,
dialogOpen: false,
}; };
export const useVegaTransaction = () => { export const useVegaTransaction = () => {
@ -48,6 +59,10 @@ export const useVegaTransaction = () => {
setTransaction(initialState); setTransaction(initialState);
}, [setTransaction]); }, [setTransaction]);
const setComplete = useCallback(() => {
setTransaction({ status: VegaTxStatus.Complete });
}, [setTransaction]);
const send = useCallback( const send = useCallback(
async (tx: TransactionSubmission) => { async (tx: TransactionSubmission) => {
setTransaction({ setTransaction({
@ -55,6 +70,7 @@ export const useVegaTransaction = () => {
txHash: null, txHash: null,
signature: null, signature: null,
status: VegaTxStatus.Requested, status: VegaTxStatus.Requested,
dialogOpen: true,
}); });
const res = await sendTx(tx); const res = await sendTx(tx);
@ -63,18 +79,14 @@ export const useVegaTransaction = () => {
setTransaction({ status: VegaTxStatus.Default }); setTransaction({ status: VegaTxStatus.Default });
return null; return null;
} }
if ('errors' in res) {
if ('error' in res) { handleError(res);
// Close dialog if user rejects the transaction } else if ('error' in res) {
if (res.error === 'User rejected') { if (res.error === 'User rejected') {
reset(); reset();
} else { } else {
handleError(res); handleError(res);
} }
return null;
} else if ('errors' in res) {
handleError(res);
return null;
} else if (res.tx?.signature?.value && res.txHash) { } else if (res.tx?.signature?.value && res.txHash) {
setTransaction({ setTransaction({
status: VegaTxStatus.Pending, status: VegaTxStatus.Pending,
@ -91,5 +103,25 @@ export const useVegaTransaction = () => {
[sendTx, handleError, setTransaction, reset] [sendTx, handleError, setTransaction, reset]
); );
return { send, transaction, reset }; const TransactionDialog = useMemo(() => {
return (props: DialogProps) => (
<VegaTransactionDialog
{...props}
isOpen={transaction.dialogOpen}
onChange={(isOpen) => {
if (!isOpen) reset();
setTransaction({ dialogOpen: isOpen });
}}
transaction={transaction}
/>
);
}, [transaction, setTransaction, reset]);
return {
send,
transaction,
reset,
setComplete,
TransactionDialog,
};
}; };

View File

@ -1,10 +1,7 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { OrderStatus, OrderType } from '@vegaprotocol/types';
import type { VegaTxState } from '../use-vega-transaction';
import { VegaTxStatus } from '../use-vega-transaction'; import { VegaTxStatus } from '../use-vega-transaction';
import type { Order } from '../wallet-types';
import type { VegaTransactionDialogProps } from './vega-transaction-dialog'; import type { VegaTransactionDialogProps } from './vega-transaction-dialog';
import { VegaDialog, VegaTransactionDialog } from './vega-transaction-dialog'; import { VegaTransactionDialog } from './vega-transaction-dialog';
jest.mock('@vegaprotocol/environment', () => ({ jest.mock('@vegaprotocol/environment', () => ({
useEnvironment: () => ({ useEnvironment: () => ({
@ -13,222 +10,109 @@ jest.mock('@vegaprotocol/environment', () => ({
})); }));
describe('VegaTransactionDialog', () => { describe('VegaTransactionDialog', () => {
let defaultProps: VegaTransactionDialogProps; let props: VegaTransactionDialogProps;
beforeEach(() => { beforeEach(() => {
defaultProps = { props = {
orderDialogOpen: true, isOpen: true,
setOrderDialogOpen: () => false, onChange: () => false,
transaction: { transaction: {
status: VegaTxStatus.Default,
error: null,
txHash: null,
signature: null,
},
finalizedOrder: {
status: OrderStatus.Cancelled,
rejectionReason: null,
size: '10',
price: '1000',
market: null,
type: OrderType.Limit,
},
reset: jest.fn(),
title: 'Order cancelled',
};
});
it('should render when an order is successfully cancelled', () => {
render(<VegaTransactionDialog {...defaultProps} />);
expect(screen.getByTestId('order-status-header')).toHaveTextContent(
'Order cancelled'
);
});
it('should render when an order is not successfully cancelled', () => {
const transaction: VegaTxState = {
status: VegaTxStatus.Default,
error: null,
txHash: null,
signature: null,
};
const finalizedOrder: Order = {
status: OrderStatus.Active,
rejectionReason: null,
size: '10',
price: '1000',
market: null,
type: OrderType.Limit,
};
const propsForTest = {
transaction,
finalizedOrder,
};
render(
<VegaTransactionDialog
{...defaultProps}
{...propsForTest}
title={'Cancellation failed'}
/>
);
expect(screen.getByTestId('order-status-header')).toHaveTextContent(
'Cancellation failed'
);
});
describe('TransactionDialog', () => {
it('should render when an order is successful', () => {
const transaction: VegaTxState = {
status: VegaTxStatus.Default,
error: null,
txHash: null,
signature: null,
};
const finalizedOrder: Order = {
status: OrderStatus.Active,
rejectionReason: null,
size: '10',
price: '1000',
market: null,
type: OrderType.Limit,
};
render(
<VegaDialog
finalizedOrder={finalizedOrder}
transaction={transaction}
title={'Order placed'}
/>
);
expect(screen.getByTestId('order-status-header')).toHaveTextContent(
'Order placed'
);
});
it('should render when transaction is requested', () => {
const transaction: VegaTxState = {
status: VegaTxStatus.Requested, status: VegaTxStatus.Requested,
error: null, error: null,
txHash: null, txHash: null,
signature: null, signature: null,
}; },
const finalizedOrder: Order = { };
status: OrderStatus.Active,
rejectionReason: null,
size: '10',
price: '1000',
market: null,
type: OrderType.Limit,
};
render(
<VegaDialog
finalizedOrder={finalizedOrder}
transaction={transaction}
title={'Order tx'}
/>
);
expect(screen.getByTestId('order-status-header')).toHaveTextContent(
'Confirm transaction in wallet'
);
});
it('should render when transaction has error', () => {
const transaction: VegaTxState = {
status: VegaTxStatus.Error,
error: null,
txHash: null,
signature: null,
};
const finalizedOrder: Order = {
status: OrderStatus.Active,
rejectionReason: null,
size: '10',
price: '1000',
market: null,
type: OrderType.Limit,
};
render(
<VegaDialog
finalizedOrder={finalizedOrder}
transaction={transaction}
title={'Order tx'}
/>
);
expect(screen.getByTestId('order-status-header')).toHaveTextContent(
'Order rejected by wallet'
);
});
it('should render when an order is rejected', () => {
const transaction: VegaTxState = {
status: VegaTxStatus.Default,
error: null,
txHash: null,
signature: null,
};
const finalizedOrder: Order = {
status: OrderStatus.Rejected,
rejectionReason: null,
size: '10',
price: '1000',
market: null,
type: OrderType.Limit,
};
render(
<VegaDialog
finalizedOrder={finalizedOrder}
transaction={transaction}
title={'Order title'}
/>
);
expect(screen.getByTestId('order-status-header')).toHaveTextContent(
'Order failed'
);
});
it('should render when pending consensus', () => {
const transaction: VegaTxState = {
status: VegaTxStatus.Error,
error: null,
txHash: null,
signature: null,
};
render(
<VegaDialog
finalizedOrder={null}
transaction={transaction}
title={'Order title'}
/>
);
expect(screen.getByTestId('order-status-header')).toHaveTextContent(
'Order rejected by wallet'
);
});
it('should render awaiting network confirmation and add link to tx in block explorer', () => {
const transaction: VegaTxState = {
status: VegaTxStatus.Default,
error: null,
txHash: 'TxHash',
signature: null,
};
render(
<VegaDialog
finalizedOrder={null}
transaction={transaction}
title={'Order Tx'}
/>
);
expect(screen.getByTestId('order-status-header')).toHaveTextContent(
'Awaiting network confirmation'
);
expect(screen.getByTestId('tx-block-explorer')).toHaveTextContent(
'View in block explorer'
);
expect(screen.getByTestId('tx-block-explorer')).toHaveAttribute(
'href',
'https://test.explorer.vega.network/txs/0xTxHash'
);
});
}); });
it('requested', () => {
render(<VegaTransactionDialog {...props} />);
expect(screen.getByTestId('dialog-title')).toHaveTextContent(/confirm/i);
expect(screen.getByTestId(VegaTxStatus.Requested)).toHaveTextContent(
/please open your wallet/i
);
});
it('pending', () => {
render(
<VegaTransactionDialog
{...props}
transaction={{
...props.transaction,
txHash: 'tx-hash',
status: VegaTxStatus.Pending,
}}
/>
);
expect(screen.getByTestId('dialog-title')).toHaveTextContent(/awaiting/i);
expect(screen.getByTestId(VegaTxStatus.Pending)).toHaveTextContent(
/please wait/i
);
testBlockExplorerLink('tx-hash');
});
it('error', () => {
render(
<VegaTransactionDialog
{...props}
transaction={{
...props.transaction,
error: { message: 'rejected' },
status: VegaTxStatus.Error,
}}
/>
);
expect(screen.getByTestId('dialog-title')).toHaveTextContent(/failed/i);
expect(screen.getByTestId(VegaTxStatus.Error)).toHaveTextContent(
/rejected/i
);
});
it('default complete', () => {
render(
<VegaTransactionDialog
{...props}
transaction={{
...props.transaction,
txHash: 'tx-hash',
status: VegaTxStatus.Complete,
}}
/>
);
expect(screen.getByTestId('dialog-title')).toHaveTextContent(/complete/i);
expect(screen.getByTestId(VegaTxStatus.Complete)).toHaveTextContent(
/confirmed/i
);
testBlockExplorerLink('tx-hash');
});
it('custom complete', () => {
render(
<VegaTransactionDialog
{...props}
transaction={{
...props.transaction,
txHash: 'tx-hash',
status: VegaTxStatus.Complete,
}}
title="Custom title"
>
<div>Custom content</div>
</VegaTransactionDialog>
);
expect(screen.getByTestId('dialog-title')).toHaveTextContent(
'Custom title'
);
expect(screen.getByText('Custom content')).toBeInTheDocument();
});
function testBlockExplorerLink(txHash: string) {
expect(screen.getByTestId('tx-block-explorer')).toHaveTextContent(
'View in block explorer'
);
expect(screen.getByTestId('tx-block-explorer')).toHaveAttribute(
'href',
`https://test.explorer.vega.network/txs/0x${txHash}`
);
}
}); });

View File

@ -1,155 +1,94 @@
import { Dialog, Intent } from '@vegaprotocol/ui-toolkit'; import { useEnvironment } from '@vegaprotocol/environment';
import { useEffect } from 'react'; import get from 'lodash/get';
import { t } from '@vegaprotocol/react-helpers';
import { Dialog, Icon, Intent, Loader } from '@vegaprotocol/ui-toolkit';
import type { ReactNode } from 'react';
import type { VegaTxState } from '../use-vega-transaction'; import type { VegaTxState } from '../use-vega-transaction';
import { VegaTxStatus } from '../use-vega-transaction'; import { VegaTxStatus } from '../use-vega-transaction';
import { Icon, Loader } from '@vegaprotocol/ui-toolkit';
import type { ReactNode } from 'react';
import {
addDecimalsFormatNumber,
formatLabel,
t,
} from '@vegaprotocol/react-helpers';
import { useEnvironment } from '@vegaprotocol/environment';
import { OrderType } from '@vegaprotocol/types';
import type { Order } from '../wallet-types';
import get from 'lodash/get';
export interface VegaTransactionDialogProps { export interface VegaTransactionDialogProps {
orderDialogOpen: boolean; isOpen: boolean;
setOrderDialogOpen: (isOpen: boolean) => void; onChange: (isOpen: boolean) => void;
finalizedOrder: Order | null;
transaction: VegaTxState; transaction: VegaTxState;
reset: () => void;
title?: string;
children?: ReactNode; children?: ReactNode;
intent?: Intent;
title?: string;
icon?: ReactNode;
} }
const getDialogIntent = (
finalizedOrder: Order | null,
transaction: VegaTxState
) => {
if (finalizedOrder) {
return !finalizedOrder.rejectionReason ? Intent.Success : Intent.Danger;
}
switch (transaction.status) {
case VegaTxStatus.Requested:
return Intent.Warning;
case VegaTxStatus.Pending:
return Intent.Warning;
case VegaTxStatus.Error:
return Intent.Danger;
default:
return Intent.None;
}
};
export const VegaTransactionDialog = ({ export const VegaTransactionDialog = ({
orderDialogOpen, isOpen,
setOrderDialogOpen, onChange,
finalizedOrder,
transaction, transaction,
reset,
title = '',
children, children,
intent,
title,
icon,
}: VegaTransactionDialogProps) => { }: VegaTransactionDialogProps) => {
// open / close dialog const computedIntent = intent ? intent : getIntent(transaction);
useEffect(() => { const computedTitle = title ? title : getTitle(transaction);
if (transaction.status !== VegaTxStatus.Default || finalizedOrder) { const computedIcon = icon ? icon : getIcon(transaction);
setOrderDialogOpen(true); // Each dialog can specify custom dialog content using data returned via
} else { // the subscription that confirms the transaction. So if we get a success state
setOrderDialogOpen(false); // and this custom content is provided, render it
} const content =
}, [finalizedOrder, setOrderDialogOpen, transaction.status]); transaction.status === VegaTxStatus.Complete && children ? (
children
) : (
<VegaDialog transaction={transaction} />
);
return ( return (
<Dialog <Dialog
open={orderDialogOpen} open={isOpen}
onChange={(isOpen) => { onChange={onChange}
setOrderDialogOpen(isOpen); intent={computedIntent}
title={computedTitle}
// If closing reset icon={computedIcon}
if (!isOpen) {
reset();
}
}}
intent={getDialogIntent(finalizedOrder, transaction)}
> >
<VegaDialog {content}
key={`${title.toLowerCase().split(' ').join('-')}-tx-${
transaction.txHash
}`}
transaction={transaction}
finalizedOrder={finalizedOrder}
title={title}
children={children}
/>
</Dialog> </Dialog>
); );
}; };
interface VegaDialogProps { interface VegaDialogProps {
transaction: VegaTxState; transaction: VegaTxState;
finalizedOrder: Order | null;
title: string;
children?: ReactNode;
} }
export const VegaDialog = ({ /**
transaction, * Default dialog content
finalizedOrder, */
title, export const VegaDialog = ({ transaction }: VegaDialogProps) => {
children,
}: VegaDialogProps) => {
const { VEGA_EXPLORER_URL } = useEnvironment(); const { VEGA_EXPLORER_URL } = useEnvironment();
const headerClassName = 'text-h5 font-bold text-black dark:text-white';
if (children && transaction.status === VegaTxStatus.Default) {
return <div>{children}</div>;
}
// Rejected by wallet
if (transaction.status === VegaTxStatus.Requested) { if (transaction.status === VegaTxStatus.Requested) {
return ( return (
<OrderDialogWrapper <p data-testid={transaction.status}>
title="Confirm transaction in wallet" {t(
icon={<Icon name="hand-up" size={20} />} 'Please open your wallet application and confirm or reject the transaction'
> )}
<p> </p>
{t(
'Please open your wallet application and confirm or reject the transaction'
)}
</p>
</OrderDialogWrapper>
); );
} }
// Transaction error
if (transaction.status === VegaTxStatus.Error) { if (transaction.status === VegaTxStatus.Error) {
return ( return (
<OrderDialogWrapper <div data-testid={transaction.status}>
title="Order rejected by wallet"
icon={<Icon name="warning-sign" size={20} />}
>
{transaction.error && ( {transaction.error && (
<pre className="text-ui break-all whitespace-pre-wrap"> <pre className="text-ui break-all whitespace-pre-wrap">
{get(transaction.error, 'error') ?? {get(transaction.error, 'error') ??
JSON.stringify(transaction.error, null, 2)} JSON.stringify(transaction.error, null, 2)}
</pre> </pre>
)} )}
</OrderDialogWrapper> </div>
); );
} }
// Pending consensus if (transaction.status === VegaTxStatus.Pending) {
if (!finalizedOrder) {
return ( return (
<OrderDialogWrapper <div data-testid={transaction.status}>
title="Awaiting network confirmation" <p className="break-all">
icon={<Loader size="small" />} {t('Please wait for your transaction to be confirmed')} - &nbsp;
> {transaction.txHash && (
{transaction.txHash && (
<p className="break-all">
{t('Waiting for few more blocks')} - &nbsp;
<a <a
className="underline" className="underline"
data-testid="tx-block-explorer" data-testid="tx-block-explorer"
@ -159,108 +98,77 @@ export const VegaDialog = ({
> >
{t('View in block explorer')} {t('View in block explorer')}
</a> </a>
</p> )}
)}
</OrderDialogWrapper>
);
}
// Order on network but was rejected
if (finalizedOrder.status === 'Rejected') {
return (
<OrderDialogWrapper
title="Order failed"
icon={<Icon name="warning-sign" size={20} />}
>
<p data-testid="error-reason">
{finalizedOrder.rejectionReason &&
t(`Reason: ${formatLabel(finalizedOrder.rejectionReason)}`)}
</p> </p>
</OrderDialogWrapper> </div>
); );
} }
return ( if (transaction.status === VegaTxStatus.Complete) {
<OrderDialogWrapper title={title} icon={<Icon name="tick" size={20} />}> return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8"> <div data-testid={transaction.status}>
{finalizedOrder.market && ( <p className="break-all">
<div> {t('Your transaction has been confirmed')} - &nbsp;
<p className={headerClassName}>{t(`Market`)}</p> {transaction.txHash && (
<p>{t(`${finalizedOrder.market.name}`)}</p>
</div>
)}
<div>
<p className={headerClassName}>{t(`Status`)}</p>
<p>{t(`${finalizedOrder.status}`)}</p>
</div>
{finalizedOrder.type === OrderType.Limit && finalizedOrder.market && (
<div>
<p className={headerClassName}>{t(`Price`)}</p>
<p>
{addDecimalsFormatNumber(
finalizedOrder.price,
finalizedOrder.market.decimalPlaces
)}
</p>
</div>
)}
<div>
<p className={headerClassName}>{t(`Amount`)}</p>
<p
className={
finalizedOrder.side === 'Buy'
? 'text-vega-green'
: 'text-vega-red'
}
>
{`${finalizedOrder.side === 'Buy' ? '+' : '-'} ${
finalizedOrder.size
}
`}
</p>
</div>
</div>
<div className="grid grid-cols-1 gap-8">
{transaction.txHash && (
<div>
<p className={headerClassName}>{t(`Transaction`)}</p>
<a <a
className="underline break-words" className="underline"
data-testid="tx-block-explorer" data-testid="tx-block-explorer"
href={`${VEGA_EXPLORER_URL}/txs/0x${transaction.txHash}`} href={`${VEGA_EXPLORER_URL}/txs/0x${transaction.txHash}`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
{transaction.txHash} {t('View in block explorer')}
</a> </a>
</div> )}
)} </p>
</div> </div>
</OrderDialogWrapper> );
); }
return null;
}; };
interface OrderDialogWrapperProps { const getIntent = (transaction: VegaTxState) => {
children: ReactNode; switch (transaction.status) {
icon: ReactNode; case VegaTxStatus.Requested:
title: string; return Intent.Warning;
} case VegaTxStatus.Pending:
return Intent.Warning;
export const OrderDialogWrapper = ({ case VegaTxStatus.Error:
children, return Intent.Danger;
icon, case VegaTxStatus.Complete:
title, return Intent.Success;
}: OrderDialogWrapperProps) => { default:
const headerClassName = 'text-h4 font-bold text-black dark:text-white'; return Intent.None;
return ( }
<div className="flex gap-12 max-w-full"> };
<div className="pt-8 fill-current">{icon}</div>
<div data-testid="order-wrapper" className="flex-1"> const getTitle = (transaction: VegaTxState) => {
<h1 data-testid="order-status-header" className={headerClassName}> switch (transaction.status) {
{title} case VegaTxStatus.Requested:
</h1> return t('Confirm transaction in wallet');
{children} case VegaTxStatus.Pending:
</div> return t('Awaiting network confirmation');
</div> case VegaTxStatus.Error:
); return t('Transaction failed');
case VegaTxStatus.Complete:
return t('Transaction complete');
default:
return '';
}
};
const getIcon = (transaction: VegaTxState) => {
switch (transaction.status) {
case VegaTxStatus.Requested:
return <Icon name="hand-up" size={20} />;
case VegaTxStatus.Pending:
return <Loader size="small" />;
case VegaTxStatus.Error:
return <Icon name="warning-sign" size={20} />;
case VegaTxStatus.Complete:
return <Icon name="tick" size={20} />;
default:
return '';
}
}; };

View File

@ -1,4 +1,3 @@
import type { OrderTimeInForce } from '@vegaprotocol/types';
import type { import type {
DelegateSubmissionBody, DelegateSubmissionBody,
OrderCancellationBody, OrderCancellationBody,
@ -37,23 +36,3 @@ export type TransactionSubmission =
| DelegateSubmissionBody | DelegateSubmissionBody
| UndelegateSubmissionBody | UndelegateSubmissionBody
| OrderAmendmentBody; | OrderAmendmentBody;
export interface Market {
name: string;
positionDecimalPlaces?: number;
decimalPlaces: number;
id?: string;
}
export interface Order {
id?: string;
status?: string;
rejectionReason?: string | null;
size: string;
price: string;
market: Market | null;
type: string | null;
side?: string;
timeInForce: OrderTimeInForce;
expiresAt?: Date | string | null;
}

View File

@ -1,23 +0,0 @@
import type { ReactNode } from 'react';
interface DialogWrapperProps {
children: ReactNode;
icon: ReactNode;
title: string;
}
export const DialogWrapper = ({
children,
icon,
title,
}: DialogWrapperProps) => {
return (
<div className="flex gap-12 max-w-full text-ui">
<div className="pt-8 fill-current">{icon}</div>
<div className="flex-1">
<h1 className="text-h4 text-black dark:text-white mb-12">{title}</h1>
<div className="text-black-40 dark:text-white-40">{children}</div>
</div>
</div>
);
};

View File

@ -4,7 +4,6 @@ import { isEthereumError } from '../ethereum-error';
import type { EthTxState } from '../use-ethereum-transaction'; import type { EthTxState } from '../use-ethereum-transaction';
import { EthTxStatus } from '../use-ethereum-transaction'; import { EthTxStatus } from '../use-ethereum-transaction';
import { ConfirmRow, TxRow, ConfirmationEventRow } from './dialog-rows'; import { ConfirmRow, TxRow, ConfirmationEventRow } from './dialog-rows';
import { DialogWrapper } from './dialog-wrapper';
export interface TransactionDialogProps { export interface TransactionDialogProps {
name: string; name: string;
@ -101,11 +100,16 @@ export const TransactionDialog = ({
return propsMap[status]; return propsMap[status];
}; };
const { intent, ...wrapperProps } = getWrapperProps(); const { intent, title, icon } = getWrapperProps();
return ( return (
<Dialog open={transaction.dialogOpen} onChange={onChange} intent={intent}> <Dialog
<DialogWrapper {...wrapperProps}>{renderContent()}</DialogWrapper> open={transaction.dialogOpen}
onChange={onChange}
intent={intent}
title={title}
icon={icon}
>
{renderContent()}
</Dialog> </Dialog>
); );
}; };

View File

@ -25,38 +25,22 @@ export const WithdrawDialog = ({
onDialogChange, onDialogChange,
}: WithdrawDialogProps) => { }: WithdrawDialogProps) => {
const { ETHERSCAN_URL } = useEnvironment(); const { ETHERSCAN_URL } = useEnvironment();
const { intent, ...props } = getProps(approval, vegaTx, ethTx, ETHERSCAN_URL); const { intent, title, icon, children } = getProps(
return ( approval,
<Dialog open={dialogOpen} intent={intent} onChange={onDialogChange}> vegaTx,
<DialogWrapper {...props} /> ethTx,
</Dialog> ETHERSCAN_URL
); );
};
interface DialogWrapperProps {
children: ReactNode;
icon: ReactNode;
title: string;
}
export const DialogWrapper = ({
children,
icon,
title,
}: DialogWrapperProps) => {
return ( return (
<div className="flex gap-12 max-w-full text-ui"> <Dialog
<div className="pt-8 fill-current">{icon}</div> open={dialogOpen}
<div className="flex-1"> onChange={onDialogChange}
<h1 intent={intent}
data-testid="dialog-title" title={title}
className="text-h4 text-black dark:text-white capitalize mb-12" icon={icon}
> >
{title} {children}
</h1> </Dialog>
{children}
</div>
</div>
); );
}; };
@ -118,6 +102,12 @@ const getProps = (
intent: Intent.None, intent: Intent.None,
children: <Step>Awaiting transaction</Step>, children: <Step>Awaiting transaction</Step>,
}, },
[VegaTxStatus.Complete]: {
title: t('Withdrawal transaction complete'),
icon: <Icon name="tick" />,
intent: Intent.Success,
children: <Step>Withdrawal created</Step>,
},
}; };
const completeProps = { const completeProps = {