feat(positions): live update market data in positions table (#4154)

This commit is contained in:
Bartłomiej Głownia 2023-06-26 12:50:57 +02:00 committed by GitHub
parent 0b33ed4299
commit 310506e5af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 184 additions and 258 deletions

View File

@ -1,13 +1,50 @@
import { marketDataErrorPolicyGuard } from '@vegaprotocol/data-provider';
import { makeDataProvider } from '@vegaprotocol/data-provider';
import type { MarketsDataQuery } from './__generated__/markets-data';
import type {
MarketsDataQuery,
MarketsDataQueryVariables,
} from './__generated__/markets-data';
import type {
MarketDataUpdateSubscription,
MarketDataUpdateFieldsFragment,
MarketDataUpdateSubscriptionVariables,
} from './__generated__/market-data';
import { MarketDataUpdateDocument } from './__generated__/market-data';
import { MarketsDataDocument } from './__generated__/markets-data';
import type { MarketData } from './market-data-provider';
const getData = (responseData: MarketsDataQuery | null): MarketData[] | null =>
const getData = (responseData: MarketsDataQuery | null): MarketData[] =>
responseData?.marketsConnection?.edges
.filter((edge) => edge.node.data)
.map((edge) => edge.node.data as MarketData) || null;
.map((edge) => edge.node.data as MarketData) || [];
export const mapMarketDataUpdateToMarketData = (
delta: MarketDataUpdateFieldsFragment
): MarketData => {
const { marketId, __typename, ...marketData } = delta;
return { ...marketData, market: { id: marketId } };
};
const update = (
data: MarketData[] | null,
delta: MarketDataUpdateFieldsFragment
) => {
const updatedData = data ? [...data] : [];
const item = mapMarketDataUpdateToMarketData(delta);
const index = updatedData.findIndex(
(data) => data.market.id === item.market.id
);
if (index !== -1) {
updatedData[index] = { ...updatedData[index], ...item };
} else {
updatedData.push(item);
}
return updatedData;
};
const getDelta = (
subscriptionData: MarketDataUpdateSubscription
): MarketDataUpdateFieldsFragment => subscriptionData.marketsData[0];
export const marketsDataProvider = makeDataProvider<
MarketsDataQuery,
@ -19,3 +56,25 @@ export const marketsDataProvider = makeDataProvider<
getData,
errorPolicyGuard: marketDataErrorPolicyGuard,
});
type Variables = { marketIds: string[] };
export const marketsLiveDataProvider = makeDataProvider<
MarketsDataQuery,
MarketData[],
MarketDataUpdateSubscription,
MarketDataUpdateFieldsFragment,
Variables,
MarketDataUpdateSubscriptionVariables,
MarketsDataQueryVariables
>({
query: MarketsDataDocument,
subscriptionQuery: MarketDataUpdateDocument,
getData,
getDelta,
update,
errorPolicyGuard: marketDataErrorPolicyGuard,
getQueryVariables: () => ({}),
getSubscriptionVariables: ({ marketIds }: Variables) =>
marketIds.map((marketId) => ({ marketId })),
});

View File

@ -10,10 +10,15 @@ import type {
} from './__generated__/markets';
import type { MarketsCandlesQueryVariables } from './__generated__/markets-candles';
import { marketsDataProvider } from './markets-data-provider';
import {
marketsDataProvider,
marketsLiveDataProvider,
mapMarketDataUpdateToMarketData,
} from './markets-data-provider';
import { marketDataProvider } from './market-data-provider';
import { marketsCandlesProvider } from './markets-candles-provider';
import type { MarketData } from './market-data-provider';
import type { MarketDataUpdateFieldsFragment } from './__generated__';
import type { MarketCandles } from './markets-candles-provider';
import { useMemo } from 'react';
import * as Schema from '@vegaprotocol/types';
@ -160,6 +165,44 @@ export const allMarketsWithDataProvider = makeDerivedDataProvider<
addData(parts[0] as Market[], parts[1] as MarketData[])
);
export const allMarketsWithLiveDataProvider = makeDerivedDataProvider<
MarketMaybeWithData[],
MarketMaybeWithData,
{ marketIds: string[] }
>(
[
(callback, client, variables) =>
marketsProvider(callback, client, undefined),
marketsLiveDataProvider,
],
(partsData, variables, prevData, parts) => {
if (prevData && parts[1].isUpdate) {
const data = mapMarketDataUpdateToMarketData(parts[1].delta);
const index = prevData.findIndex(
(market) => market.id === data.market.id
);
if (index !== -1) {
const updatedData = [...prevData];
updatedData[index] = { ...updatedData[index], data };
return updatedData;
} else {
return prevData;
}
}
return addData(partsData[0] as Market[], partsData[1] as MarketData[]);
},
(data, parts) => {
if (!parts[1].isUpdate && parts[1].delta) {
return;
}
return data.find(
(market) =>
market.id ===
(parts[1].delta as MarketDataUpdateFieldsFragment).marketId
);
}
);
export type MarketMaybeWithDataAndCandles = MarketMaybeWithData &
MarketMaybeWithCandles;

