Task/Orders data provider for libs/order-list (#173)
* add small/large options for loader * switch order-list lib to use orders data provider * prepare incoming orders for table insertion * delete dupe file, fix test * ignore sentry-cli from netlify build * move sentry/nextjs to dev deps * exclude sentry-cli from netlify
This commit is contained in:
parent
018e5fe139
commit
3b56dcd2b9
@ -37,7 +37,7 @@ export const OrderDialog = ({
|
||||
return (
|
||||
<OrderDialogWrapper
|
||||
title="Awaiting network confirmation"
|
||||
icon={<Loader />}
|
||||
icon={<Loader size="small" />}
|
||||
>
|
||||
{transaction.hash && (
|
||||
<p data-testid="tx-hash" className="break-all">
|
||||
|
4
libs/order-list/package.json
Normal file
4
libs/order-list/package.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "@vegaprotocol/order-list",
|
||||
"version": "0.0.1"
|
||||
}
|
@ -4,6 +4,26 @@
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nrwl/web:rollup",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/libs/order-list",
|
||||
"tsConfig": "libs/order-list/tsconfig.lib.json",
|
||||
"project": "libs/order-list/package.json",
|
||||
"entryFile": "libs/order-list/src/index.ts",
|
||||
"external": ["react/jsx-runtime"],
|
||||
"rollupConfig": "@nrwl/react/plugins/bundle-rollup",
|
||||
"compiler": "babel",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "libs/order-list/README.md",
|
||||
"input": ".",
|
||||
"output": "."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nrwl/linter:eslint",
|
||||
"outputs": ["{options.outputFile}"],
|
||||
|
@ -1,2 +1,2 @@
|
||||
export * from './order-list';
|
||||
export * from './order-list-container';
|
||||
export * from './lib/order-list';
|
||||
export * from './lib/order-list-container';
|
||||
|
@ -1,17 +1,17 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { OrderListManager } from './order-list-manager';
|
||||
import * as useOrdersHook from './use-orders';
|
||||
import * as useDataProviderHook from '@vegaprotocol/react-helpers';
|
||||
import type { Orders_party_orders } from './__generated__/Orders';
|
||||
import * as orderListMock from './order-list';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
jest.mock('./order-list', () => ({
|
||||
OrderList: () => <div>OrderList</div>,
|
||||
}));
|
||||
jest.mock('./order-list');
|
||||
|
||||
test('Renders a loading state while awaiting orders', () => {
|
||||
jest.spyOn(useOrdersHook, 'useOrders').mockReturnValue({
|
||||
orders: [],
|
||||
jest.spyOn(useDataProviderHook, 'useDataProvider').mockReturnValue({
|
||||
data: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
error: undefined,
|
||||
});
|
||||
render(<OrderListManager partyId="0x123" />);
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
@ -19,8 +19,8 @@ test('Renders a loading state while awaiting orders', () => {
|
||||
|
||||
test('Renders an error state', () => {
|
||||
const errorMsg = 'Oops! An Error';
|
||||
jest.spyOn(useOrdersHook, 'useOrders').mockReturnValue({
|
||||
orders: [],
|
||||
jest.spyOn(useDataProviderHook, 'useDataProvider').mockReturnValue({
|
||||
data: [],
|
||||
loading: false,
|
||||
error: new Error(errorMsg),
|
||||
});
|
||||
@ -31,10 +31,13 @@ test('Renders an error state', () => {
|
||||
});
|
||||
|
||||
test('Renders the order list if orders provided', async () => {
|
||||
jest.spyOn(useOrdersHook, 'useOrders').mockReturnValue({
|
||||
orders: [{ id: '1' } as Orders_party_orders],
|
||||
// @ts-ignore Orderlist is read only but we need to override with the forwardref to
|
||||
// avoid warnings about padding refs
|
||||
orderListMock.OrderList = forwardRef(() => <div>OrderList</div>);
|
||||
jest.spyOn(useDataProviderHook, 'useDataProvider').mockReturnValue({
|
||||
data: [{ id: '1' } as Orders_party_orders],
|
||||
loading: false,
|
||||
error: null,
|
||||
error: undefined,
|
||||
});
|
||||
render(<OrderListManager partyId="0x123" />);
|
||||
expect(await screen.findByText('OrderList')).toBeInTheDocument();
|
79
libs/order-list/src/lib/order-list-manager.tsx
Normal file
79
libs/order-list/src/lib/order-list-manager.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
|
||||
import { OrderList } from './order-list';
|
||||
import type { OrderFields } from './__generated__/OrderFields';
|
||||
import { useDataProvider } from '@vegaprotocol/react-helpers';
|
||||
import {
|
||||
ordersDataProvider,
|
||||
prepareIncomingOrders,
|
||||
sortOrders,
|
||||
} from './orders-data-provider';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type { AgGridReact } from 'ag-grid-react';
|
||||
import type { OrderSub_orders } from './__generated__/OrderSub';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
|
||||
interface OrderListManagerProps {
|
||||
partyId: string;
|
||||
}
|
||||
|
||||
export const OrderListManager = ({ partyId }: OrderListManagerProps) => {
|
||||
const gridRef = useRef<AgGridReact | null>(null);
|
||||
const variables = useMemo(() => ({ partyId }), [partyId]);
|
||||
|
||||
// Apply updates to the table
|
||||
const update = useCallback((delta: OrderSub_orders[]) => {
|
||||
if (!gridRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const incoming = prepareIncomingOrders(delta);
|
||||
|
||||
const update: OrderFields[] = [];
|
||||
const add: OrderFields[] = [];
|
||||
|
||||
incoming.forEach((d) => {
|
||||
if (!gridRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rowNode = gridRef.current.api.getRowNode(d.id);
|
||||
|
||||
if (rowNode) {
|
||||
if (!isEqual) {
|
||||
update.push(d);
|
||||
}
|
||||
} else {
|
||||
add.push(d);
|
||||
}
|
||||
});
|
||||
|
||||
if (update.length || add.length) {
|
||||
gridRef.current.api.applyTransactionAsync({
|
||||
update,
|
||||
add,
|
||||
addIndex: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const { data, error, loading } = useDataProvider(
|
||||
ordersDataProvider,
|
||||
update,
|
||||
variables
|
||||
);
|
||||
|
||||
const orders = useMemo(() => {
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
return sortOrders(data);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<AsyncRenderer loading={loading} error={error} data={orders}>
|
||||
{(data) => <OrderList ref={gridRef} data={data} />}
|
||||
</AsyncRenderer>
|
||||
);
|
||||
};
|
@ -12,7 +12,7 @@ import { OrderList } from './order-list';
|
||||
|
||||
test('No orders message shown', async () => {
|
||||
await act(async () => {
|
||||
render(<OrderList orders={[]} />);
|
||||
render(<OrderList data={[]} />);
|
||||
});
|
||||
expect(screen.getByText('No orders')).toBeInTheDocument();
|
||||
});
|
||||
@ -77,7 +77,7 @@ const limitOrder: Orders_party_orders = {
|
||||
|
||||
test('Correct columns are rendered', async () => {
|
||||
await act(async () => {
|
||||
render(<OrderList orders={[marketOrder]} />);
|
||||
render(<OrderList data={[marketOrder]} />);
|
||||
});
|
||||
|
||||
const headers = screen.getAllByRole('columnheader');
|
||||
@ -87,7 +87,7 @@ test('Correct columns are rendered', async () => {
|
||||
'Amount',
|
||||
'Type',
|
||||
'Status',
|
||||
'Remaining',
|
||||
'Filled',
|
||||
'Price',
|
||||
'Time In Force',
|
||||
'Created At',
|
||||
@ -96,7 +96,7 @@ test('Correct columns are rendered', async () => {
|
||||
|
||||
test('Correct formatting applied for market order', async () => {
|
||||
await act(async () => {
|
||||
render(<OrderList orders={[marketOrder]} />);
|
||||
render(<OrderList data={[marketOrder]} />);
|
||||
});
|
||||
|
||||
const cells = screen.getAllByRole('gridcell');
|
||||
@ -117,7 +117,7 @@ test('Correct formatting applied for market order', async () => {
|
||||
|
||||
test('Correct formatting applied for GTT limit order', async () => {
|
||||
await act(async () => {
|
||||
render(<OrderList orders={[limitOrder]} />);
|
||||
render(<OrderList data={[limitOrder]} />);
|
||||
});
|
||||
const cells = screen.getAllByRole('gridcell');
|
||||
const expectedValues = [
|
||||
@ -144,7 +144,7 @@ test('Correct formatting applied for a rejected order', async () => {
|
||||
rejectionReason: OrderRejectionReason.InsufficientAssetBalance,
|
||||
};
|
||||
await act(async () => {
|
||||
render(<OrderList orders={[rejectedOrder]} />);
|
||||
render(<OrderList data={[rejectedOrder]} />);
|
||||
});
|
||||
const cells = screen.getAllByRole('gridcell');
|
||||
expect(cells[3]).toHaveTextContent(
|
89
libs/order-list/src/lib/order-list.tsx
Normal file
89
libs/order-list/src/lib/order-list.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import { OrderTimeInForce, OrderStatus, Side } from '@vegaprotocol/types';
|
||||
import type { Orders_party_orders } from './__generated__/Orders';
|
||||
import { formatNumber, getDateTimeFormat } from '@vegaprotocol/react-helpers';
|
||||
import { AgGridDynamic as AgGrid } from '@vegaprotocol/ui-toolkit';
|
||||
import type { ValueFormatterParams } from 'ag-grid-community';
|
||||
import type { AgGridReact } from 'ag-grid-react';
|
||||
import { AgGridColumn } from 'ag-grid-react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
interface OrderListProps {
|
||||
data: Orders_party_orders[] | null;
|
||||
}
|
||||
|
||||
export const OrderList = forwardRef<AgGridReact, OrderListProps>(
|
||||
({ data }, ref) => {
|
||||
return (
|
||||
<AgGrid
|
||||
ref={ref}
|
||||
rowData={data}
|
||||
overlayNoRowsTemplate="No orders"
|
||||
defaultColDef={{ flex: 1, resizable: true }}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
getRowNodeId={(data) => data.id}
|
||||
>
|
||||
<AgGridColumn
|
||||
headerName="Market"
|
||||
field="market.tradableInstrument.instrument.code"
|
||||
/>
|
||||
<AgGridColumn
|
||||
headerName="Amount"
|
||||
field="size"
|
||||
cellClass="font-mono"
|
||||
valueFormatter={({ value, data }: ValueFormatterParams) => {
|
||||
const prefix = data.side === Side.Buy ? '+' : '-';
|
||||
return prefix + value;
|
||||
}}
|
||||
/>
|
||||
<AgGridColumn field="type" />
|
||||
<AgGridColumn
|
||||
field="status"
|
||||
valueFormatter={({ value, data }: ValueFormatterParams) => {
|
||||
if (value === OrderStatus.Rejected) {
|
||||
return `${value}: ${data.rejectionReason}`;
|
||||
}
|
||||
|
||||
return value;
|
||||
}}
|
||||
/>
|
||||
<AgGridColumn
|
||||
headerName="Filled"
|
||||
field="remaining"
|
||||
cellClass="font-mono"
|
||||
valueFormatter={({ data }: ValueFormatterParams) => {
|
||||
return `${Number(data.size) - Number(data.remaining)}/${data.size}`;
|
||||
}}
|
||||
/>
|
||||
<AgGridColumn
|
||||
field="price"
|
||||
cellClass="font-mono"
|
||||
valueFormatter={({ value, data }: ValueFormatterParams) => {
|
||||
if (data.type === 'Market') {
|
||||
return '-';
|
||||
}
|
||||
return formatNumber(value, data.market.decimalPlaces);
|
||||
}}
|
||||
/>
|
||||
<AgGridColumn
|
||||
field="timeInForce"
|
||||
valueFormatter={({ value, data }: ValueFormatterParams) => {
|
||||
if (value === OrderTimeInForce.GTT && data.expiresAt) {
|
||||
const expiry = getDateTimeFormat().format(
|
||||
new Date(data.expiresAt)
|
||||
);
|
||||
return `${value}: ${expiry}`;
|
||||
}
|
||||
|
||||
return value;
|
||||
}}
|
||||
/>
|
||||
<AgGridColumn
|
||||
field="createdAt"
|
||||
valueFormatter={({ value }: ValueFormatterParams) => {
|
||||
return getDateTimeFormat().format(new Date(value));
|
||||
}}
|
||||
/>
|
||||
</AgGrid>
|
||||
);
|
||||
}
|
||||
);
|
104
libs/order-list/src/lib/orders-data-provider.ts
Normal file
104
libs/order-list/src/lib/orders-data-provider.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { gql } from '@apollo/client';
|
||||
import { makeDataProvider } from '@vegaprotocol/react-helpers';
|
||||
import type { OrderFields } from './__generated__/OrderFields';
|
||||
import type { Orders, Orders_party_orders } from './__generated__/Orders';
|
||||
import type { OrderSub } from './__generated__/OrderSub';
|
||||
import orderBy from 'lodash/orderBy';
|
||||
import uniqBy from 'lodash/uniqBy';
|
||||
|
||||
const ORDER_FRAGMENT = gql`
|
||||
fragment OrderFields on Order {
|
||||
id
|
||||
market {
|
||||
id
|
||||
name
|
||||
decimalPlaces
|
||||
tradableInstrument {
|
||||
instrument {
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
type
|
||||
side
|
||||
size
|
||||
status
|
||||
rejectionReason
|
||||
price
|
||||
timeInForce
|
||||
remaining
|
||||
expiresAt
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
`;
|
||||
|
||||
export const ORDERS_QUERY = gql`
|
||||
${ORDER_FRAGMENT}
|
||||
query Orders($partyId: ID!) {
|
||||
party(id: $partyId) {
|
||||
id
|
||||
orders {
|
||||
...OrderFields
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const ORDERS_SUB = gql`
|
||||
${ORDER_FRAGMENT}
|
||||
subscription OrderSub($partyId: ID!) {
|
||||
orders(partyId: $partyId) {
|
||||
...OrderFields
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// A single update can contain the same order with multiple updates, so we need to find
|
||||
// the latest version of the order and only update using that
|
||||
export const sortOrders = (orders: OrderFields[]) => {
|
||||
return orderBy(
|
||||
orders,
|
||||
(o) => {
|
||||
if (!o.updatedAt) return new Date(o.createdAt).getTime();
|
||||
return new Date(o.updatedAt).getTime();
|
||||
},
|
||||
'desc'
|
||||
);
|
||||
};
|
||||
|
||||
export const uniqOrders = (orders: OrderFields[]) => {
|
||||
return uniqBy(orders, 'id');
|
||||
};
|
||||
|
||||
export const prepareIncomingOrders = (delta: OrderFields[]) => {
|
||||
const sortedOrders = sortOrders(delta);
|
||||
const incoming = uniqOrders(sortedOrders);
|
||||
return incoming;
|
||||
};
|
||||
|
||||
const update = (draft: OrderFields[], delta: OrderFields[]) => {
|
||||
const incoming = prepareIncomingOrders(delta);
|
||||
|
||||
// Add or update incoming orders
|
||||
incoming.forEach((order) => {
|
||||
const index = draft.findIndex((o) => o.id === order.id);
|
||||
if (index === -1) {
|
||||
draft.unshift(order);
|
||||
} else {
|
||||
draft[index] = order;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getData = (responseData: Orders): Orders_party_orders[] | null =>
|
||||
responseData?.party?.orders || null;
|
||||
const getDelta = (subscriptionData: OrderSub) => subscriptionData.orders || [];
|
||||
|
||||
export const ordersDataProvider = makeDataProvider(
|
||||
ORDERS_QUERY,
|
||||
ORDERS_SUB,
|
||||
update,
|
||||
getData,
|
||||
getDelta
|
||||
);
|
@ -1,18 +0,0 @@
|
||||
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
|
||||
import { useOrders } from './use-orders';
|
||||
import { OrderList } from './order-list';
|
||||
import type { OrderFields } from './__generated__/OrderFields';
|
||||
|
||||
interface OrderListManagerProps {
|
||||
partyId: string;
|
||||
}
|
||||
|
||||
export const OrderListManager = ({ partyId }: OrderListManagerProps) => {
|
||||
const { orders, loading, error } = useOrders(partyId);
|
||||
|
||||
return (
|
||||
<AsyncRenderer<OrderFields[]> loading={loading} error={error} data={orders}>
|
||||
{(data) => <OrderList orders={data} />}
|
||||
</AsyncRenderer>
|
||||
);
|
||||
};
|
@ -1,96 +0,0 @@
|
||||
import { OrderTimeInForce, OrderStatus, Side } from '@vegaprotocol/types';
|
||||
import type { Orders_party_orders } from './__generated__/Orders';
|
||||
import {
|
||||
formatNumber,
|
||||
getDateTimeFormat,
|
||||
t,
|
||||
useApplyGridTransaction,
|
||||
} from '@vegaprotocol/react-helpers';
|
||||
import { AgGridDynamic as AgGrid } from '@vegaprotocol/ui-toolkit';
|
||||
import type { GridApi, ValueFormatterParams } from 'ag-grid-community';
|
||||
import { AgGridColumn } from 'ag-grid-react';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
interface OrderListProps {
|
||||
orders: Orders_party_orders[];
|
||||
}
|
||||
|
||||
export const OrderList = ({ orders }: OrderListProps) => {
|
||||
// Store initial orders for initial table data set, further updates
|
||||
// are handled by the effect below
|
||||
const [initialOrders] = useState(orders);
|
||||
const gridApi = useRef<GridApi | null>(null);
|
||||
useApplyGridTransaction<Orders_party_orders>(orders, gridApi.current);
|
||||
|
||||
return (
|
||||
<AgGrid
|
||||
rowData={initialOrders}
|
||||
overlayNoRowsTemplate="No orders"
|
||||
defaultColDef={{ flex: 1, resizable: true }}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
onGridReady={(params) => {
|
||||
gridApi.current = params.api;
|
||||
}}
|
||||
getRowNodeId={(data) => data.id}
|
||||
>
|
||||
<AgGridColumn
|
||||
headerName={t('Market')}
|
||||
field="market.tradableInstrument.instrument.code"
|
||||
/>
|
||||
<AgGridColumn
|
||||
headerName={t('Amount')}
|
||||
field="size"
|
||||
cellClass="font-mono"
|
||||
valueFormatter={({ value, data }: ValueFormatterParams) => {
|
||||
const prefix = data.side === Side.Buy ? '+' : '-';
|
||||
return prefix + value;
|
||||
}}
|
||||
/>
|
||||
<AgGridColumn field="type" />
|
||||
<AgGridColumn
|
||||
field="status"
|
||||
valueFormatter={({ value, data }: ValueFormatterParams) => {
|
||||
if (value === OrderStatus.Rejected) {
|
||||
return `${value}: ${data.rejectionReason}`;
|
||||
}
|
||||
|
||||
return value;
|
||||
}}
|
||||
/>
|
||||
<AgGridColumn
|
||||
field="remaining"
|
||||
cellClass="font-mono"
|
||||
valueFormatter={({ data }: ValueFormatterParams) => {
|
||||
return `${Number(data.size) - Number(data.remaining)}/${data.size}`;
|
||||
}}
|
||||
/>
|
||||
<AgGridColumn
|
||||
field="price"
|
||||
cellClass="font-mono"
|
||||
valueFormatter={({ value, data }: ValueFormatterParams) => {
|
||||
if (data.type === 'Market') {
|
||||
return '-';
|
||||
}
|
||||
return formatNumber(value, data.market.decimalPlaces);
|
||||
}}
|
||||
/>
|
||||
<AgGridColumn
|
||||
field="timeInForce"
|
||||
valueFormatter={({ value, data }: ValueFormatterParams) => {
|
||||
if (value === OrderTimeInForce.GTT && data.expiresAt) {
|
||||
const expiry = getDateTimeFormat().format(new Date(data.expiresAt));
|
||||
return `${value}: ${expiry}`;
|
||||
}
|
||||
|
||||
return value;
|
||||
}}
|
||||
/>
|
||||
<AgGridColumn
|
||||
field="createdAt"
|
||||
valueFormatter={({ value }: ValueFormatterParams) => {
|
||||
return getDateTimeFormat().format(new Date(value));
|
||||
}}
|
||||
/>
|
||||
</AgGrid>
|
||||
);
|
||||
};
|
@ -1,138 +0,0 @@
|
||||
import type { MockedResponse } from '@apollo/client/testing';
|
||||
import { MockedProvider } from '@apollo/client/testing';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import type { OrderFields } from './__generated__/OrderFields';
|
||||
import type { Orders } from './__generated__/Orders';
|
||||
import type { OrderSub } from './__generated__/OrderSub';
|
||||
import {
|
||||
OrderStatus,
|
||||
Side,
|
||||
OrderType,
|
||||
OrderTimeInForce,
|
||||
} from '@vegaprotocol/types';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ORDERS_QUERY, ORDERS_SUB, useOrders } from './use-orders';
|
||||
|
||||
const partyId = '0x123';
|
||||
|
||||
function generateOrder(order?: Partial<OrderFields>): OrderFields {
|
||||
return {
|
||||
__typename: 'Order',
|
||||
id: '1',
|
||||
market: {
|
||||
__typename: 'Market',
|
||||
id: 'market-id',
|
||||
name: 'market-name',
|
||||
decimalPlaces: 0,
|
||||
tradableInstrument: {
|
||||
__typename: 'TradableInstrument',
|
||||
instrument: {
|
||||
__typename: 'Instrument',
|
||||
code: 'instrument-code',
|
||||
},
|
||||
},
|
||||
},
|
||||
type: OrderType.Market,
|
||||
side: Side.Buy,
|
||||
size: '10',
|
||||
status: OrderStatus.Active,
|
||||
rejectionReason: null,
|
||||
price: '',
|
||||
timeInForce: OrderTimeInForce.GTC,
|
||||
remaining: '10',
|
||||
createdAt: '2022-01-01T00:00:00',
|
||||
updatedAt: null,
|
||||
expiresAt: null,
|
||||
...order,
|
||||
};
|
||||
}
|
||||
|
||||
function setup(mocks: MockedResponse[] = [], id: string | null) {
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<MockedProvider mocks={mocks}>{children}</MockedProvider>
|
||||
);
|
||||
return renderHook(() => useOrders(id as string), { wrapper });
|
||||
}
|
||||
|
||||
test('Fetches and subscribes to orders and merges appropriately', async () => {
|
||||
const order = generateOrder();
|
||||
const mockOrderQuery: MockedResponse<Orders> = {
|
||||
request: {
|
||||
query: ORDERS_QUERY,
|
||||
variables: { partyId },
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
party: {
|
||||
__typename: 'Party',
|
||||
id: partyId,
|
||||
orders: [order],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const updatedOrder = generateOrder({
|
||||
id: '1',
|
||||
remaining: '5',
|
||||
updatedAt: '2022-01-01T00:01:00',
|
||||
});
|
||||
const newOrder = generateOrder({
|
||||
id: '2',
|
||||
createdAt: '2022-01-01T01:00:00',
|
||||
});
|
||||
const mockOrderSub: MockedResponse<OrderSub> = {
|
||||
request: {
|
||||
query: ORDERS_SUB,
|
||||
variables: { partyId },
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
orders: [updatedOrder, newOrder],
|
||||
},
|
||||
},
|
||||
delay: 100,
|
||||
};
|
||||
const { result, waitForNextUpdate } = setup(
|
||||
[mockOrderQuery, mockOrderSub],
|
||||
partyId
|
||||
);
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.error).toBe(null);
|
||||
await waitForNextUpdate();
|
||||
expect(result.current.orders).toEqual([order]);
|
||||
expect(result.current.loading).toBe(false);
|
||||
await waitForNextUpdate();
|
||||
expect(result.current.orders).toEqual([newOrder, updatedOrder]);
|
||||
});
|
||||
|
||||
test('Returns an error if fetch fails', async () => {
|
||||
const error = new Error('Something failed');
|
||||
const mockFailedOrderQuery: MockedResponse<Orders> = {
|
||||
request: {
|
||||
query: ORDERS_QUERY,
|
||||
variables: { partyId },
|
||||
},
|
||||
error,
|
||||
};
|
||||
const { result, waitForNextUpdate } = setup([mockFailedOrderQuery], partyId);
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.error).toBe(null);
|
||||
await waitForNextUpdate();
|
||||
expect(result.current.error).toEqual(error);
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
test('No queries are made if no pubkey provided', () => {
|
||||
const mockQuery: MockedResponse<Orders> = {
|
||||
request: {
|
||||
query: ORDERS_QUERY,
|
||||
variables: { partyId },
|
||||
},
|
||||
newData: jest.fn(),
|
||||
};
|
||||
const { result } = setup([mockQuery], null);
|
||||
expect(mockQuery.newData).not.toBeCalled();
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBe(null);
|
||||
});
|
@ -1,138 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { gql, useApolloClient } from '@apollo/client';
|
||||
import uniqBy from 'lodash/uniqBy';
|
||||
import orderBy from 'lodash/orderBy';
|
||||
import type { Orders, OrdersVariables } from './__generated__/Orders';
|
||||
import type { OrderSub, OrderSubVariables } from './__generated__/OrderSub';
|
||||
import type { OrderFields } from './__generated__/OrderFields';
|
||||
|
||||
const ORDER_FRAGMENT = gql`
|
||||
fragment OrderFields on Order {
|
||||
id
|
||||
market {
|
||||
id
|
||||
name
|
||||
decimalPlaces
|
||||
tradableInstrument {
|
||||
instrument {
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
type
|
||||
side
|
||||
size
|
||||
status
|
||||
rejectionReason
|
||||
price
|
||||
timeInForce
|
||||
remaining
|
||||
expiresAt
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
`;
|
||||
|
||||
export const ORDERS_QUERY = gql`
|
||||
${ORDER_FRAGMENT}
|
||||
query Orders($partyId: ID!) {
|
||||
party(id: $partyId) {
|
||||
id
|
||||
orders {
|
||||
...OrderFields
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const ORDERS_SUB = gql`
|
||||
${ORDER_FRAGMENT}
|
||||
subscription OrderSub($partyId: ID!) {
|
||||
orders(partyId: $partyId) {
|
||||
...OrderFields
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface UseOrders {
|
||||
orders: OrderFields[];
|
||||
error: Error | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export const useOrders = (partyId: string): UseOrders => {
|
||||
const client = useApolloClient();
|
||||
const [orders, setOrders] = useState<OrderFields[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const mergeOrders = useCallback((update: OrderFields[]) => {
|
||||
// A subscription payload can contain multiple updates for a single order so we need to first
|
||||
// sort them by updatedAt (or createdAt if the order hasn't been updated) with the newest first,
|
||||
// then use uniqBy, which selects the first occuring order for an id to ensure we only get the latest order
|
||||
setOrders((curr) => {
|
||||
const sorted = orderBy(
|
||||
[...curr, ...update],
|
||||
(o) => {
|
||||
if (!o.updatedAt) return new Date(o.createdAt).getTime();
|
||||
return new Date(o.updatedAt).getTime();
|
||||
},
|
||||
'desc'
|
||||
);
|
||||
return uniqBy(sorted, 'id');
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Make initial fetch
|
||||
useEffect(() => {
|
||||
const fetchOrders = async () => {
|
||||
if (!partyId) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await client.query<Orders, OrdersVariables>({
|
||||
query: ORDERS_QUERY,
|
||||
variables: { partyId },
|
||||
});
|
||||
|
||||
if (!res.data.party?.orders?.length) return;
|
||||
|
||||
mergeOrders(res.data.party.orders);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err : new Error('Something went wrong')
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchOrders();
|
||||
}, [mergeOrders, partyId, client]);
|
||||
|
||||
// Start subscription
|
||||
useEffect(() => {
|
||||
if (!partyId) return;
|
||||
|
||||
const sub = client
|
||||
.subscribe<OrderSub, OrderSubVariables>({
|
||||
query: ORDERS_SUB,
|
||||
variables: { partyId },
|
||||
})
|
||||
.subscribe(({ data }) => {
|
||||
if (!data?.orders) {
|
||||
return;
|
||||
}
|
||||
mergeOrders(data.orders);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (sub) {
|
||||
sub.unsubscribe();
|
||||
}
|
||||
};
|
||||
}, [client, partyId, mergeOrders]);
|
||||
|
||||
return { orders, error, loading };
|
||||
};
|
@ -8,8 +8,7 @@ interface AsyncRendererProps<T> {
|
||||
children: (data: T) => ReactNode;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
export function AsyncRenderer<T = any>({
|
||||
export function AsyncRenderer<T = object>({
|
||||
loading,
|
||||
error,
|
||||
data,
|
||||
|
@ -1,6 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const Loader = () => {
|
||||
interface LoaderProps {
|
||||
size?: 'small' | 'large';
|
||||
}
|
||||
|
||||
export const Loader = ({ size = 'large' }: LoaderProps) => {
|
||||
const [, forceRender] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@ -11,13 +15,17 @@ export const Loader = () => {
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const wrapperClasses = size === 'small' ? 'w-[15px] h-[15px]' : 'w-64 h-64';
|
||||
const gridItemClasses = size === 'small' ? 'w-[5px] h-[5px]' : 'w-16 h-16';
|
||||
const items = size === 'small' ? 9 : 16;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center" data-testid="splash-loader">
|
||||
<div className="w-64 h-64 flex flex-wrap">
|
||||
{new Array(16).fill(null).map((_, i) => {
|
||||
<div className={`${wrapperClasses} flex flex-wrap`}>
|
||||
{new Array(items).fill(null).map((_, i) => {
|
||||
return (
|
||||
<div
|
||||
className="w-16 h-16 dark:bg-white bg-black"
|
||||
className={`${gridItemClasses} dark:bg-white bg-black`}
|
||||
key={i}
|
||||
style={{
|
||||
opacity: Math.random() > 0.75 ? 1 : 0,
|
||||
|
@ -1,2 +1,5 @@
|
||||
[build]
|
||||
ignore = "node ignore-netlify-build.js"
|
||||
ignore = "node ignore-netlify-build.js"
|
||||
|
||||
[functions]
|
||||
included_files = ["!node_modules/@sentry/cli/sentry-cli"]
|
Loading…
Reference in New Issue
Block a user