Feat/522 close position (#1762)

* feat: use close position hook and dialog setup

* chore: update wallet tx interface for batch market instruction

* feat: add usage of data provider to show relevant order information

* feat: render correctly formatted values in close position dialog

* feat: make vega tx dialog more flexibly by allowing custom ui for every state of the tx

* feat: adjust text alignment and spacing between active orders and order to close

* feat: add unit tests

* chore: remove stray log

* chore: fix lint

* chore: ignore ts error for formatter function of vesting chart

* feat: split components up, memozie variables

* feat: add shared loading state to prevent content popping in

* feat: add time in force label

* feat: move transaction result hook to wallet lib

* feat: prevent being able to close vega tx dialog, must reject tx

* chore: add test for useTransactionResult hook

* chore: fix positiosn test after hook relocation

* Revert "feat: prevent being able to close vega tx dialog, must reject tx"

This reverts commit d1ecda69c3c55822bb042320f82b2e1c3833b99a.

* chore: add check for order edge to be defined

* chore: remove close callback

* feat: use tx result state to determine dialog state

* chore: update close position hook to check for transaction result

* fix: readd types tif selection persistance

* feat: convert order event func to be async, use it in close position for more result context

* fix: rename utils

* chore: adjust error language

Co-authored-by: Madalina Raicu <madalina@raygroup.uk>
This commit is contained in:
Matthew Russell 2022-10-24 13:16:15 -05:00 committed by GitHub
parent 317dfc4bb1
commit d0976bbd46
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 2534 additions and 1269 deletions

View File

@ -321,9 +321,15 @@ export const DealTicketSteps = ({ market }: DealTicketMarketProps) => {
title={getOrderDialogTitle(finalizedOrder?.status)} title={getOrderDialogTitle(finalizedOrder?.status)}
intent={getOrderDialogIntent(finalizedOrder?.status)} intent={getOrderDialogIntent(finalizedOrder?.status)}
icon={getOrderDialogIcon(finalizedOrder?.status)} icon={getOrderDialogIcon(finalizedOrder?.status)}
> content={{
<OrderFeedback transaction={transaction} order={finalizedOrder} /> Complete: (
</Dialog> <OrderFeedback
transaction={transaction}
order={finalizedOrder}
/>
),
}}
/>
</div> </div>
), ),
disabled: true, disabled: true,

View File