View File

@ -4,4 +4,3 @@ export * from './lib/positions-data-providers';
export * from './lib/positions-table';
export * from './lib/use-market-margin';
export * from './lib/use-open-volume';
export * from './lib/use-positions-data';

View File

@ -14,7 +14,7 @@ import type {
MarketMaybeWithData,
MarketDataQueryVariables,
} from '@vegaprotocol/markets';
import { allMarketsWithDataProvider } from '@vegaprotocol/markets';
import { allMarketsWithLiveDataProvider } from '@vegaprotocol/markets';
import type {
PositionsQuery,
PositionFieldsFragment,
@ -144,8 +144,9 @@ export const update = (
data: PositionFieldsFragment[] | null,
deltas: PositionsSubscriptionSubscription['positions']
) => {
return produce(data || [], (draft) => {
const updatedData = produce(data || [], (draft) => {
deltas.forEach((delta) => {
const { marketId, partyId, __typename, ...position } = delta;
const index = draft.findIndex(
(node) =>
node.market.id === delta.marketId && node.party.id === delta.partyId
@ -154,29 +155,25 @@ export const update = (
const currNode = draft[index];
draft[index] = {
...currNode,
realisedPNL: delta.realisedPNL,
unrealisedPNL: delta.unrealisedPNL,
openVolume: delta.openVolume,
averageEntryPrice: delta.averageEntryPrice,
updatedAt: delta.updatedAt,
lossSocializationAmount: delta.lossSocializationAmount,
positionStatus: delta.positionStatus,
...position,
};
} else {
draft.unshift({
...delta,
...position,
__typename: 'Position',
market: {
__typename: 'Market',
id: delta.marketId,
},
party: {
__typename: 'Party',
id: delta.partyId,
},
});
}
});
});
return updatedData;
};
const getSubscriptionVariables = (
@ -242,41 +239,54 @@ export const rejoinPositionData = (
| null => {
if (positions && marketsData) {
return positions.map((node) => {
const market =
marketsData?.find((market) => market.id === node.market.id) || null;
return {
...node,
market:
marketsData?.find((market) => market.id === node.market.id) || null,
market,
};
});
}
return null;
};
export const positionsMarketsProvider = makeDerivedDataProvider<
string[],
never,
PositionsQueryVariables
>([positionsDataProvider], ([positions]) => {
return Array.from(
new Set(
(positions as PositionFieldsFragment[]).map(
(position) => position.market.id
)
)
).sort();
});
export const positionsMetricsProvider = makeDerivedDataProvider<
Position[],
Position[],
PositionsQueryVariables
PositionsQueryVariables & { marketIds: string[] }
>(
[
positionsDataProvider,
(callback, client, variables) =>
positionsDataProvider(callback, client, { partyIds: variables.partyIds }),
(callback, client, variables) =>
accountsDataProvider(callback, client, {
partyId: Array.isArray(variables.partyIds)
? variables.partyIds[0]
: variables.partyIds,
}),
(callback, client) =>
allMarketsWithDataProvider(callback, client, undefined),
(callback, client, variables) =>
allMarketsWithLiveDataProvider(callback, client, {
marketIds: variables.marketIds,
}),
],
([positions, accounts, marketsData], variables) => {
([positions, accounts, marketsData]) => {
const positionsData = rejoinPositionData(positions, marketsData);
if (!variables) {
return [];
}
return sortBy(
getMetrics(positionsData, accounts as Account[] | null),
'marketName'
);
const metrics = getMetrics(positionsData, accounts as Account[] | null);
return sortBy(metrics, 'marketName');
},
(data, delta, previousData) =>
data.filter((row) => {

View File

@ -1,11 +1,13 @@
import { useRef } from 'react';
import { usePositionsData } from './use-positions-data';
import { useCallback } from 'react';
import { PositionsTable } from './positions-table';
import type { AgGridReact } from 'ag-grid-react';
import * as Schema from '@vegaprotocol/types';
import { useVegaTransactionStore } from '@vegaprotocol/wallet';
import { t } from '@vegaprotocol/i18n';
import { useBottomPlaceholder } from '@vegaprotocol/datagrid';
import { useDataProvider } from '@vegaprotocol/data-provider';
import {
positionsMetricsProvider,
positionsMarketsProvider,
} from './positions-data-providers';
import { useVegaWallet } from '@vegaprotocol/wallet';
interface PositionsManagerProps {
@ -24,42 +26,43 @@ export const PositionsManager = ({
storeKey,
}: PositionsManagerProps) => {
const { pubKeys, pubKey } = useVegaWallet();
const gridRef = useRef<AgGridReact | null>(null);
const { data, error } = usePositionsData(partyIds, gridRef);
const create = useVegaTransactionStore((store) => store.create);
const onClose = ({
marketId,
openVolume,
}: {
marketId: string;
openVolume: string;
}) =>
create({
batchMarketInstructions: {
cancellations: [
{
marketId,
orderId: '', // omit order id to cancel all active orders
},
],
submissions: [
{
marketId: marketId,
type: Schema.OrderType.TYPE_MARKET as const,
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC as const,
side: openVolume.startsWith('-')
? Schema.Side.SIDE_BUY
: Schema.Side.SIDE_SELL,
size: openVolume.replace('-', ''),
reduceOnly: true,
},
],
},
});
const onClose = useCallback(
({ marketId, openVolume }: { marketId: string; openVolume: string }) =>
create({
batchMarketInstructions: {
cancellations: [
{
marketId,
orderId: '', // omit order id to cancel all active orders
},
],
submissions: [
{
marketId: marketId,
type: Schema.OrderType.TYPE_MARKET as const,
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC as const,
side: openVolume.startsWith('-')
? Schema.Side.SIDE_BUY
: Schema.Side.SIDE_SELL,
size: openVolume.replace('-', ''),
reduceOnly: true,
},
],
},
}),
[create]
);
const bottomPlaceholderProps = useBottomPlaceholder({
gridRef,
disabled: noBottomPlaceholder,
const { data: marketIds } = useDataProvider({
dataProvider: positionsMarketsProvider,
variables: { partyIds },
});
const { data, error } = useDataProvider({
dataProvider: positionsMetricsProvider,
variables: { partyIds, marketIds: marketIds || [] },
skip: !marketIds,
});
return (
@ -68,11 +71,9 @@ export const PositionsManager = ({
pubKey={pubKey}
pubKeys={pubKeys}
rowData={error ? [] : data}
ref={gridRef}
onMarketClick={onMarketClick}
onClose={onClose}
isReadOnly={isReadOnly}
{...bottomPlaceholderProps}
storeKey={storeKey}
multipleKeys={partyIds.length > 1}
overlayNoRowsTemplate={error ? error.message : t('No positions')}

View File

@ -36,7 +36,6 @@ import { t } from '@vegaprotocol/i18n';
import type { AgGridReact } from 'ag-grid-react';
import type { Position } from './positions-data-providers';
import * as Schema from '@vegaprotocol/types';
import { getRowId } from './use-positions-data';
import { PositionStatus, PositionStatusMapping } from '@vegaprotocol/types';
import { DocsLinks } from '@vegaprotocol/environment';
import { PositionTableActions } from './position-actions-dropdown';
@ -86,6 +85,9 @@ export const AmountCell = ({ valueFormatted }: AmountCellProps) => {
AmountCell.displayName = 'AmountCell';
export const getRowId = ({ data }: { data: Position }) =>
`${data.partyId}-${data.marketId}`;
export const PositionsTable = forwardRef<AgGridReact, Props>(
(
{

View File

@ -1,107 +0,0 @@
import type { AgGridReact } from 'ag-grid-react';
import { MockedProvider } from '@apollo/client/testing';
import { renderHook, waitFor } from '@testing-library/react';
import { usePositionsData } from './use-positions-data';
import type { Position } from './positions-data-providers';
let mockData: Position[] = [
{
marketName: 'M1',
marketId: 'market-0',
openVolume: '1',
},
{
marketName: 'M2',
marketId: 'market-1',
openVolume: '-1985',
},
{
marketName: 'M3',
marketId: 'market-2',
openVolume: '0',
},
{
marketName: 'M4',
marketId: 'market-3',
openVolume: '0',
},
{
marketName: 'M5',
marketId: 'market-4',
openVolume: '3',
},
] as Position[];
let mockDataProviderData = {
data: mockData,
error: undefined,
loading: false,
totalCount: undefined,
};
let updateMock: jest.Mock;
const mockDataProvider = jest.fn((args) => {
updateMock = args.update;
return mockDataProviderData;
});
jest.mock('@vegaprotocol/data-provider', () => ({
...jest.requireActual('@vegaprotocol/data-provider'),
useDataProvider: jest.fn((args) => mockDataProvider(args)),
}));
describe('usePositionData Hook', () => {
afterEach(() => {
jest.clearAllMocks();
});
const mockRefreshInfiniteCache = jest.fn();
const mockGetRowNode = jest
.fn()
.mockImplementation((id: string) =>
mockData.find((position) => position.marketId === id)
);
const partyIds = ['partyId'];
const anUpdatedOne = {
marketId: 'market-1',
openVolume: '1',
};
const gridRef = {
current: {
api: {
refreshInfiniteCache: mockRefreshInfiniteCache,
getRowNode: mockGetRowNode,
getModel: () => ({ getType: () => 'infinite' }),
},
} as unknown as AgGridReact,
};
it('should return proper data', async () => {
const { result } = renderHook(() => usePositionsData(partyIds, gridRef), {
wrapper: MockedProvider,
});
expect(result.current.data?.length ?? 0).toEqual(5);
});
it('should call mockRefreshInfiniteCache', async () => {
renderHook(() => usePositionsData(partyIds, gridRef), {
wrapper: MockedProvider,
});
await waitFor(() => {
updateMock({ delta: [anUpdatedOne] as Position[] });
});
expect(mockRefreshInfiniteCache).toHaveBeenCalledWith();
});
it('no data should return null', () => {
mockData = [];
mockDataProviderData = {
...mockDataProviderData,
data: mockData,
loading: false,
};
const { result } = renderHook(() => usePositionsData(partyIds, gridRef), {
wrapper: MockedProvider,
});
expect(result.current.data).toEqual([]);
});
});

View File

@ -1,81 +0,0 @@
import { useCallback, useMemo, useRef } from 'react';
import type { RefObject } from 'react';
import type { AgGridReact } from 'ag-grid-react';
import type { Position } from './positions-data-providers';
import { positionsMetricsProvider } from './positions-data-providers';
import type { PositionsQueryVariables } from './__generated__/Positions';
import { updateGridData } from '@vegaprotocol/datagrid';
import { useDataProvider } from '@vegaprotocol/data-provider';
import type { GetRowsParams } from '@vegaprotocol/datagrid';
import isEqual from 'lodash/isEqual';
export const getRowId = ({
data,
}: {
data: Position & { isLastPlaceholder?: boolean; id?: string };
}) =>
data.isLastPlaceholder && data.id
? data.id
: `${data.partyId}-${data.marketId}`;
export const usePositionsData = (
partyIds: string[],
gridRef: RefObject<AgGridReact>
) => {
const variables = useMemo<PositionsQueryVariables>(
() => ({ partyIds }),
[partyIds]
);
const dataRef = useRef<Position[] | null>(null);
const update = useCallback(
({ data }: { data: Position[] | null }) => {
if (gridRef.current?.api?.getModel().getType() === 'infinite') {
return updateGridData(dataRef, data, gridRef);
}
const update: Position[] = [];
const add: Position[] = [];
data?.forEach((row) => {
const rowNode = gridRef.current?.api?.getRowNode(
getRowId({ data: row })
);
if (rowNode) {
if (!isEqual(rowNode.data, row)) {
update.push(row);
}
} else {
add.push(row);
}
});
gridRef.current?.api?.applyTransaction({
update,
add,
addIndex: 0,
});
return true;
},
[gridRef]
);
const { data, error, loading, reload } = useDataProvider({
dataProvider: positionsMetricsProvider,
update,
variables,
});
const getRows = useCallback(
async ({ successCallback, startRow, endRow }: GetRowsParams<Position>) => {
const rowsThisBlock = dataRef.current
? dataRef.current.slice(startRow, endRow)
: [];
const lastRow = dataRef.current ? dataRef.current.length : 0;
successCallback(rowsThisBlock, lastRow);
},
[]
);
return {
data,
error,
loading,
getRows,
reload,
};
};