From 310506e5af9ca04e02f2b0d575f3155061a1c6e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20G=C5=82ownia?= Date: Mon, 26 Jun 2023 12:50:57 +0200 Subject: [PATCH] feat(positions): live update market data in positions table (#4154) --- libs/markets/src/lib/markets-data-provider.ts | 65 ++++++++++- libs/markets/src/lib/markets-provider.ts | 45 +++++++- libs/positions/src/index.ts | 1 - .../src/lib/positions-data-providers.ts | 58 ++++++---- libs/positions/src/lib/positions-manager.tsx | 81 ++++++------- libs/positions/src/lib/positions-table.tsx | 4 +- .../src/lib/use-positions-data.spec.tsx | 107 ------------------ libs/positions/src/lib/use-positions-data.tsx | 81 ------------- 8 files changed, 184 insertions(+), 258 deletions(-) delete mode 100644 libs/positions/src/lib/use-positions-data.spec.tsx delete mode 100644 libs/positions/src/lib/use-positions-data.tsx diff --git a/libs/markets/src/lib/markets-data-provider.ts b/libs/markets/src/lib/markets-data-provider.ts index 3d284da8d..2fb40d022 100644 --- a/libs/markets/src/lib/markets-data-provider.ts +++ b/libs/markets/src/lib/markets-data-provider.ts @@ -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 })), +}); diff --git a/libs/markets/src/lib/markets-provider.ts b/libs/markets/src/lib/markets-provider.ts index b1cb11235..cc80c6125 100644 --- a/libs/markets/src/lib/markets-provider.ts +++ b/libs/markets/src/lib/markets-provider.ts @@ -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; diff --git a/libs/positions/src/index.ts b/libs/positions/src/index.ts index 6139213d0..c48ad2b54 100644 --- a/libs/positions/src/index.ts +++ b/libs/positions/src/index.ts @@ -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'; diff --git a/libs/positions/src/lib/positions-data-providers.ts b/libs/positions/src/lib/positions-data-providers.ts index b7685804f..3c139764d 100644 --- a/libs/positions/src/lib/positions-data-providers.ts +++ b/libs/positions/src/lib/positions-data-providers.ts @@ -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) => { diff --git a/libs/positions/src/lib/positions-manager.tsx b/libs/positions/src/lib/positions-manager.tsx index 9899c235d..517ee62f0 100644 --- a/libs/positions/src/lib/positions-manager.tsx +++ b/libs/positions/src/lib/positions-manager.tsx @@ -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(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')} diff --git a/libs/positions/src/lib/positions-table.tsx b/libs/positions/src/lib/positions-table.tsx index 68d2944b6..a774b6cfa 100644 --- a/libs/positions/src/lib/positions-table.tsx +++ b/libs/positions/src/lib/positions-table.tsx @@ -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( ( { diff --git a/libs/positions/src/lib/use-positions-data.spec.tsx b/libs/positions/src/lib/use-positions-data.spec.tsx deleted file mode 100644 index ba0848017..000000000 --- a/libs/positions/src/lib/use-positions-data.spec.tsx +++ /dev/null @@ -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([]); - }); -}); diff --git a/libs/positions/src/lib/use-positions-data.tsx b/libs/positions/src/lib/use-positions-data.tsx deleted file mode 100644 index 46dd617fe..000000000 --- a/libs/positions/src/lib/use-positions-data.tsx +++ /dev/null @@ -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 -) => { - const variables = useMemo( - () => ({ partyIds }), - [partyIds] - ); - const dataRef = useRef(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) => { - 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, - }; -};