@ -67,20 +67,26 @@ const OrdersManager = () => {
<orderCancel.Dialog <orderCancel.Dialog
title={getCancelDialogTitle(orderCancel.cancelledOrder?.status)} title={getCancelDialogTitle(orderCancel.cancelledOrder?.status)}
intent={getCancelDialogIntent(orderCancel.cancelledOrder?.status)} intent={getCancelDialogIntent(orderCancel.cancelledOrder?.status)}
> content={{
<OrderFeedback Complete: (
transaction={orderCancel.transaction} <OrderFeedback
order={orderCancel.cancelledOrder} transaction={orderCancel.transaction}
/> order={orderCancel.cancelledOrder}
</orderCancel.Dialog> />
),
}}
/>
<orderEdit.Dialog <orderEdit.Dialog
title={getEditDialogTitle(orderEdit.updatedOrder?.status)} title={getEditDialogTitle(orderEdit.updatedOrder?.status)}
> content={{
<OrderFeedback Complete: (
transaction={orderEdit.transaction} <OrderFeedback
order={orderEdit.updatedOrder} transaction={orderEdit.transaction}
/> order={orderEdit.updatedOrder}
</orderEdit.Dialog> />
),
}}
/>
{editOrder && ( {editOrder && (
<OrderEditDialog <OrderEditDialog
isOpen={Boolean(editOrder)} isOpen={Boolean(editOrder)}

View File

@ -14,16 +14,23 @@ interface ProposalFormTransactionDialogProps {
export const ProposalFormTransactionDialog = ({ export const ProposalFormTransactionDialog = ({
finalizedProposal, finalizedProposal,
TransactionDialog, TransactionDialog,
}: ProposalFormTransactionDialogProps) => ( }: ProposalFormTransactionDialogProps) => {
<div data-testid="proposal-transaction-dialog"> // Render a custom complete UI if the proposal was rejected other wise
<TransactionDialog // pass undefined so that the default vega transaction dialog UI gets used
title={getProposalDialogTitle(finalizedProposal?.state)} const completeContent = finalizedProposal?.rejectionReason ? (
intent={getProposalDialogIntent(finalizedProposal?.state)} <p>{finalizedProposal.rejectionReason}</p>
icon={getProposalDialogIcon(finalizedProposal?.state)} ) : undefined;
>
{finalizedProposal?.rejectionReason ? ( return (
<p>{finalizedProposal.rejectionReason}</p> <div data-testid="proposal-transaction-dialog">
) : undefined} <TransactionDialog
</TransactionDialog> title={getProposalDialogTitle(finalizedProposal?.state)}
</div> intent={getProposalDialogIntent(finalizedProposal?.state)}
); icon={getProposalDialogIcon(finalizedProposal?.state)}
content={{
Complete: completeContent,
}}
/>
</div>
);
};

View File

@ -48,6 +48,7 @@ export const VestingChart = () => {
<Tooltip <Tooltip
contentStyle={{ backgroundColor: colors.black }} contentStyle={{ backgroundColor: colors.black }}
separator=":" separator=":"
// @ts-ignore formatter doesnt seem to allow returning JSX but nonetheless it works
formatter={(value: number) => { formatter={(value: number) => {
return ( return (
<div <div

View File

@ -36,9 +36,12 @@ export const DealTicketManager = ({
title={getOrderDialogTitle(finalizedOrder?.status)} title={getOrderDialogTitle(finalizedOrder?.status)}
intent={getOrderDialogIntent(finalizedOrder?.status)} intent={getOrderDialogIntent(finalizedOrder?.status)}
icon={getOrderDialogIcon(finalizedOrder?.status)} icon={getOrderDialogIcon(finalizedOrder?.status)}
> content={{
<OrderFeedback transaction={transaction} order={finalizedOrder} /> Complete: (
</Dialog> <OrderFeedback transaction={transaction} order={finalizedOrder} />
),
}}
/>
</> </>
); );
}; };

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { FormGroup, Select } from '@vegaprotocol/ui-toolkit'; import { FormGroup, Select } from '@vegaprotocol/ui-toolkit';
import { Schema } from '@vegaprotocol/types'; import { Schema } from '@vegaprotocol/types';
import { t } from '@vegaprotocol/react-helpers'; import { t } from '@vegaprotocol/react-helpers';
import { timeInForceLabel } from '@vegaprotocol/orders';
interface TimeInForceSelectorProps { interface TimeInForceSelectorProps {
value: Schema.OrderTimeInForce; value: Schema.OrderTimeInForce;
@ -9,26 +10,6 @@ interface TimeInForceSelectorProps {
onSelect: (tif: Schema.OrderTimeInForce) => void; onSelect: (tif: Schema.OrderTimeInForce) => void;
} }
// More detail in https://docs.vega.xyz/docs/mainnet/graphql/enums/order-time-in-force
export const timeInForceLabel = (tif: string) => {
switch (tif) {
case Schema.OrderTimeInForce.TIME_IN_FORCE_GTC:
return t(`Good 'til Cancelled (GTC)`);
case Schema.OrderTimeInForce.TIME_IN_FORCE_IOC:
return t('Immediate or Cancel (IOC)');
case Schema.OrderTimeInForce.TIME_IN_FORCE_FOK:
return t('Fill or Kill (FOK)');
case Schema.OrderTimeInForce.TIME_IN_FORCE_GTT:
return t(`Good 'til Time (GTT)`);
case Schema.OrderTimeInForce.TIME_IN_FORCE_GFN:
return t('Good for Normal (GFN)');
case Schema.OrderTimeInForce.TIME_IN_FORCE_GFA:
return t('Good for Auction (GFA)');
default:
return t(tif);
}
};
type PossibleOrderKeys = Exclude< type PossibleOrderKeys = Exclude<
Schema.OrderType, Schema.OrderType,
Schema.OrderType.TYPE_NETWORK Schema.OrderType.TYPE_NETWORK

View File

@ -62,20 +62,26 @@ export const OrderList = forwardRef<AgGridReact, OrderListProps>(
<orderCancel.Dialog <orderCancel.Dialog
title={getCancelDialogTitle(orderCancel.cancelledOrder?.status)} title={getCancelDialogTitle(orderCancel.cancelledOrder?.status)}
intent={getCancelDialogIntent(orderCancel.cancelledOrder?.status)} intent={getCancelDialogIntent(orderCancel.cancelledOrder?.status)}
> content={{
<OrderFeedback Complete: (
transaction={orderCancel.transaction} <OrderFeedback
order={orderCancel.cancelledOrder} transaction={orderCancel.transaction}
/> order={orderCancel.cancelledOrder}
</orderCancel.Dialog> />
),
}}
/>
<orderEdit.Dialog <orderEdit.Dialog
title={getEditDialogTitle(orderEdit.updatedOrder?.status)} title={getEditDialogTitle(orderEdit.updatedOrder?.status)}
> content={{
<OrderFeedback Complete: (
transaction={orderEdit.transaction} <OrderFeedback
order={orderEdit.updatedOrder} transaction={orderEdit.transaction}
/> order={orderEdit.updatedOrder}
</orderEdit.Dialog> />
),
}}
/>
{editOrder && ( {editOrder && (
<OrderEditDialog <OrderEditDialog
isOpen={Boolean(editOrder)} isOpen={Boolean(editOrder)}

View File

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

View File

@ -3,3 +3,4 @@ export * from './order-event-query';
export * from './use-order-cancel'; export * from './use-order-cancel';
export * from './use-order-submit'; export * from './use-order-submit';
export * from './use-order-edit'; export * from './use-order-edit';
export * from './use-order-event';

View File

@ -46,10 +46,9 @@ export const useOrderCancel = () => {
}, },
}); });
waitForOrderEvent(args.orderId, pubKey, (cancelledOrder) => { const cancelledOrder = await waitForOrderEvent(args.orderId, pubKey);
setCancelledOrder(cancelledOrder); setCancelledOrder(cancelledOrder);
setComplete(); setComplete();
});
} catch (e) { } catch (e) {
Sentry.captureException(e); Sentry.captureException(e);
return; return;

View File

@ -54,10 +54,9 @@ export const useOrderEdit = (order: Order | null) => {
}, },
}); });
waitForOrderEvent(order.id, pubKey, (updatedOrder) => { const updatedOrder = await waitForOrderEvent(order.id, pubKey);
setUpdatedOrder(updatedOrder); setUpdatedOrder(updatedOrder);
setComplete(); setComplete();
});
} catch (e) { } catch (e) {
Sentry.captureException(e); Sentry.captureException(e);
return; return;

View File

@ -8,44 +8,48 @@ import type {
} from './'; } from './';
import type { Subscription } from 'zen-observable-ts'; import type { Subscription } from 'zen-observable-ts';
import type { VegaTxState } from '@vegaprotocol/wallet'; import type { VegaTxState } from '@vegaprotocol/wallet';
import { BusEventType } from '@vegaprotocol/types';
type WaitFunc = (
orderId: string,
partyId: string
) => Promise<OrderEvent_busEvents_event_Order>;
export const useOrderEvent = (transaction: VegaTxState) => { export const useOrderEvent = (transaction: VegaTxState) => {
const client = useApolloClient(); const client = useApolloClient();
const subRef = useRef<Subscription | null>(null); const subRef = useRef<Subscription | null>(null);
const waitForOrderEvent = useCallback( const waitForOrderEvent = useCallback<WaitFunc>(
( (id: string, partyId: string) => {
id: string, return new Promise((resolve) => {
partyId: string, subRef.current = client
callback: (order: OrderEvent_busEvents_event_Order) => void .subscribe<OrderEvent, OrderEventVariables>({
) => { query: ORDER_EVENT_SUB,
subRef.current = client variables: { partyId },
.subscribe<OrderEvent, OrderEventVariables>({ })
query: ORDER_EVENT_SUB, .subscribe(({ data }) => {
variables: { partyId }, if (!data?.busEvents?.length) {
}) return;
.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; // No types available for the subscription result
}); const matchingOrderEvent = data.busEvents.find((e) => {
if (e.event.__typename !== BusEventType.Order) {
return false;
}
if ( return e.event.id === id;
matchingOrderEvent && });
matchingOrderEvent.event.__typename === 'Order'
) { if (
callback(matchingOrderEvent.event); matchingOrderEvent &&
subRef.current?.unsubscribe(); matchingOrderEvent.event.__typename === BusEventType.Order
} ) {
}); resolve(matchingOrderEvent.event);
subRef.current?.unsubscribe();
}
});
});
}, },
[client] [client]
); );

View File

@ -132,10 +132,9 @@ export const useOrderSubmit = () => {
if (res) { if (res) {
const orderId = determineId(res.signature); const orderId = determineId(res.signature);
if (orderId) { if (orderId) {
waitForOrderEvent(orderId, pubKey, (order) => { const order = await waitForOrderEvent(orderId, pubKey);
setFinalizedOrder(order); setFinalizedOrder(order);
setComplete(); setComplete();
});
} }
} }
} catch (e) { } catch (e) {

View File

@ -0,0 +1,22 @@
import { t } from '@vegaprotocol/react-helpers';
import { OrderTimeInForce } from '@vegaprotocol/types';
// More detail in https://docs.vega.xyz/docs/mainnet/graphql/enums/order-time-in-force
export const timeInForceLabel = (tif: string) => {
switch (tif) {
case OrderTimeInForce.TIME_IN_FORCE_GTC:
return t(`Good 'til Cancelled (GTC)`);
case OrderTimeInForce.TIME_IN_FORCE_IOC:
return t('Immediate or Cancel (IOC)');
case OrderTimeInForce.TIME_IN_FORCE_FOK:
return t('Fill or Kill (FOK)');
case OrderTimeInForce.TIME_IN_FORCE_GTT:
return t(`Good 'til Time (GTT)`);
case OrderTimeInForce.TIME_IN_FORCE_GFN:
return t('Good for Normal (GFN)');
case OrderTimeInForce.TIME_IN_FORCE_GFA:
return t('Good for Auction (GFA)');
default:
return t(tif);
}
};

View File

@ -2,13 +2,8 @@
export default { export default {
displayName: 'positions', displayName: 'positions',
preset: '../../jest.preset.js', preset: '../../jest.preset.js',
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json',
},
},
transform: { transform: {
'^.+\\.[tj]sx?$': 'ts-jest', '^.+\\.[tj]sx?$': 'babel-jest',
}, },
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
coverageDirectory: '../../coverage/libs/positions', coverageDirectory: '../../coverage/libs/positions',

View File

@ -3,6 +3,5 @@ export * from './lib/positions-container';
export * from './lib/positions-data-providers'; export * from './lib/positions-data-providers';
export * from './lib/positions-table'; export * from './lib/positions-table';
export * from './lib/use-close-position'; export * from './lib/use-close-position';
export * from './lib/use-position-event';
export * from './lib/use-positions-data'; export * from './lib/use-positions-data';
export * from './lib/use-positions-assets'; export * from './lib/use-positions-assets';

View File

@ -0,0 +1,108 @@
import { useEnvironment } from '@vegaprotocol/environment';
import type { OrderEvent_busEvents_event_Order } from '@vegaprotocol/orders';
import { t, truncateByChars } from '@vegaprotocol/react-helpers';
import { OrderRejectionReasonMapping, OrderStatus } from '@vegaprotocol/types';
import { Link } from '@vegaprotocol/ui-toolkit';
import type { TransactionResult, VegaTxState } from '@vegaprotocol/wallet';
import type { ClosingOrder as IClosingOrder } from '../use-close-position';
import { useRequestClosePositionData } from '../use-request-close-position-data';
import { ClosingOrder } from './shared';
interface CompleteProps {
partyId: string;
transaction: VegaTxState;
transactionResult?: TransactionResult;
closingOrder?: IClosingOrder;
closingOrderResult?: OrderEvent_busEvents_event_Order;
}
export const Complete = ({
partyId,
transaction,
transactionResult,
closingOrder,
closingOrderResult,
}: CompleteProps) => {
const { VEGA_EXPLORER_URL } = useEnvironment();
if (!transactionResult || !closingOrderResult) return null;
return (
<>
{closingOrderResult.status === OrderStatus.STATUS_FILLED &&
transactionResult.status ? (
<Success partyId={partyId} order={closingOrder} />
) : (
<Error
transactionResult={transactionResult}
closingOrderResult={closingOrderResult}
/>
)}
{transaction.txHash && (
<>
<p className="font-semibold mt-4">{t('Transaction')}</p>
<p>
<Link
href={`${VEGA_EXPLORER_URL}/txs/${transaction.txHash}`}
target="_blank"
>
{truncateByChars(transaction.txHash)}
</Link>
</p>
</>
)}
</>
);
};
const Success = ({
partyId,
order,
}: {
partyId: string;
order?: IClosingOrder;
}) => {
const { market, marketData, orders } = useRequestClosePositionData(
order?.marketId,
partyId
);
if (!market || !marketData || !orders) {
return <div>{t('Loading...')}</div>;
}
if (!order) {
return (
<div className="text-vega-red">{t('Could retrieve closing order')}</div>
);
}
return (
<>
<h2 className="font-bold">{t('Position closed')}</h2>
<ClosingOrder order={order} market={market} marketData={marketData} />
</>
);
};
const Error = ({
transactionResult,
closingOrderResult,
}: {
transactionResult: TransactionResult;
closingOrderResult: OrderEvent_busEvents_event_Order;
}) => {
const reason =
closingOrderResult.rejectionReason &&
OrderRejectionReasonMapping[closingOrderResult.rejectionReason];
return (
<div className="text-vega-red">
{reason ? (
<p>{reason}</p>
) : (
<p>
{t('Transaction failed')}: {transactionResult.error}
</p>
)}
</div>
);
};

View File

@ -0,0 +1,101 @@
import { render, screen, within } from '@testing-library/react';
import { OrderTimeInForce, OrderType, Side } from '@vegaprotocol/types';
import * as dataHook from '../use-request-close-position-data';
import { Requested } from './requested';
jest.mock('./use-request-close-position-data');
describe('Close position dialog - Request', () => {
const props = {
partyId: 'party-id',
order: {
marketId: 'market-id',
type: OrderType.TYPE_MARKET as const,
timeInForce: OrderTimeInForce.TIME_IN_FORCE_FOK as const,
side: Side.SIDE_BUY,
size: '10',
},
};
it('loading state', async () => {
jest.spyOn(dataHook, 'useRequestClosePositionData').mockReturnValue({
market: null,
marketData: null,
orders: [],
});
render(<Requested {...props} />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('renders message if no closing order found', async () => {
const orders = [
{
size: '200',
price: '999',
side: Side.SIDE_BUY,
timeInForce: OrderTimeInForce.TIME_IN_FORCE_GTC,
},
{
size: '300',
price: '888',
side: Side.SIDE_SELL,
timeInForce: OrderTimeInForce.TIME_IN_FORCE_GTC,
},
];
jest.spyOn(dataHook, 'useRequestClosePositionData').mockReturnValue({
market: {
decimalPlaces: 2,
positionDecimalPlaces: 2,
tradableInstrument: {
instrument: {
name: 'test market',
product: {
// @ts-ignore avoiding having to add every property on the type
settlementAsset: {
symbol: 'SYM',
},
},
},
},
},
// @ts-ignore avoid all fields
marketData: {
markPrice: '100',
},
// @ts-ignore avoid all fields
orders,
});
render(<Requested {...props} />);
// closing order
const closingOrderHeader = screen.getByText('Position to be closed');
const closingOrderTable = within(
closingOrderHeader.nextElementSibling?.querySelector(
'tbody'
) as HTMLElement
);
const closingOrderRow = closingOrderTable.getAllByRole('row');
expect(closingOrderRow[0].children[0]).toHaveTextContent('test market');
expect(closingOrderRow[0].children[1]).toHaveTextContent('+0.10');
expect(closingOrderRow[0].children[2]).toHaveTextContent('~1.00 SYM');
// orders
const ordersHeading = screen.getByText('Orders to be closed');
const ordersTable = within(
ordersHeading.nextElementSibling?.querySelector('tbody') as HTMLElement
);
const orderRows = ordersTable.getAllByRole('row');
expect(orderRows).toHaveLength(orders.length);
expect(orderRows[0].children[0]).toHaveTextContent('+2.00');
expect(orderRows[0].children[1]).toHaveTextContent('9.99 SYM');
expect(orderRows[0].children[2]).toHaveTextContent(
"Good 'til Cancelled (GTC)"
);
expect(orderRows[1].children[0]).toHaveTextContent('-3.00');
expect(orderRows[1].children[1]).toHaveTextContent('8.88 SYM');
expect(orderRows[1].children[2]).toHaveTextContent(
"Good 'til Cancelled (GTC)"
);
});
});

View File

@ -0,0 +1,35 @@
import { t } from '@vegaprotocol/react-helpers';
import type { ClosingOrder as IClosingOrder } from '../use-close-position';
import { useRequestClosePositionData } from '../use-request-close-position-data';
import { ActiveOrders, ClosingOrder } from './shared';
export const Requested = ({
order,
partyId,
}: {
order?: IClosingOrder;
partyId: string;
}) => {
const { market, marketData, orders, loading } = useRequestClosePositionData(
order?.marketId,
partyId
);
if (loading || !market || !marketData || !orders) {
return <div>{t('Loading...')}</div>;
}
if (!order) {
return (
<div className="text-vega-red">{t('Could not create closing order')}</div>
);
}
return (
<>
<h2 className="font-bold">{t('Position to be closed')}</h2>
<ClosingOrder order={order} market={market} marketData={marketData} />
<ActiveOrders market={market} orders={orders} />
</>
);
};

View File

@ -0,0 +1,115 @@
import type {
MarketDataFieldsFragment,
SingleMarketFieldsFragment,
} from '@vegaprotocol/market-list';
import type { Order } from '@vegaprotocol/orders';
import { timeInForceLabel } from '@vegaprotocol/orders';
import { addDecimalsFormatNumber, Size, t } from '@vegaprotocol/react-helpers';
import type { ReactNode } from 'react';
import type { ClosingOrder as IClosingOrder } from '../use-close-position';
export const ClosingOrder = ({
order,
market,
marketData,
}: {
order: IClosingOrder;
market: SingleMarketFieldsFragment;
marketData: MarketDataFieldsFragment;
}) => {
const asset = market.tradableInstrument.instrument.product.settlementAsset;
const estimatedPrice =
marketData && market
? addDecimalsFormatNumber(marketData.markPrice, market.decimalPlaces)
: '-';
const size = market ? (
<Size
value={order.size}
side={order.side}
positionDecimalPlaces={market.positionDecimalPlaces}
/>
) : (
'-'
);
return (
<BasicTable
headers={[t('Market'), t('Amount'), t('Est price')]}
rows={[
[
market.tradableInstrument.instrument.name,
size,
`~${estimatedPrice} ${asset?.symbol}`,
],
]}
/>
);
};
export const ActiveOrders = ({
market,
orders,
}: {
market: SingleMarketFieldsFragment;
orders: Order[];
}) => {
const asset = market.tradableInstrument.instrument.product.settlementAsset;
if (!orders.length) {
return null;
}
return (
<div className="mt-4">
<h2 className="font-bold">{t('Orders to be closed')}</h2>
<BasicTable
headers={[t('Amount'), t('Target price'), t('Time in force')]}
rows={orders.map((o) => {
return [
<Size
value={o.size}
side={o.side}
positionDecimalPlaces={market.positionDecimalPlaces}
/>,
`${addDecimalsFormatNumber(o.price, market.decimalPlaces)} ${
asset.symbol
}`,
timeInForceLabel(o.timeInForce),
];
})}
/>
</div>
);
};
interface BasicTableProps {
headers: ReactNode[];
rows: ReactNode[][];
}
const BasicTable = ({ headers, rows }: BasicTableProps) => {
return (
<table className="w-full">
<thead>
<tr>
{headers.map((h, i) => (
<th key={i} className="text-left font-medium">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((cells, i) => (
<tr key={i}>
{cells.map((c, i) => (
<td key={i} className="align-top">
{c}
</td>
))}
</tr>
))}
</tbody>
</table>
);
};

View File

@ -1,24 +1,28 @@
import { useCallback, useRef } from 'react'; import { useRef } from 'react';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit'; import { AsyncRenderer, Icon, Intent } from '@vegaprotocol/ui-toolkit';
import type { Position } from './positions-data-providers'; import { useClosePosition, usePositionsData, PositionsTable } from '../';
import { PositionsTable, useClosePosition, usePositionsData } from '../';
import type { AgGridReact } from 'ag-grid-react'; import type { AgGridReact } from 'ag-grid-react';
import { Requested } from './close-position-dialog/requested';
import { Complete } from './close-position-dialog/complete';
import type { TransactionResult } from '@vegaprotocol/wallet';
import { t } from '@vegaprotocol/react-helpers';
interface PositionsManagerProps { interface PositionsManagerProps {
partyId: string; partyId: string;
} }
export const PositionsManager = ({ partyId }: PositionsManagerProps) => { export const PositionsManager = ({ partyId }: PositionsManagerProps) => {
const { submit, Dialog } = useClosePosition();
const onClose = useCallback(
(position: Position) => {
submit(position);
},
[submit]
);
const gridRef = useRef<AgGridReact | null>(null); const gridRef = useRef<AgGridReact | null>(null);
const { data, error, loading, getRows } = usePositionsData(partyId, gridRef); const { data, error, loading, getRows } = usePositionsData(partyId, gridRef);
const {
submit,
closingOrder,
closingOrderResult,
transaction,
transactionResult,
Dialog,
} = useClosePosition();
return ( return (
<> <>
<AsyncRenderer loading={loading} error={error} data={data}> <AsyncRenderer loading={loading} error={error} data={data}>
@ -29,13 +33,66 @@ export const PositionsManager = ({ partyId }: PositionsManagerProps) => {
rowModelType={data?.length ? 'infinite' : 'clientSide'} rowModelType={data?.length ? 'infinite' : 'clientSide'}
rowData={data?.length ? undefined : []} rowData={data?.length ? undefined : []}
datasource={{ getRows }} datasource={{ getRows }}
onClose={onClose} onClose={(position) => submit(position)}
/> />
</AsyncRenderer> </AsyncRenderer>
<Dialog
<Dialog> intent={getDialogIntent(transactionResult)}
<p>Your position was not closed! This is still not implemented.</p> icon={getDialogIcon(transactionResult)}
</Dialog> title={getDialogTitle(transactionResult)}
content={{
Requested: <Requested partyId={partyId} order={closingOrder} />,
Complete: (
<Complete
partyId={partyId}
closingOrder={closingOrder}
closingOrderResult={closingOrderResult}
transaction={transaction}
transactionResult={transactionResult}
/>
),
}}
/>
</> </>
); );
}; };
const getDialogIntent = (transactionResult?: TransactionResult) => {
if (!transactionResult) {
return;
}
if (
transactionResult &&
'error' in transactionResult &&
transactionResult.error
) {
return Intent.Danger;
}
return Intent.Success;
};
const getDialogIcon = (transactionResult?: TransactionResult) => {
if (!transactionResult) {
return;
}
if (transactionResult.status) {
return <Icon name="tick" />;
}
return <Icon name="error" />;
};
const getDialogTitle = (transactionResult?: TransactionResult) => {
if (!transactionResult) {
return;
}
if (transactionResult.status) {
return t('Position closed');
}
return t('Position not closed');
};

View File

@ -0,0 +1,233 @@
import type { ReactNode } from 'react';
import type { MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing';
import { renderHook, waitFor } from '@testing-library/react';
import {
BusEventType,
OrderStatus,
OrderTimeInForce,
OrderType,
Schema as Types,
Side,
} from '@vegaprotocol/types';
import { useClosePosition } from './use-close-position';
import { VegaTxStatus, VegaWalletContext } from '@vegaprotocol/wallet';
import type { VegaWalletContextShape } from '@vegaprotocol/wallet';
import { initialState } from '@vegaprotocol/wallet';
import type { TransactionEventSubscription } from '@vegaprotocol/wallet';
import { TransactionEventDocument } from '@vegaprotocol/wallet';
import { act } from 'react-dom/test-utils';
import type { OrderEvent } from '@vegaprotocol/orders';
import { ORDER_EVENT_SUB } from '@vegaprotocol/orders';
const pubKey = 'test-pubkey';
const defaultWalletContext = {
pubKey,
pubKeys: [{ publicKey: pubKey, name: 'test pubkey' }],
sendTx: jest.fn().mockReturnValue(Promise.resolve(null)),
connect: jest.fn(),
disconnect: jest.fn(),
selectPubKey: jest.fn(),
connector: null,
};
const txResult = {
__typename: 'TransactionResult',
partyId: pubKey,
hash: '0x123',
status: true,
error: null,
};
function setup(context?: Partial<VegaWalletContextShape>) {
const mockTransactionResult: MockedResponse<TransactionEventSubscription> = {
request: {
query: TransactionEventDocument,
variables: {
partyId: context?.pubKey || '',
},
},
result: {
data: {
busEvents: [
{
type: Types.BusEventType.TransactionResult,
event: txResult,
__typename: 'BusEvent',
},
] as TransactionEventSubscription['busEvents'],
},
},
};
const mockOrderResult: MockedResponse<OrderEvent> = {
request: {
query: ORDER_EVENT_SUB,
variables: {
partyId: context?.pubKey || '',
},
},
result: {
data: {
busEvents: [
{
type: BusEventType.Order,
event: {
type: OrderType.TYPE_LIMIT,
id: '2fca514cebf9f465ae31ecb4c5721e3a6f5f260425ded887ca50ba15b81a5d50',
status: OrderStatus.STATUS_ACTIVE,
rejectionReason: null,
createdAt: '2022-07-05T14:25:47.815283706Z',
expiresAt: '2022-07-05T14:25:47.815283706Z',
size: '10',
price: '300000',
timeInForce: OrderTimeInForce.TIME_IN_FORCE_GTC,
side: Side.SIDE_BUY,
market: {
id: 'market-id',
decimalPlaces: 5,
positionDecimalPlaces: 0,
tradableInstrument: {
__typename: 'TradableInstrument',
instrument: {
name: 'UNIDAI Monthly (30 Jun 2022)',
__typename: 'Instrument',
},
},
__typename: 'Market',
},
__typename: 'Order',
},
__typename: 'BusEvent',
},
],
},
},
};
const wrapper = ({ children }: { children: ReactNode }) => (
<MockedProvider mocks={[mockTransactionResult, mockOrderResult]}>
<VegaWalletContext.Provider
value={{ ...defaultWalletContext, ...context }}
>
{children}
</VegaWalletContext.Provider>
</MockedProvider>
);
return renderHook(() => useClosePosition(), { wrapper });
}
describe('useClosePosition', () => {
const txResponse = {
signature:
'cfe592d169f87d0671dd447751036d0dddc165b9c4b65e5a5060e2bbadd1aa726d4cbe9d3c3b327bcb0bff4f83999592619a2493f9bbd251fae99ce7ce766909',
transactionHash: '0x123',
};
it('doesnt send the tx if there is no open volume', () => {
const mockSend = jest.fn();
const { result } = setup({ sendTx: mockSend });
expect(result.current).toEqual({
submit: expect.any(Function),
transaction: initialState,
Dialog: expect.any(Function),
});
result.current.submit({ marketId: 'test-market', openVolume: '0' });
expect(mockSend).not.toBeCalled();
expect(result.current.transaction.status).toEqual(VegaTxStatus.Default);
});
it('doesnt send the tx if there is no pubkey', () => {
const mockSend = jest.fn();
const { result } = setup({ sendTx: mockSend, pubKey: null });
result.current.submit({ marketId: 'test-market', openVolume: '1000' });
expect(mockSend).not.toBeCalled();
expect(result.current.transaction.status).toEqual(VegaTxStatus.Default);
});
it('closes long positions', async () => {
const marketId = 'test-market';
const openVolume = '1000';
const mockSend = jest.fn().mockResolvedValue(txResponse);
const { result } = setup({ sendTx: mockSend, pubKey });
act(() => {
result.current.submit({ marketId, openVolume });
});
expect(mockSend).toBeCalledWith(defaultWalletContext.pubKey, {
batchMarketInstructions: {
cancellations: [
{
marketId,
orderId: '',
},
],
submissions: [
{
marketId,
type: OrderType.TYPE_MARKET,
timeInForce: OrderTimeInForce.TIME_IN_FORCE_FOK,
side: Side.SIDE_SELL,
size: openVolume,
},
],
},
});
expect(result.current.transaction.status).toEqual(VegaTxStatus.Requested);
await waitFor(() => {
expect(result.current.transaction).toEqual({
status: VegaTxStatus.Complete,
signature: txResponse.signature,
txHash: txResponse.transactionHash,
dialogOpen: true,
error: null,
});
expect(result.current.transactionResult).toEqual(txResult);
});
});
it('closes short positions', async () => {
const marketId = 'test-market';
const openVolume = '-1000';
const mockSend = jest.fn().mockResolvedValue(txResponse);
const { result } = setup({ sendTx: mockSend, pubKey });
act(() => {
result.current.submit({ marketId, openVolume });
});
expect(mockSend).toBeCalledWith(defaultWalletContext.pubKey, {
batchMarketInstructions: {
cancellations: [
{
marketId,
orderId: '',
},
],
submissions: [
{
marketId,
type: OrderType.TYPE_MARKET,
timeInForce: OrderTimeInForce.TIME_IN_FORCE_FOK,
side: Side.SIDE_BUY,
size: openVolume.replace('-', ''),
},
],
},
});
expect(result.current.transaction.status).toEqual(VegaTxStatus.Requested);
await waitFor(() => {
expect(result.current.transaction).toEqual({
status: VegaTxStatus.Complete,
signature: txResponse.signature,
txHash: txResponse.transactionHash,
dialogOpen: true,
error: null,
});
expect(result.current.transactionResult).toEqual(txResult);
});
});
});

View File

@ -1,61 +1,105 @@
import { useCallback } from 'react'; import { useCallback, useState } from 'react';
import { useVegaWallet } from '@vegaprotocol/wallet'; import type { TransactionResult } from '@vegaprotocol/wallet';
import { useVegaTransaction, determineId } from '@vegaprotocol/wallet'; import { determineId } from '@vegaprotocol/wallet';
import { useVegaWallet, useTransactionResult } from '@vegaprotocol/wallet';
import { useVegaTransaction } from '@vegaprotocol/wallet';
import * as Sentry from '@sentry/react'; import * as Sentry from '@sentry/react';
import { usePositionEvent } from '../'; import { OrderTimeInForce, OrderType, Side } from '@vegaprotocol/types';
import type { Position } from '../'; import { useOrderEvent } from '@vegaprotocol/orders';
import type { OrderEvent_busEvents_event_Order } from '@vegaprotocol/orders';
export interface ClosingOrder {
marketId: string;
type: OrderType.TYPE_MARKET;
timeInForce: OrderTimeInForce.TIME_IN_FORCE_FOK;
side: Side;
size: string;
}
export const useClosePosition = () => { export const useClosePosition = () => {
const { pubKey } = useVegaWallet(); const { pubKey } = useVegaWallet();
const { send, transaction, setComplete, Dialog } = useVegaTransaction();
const { const [closingOrder, setClosingOrder] = useState<ClosingOrder>();
send, const [closingOrderResult, setClosingOrderResult] =
transaction, useState<OrderEvent_busEvents_event_Order>();
reset: resetTransaction, const [transactionResult, setTransactionResult] =
setComplete, useState<TransactionResult>();
Dialog, const waitForTransactionResult = useTransactionResult();
} = useVegaTransaction(); const waitForOrder = useOrderEvent(transaction);
const waitForPositionEvent = usePositionEvent(transaction);
const reset = useCallback(() => {
resetTransaction();
}, [resetTransaction]);
const submit = useCallback( const submit = useCallback(
async (position: Position) => { async ({
if (!pubKey || position.openVolume === '0') { marketId,
openVolume,
}: {
marketId: string;
openVolume: string;
}) => {
if (!pubKey || openVolume === '0') {
return; return;
} }
try { setTransactionResult(undefined);
const res = await send(pubKey, { setClosingOrder(undefined);
orderCancellation: {
marketId: position.marketId,
orderId: '',
},
});
if (res?.signature) { try {
const resId = determineId(res.signature); // figure out if opsition is long or short and make side the opposite
if (resId) { const side = openVolume.startsWith('-')
waitForPositionEvent(resId, pubKey, () => { ? Side.SIDE_BUY
setComplete(); : Side.SIDE_SELL;
});
} // volume could be prefixed with '-' if position is short, remove it
const size = openVolume.replace('-', '');
const closingOrder = {
marketId: marketId,
type: OrderType.TYPE_MARKET as const,
timeInForce: OrderTimeInForce.TIME_IN_FORCE_FOK as const,
side,
size,
};
setClosingOrder(closingOrder);
const command = {
batchMarketInstructions: {
cancellations: [
{
marketId,
orderId: '', // omit order id to cancel all active orders
},
],
submissions: [closingOrder],
},
};
const res = await send(pubKey, command);
if (res) {
const orderId = determineId(res.signature);
const [txResult, orderResult] = await Promise.all([
waitForTransactionResult(res.transactionHash, pubKey),
waitForOrder(orderId, pubKey),
]);
setTransactionResult(txResult);
setClosingOrderResult(orderResult);
setComplete();
} }
return res; return res;
} catch (e) { } catch (e) {
Sentry.captureException(e); Sentry.captureException(e);
return; return;
} }
}, },
[pubKey, send, setComplete, waitForPositionEvent] [pubKey, send, setComplete, waitForTransactionResult, waitForOrder]
); );
return { return {
transaction, transaction,
Dialog, transactionResult,
submit, submit,
reset, closingOrder,
closingOrderResult,
Dialog,
}; };
}; };

View File

@ -1,15 +0,0 @@
import type { VegaTxState } from '@vegaprotocol/wallet';
import { useCallback } from 'react';
// this should be replaced by implementation of busEvents listener when it will be available
export const usePositionEvent = (transaction: VegaTxState) => {
const waitForOrderEvent = useCallback(
(id: string, partyId: string, callback: () => void) => {
Promise.resolve().then(() => {
callback();
});
},
[]
);
return waitForOrderEvent;
};

View File

@ -0,0 +1,58 @@
import { marketDataProvider, marketProvider } from '@vegaprotocol/market-list';
import { isOrderActive, ordersWithMarketProvider } from '@vegaprotocol/orders';
import { useDataProvider } from '@vegaprotocol/react-helpers';
import { useMemo } from 'react';
export const useRequestClosePositionData = (
marketId?: string,
partyId?: string
) => {
const marketVariables = useMemo(() => ({ marketId }), [marketId]);
const orderVariables = useMemo(() => ({ partyId }), [partyId]);
const { data: market, loading: marketLoading } = useDataProvider({
dataProvider: marketProvider,
variables: marketVariables,
skip: !marketId,
});
const { data: marketData, loading: marketDataLoading } = useDataProvider({
dataProvider: marketDataProvider,
variables: marketVariables,
});
const { data: orderData, loading: orderDataLoading } = useDataProvider({
dataProvider: ordersWithMarketProvider,
variables: orderVariables,
});
const orders = useMemo(() => {
if (!orderData || !market) return [];
return (
orderData
.filter((o) => {
// Filter out orders not on market for position
if (
!o ||
!o.node ||
!o.node.market ||
o.node.market.id !== market.id
) {
return false;
}
if (!isOrderActive(o.node.status)) {
return false;
}
return true;
})
// @ts-ignore o is never null as its been filtered out above
.map((o) => o.node)
);
}, [orderData, market]);
return {
market,
marketData,
orders,
loading: marketLoading || marketDataLoading || orderDataLoading,
};
};

View File

@ -2,22 +2,19 @@
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "../../dist/out-tsc", "outDir": "../../dist/out-tsc",
"types": ["node"] "module": "commonjs",
"types": ["jest", "node", "@testing-library/jest-dom"]
}, },
"files": [ "include": [
"../../node_modules/@nrwl/react/typings/cssmodule.d.ts",
"../../node_modules/@nrwl/next/typings/image.d.ts"
],
"exclude": [
"**/*.spec.ts",
"**/*.test.ts", "**/*.test.ts",
"**/*.spec.tsx", "**/*.spec.ts",
"**/*.test.tsx", "**/*.test.tsx",
"**/*.spec.js", "**/*.spec.tsx",
"**/*.test.js", "**/*.test.js",
"**/*.spec.jsx", "**/*.spec.js",
"**/*.test.jsx", "**/*.test.jsx",
"**/*.spec.jsx",
"**/*.d.ts",
"jest.config.ts" "jest.config.ts"
], ]
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]
} }

View File

@ -0,0 +1,13 @@
subscription TransactionEvent($partyId: ID!) {
busEvents(partyId: $partyId, batchSize: 0, types: [TransactionResult]) {
type
event {
... on TransactionResult {
partyId
hash
status
error
}
}
}
}

View File

@ -0,0 +1,51 @@
import { Schema as Types } from '@vegaprotocol/types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type TransactionEventSubscriptionVariables = Types.Exact<{
partyId: Types.Scalars['ID'];
}>;
export type TransactionEventSubscription = { __typename?: 'Subscription', busEvents?: Array<{ __typename?: 'BusEvent', type: Types.BusEventType, event: { __typename?: 'Account' } | { __typename?: 'Asset' } | { __typename?: 'AuctionEvent' } | { __typename?: 'Deposit' } | { __typename?: 'LiquidityProvision' } | { __typename?: 'LossSocialization' } | { __typename?: 'MarginLevels' } | { __typename?: 'Market' } | { __typename?: 'MarketData' } | { __typename?: 'MarketEvent' } | { __typename?: 'MarketTick' } | { __typename?: 'NodeSignature' } | { __typename?: 'OracleSpec' } | { __typename?: 'Order' } | { __typename?: 'Party' } | { __typename?: 'PositionResolution' } | { __typename?: 'Proposal' } | { __typename?: 'RiskFactor' } | { __typename?: 'SettleDistressed' } | { __typename?: 'SettlePosition' } | { __typename?: 'TimeUpdate' } | { __typename?: 'Trade' } | { __typename?: 'TransactionResult', partyId: string, hash: string, status: boolean, error?: string | null } | { __typename?: 'TransferResponses' } | { __typename?: 'Vote' } | { __typename?: 'Withdrawal' } }> | null };
export const TransactionEventDocument = gql`
subscription TransactionEvent($partyId: ID!) {
busEvents(partyId: $partyId, batchSize: 0, types: [TransactionResult]) {
type
event {
... on TransactionResult {
partyId
hash
status
error
}
}
}
}
`;
/**
* __useTransactionEventSubscription__
*
* To run a query within a React component, call `useTransactionEventSubscription` and pass it any options that fit your needs.
* When your component renders, `useTransactionEventSubscription` 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 subscription, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useTransactionEventSubscription({
* variables: {
* partyId: // value for 'partyId'
* },
* });
*/
export function useTransactionEventSubscription(baseOptions: Apollo.SubscriptionHookOptions<TransactionEventSubscription, TransactionEventSubscriptionVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useSubscription<TransactionEventSubscription, TransactionEventSubscriptionVariables>(TransactionEventDocument, options);
}
export type TransactionEventSubscriptionHookResult = ReturnType<typeof useTransactionEventSubscription>;
export type TransactionEventSubscriptionResult = Apollo.SubscriptionResult<TransactionEventSubscription>;

View File

@ -136,7 +136,7 @@ const Connecting = ({
</Center> </Center>
<p className="text-center"> <p className="text-center">
{t(`${window.location.host} now has access to your Wallet, however you don't {t(`${window.location.host} now has access to your Wallet, however you don't
have sufficient permissions to retrieve your public keys. Got to your wallet to approve new permissions.`)} have sufficient permissions to retrieve your public keys. Go to your wallet to approve new permissions.`)}
</p> </p>
</> </>
); );

View File

@ -20,36 +20,41 @@ export interface UndelegateSubmissionBody {
}; };
} }
interface OrderSubmission {
marketId: string;
reference?: string;
type: OrderType;
side: Side;
timeInForce: OrderTimeInForce;
size: string;
price?: string;
expiresAt?: string;
}
interface OrderCancellation {
orderId: string;
marketId: string;
}
interface OrderAmendment {
marketId: string;
orderId: string;
reference?: string;
timeInForce: OrderTimeInForce;
sizeDelta?: number;
price?: string;
expiresAt?: string;
}
export interface OrderSubmissionBody { export interface OrderSubmissionBody {
orderSubmission: { orderSubmission: OrderSubmission;
marketId: string;
reference?: string;
type: OrderType;
side: Side;
timeInForce: OrderTimeInForce;
size: string;
price?: string;
expiresAt?: string;
};
} }
export interface OrderCancellationBody { export interface OrderCancellationBody {
orderCancellation: { orderCancellation: OrderCancellation;
orderId: string;
marketId: string;
};
} }
export interface OrderAmendmentBody { export interface OrderAmendmentBody {
orderAmendment: { orderAmendment: OrderAmendment;
marketId: string;
orderId: string;
reference?: string;
timeInForce: OrderTimeInForce;
sizeDelta?: number;
price?: string;
expiresAt?: string;
};
} }
export interface VoteSubmissionBody { export interface VoteSubmissionBody {
@ -250,6 +255,18 @@ export interface ProposalSubmissionBody {
proposalSubmission: ProposalSubmission; proposalSubmission: ProposalSubmission;
} }
export interface BatchMarketInstructionSubmissionBody {
batchMarketInstructions: {
// Will be processed in this order and the total amount of instructions is
// restricted by the net param spam.protection.max.batchSize
cancellations?: OrderCancellation[];
amendments?: OrderAmendment[];
// Note: If multiple orders are submitted the first order ID is determined by hashing the signature of the transaction
// (see determineId function). For each subsequent order's ID, a hash of the previous orders ID is used
submissions?: OrderSubmission[];
};
}
export type Transaction = export type Transaction =
| OrderSubmissionBody | OrderSubmissionBody
| OrderCancellationBody | OrderCancellationBody
@ -258,7 +275,8 @@ export type Transaction =
| DelegateSubmissionBody | DelegateSubmissionBody
| UndelegateSubmissionBody | UndelegateSubmissionBody
| OrderAmendmentBody | OrderAmendmentBody
| ProposalSubmissionBody; | ProposalSubmissionBody
| BatchMarketInstructionSubmissionBody;
export interface TransactionResponse { export interface TransactionResponse {
transactionHash: string; transactionHash: string;

View File

@ -2,6 +2,7 @@ export * from './context';
export * from './use-vega-wallet'; export * from './use-vega-wallet';
export * from './connectors'; export * from './connectors';
export * from './use-vega-transaction'; export * from './use-vega-transaction';
export * from './use-transaction-result';
export * from './use-eager-connect'; export * from './use-eager-connect';
export * from './manage-dialog'; export * from './manage-dialog';
export * from './vega-transaction-dialog'; export * from './vega-transaction-dialog';
@ -9,3 +10,4 @@ export * from './provider';
export * from './connect-dialog'; export * from './connect-dialog';
export * from './utils'; export * from './utils';
export * from './constants'; export * from './constants';
export * from './__generated___/TransactionResult';

View File

@ -0,0 +1,57 @@
import type { ReactNode } from 'react';
import type { MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing';
import { renderHook } from '@testing-library/react';
import { Schema as Types } from '@vegaprotocol/types';
import type { TransactionEventSubscription } from './__generated___/TransactionResult';
import { TransactionEventDocument } from './__generated___/TransactionResult';
import { useTransactionResult } from './use-transaction-result';
const pubKey = 'test-pubkey';
const txHash = '0x123';
const event = {
__typename: 'TransactionResult',
partyId: pubKey,
hash: txHash,
status: true,
error: null,
};
function setup(mock: MockedResponse) {
const wrapper = ({ children }: { children: ReactNode }) => (
<MockedProvider mocks={[mock]}>{children}</MockedProvider>
);
return renderHook(() => useTransactionResult(), { wrapper });
}
describe('useTransactionResult', () => {
it('resolves when a matching txhash is found', async () => {
const mock: MockedResponse<TransactionEventSubscription> = {
request: {
query: TransactionEventDocument,
variables: {
partyId: pubKey,
},
},
result: {
data: {
busEvents: [
{
type: Types.BusEventType.TransactionResult,
event,
__typename: 'BusEvent',
},
] as TransactionEventSubscription['busEvents'],
},
},
};
const { result } = setup(mock);
expect(result.current).toEqual(expect.any(Function));
const promi = result.current(txHash, pubKey);
expect(typeof promi === 'object' && typeof promi.then === 'function').toBe(
true
);
const res = await promi;
expect(res).toEqual(event);
});
});

View File

@ -0,0 +1,74 @@
import { useApolloClient } from '@apollo/client';
import { BusEventType } from '@vegaprotocol/types';
import { useCallback, useEffect, useRef } from 'react';
import type { Subscription } from 'zen-observable-ts';
import type {
TransactionEventSubscription,
TransactionEventSubscriptionVariables,
} from './__generated___/TransactionResult';
import { TransactionEventDocument } from './__generated___/TransactionResult';
export interface TransactionResult {
partyId: string;
hash: string;
status: boolean;
error?: string | null;
__typename: 'TransactionResult';
}
type WaitFunc = (txHash: string, partyId: string) => Promise<TransactionResult>;
/**
* Returns a function that can be called to subscribe to a transaction
* result event and resolves when an event with a matching txhash is seen
*/
export const useTransactionResult = () => {
const client = useApolloClient();
const subRef = useRef<Subscription | null>(null);
const waitForTransactionResult = useCallback<WaitFunc>(
(txHash: string, partyId: string) => {
return new Promise((resolve) => {
subRef.current = client
.subscribe<
TransactionEventSubscription,
TransactionEventSubscriptionVariables
>({
query: TransactionEventDocument,
variables: { partyId },
})
.subscribe(({ data }) => {
if (!data?.busEvents?.length) {
return;
}
const matchingTransaction = data.busEvents.find((e) => {
if (e.event.__typename !== BusEventType.TransactionResult) {
return false;
}
return e.event.hash.toLocaleLowerCase() === txHash.toLowerCase();
});
if (
matchingTransaction &&
matchingTransaction.event.__typename ===
BusEventType.TransactionResult
) {
resolve(matchingTransaction.event as TransactionResult);
subRef.current?.unsubscribe();
}
});
});
},
[client]
);
useEffect(() => {
return () => {
subRef.current?.unsubscribe();
};
}, []);
return waitForTransactionResult;
};

View File

@ -1,6 +1,7 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { useVegaWallet } from './use-vega-wallet'; import { useVegaWallet } from './use-vega-wallet';
import type { VegaTransactionContentMap } from './vega-transaction-dialog';
import { VegaTransactionDialog } from './vega-transaction-dialog'; import { VegaTransactionDialog } from './vega-transaction-dialog';
import type { Intent } from '@vegaprotocol/ui-toolkit'; import type { Intent } from '@vegaprotocol/ui-toolkit';
import type { Transaction } from './connectors'; import type { Transaction } from './connectors';
@ -8,10 +9,10 @@ import { ClientErrors } from './connectors';
import { WalletError } from './connectors'; import { WalletError } from './connectors';
export interface DialogProps { export interface DialogProps {
children?: JSX.Element;
intent?: Intent; intent?: Intent;
title?: string; title?: string;
icon?: ReactNode; icon?: ReactNode;
content?: VegaTransactionContentMap;
} }
export enum VegaTxStatus { export enum VegaTxStatus {

View File

@ -96,25 +96,30 @@ describe('VegaTransactionDialog', () => {
testBlockExplorerLink('tx-hash'); testBlockExplorerLink('tx-hash');
}); });
it('custom complete', () => { it.each(Object.keys(VegaTxStatus))(
render( 'renders custom content for %s',
<VegaTransactionDialog (status) => {
{...props} const title = `${status} title`;
transaction={{ const text = `${status} content`;
...props.transaction, const content = {
txHash: 'tx-hash', [status]: <div>{text}</div>,
status: VegaTxStatus.Complete, };
}} render(
title="Custom title" <VegaTransactionDialog
> {...props}
<div>Custom content</div> transaction={{
</VegaTransactionDialog> ...props.transaction,
); txHash: 'tx-hash',
expect(screen.getByTestId('dialog-title')).toHaveTextContent( status: status as VegaTxStatus,
'Custom title' }}
); title={title}
expect(screen.getByText('Custom content')).toBeInTheDocument(); content={content}
}); />
);
expect(screen.getByTestId('dialog-title')).toHaveTextContent(title);
expect(screen.getByText(text)).toBeInTheDocument();
}
);
function testBlockExplorerLink(txHash: string) { function testBlockExplorerLink(txHash: string) {
expect(screen.getByTestId('tx-block-explorer')).toHaveTextContent( expect(screen.getByTestId('tx-block-explorer')).toHaveTextContent(

View File

@ -5,37 +5,32 @@ 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';
export type VegaTransactionContentMap = {
[C in VegaTxStatus]?: JSX.Element;
};
export interface VegaTransactionDialogProps { export interface VegaTransactionDialogProps {
isOpen: boolean; isOpen: boolean;
onChange: (isOpen: boolean) => void; onChange: (isOpen: boolean) => void;
transaction: VegaTxState; transaction: VegaTxState;
children?: ReactNode;
intent?: Intent; intent?: Intent;
title?: string; title?: string;
icon?: ReactNode; icon?: ReactNode;
content?: VegaTransactionContentMap;
} }
export const VegaTransactionDialog = ({ export const VegaTransactionDialog = ({
isOpen, isOpen,
onChange, onChange,
transaction, transaction,
children,
intent, intent,
title, title,
icon, icon,
content,
}: VegaTransactionDialogProps) => { }: VegaTransactionDialogProps) => {
const computedIntent = intent ? intent : getIntent(transaction); const computedIntent = intent ? intent : getIntent(transaction);
const computedTitle = title ? title : getTitle(transaction); const computedTitle = title ? title : getTitle(transaction);
const computedIcon = icon ? icon : getIcon(transaction); const computedIcon = icon ? icon : getIcon(transaction);
// Each dialog can specify custom dialog content using data returned via
// the subscription that confirms the transaction. So if we get a success state
// and this custom content is provided, render it
const content =
transaction.status === VegaTxStatus.Complete && children ? (
children
) : (
<VegaDialog transaction={transaction} />
);
return ( return (
<Dialog <Dialog
open={isOpen} open={isOpen}
@ -45,10 +40,42 @@ export const VegaTransactionDialog = ({
icon={computedIcon} icon={computedIcon}
size="small" size="small"
> >
{content} <Content transaction={transaction} content={content} />
</Dialog> </Dialog>
); );
}; };
interface ContentProps {
transaction: VegaTxState;
content?: VegaTransactionContentMap;
}
const Content = ({ transaction, content }: ContentProps) => {
if (!content) {
return <VegaDialog transaction={transaction} />;
}
if (transaction.status === VegaTxStatus.Default && content.Default) {
return content.Default;
}
if (transaction.status === VegaTxStatus.Requested && content.Requested) {
return content.Requested;
}
if (transaction.status === VegaTxStatus.Pending && content.Pending) {
return content.Pending;
}
if (transaction.status === VegaTxStatus.Error && content.Error) {
return content.Error;
}
if (transaction.status === VegaTxStatus.Complete && content.Complete) {
return content.Complete;
}
return <VegaDialog transaction={transaction} />;
};
interface VegaDialogProps { interface VegaDialogProps {
transaction: VegaTxState; transaction: VegaTxState;

View File

@ -35,17 +35,21 @@ export const WithdrawalDialogs = ({
}} }}
/> />
</Dialog> </Dialog>
<createWithdraw.Dialog> <createWithdraw.Dialog
<WithdrawalFeedback content={{
withdrawal={createWithdraw.withdrawal} Complete: (
transaction={createWithdraw.transaction} <WithdrawalFeedback
availableTimestamp={createWithdraw.availableTimestamp} withdrawal={createWithdraw.withdrawal}
submitWithdraw={(id) => { transaction={createWithdraw.transaction}
createWithdraw.reset(); availableTimestamp={createWithdraw.availableTimestamp}
completeWithdraw.submit(id); submitWithdraw={(id) => {
}} createWithdraw.reset();
/> completeWithdraw.submit(id);
</createWithdraw.Dialog> }}
/>
),
}}
/>
<completeWithdraw.Dialog /> <completeWithdraw.Dialog />
</> </>
); );

2267
yarn.lock

File diff suppressed because it is too large Load Diff