Feat/1500 cancel and edit buttons conditional rendering (#1528)

* feat: allow second type arg to be generic as you dont always need the value

* feat: update order data provider to include fields required to determine if editable or cancellable

* feat: use ag grid type helpers and add rendering logic to cancel and amend buttons

* feat: combine cancel/edit buttons into single cell for better spacing

* feat: add test cases for dispaly of amend/cancel buttons

* chore: add missing fields to mock generate function

* chore: remove unnecessary wait for in fills test that was sporadically failing

* fix: add missing fields to generate order function

* fix: add missing fields to generate order function for console-lite
This commit is contained in:
Matthew Russell 2022-09-29 02:00:39 -07:00 committed by GitHub
parent 7a7c4ad452
commit 032805b5b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 299 additions and 132 deletions

View File

@ -31,6 +31,8 @@ export const generateOrders = (override?: PartialDeep<Orders>): Orders => {
updatedAt: null,
expiresAt: null,
rejectionReason: null,
liquidityProvision: null,
peggedOrder: null,
},
{
__typename: 'Order',
@ -50,6 +52,8 @@ export const generateOrders = (override?: PartialDeep<Orders>): Orders => {
updatedAt: null,
expiresAt: null,
rejectionReason: null,
liquidityProvision: null,
peggedOrder: null,
},
{
__typename: 'Order',
@ -69,6 +73,8 @@ export const generateOrders = (override?: PartialDeep<Orders>): Orders => {
updatedAt: null,
expiresAt: null,
rejectionReason: null,
liquidityProvision: null,
peggedOrder: null,
},
{
__typename: 'Order',
@ -88,6 +94,8 @@ export const generateOrders = (override?: PartialDeep<Orders>): Orders => {
updatedAt: null,
expiresAt: null,
rejectionReason: null,
liquidityProvision: null,
peggedOrder: null,
},
{
__typename: 'Order',
@ -106,6 +114,8 @@ export const generateOrders = (override?: PartialDeep<Orders>): Orders => {
createdAt: new Date(2020, 1, 27).toISOString(),
updatedAt: null,
expiresAt: null,
liquidityProvision: null,
peggedOrder: null,
rejectionReason: null,
},
];

View File

@ -31,6 +31,8 @@ export const generateOrders = (override?: PartialDeep<Orders>): Orders => {
updatedAt: null,
expiresAt: null,
rejectionReason: null,
liquidityProvision: null,
peggedOrder: null,
},
{
__typename: 'Order',
@ -50,6 +52,8 @@ export const generateOrders = (override?: PartialDeep<Orders>): Orders => {
updatedAt: null,
expiresAt: null,
rejectionReason: null,
liquidityProvision: null,
peggedOrder: null,
},
{
__typename: 'Order',
@ -69,6 +73,8 @@ export const generateOrders = (override?: PartialDeep<Orders>): Orders => {
updatedAt: null,
expiresAt: null,
rejectionReason: null,
liquidityProvision: null,
peggedOrder: null,
},
{
__typename: 'Order',
@ -88,6 +94,8 @@ export const generateOrders = (override?: PartialDeep<Orders>): Orders => {
updatedAt: null,
expiresAt: null,
rejectionReason: null,
liquidityProvision: null,
peggedOrder: null,
},
{
__typename: 'Order',
@ -107,6 +115,8 @@ export const generateOrders = (override?: PartialDeep<Orders>): Orders => {
updatedAt: null,
expiresAt: null,
rejectionReason: null,
liquidityProvision: null,
peggedOrder: null,
},
];

View File

@ -51,20 +51,18 @@ describe('FillsTable', () => {
await waitForGridToBeInTheDOM();
await waitForDataToHaveLoaded();
await waitFor(async () => {
await screen.findByText('Market');
const headers = screen.getAllByRole('columnheader');
expect(headers).toHaveLength(7);
expect(headers.map((h) => h.textContent?.trim())).toEqual([
'Market',
'Size',
'Value',
'Filled value',
'Role',
'Fee',
'Date',
]);
});
await screen.findByText('Market');
const headers = screen.getAllByRole('columnheader');
expect(headers).toHaveLength(7);
expect(headers.map((h) => h.textContent?.trim())).toEqual([
'Market',
'Size',
'Value',
'Filled value',
'Role',
'Fee',
'Date',
]);
});
it('formats cells correctly for buyer fill', async () => {
@ -83,6 +81,7 @@ describe('FillsTable', () => {
});
render(<FillsTable partyId={partyId} rowData={[buyerFill]} />);
await waitForGridToBeInTheDOM();
await waitForDataToHaveLoaded();

View File

@ -69,6 +69,8 @@ export const generateOrder = (partialOrder?: PartialDeep<OrderWithMarket>) => {
updatedAt: null,
expiresAt: null,
rejectionReason: null,
liquidityProvision: null,
peggedOrder: null,
};
return merge(order, partialOrder);
};

View File

@ -9,6 +9,10 @@ import { OrderType, Side, OrderStatus, OrderRejectionReason, OrderTimeInForce }
// GraphQL subscription operation: OrderSub
// ====================================================
export interface OrderSub_orders_peggedOrder {
__typename: "PeggedOrder";
}
export interface OrderSub_orders {
__typename: "OrderUpdate";
/**
@ -63,6 +67,14 @@ export interface OrderSub_orders {
* RFC3339Nano time the order was altered
*/
updatedAt: string | null;
/**
* The liquidity provision this order was created from
*/
liquidityProvisionId: string | null;
/**
* PeggedOrder contains the details about a pegged order
*/
peggedOrder: OrderSub_orders_peggedOrder | null;
}
export interface OrderSub {

View File

@ -17,6 +17,14 @@ export interface Orders_party_ordersConnection_edges_node_market {
id: string;
}
export interface Orders_party_ordersConnection_edges_node_liquidityProvision {
__typename: "LiquidityProvision";
}
export interface Orders_party_ordersConnection_edges_node_peggedOrder {
__typename: "PeggedOrder";
}
export interface Orders_party_ordersConnection_edges_node {
__typename: "Order";
/**
@ -71,6 +79,14 @@ export interface Orders_party_ordersConnection_edges_node {
* RFC3339Nano time the order was altered
*/
updatedAt: string | null;
/**
* The liquidity provision this order was created from
*/
liquidityProvision: Orders_party_ordersConnection_edges_node_liquidityProvision | null;
/**
* PeggedOrder contains the details about a pegged order
*/
peggedOrder: Orders_party_ordersConnection_edges_node_peggedOrder | null;
}
export interface Orders_party_ordersConnection_edges {

View File

@ -14,6 +14,7 @@ import type {
Orders,
Orders_party_ordersConnection_edges,
Orders_party_ordersConnection_edges_node,
Orders_party_ordersConnection_edges_node_liquidityProvision,
OrderSub,
OrderSub_orders,
} from '../';
@ -40,6 +41,12 @@ export const ORDERS_QUERY = gql`
expiresAt
createdAt
updatedAt
liquidityProvision {
__typename
}
peggedOrder {
__typename
}
}
cursor
}
@ -70,6 +77,10 @@ export const ORDERS_SUB = gql`
expiresAt
createdAt
updatedAt
liquidityProvisionId
peggedOrder {
__typename
}
}
}
`;
@ -98,10 +109,20 @@ export const update = (
draft.unshift(...draft.splice(index, 1));
}
} else if (newer) {
const { marketId, ...order } = node;
const { marketId, liquidityProvisionId, ...order } = node;
// If there is a liquidity provision id add the object to the resulting order
const liquidityProvision: Orders_party_ordersConnection_edges_node_liquidityProvision | null =
liquidityProvisionId
? {
__typename: 'LiquidityProvision',
}
: null;
draft.unshift({
node: {
...order,
liquidityProvision: liquidityProvision,
market: {
__typename: 'Market',
id: marketId,

View File

@ -1,6 +1,7 @@
import { act, render, screen } from '@testing-library/react';
import { act, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { addDecimal, getDateTimeFormat } from '@vegaprotocol/react-helpers';
import { OrderType } from '@vegaprotocol/types';
import { OrderTimeInForce, OrderType } from '@vegaprotocol/types';
import {
OrderRejectionReasonMapping,
OrderTimeInForceMapping,
@ -16,22 +17,28 @@ import type { VegaWalletContextShape } from '@vegaprotocol/wallet';
import { VegaWalletContext } from '@vegaprotocol/wallet';
import { MockedProvider } from '@apollo/client/testing';
import type { OrderListTableProps } from '../';
import { OrderListTable } from '../';
import type { OrderWithMarket } from '../';
import { limitOrder, marketOrder } from '../mocks/generate-orders';
import {
generateOrder,
limitOrder,
marketOrder,
} from '../mocks/generate-orders';
const defaultProps: OrderListTableProps = {
rowData: [],
setEditOrder: jest.fn(),
cancel: jest.fn(),
};
const generateJsx = (
orders: OrderWithMarket[] | null,
props: Partial<OrderListTableProps> = defaultProps,
context: PartialDeep<VegaWalletContextShape> = { keypair: { pub: '0x123' } }
) => {
return (
<MockedProvider>
<VegaWalletContext.Provider value={context as VegaWalletContextShape}>
<OrderListTable
rowData={orders}
cancel={jest.fn()}
setEditOrder={jest.fn()}
/>
<OrderListTable {...defaultProps} {...props} />
</VegaWalletContext.Provider>
</MockedProvider>
);
@ -40,19 +47,16 @@ const generateJsx = (
describe('OrderListTable', () => {
it('should show no orders message', async () => {
await act(async () => {
render(generateJsx([]));
render(generateJsx({ rowData: [] }));
});
expect(screen.getByText('No orders')).toBeInTheDocument();
});
it('should render correct columns', async () => {
await act(async () => {
render(generateJsx([marketOrder, limitOrder]));
render(generateJsx({ rowData: [marketOrder, limitOrder] }));
});
const headers = screen.getAllByRole('columnheader');
expect(headers).toHaveLength(11);
expect(headers.map((h) => h.textContent?.trim())).toEqual([
const expectedHeaders = [
'Market',
'Size',
'Type',
@ -62,14 +66,16 @@ describe('OrderListTable', () => {
'Time In Force',
'Created At',
'Updated At',
'Edit',
'Cancel',
]);
'', // no cell header for edit/cancel
];
const headers = screen.getAllByRole('columnheader');
expect(headers).toHaveLength(expectedHeaders.length);
expect(headers.map((h) => h.textContent?.trim())).toEqual(expectedHeaders);
});
it('should apply correct formatting for market order', async () => {
await act(async () => {
render(generateJsx([marketOrder]));
render(generateJsx({ rowData: [marketOrder] }));
});
const cells = screen.getAllByRole('gridcell');
@ -84,7 +90,6 @@ describe('OrderListTable', () => {
getDateTimeFormat().format(new Date(marketOrder.createdAt)),
'-',
'Edit',
'Cancel',
];
cells.forEach((cell, i) =>
expect(cell).toHaveTextContent(expectedValues[i])
@ -93,7 +98,7 @@ describe('OrderListTable', () => {
it('should apply correct formatting applied for GTT limit order', async () => {
await act(async () => {
render(generateJsx([limitOrder]));
render(generateJsx({ rowData: [limitOrder] }));
});
const cells = screen.getAllByRole('gridcell');
@ -110,7 +115,6 @@ describe('OrderListTable', () => {
getDateTimeFormat().format(new Date(limitOrder.createdAt)),
'-',
'Edit',
'Cancel',
];
cells.forEach((cell, i) =>
expect(cell).toHaveTextContent(expectedValues[i])
@ -125,7 +129,7 @@ describe('OrderListTable', () => {
OrderRejectionReason.ORDER_ERROR_INSUFFICIENT_ASSET_BALANCE,
};
await act(async () => {
render(generateJsx([rejectedOrder]));
render(generateJsx({ rowData: [rejectedOrder] }));
});
const cells = screen.getAllByRole('gridcell');
expect(cells[3]).toHaveTextContent(
@ -134,4 +138,108 @@ describe('OrderListTable', () => {
}`
);
});
describe('amend cell', () => {
it('allows cancelling and editing for permitted orders', async () => {
const mockEdit = jest.fn();
const mockCancel = jest.fn();
const order = generateOrder({
type: OrderType.TYPE_LIMIT,
timeInForce: OrderTimeInForce.TIME_IN_FORCE_GTC,
liquidityProvision: null,
peggedOrder: null,
});
await act(async () => {
render(
generateJsx({
rowData: [order],
setEditOrder: mockEdit,
cancel: mockCancel,
})
);
});
const amendCell = getAmendCell();
expect(amendCell.getAllByRole('button')).toHaveLength(2);
await userEvent.click(amendCell.getByTestId('edit'));
expect(mockEdit).toHaveBeenCalledWith(order);
await userEvent.click(amendCell.getByTestId('cancel'));
expect(mockCancel).toHaveBeenCalledWith(order);
});
it('doesnt show buttons for liquidity provision orders', async () => {
const order = generateOrder({
type: OrderType.TYPE_LIMIT,
timeInForce: OrderTimeInForce.TIME_IN_FORCE_GTC,
liquidityProvision: { __typename: 'LiquidityProvision' },
});
await act(async () => {
render(generateJsx({ rowData: [order] }));
});
const amendCell = getAmendCell();
expect(amendCell.queryAllByRole('button')).toHaveLength(0);
});
it('doesnt show buttons for pegged orders', async () => {
const order = generateOrder({
type: OrderType.TYPE_LIMIT,
timeInForce: OrderTimeInForce.TIME_IN_FORCE_GTC,
peggedOrder: {
__typename: 'PeggedOrder',
},
});
await act(async () => {
render(generateJsx({ rowData: [order] }));
});
const amendCell = getAmendCell();
expect(amendCell.queryAllByRole('button')).toHaveLength(0);
});
it.each([OrderStatus.STATUS_ACTIVE, OrderStatus.STATUS_PARKED])(
'shows buttons for %s orders',
async (status) => {
const order = generateOrder({
type: OrderType.TYPE_LIMIT,
status,
});
await act(async () => {
render(generateJsx({ rowData: [order] }));
});
const amendCell = getAmendCell();
expect(amendCell.getAllByRole('button')).toHaveLength(2);
}
);
it.each([
OrderStatus.STATUS_CANCELLED,
OrderStatus.STATUS_EXPIRED,
OrderStatus.STATUS_FILLED,
OrderStatus.STATUS_REJECTED,
OrderStatus.STATUS_STOPPED,
])('doesnt show buttons for %s orders', async (status) => {
const order = generateOrder({
type: OrderType.TYPE_LIMIT,
status,
});
await act(async () => {
render(generateJsx({ rowData: [order] }));
});
const amendCell = getAmendCell();
expect(amendCell.queryAllByRole('button')).toHaveLength(0);
});
const getAmendCell = () => {
const cells = screen.getAllByRole('gridcell');
return within(
cells.find((c) => c.getAttribute('col-id') === 'amend') as HTMLElement
);
};
});
});

View File

@ -15,19 +15,18 @@ import {
positiveClassNames,
negativeClassNames,
} from '@vegaprotocol/react-helpers';
import type {
VegaICellRendererParams,
VegaValueFormatterParams,
} from '@vegaprotocol/ui-toolkit';
import {
AgGridDynamic as AgGrid,
Button,
Intent,
} from '@vegaprotocol/ui-toolkit';
import type {
ICellRendererParams,
ValueFormatterParams,
} from 'ag-grid-community';
import type { AgGridReact, AgGridReactProps } from 'ag-grid-react';
import { AgGridColumn } from 'ag-grid-react';
import { forwardRef, useState } from 'react';
import type { Orders_party_ordersConnection_edges_node } from '../';
import BigNumber from 'bignumber.js';
import { useOrderCancel } from '../../order-hooks/use-order-cancel';
@ -93,14 +92,7 @@ export const OrderList = forwardRef<AgGridReact, OrderListProps>(
}
);
type OrderListTableValueFormatterParams = Omit<
ValueFormatterParams,
'data' | 'value'
> & {
data: OrderWithMarket | null;
};
type OrderListTableProps = AgGridReactProps & {
export type OrderListTableProps = AgGridReactProps & {
cancel: (order: OrderWithMarket) => void;
setEditOrder: (order: OrderWithMarket) => void;
};
@ -135,11 +127,9 @@ export const OrderListTable = forwardRef<AgGridReact, OrderListTableProps>(
valueFormatter={({
value,
data,
}: OrderListTableValueFormatterParams & {
value?: OrderWithMarket['size'];
}) => {
if (value === undefined || !data || !data.market) {
return undefined;
}: VegaValueFormatterParams<OrderWithMarket, 'size'>) => {
if (!data.market) {
return '-';
}
const prefix = data
? data.side === Side.SIDE_BUY
@ -155,21 +145,17 @@ export const OrderListTable = forwardRef<AgGridReact, OrderListTableProps>(
field="type"
valueFormatter={({
value,
}: ValueFormatterParams & {
value?: Orders_party_ordersConnection_edges_node['type'];
}) => OrderTypeMapping[value as OrderType]}
}: VegaValueFormatterParams<OrderWithMarket, 'type'>) => {
if (!value) return '-';
return OrderTypeMapping[value];
}}
/>
<AgGridColumn
field="status"
valueFormatter={({
value,
data,
}: OrderListTableValueFormatterParams & {
value?: Orders_party_ordersConnection_edges_node['status'];
}) => {
if (value === undefined || !data || !data.market) {
return undefined;
}
}: VegaValueFormatterParams<OrderWithMarket, 'status'>) => {
if (value === OrderStatus.STATUS_REJECTED) {
return `${OrderStatusMapping[value]}: ${
data.rejectionReason &&
@ -187,11 +173,9 @@ export const OrderListTable = forwardRef<AgGridReact, OrderListTableProps>(
valueFormatter={({
data,
value,
}: OrderListTableValueFormatterParams & {
value?: Orders_party_ordersConnection_edges_node['remaining'];
}) => {
if (value === undefined || !data || !data.market) {
return undefined;
}: VegaValueFormatterParams<OrderWithMarket, 'remaining'>) => {
if (!data.market) {
return '-';
}
const dps = data.market.positionDecimalPlaces;
const size = new BigNumber(data.size);
@ -210,15 +194,8 @@ export const OrderListTable = forwardRef<AgGridReact, OrderListTableProps>(
valueFormatter={({
value,
data,
}: OrderListTableValueFormatterParams & {
value?: Orders_party_ordersConnection_edges_node['price'];
}) => {
if (
value === undefined ||
!data ||
!data.market ||
data.type === OrderType.TYPE_MARKET
) {
}: VegaValueFormatterParams<OrderWithMarket, 'price'>) => {
if (!data.market || data.type === OrderType.TYPE_MARKET) {
return '-';
}
return addDecimal(value, data.market.decimalPlaces);
@ -229,12 +206,7 @@ export const OrderListTable = forwardRef<AgGridReact, OrderListTableProps>(
valueFormatter={({
value,
data,
}: OrderListTableValueFormatterParams & {
value?: Orders_party_ordersConnection_edges_node['timeInForce'];
}) => {
if (value === undefined || !data || !data.market) {
return undefined;
}
}: VegaValueFormatterParams<OrderWithMarket, 'timeInForce'>) => {
if (
value === OrderTimeInForce.TIME_IN_FORCE_GTT &&
data.expiresAt
@ -252,9 +224,7 @@ export const OrderListTable = forwardRef<AgGridReact, OrderListTableProps>(
field="createdAt"
valueFormatter={({
value,
}: OrderListTableValueFormatterParams & {
value?: Orders_party_ordersConnection_edges_node['createdAt'];
}) => {
}: VegaValueFormatterParams<OrderWithMarket, 'createdAt'>) => {
return value ? getDateTimeFormat().format(new Date(value)) : value;
}}
/>
@ -262,46 +232,35 @@ export const OrderListTable = forwardRef<AgGridReact, OrderListTableProps>(
field="updatedAt"
valueFormatter={({
value,
}: OrderListTableValueFormatterParams & {
value?: Orders_party_ordersConnection_edges_node['updatedAt'];
}) => {
}: VegaValueFormatterParams<OrderWithMarket, 'updatedAt'>) => {
return value ? getDateTimeFormat().format(new Date(value)) : '-';
}}
/>
<AgGridColumn
field="edit"
cellRenderer={({ data }: ICellRendererParams) => {
if (!data) return null;
if (isOrderActive(data.status)) {
colId="amend"
headerName=""
field="status"
cellRenderer={({
data,
}: VegaICellRendererParams<OrderWithMarket>) => {
if (isOrderAmendable(data)) {
return (
<Button
data-testid="edit"
onClick={() => {
setEditOrder(data);
}}
size="xs"
>
{t('Edit')}
</Button>
);
}
return null;
}}
/>
<AgGridColumn
field="cancel"
cellRenderer={({ data }: ICellRendererParams) => {
if (!data) return null;
if (isOrderActive(data.status)) {
return (
<Button
size="xs"
data-testid="cancel"
onClick={() => cancel(data)}
>
Cancel
</Button>
<div className="flex gap-2">
<Button
data-testid="edit"
onClick={() => setEditOrder(data)}
size="xs"
>
{t('Edit')}
</Button>
<Button
size="xs"
data-testid="cancel"
onClick={() => cancel(data)}
>
{t('Cancel')}
</Button>
</div>
);
}
@ -327,6 +286,18 @@ export const isOrderActive = (status: OrderStatus) => {
].includes(status);
};
export const isOrderAmendable = (order: OrderWithMarket | undefined) => {
if (!order || order.peggedOrder || order.liquidityProvision) {
return false;
}
if (isOrderActive(order.status)) {
return true;
}
return false;
};
export const getEditDialogTitle = (
status?: OrderStatus
): string | undefined => {

View File

@ -3161,6 +3161,8 @@ export type Query = {
assetsConnection?: Maybe<AssetsConnection>;
/** Find a deposit using its ID */
deposit?: Maybe<Deposit>;
/** Fetch all deposits */
deposits?: Maybe<DepositsConnection>;
/** Get data for a specific epoch, if ID omitted it gets the current epoch. If the string is 'next', fetch the next epoch */
epoch: Epoch;
/** Get the signatures bundle to allowlist an ERC20 token in the collateral bridge */
@ -3321,6 +3323,8 @@ export type Query = {
updateMarketProposals?: Maybe<Array<Proposal>>;
/** Find a withdrawal using its ID */
withdrawal?: Maybe<Withdrawal>;
/** Fetch all withdrawals */
withdrawals?: Maybe<WithdrawalsConnection>;
};
@ -3343,6 +3347,13 @@ export type QuerydepositArgs = {
};
/** Queries allow a caller to read data and filter data via GraphQL. */
export type QuerydepositsArgs = {
dateRange?: InputMaybe<DateRange>;
pagination?: InputMaybe<Pagination>;
};
/** Queries allow a caller to read data and filter data via GraphQL. */
export type QueryepochArgs = {
id?: InputMaybe<Scalars['ID']>;
@ -3667,6 +3678,13 @@ export type QuerywithdrawalArgs = {
id: Scalars['ID'];
};
/** Queries allow a caller to read data and filter data via GraphQL. */
export type QuerywithdrawalsArgs = {
dateRange?: InputMaybe<DateRange>;
pagination?: InputMaybe<Pagination>;
};
export type RankingScore = {
__typename?: 'RankingScore';
/** The performance score of the validator */
@ -4406,6 +4424,8 @@ export enum TransferStatus {
/** A proposal to update an asset's details */
export type UpdateAsset = {
__typename?: 'UpdateAsset';
/** The asset to update */
assetId: Scalars['ID'];
/** The minimum economically meaningful amount of this specific asset */
quantum: Scalars['String'];
/** The source of the updated asset */

View File

@ -17,14 +17,12 @@ type RowHelper<TObj, TRow, TField extends Field> = Omit<
value: Get<TRow, TField>;
};
export type VegaValueFormatterParams<TRow, TField extends Field> = RowHelper<
ValueFormatterParams,
export type VegaValueFormatterParams<
TRow,
TField
>;
TField extends Field = string
> = RowHelper<ValueFormatterParams, TRow, TField>;
export type VegaICellRendererParams<TRow, TField extends Field> = RowHelper<
ICellRendererParams,
export type VegaICellRendererParams<
TRow,
TField
>;
TField extends Field = string
> = RowHelper<ICellRendererParams, TRow, TField>;