Feature/218 trades scrolling (#740)

* feat(#218): add skip pagination support to data provider

* feat(#218): add new rows after user scroll top

* feat(#218): add pagination/scroll scenarios to storybook

* feat(#218): switch fills to infinite scroll mode

* feat(#218): switch trades to infinite scroll mode

* feat(#218): fix e2e tests

* feat(#218): set rowModelType to infinite

* feat(#218): fix markets-list

* feat(#218): remove totalCount

* feat(#218): remove totalCount from mocks, revert cypress config

* feat(#218): allow null data in data-provider whan using pagination

* feat(#218): add missing hasNextPage, handle only one page scenario

* feat(#218): improve typing in fills, trades ann market-list tables
This commit is contained in:
Bartłomiej Głownia 2022-07-20 11:37:28 +02:00 committed by GitHub
parent 4fe3c916be
commit 556be89bfd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 1534 additions and 619 deletions

View File

@ -51,7 +51,6 @@ export const generateFills = (override?: PartialDeep<Fills>): Fills => {
id: 'buyer-id', id: 'buyer-id',
tradesConnection: { tradesConnection: {
__typename: 'TradeConnection', __typename: 'TradeConnection',
totalCount: 1,
edges: fills.map((f) => { edges: fills.map((f) => {
return { return {
__typename: 'TradeEdge', __typename: 'TradeEdge',
@ -63,6 +62,8 @@ export const generateFills = (override?: PartialDeep<Fills>): Fills => {
__typename: 'PageInfo', __typename: 'PageInfo',
startCursor: '1', startCursor: '1',
endCursor: '2', endCursor: '2',
hasNextPage: false,
hasPreviousPage: false,
}, },
}, },
__typename: 'Party', __typename: 'Party',

View File

@ -1,9 +1,12 @@
import merge from 'lodash/merge'; import merge from 'lodash/merge';
import type { PartialDeep } from 'type-fest'; import type { PartialDeep } from 'type-fest';
import type { Trades, Trades_market_trades } from '@vegaprotocol/trades'; import type {
Trades,
Trades_market_tradesConnection_edges_node,
} from '@vegaprotocol/trades';
export const generateTrades = (override?: PartialDeep<Trades>): Trades => { export const generateTrades = (override?: PartialDeep<Trades>): Trades => {
const trades: Trades_market_trades[] = [ const trades: Trades_market_tradesConnection_edges_node[] = [
{ {
id: 'FFFFBC80005C517A10ACF481F7E6893769471098E696D0CC407F18134044CB16', id: 'FFFFBC80005C517A10ACF481F7E6893769471098E696D0CC407F18134044CB16',
price: '17116898', price: '17116898',
@ -44,10 +47,26 @@ export const generateTrades = (override?: PartialDeep<Trades>): Trades => {
__typename: 'Trade', __typename: 'Trade',
}, },
]; ];
const defaultResult = { const defaultResult: Trades = {
market: { market: {
id: 'market-0', id: 'market-0',
trades, tradesConnection: {
__typename: 'TradeConnection',
edges: trades.map((node, i) => {
return {
__typename: 'TradeEdge',
node,
cursor: (i + 1).toString(),
};
}),
pageInfo: {
__typename: 'PageInfo',
startCursor: '0',
endCursor: trades.length.toString(),
hasNextPage: false,
hasPreviousPage: false,
},
},
__typename: 'Market', __typename: 'Market',
}, },
}; };

View File

@ -206,14 +206,12 @@ export interface Fills_party_tradesConnection_pageInfo {
__typename: "PageInfo"; __typename: "PageInfo";
startCursor: string; startCursor: string;
endCursor: string; endCursor: string;
hasNextPage: boolean;
hasPreviousPage: boolean;
} }
export interface Fills_party_tradesConnection { export interface Fills_party_tradesConnection {
__typename: "TradeConnection"; __typename: "TradeConnection";
/**
* The total number of trades in this connection
*/
totalCount: number;
/** /**
* The trade in this connection * The trade in this connection
*/ */

View File

@ -1,11 +1,16 @@
import produce from 'immer'; import produce from 'immer';
import orderBy from 'lodash/orderBy';
import { gql } from '@apollo/client'; import { gql } from '@apollo/client';
import { makeDataProvider } from '@vegaprotocol/react-helpers'; import {
import type { PageInfo, Pagination } from '@vegaprotocol/react-helpers'; makeDataProvider,
defaultAppend as append,
} from '@vegaprotocol/react-helpers';
import type { PageInfo } from '@vegaprotocol/react-helpers';
import type { FillFields } from './__generated__/FillFields'; import type { FillFields } from './__generated__/FillFields';
import type { import type {
Fills, Fills,
Fills_party_tradesConnection_edges, Fills_party_tradesConnection_edges,
Fills_party_tradesConnection_edges_node,
} from './__generated__/Fills'; } from './__generated__/Fills';
import type { FillsSub } from './__generated__/FillsSub'; import type { FillsSub } from './__generated__/FillsSub';
@ -64,7 +69,6 @@ export const FILLS_QUERY = gql`
party(id: $partyId) { party(id: $partyId) {
id id
tradesConnection(marketId: $marketId, pagination: $pagination) { tradesConnection(marketId: $marketId, pagination: $pagination) {
totalCount
edges { edges {
node { node {
...FillFields ...FillFields
@ -74,6 +78,8 @@ export const FILLS_QUERY = gql`
pageInfo { pageInfo {
startCursor startCursor
endCursor endCursor
hasNextPage
hasPreviousPage
} }
} }
} }
@ -90,16 +96,24 @@ export const FILLS_SUB = gql`
`; `;
const update = ( const update = (
data: Fills_party_tradesConnection_edges[], data: (Fills_party_tradesConnection_edges | null)[],
delta: FillFields[] delta: FillFields[]
) => { ) => {
return produce(data, (draft) => { return produce(data, (draft) => {
delta.forEach((node) => { orderBy(delta, 'createdAt').forEach((node) => {
const index = draft.findIndex((edge) => edge.node.id === node.id); const index = draft.findIndex((edge) => edge?.node.id === node.id);
if (index !== -1) { if (index !== -1) {
Object.assign(draft[index].node, node); if (draft[index]?.node) {
Object.assign(
draft[index]?.node as Fills_party_tradesConnection_edges_node,
node
);
}
} else { } else {
draft.unshift({ node, cursor: '', __typename: 'TradeEdge' }); const firstNode = draft[0]?.node;
if (firstNode && node.createdAt >= firstNode.createdAt) {
draft.unshift({ node, cursor: '', __typename: 'TradeEdge' });
}
} }
}); });
}); });
@ -113,40 +127,8 @@ const getData = (
const getPageInfo = (responseData: Fills): PageInfo | null => const getPageInfo = (responseData: Fills): PageInfo | null =>
responseData.party?.tradesConnection.pageInfo || null; responseData.party?.tradesConnection.pageInfo || null;
const getTotalCount = (responseData: Fills): number | undefined =>
responseData.party?.tradesConnection.totalCount;
const getDelta = (subscriptionData: FillsSub) => subscriptionData.trades || []; const getDelta = (subscriptionData: FillsSub) => subscriptionData.trades || [];
const append = (
data: Fills_party_tradesConnection_edges[] | null,
pageInfo: PageInfo,
insertionData: Fills_party_tradesConnection_edges[] | null,
insertionPageInfo: PageInfo | null,
pagination?: Pagination
) => {
if (data && insertionData && insertionPageInfo) {
if (pagination?.after) {
if (data[data.length - 1].cursor === pagination.after) {
return {
data: [...data, ...insertionData],
pageInfo: { ...pageInfo, endCursor: insertionPageInfo.endCursor },
};
} else {
const cursors = data.map((item) => item.cursor);
const startIndex = cursors.lastIndexOf(pagination.after);
if (startIndex !== -1) {
return {
data: [...data.slice(0, startIndex), ...insertionData],
pageInfo: { ...pageInfo, endCursor: insertionPageInfo.endCursor },
};
}
}
}
}
return { data, pageInfo };
};
export const fillsDataProvider = makeDataProvider( export const fillsDataProvider = makeDataProvider(
FILLS_QUERY, FILLS_QUERY,
FILLS_SUB, FILLS_SUB,
@ -155,7 +137,6 @@ export const fillsDataProvider = makeDataProvider(
getDelta, getDelta,
{ {
getPageInfo, getPageInfo,
getTotalCount,
append, append,
first: 100, first: 100,
} }

View File

@ -3,7 +3,11 @@ import { useCallback, useRef, useMemo } from 'react';
import { useDataProvider } from '@vegaprotocol/react-helpers'; import { useDataProvider } from '@vegaprotocol/react-helpers';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit'; import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { FillsTable } from './fills-table'; import { FillsTable } from './fills-table';
import type { IGetRowsParams } from 'ag-grid-community'; import type {
IGetRowsParams,
BodyScrollEvent,
BodyScrollEndEvent,
} from 'ag-grid-community';
import { fillsDataProvider as dataProvider } from './fills-data-provider'; import { fillsDataProvider as dataProvider } from './fills-data-provider';
import type { Fills_party_tradesConnection_edges } from './__generated__/Fills'; import type { Fills_party_tradesConnection_edges } from './__generated__/Fills';
@ -15,14 +19,46 @@ interface FillsManagerProps {
export const FillsManager = ({ partyId }: FillsManagerProps) => { export const FillsManager = ({ partyId }: FillsManagerProps) => {
const gridRef = useRef<AgGridReact | null>(null); const gridRef = useRef<AgGridReact | null>(null);
const dataRef = useRef<Fills_party_tradesConnection_edges[] | null>(null); const dataRef = useRef<(Fills_party_tradesConnection_edges | null)[] | null>(
null
);
const totalCountRef = useRef<number | undefined>(undefined); const totalCountRef = useRef<number | undefined>(undefined);
const newRows = useRef(0);
const scrolledToTop = useRef(true);
const addNewRows = useCallback(() => {
if (newRows.current === 0) {
return;
}
if (totalCountRef.current !== undefined) {
totalCountRef.current += newRows.current;
}
newRows.current = 0;
if (!gridRef.current?.api) {
return;
}
gridRef.current.api.refreshInfiniteCache();
}, []);
const update = useCallback( const update = useCallback(
({ data }: { data: Fills_party_tradesConnection_edges[] }) => { ({
data,
delta,
}: {
data: (Fills_party_tradesConnection_edges | null)[];
delta: FillsSub_trades[];
}) => {
if (!gridRef.current?.api) { if (!gridRef.current?.api) {
return false; return false;
} }
if (!scrolledToTop.current) {
const createdAt = dataRef.current?.[0]?.node.createdAt;
if (createdAt) {
newRows.current += delta.filter(
(trade) => trade.createdAt > createdAt
).length;
}
}
dataRef.current = data; dataRef.current = data;
gridRef.current.api.refreshInfiniteCache(); gridRef.current.api.refreshInfiniteCache();
return true; return true;
@ -35,7 +71,7 @@ export const FillsManager = ({ partyId }: FillsManagerProps) => {
data, data,
totalCount, totalCount,
}: { }: {
data: Fills_party_tradesConnection_edges[]; data: (Fills_party_tradesConnection_edges | null)[];
totalCount?: number; totalCount?: number;
}) => { }) => {
dataRef.current = data; dataRef.current = data;
@ -48,7 +84,7 @@ export const FillsManager = ({ partyId }: FillsManagerProps) => {
const variables = useMemo(() => ({ partyId }), [partyId]); const variables = useMemo(() => ({ partyId }), [partyId]);
const { data, error, loading, load, totalCount } = useDataProvider< const { data, error, loading, load, totalCount } = useDataProvider<
Fills_party_tradesConnection_edges[], (Fills_party_tradesConnection_edges | null)[],
FillsSub_trades[] FillsSub_trades[]
>({ dataProvider, update, insert, variables }); >({ dataProvider, update, insert, variables });
totalCountRef.current = totalCount; totalCountRef.current = totalCount;
@ -60,15 +96,14 @@ export const FillsManager = ({ partyId }: FillsManagerProps) => {
startRow, startRow,
endRow, endRow,
}: IGetRowsParams) => { }: IGetRowsParams) => {
startRow += newRows.current;
endRow += newRows.current;
try { try {
if (dataRef.current && dataRef.current.length < endRow) { if (dataRef.current && dataRef.current.indexOf(null) < endRow) {
await load({ await load();
first: endRow - startRow,
after: dataRef.current[dataRef.current.length - 1].cursor,
});
} }
const rowsThisBlock = dataRef.current const rowsThisBlock = dataRef.current
? dataRef.current.slice(startRow, endRow).map((edge) => edge.node) ? dataRef.current.slice(startRow, endRow).map((edge) => edge?.node)
: []; : [];
let lastRow = -1; let lastRow = -1;
if (totalCountRef.current !== undefined) { if (totalCountRef.current !== undefined) {
@ -77,6 +112,8 @@ export const FillsManager = ({ partyId }: FillsManagerProps) => {
} else if (totalCountRef.current <= endRow) { } else if (totalCountRef.current <= endRow) {
lastRow = totalCountRef.current; lastRow = totalCountRef.current;
} }
} else if (rowsThisBlock.length < endRow - startRow) {
lastRow = rowsThisBlock.length;
} }
successCallback(rowsThisBlock, lastRow); successCallback(rowsThisBlock, lastRow);
} catch (e) { } catch (e) {
@ -84,9 +121,26 @@ export const FillsManager = ({ partyId }: FillsManagerProps) => {
} }
}; };
const onBodyScrollEnd = (event: BodyScrollEndEvent) => {
if (event.top === 0) {
addNewRows();
}
};
const onBodyScroll = (event: BodyScrollEvent) => {
scrolledToTop.current = event.top <= 0;
};
return ( return (
<AsyncRenderer loading={loading} error={error} data={data}> <AsyncRenderer loading={loading} error={error} data={data}>
<FillsTable ref={gridRef} partyId={partyId} datasource={{ getRows }} /> <FillsTable
ref={gridRef}
partyId={partyId}
datasource={{ getRows }}
rowModelType="infinite"
onBodyScrollEnd={onBodyScrollEnd}
onBodyScroll={onBodyScroll}
/>
</AsyncRenderer> </AsyncRenderer>
); );
}; };

View File

@ -4,7 +4,7 @@ import { Side } from '@vegaprotocol/types';
import type { PartialDeep } from 'type-fest'; import type { PartialDeep } from 'type-fest';
import { FillsTable } from './fills-table'; import { FillsTable } from './fills-table';
import { generateFill, makeGetRows } from './test-helpers'; import { generateFill } from './test-helpers';
import type { FillFields } from './__generated__/FillFields'; import type { FillFields } from './__generated__/FillFields';
const waitForGridToBeInTheDOM = () => { const waitForGridToBeInTheDOM = () => {
@ -47,12 +47,7 @@ describe('FillsTable', () => {
}); });
it('correct columns are rendered', async () => { it('correct columns are rendered', async () => {
render( render(<FillsTable partyId="party-id" rowData={[generateFill()]} />);
<FillsTable
partyId="party-id"
datasource={{ getRows: makeGetRows([generateFill()]) }}
/>
);
await waitForGridToBeInTheDOM(); await waitForGridToBeInTheDOM();
await waitForDataToHaveLoaded(); await waitForDataToHaveLoaded();
@ -84,12 +79,7 @@ describe('FillsTable', () => {
}, },
}); });
render( render(<FillsTable partyId={partyId} rowData={[buyerFill]} />);
<FillsTable
partyId={partyId}
datasource={{ getRows: makeGetRows([buyerFill]) }}
/>
);
await waitForGridToBeInTheDOM(); await waitForGridToBeInTheDOM();
await waitForDataToHaveLoaded(); await waitForDataToHaveLoaded();
@ -126,12 +116,7 @@ describe('FillsTable', () => {
}, },
}); });
render( render(<FillsTable partyId={partyId} rowData={[buyerFill]} />);
<FillsTable
partyId={partyId}
datasource={{ getRows: makeGetRows([buyerFill]) }}
/>
);
await waitForGridToBeInTheDOM(); await waitForGridToBeInTheDOM();
await waitForDataToHaveLoaded(); await waitForDataToHaveLoaded();
@ -163,10 +148,7 @@ describe('FillsTable', () => {
}); });
const { rerender } = render( const { rerender } = render(
<FillsTable <FillsTable partyId={partyId} rowData={[takerFill]} />
partyId={partyId}
datasource={{ getRows: makeGetRows([takerFill]) }}
/>
); );
await waitForGridToBeInTheDOM(); await waitForGridToBeInTheDOM();
await waitForDataToHaveLoaded(); await waitForDataToHaveLoaded();
@ -184,12 +166,7 @@ describe('FillsTable', () => {
aggressor: Side.Buy, aggressor: Side.Buy,
}); });
rerender( rerender(<FillsTable partyId={partyId} rowData={[makerFill]} />);
<FillsTable
partyId={partyId}
datasource={{ getRows: makeGetRows([makerFill]) }}
/>
);
await waitForGridToBeInTheDOM(); await waitForGridToBeInTheDOM();
await waitForDataToHaveLoaded(); await waitForDataToHaveLoaded();

View File

@ -1,22 +1,397 @@
import type { Story, Meta } from '@storybook/react'; import type { Story, Meta } from '@storybook/react';
import type { FillsTableProps } from './fills-table'; import type { Props } from './fills-table';
import type { AgGridReact } from 'ag-grid-react';
import { AsyncRenderer, Button } from '@vegaprotocol/ui-toolkit';
import { useCallback, useRef } from 'react';
import { FillsTable } from './fills-table'; import { FillsTable } from './fills-table';
import { generateFills, makeGetRows } from './test-helpers'; import { generateFills, generateFill } from './test-helpers';
import type { Fills_party_tradesConnection_edges } from './__generated__/Fills';
import type { FillsSub_trades } from './__generated__/FillsSub';
import type {
IGetRowsParams,
BodyScrollEvent,
BodyScrollEndEvent,
} from 'ag-grid-community';
export default { export default {
component: FillsTable, component: FillsTable,
title: 'FillsTable', title: 'FillsTable',
} as Meta; } as Meta;
const Template: Story<FillsTableProps> = (args) => <FillsTable {...args} />; const Template: Story<Props> = (args) => <FillsTable {...args} />;
export const Default = Template.bind({}); export const Default = Template.bind({});
const createdAt = new Date('2005-04-02 21:37:00').getTime();
const fills = generateFills(); const fills = generateFills();
Default.args = { Default.args = {
partyId: 'party-id', partyId: 'party-id',
datasource: { rowData: fills.party?.tradesConnection.edges.map((e) => e.node) || [],
getRows: makeGetRows(
fills.party?.tradesConnection.edges.map((e) => e.node) || []
),
},
}; };
const getData = (
start: number,
end: number
): Fills_party_tradesConnection_edges[] =>
new Array(end - start).fill(null).map((v, i) => ({
__typename: 'TradeEdge',
node: generateFill({
id: (start + i).toString(),
createdAt: new Date(createdAt - 1000 * (start + i)).toISOString(),
}),
cursor: (start + i).toString(),
}));
const totalCount = 550;
const partyId = 'partyId';
const useDataProvider = ({
insert,
}: {
insert: ({
insertionData,
data,
totalCount,
}: {
insertionData: Fills_party_tradesConnection_edges[];
data: Fills_party_tradesConnection_edges[];
totalCount?: number;
}) => boolean;
}) => {
const data = [...getData(0, 100), ...new Array(totalCount - 100).fill(null)];
return {
data,
error: null,
loading: false,
load: (start?: number, end?: number) => {
if (start === undefined) {
start = data.findIndex((v) => !v);
}
if (end === undefined) {
end = start + 100;
}
end = Math.min(end, totalCount);
const insertionData = getData(start, end);
data.splice(start, end - start, ...insertionData);
insert({ data, totalCount, insertionData });
return Promise.resolve();
},
totalCount,
};
};
interface PaginationManagerProps {
pagination: boolean;
}
const PaginationManager = ({ pagination }: PaginationManagerProps) => {
const gridRef = useRef<AgGridReact | null>(null);
const dataRef = useRef<Fills_party_tradesConnection_edges[] | null>(null);
const totalCountRef = useRef<number | undefined>(undefined);
const newRows = useRef(0);
const scrolledToTop = useRef(true);
const addNewRows = useCallback(() => {
if (newRows.current === 0) {
return;
}
if (totalCountRef.current !== undefined) {
totalCountRef.current += newRows.current;
}
newRows.current = 0;
if (!gridRef.current?.api) {
return;
}
gridRef.current.api.refreshInfiniteCache();
}, []);
const update = useCallback(
({
data,
delta,
}: {
data: Fills_party_tradesConnection_edges[];
delta: FillsSub_trades[];
}) => {
if (!gridRef.current?.api) {
return false;
}
if (!scrolledToTop.current) {
const createdAt = dataRef.current?.[0].node.createdAt;
if (createdAt) {
newRows.current += delta.filter(
(trade) => trade.createdAt > createdAt
).length;
}
}
dataRef.current = data;
gridRef.current.api.refreshInfiniteCache();
return true;
},
[]
);
const insert = useCallback(
({
data,
totalCount,
}: {
data: Fills_party_tradesConnection_edges[];
totalCount?: number;
}) => {
dataRef.current = data;
totalCountRef.current = totalCount;
return true;
},
[]
);
const { data, error, loading, load, totalCount } = useDataProvider({
insert,
});
totalCountRef.current = totalCount;
dataRef.current = data;
const getRows = async ({
successCallback,
failCallback,
startRow,
endRow,
}: IGetRowsParams) => {
startRow += newRows.current;
endRow += newRows.current;
try {
if (
dataRef.current &&
dataRef.current.slice(startRow, endRow).some((i) => !i)
) {
await load(startRow, endRow);
}
const rowsThisBlock = dataRef.current
? dataRef.current.slice(startRow, endRow).map((edge) => edge.node)
: [];
let lastRow = -1;
if (totalCountRef.current !== undefined) {
if (!totalCountRef.current) {
lastRow = 0;
} else {
lastRow = totalCountRef.current;
}
} else if (rowsThisBlock.length < endRow - startRow) {
lastRow = rowsThisBlock.length;
}
successCallback(rowsThisBlock, lastRow);
} catch (e) {
failCallback();
}
};
const onBodyScrollEnd = (event: BodyScrollEndEvent) => {
if (event.top === 0) {
addNewRows();
}
};
const onBodyScroll = (event: BodyScrollEvent) => {
scrolledToTop.current = event.top <= 0;
};
// id and onclick is needed only for mocked data
let id = 0;
const onClick = () => {
if (!dataRef.current) {
return;
}
const node = generateFill({
id: (--id).toString(),
createdAt: new Date(createdAt - 1000 * id).toISOString(),
});
update({
data: [
{ cursor: '0', node, __typename: 'TradeEdge' },
...dataRef.current,
],
delta: [node],
});
};
return (
<>
<Button onClick={onClick}>Add row on top</Button>
<AsyncRenderer loading={loading} error={error} data={data}>
<FillsTable
rowModelType="infinite"
pagination={pagination}
ref={gridRef}
partyId={partyId}
datasource={{ getRows }}
onBodyScrollEnd={onBodyScrollEnd}
onBodyScroll={onBodyScroll}
/>
</AsyncRenderer>
</>
);
};
const PaginationTemplate: Story<PaginationManagerProps> = (args) => (
<PaginationManager {...args} />
);
export const Pagination = PaginationTemplate.bind({});
Pagination.args = { pagination: true };
export const PaginationScroll = PaginationTemplate.bind({});
PaginationScroll.args = { pagination: false };
const InfiniteScrollManager = () => {
const gridRef = useRef<AgGridReact | null>(null);
const dataRef = useRef<(Fills_party_tradesConnection_edges | null)[] | null>(
null
);
const totalCountRef = useRef<number | undefined>(undefined);
const newRows = useRef(0);
const scrolledToTop = useRef(true);
const addNewRows = useCallback(() => {
if (newRows.current === 0) {
return;
}
if (totalCountRef.current !== undefined) {
totalCountRef.current += newRows.current;
}
newRows.current = 0;
if (!gridRef.current?.api) {
return;
}
gridRef.current.api.refreshInfiniteCache();
}, []);
const update = useCallback(
({
data,
delta,
}: {
data: (Fills_party_tradesConnection_edges | null)[];
delta: FillsSub_trades[];
}) => {
if (!gridRef.current?.api) {
return false;
}
if (!scrolledToTop.current) {
const createdAt = dataRef.current?.[0]?.node.createdAt;
if (createdAt) {
newRows.current += delta.filter(
(trade) => trade.createdAt > createdAt
).length;
}
}
dataRef.current = data;
gridRef.current.api.refreshInfiniteCache();
return true;
},
[]
);
const insert = useCallback(
({
data,
totalCount,
}: {
data: Fills_party_tradesConnection_edges[];
totalCount?: number;
}) => {
dataRef.current = data;
totalCountRef.current = totalCount;
return true;
},
[]
);
const { data, error, loading, load, totalCount } = useDataProvider({
insert,
});
totalCountRef.current = totalCount;
dataRef.current = data;
const getRows = async ({
successCallback,
failCallback,
startRow,
endRow,
}: IGetRowsParams) => {
startRow += newRows.current;
endRow += newRows.current;
try {
if (dataRef.current && dataRef.current.indexOf(null) < endRow) {
await load();
}
const rowsThisBlock = dataRef.current
? dataRef.current.slice(startRow, endRow).map((edge) => edge?.node)
: [];
let lastRow = -1;
if (totalCountRef.current !== undefined) {
if (!totalCountRef.current) {
lastRow = 0;
} else if (totalCountRef.current <= endRow) {
lastRow = totalCountRef.current;
}
} else if (rowsThisBlock.length < endRow - startRow) {
lastRow = rowsThisBlock.length;
}
successCallback(rowsThisBlock, lastRow);
} catch (e) {
failCallback();
}
};
const onBodyScrollEnd = (event: BodyScrollEndEvent) => {
if (event.top === 0) {
addNewRows();
}
};
const onBodyScroll = (event: BodyScrollEvent) => {
scrolledToTop.current = event.top <= 0;
};
// id and onclick is needed only for mocked data
let id = 0;
const onClick = () => {
if (!dataRef.current) {
return;
}
const node = generateFill({
id: (--id).toString(),
createdAt: new Date(createdAt - 1000 * id).toISOString(),
});
update({
data: [
{ cursor: '0', node, __typename: 'TradeEdge' },
...dataRef.current,
],
delta: [node],
});
};
return (
<>
<Button onClick={onClick}>Add row on top</Button>
<AsyncRenderer loading={loading} error={error} data={data}>
<FillsTable
ref={gridRef}
partyId={partyId}
datasource={{ getRows }}
rowModelType="infinite"
onBodyScroll={onBodyScroll}
onBodyScrollEnd={onBodyScrollEnd}
/>
</AsyncRenderer>
</>
);
};
const InfiniteScrollTemplate: Story<Record<string, never>> = () => (
<InfiniteScrollManager />
);
export const InfiniteScroll = InfiniteScrollTemplate.bind({});

View File

@ -6,21 +6,32 @@ import {
getDateTimeFormat, getDateTimeFormat,
t, t,
} from '@vegaprotocol/react-helpers'; } from '@vegaprotocol/react-helpers';
import { Side } from '@vegaprotocol/types';
import { AgGridColumn } from 'ag-grid-react'; import { AgGridColumn } from 'ag-grid-react';
import { AgGridDynamic as AgGrid } from '@vegaprotocol/ui-toolkit'; import { AgGridDynamic as AgGrid } from '@vegaprotocol/ui-toolkit';
import { forwardRef } from 'react'; import { forwardRef } from 'react';
import type { FillFields } from './__generated__/FillFields'; import type { ValueFormatterParams } from 'ag-grid-community';
import type { ValueFormatterParams, IDatasource } from 'ag-grid-community';
import BigNumber from 'bignumber.js'; import BigNumber from 'bignumber.js';
import { Side } from '@vegaprotocol/types'; import type { AgGridReactProps, AgReactUiProps } from 'ag-grid-react';
import type {
FillFields,
FillFields_market_tradableInstrument_instrument_product,
} from './__generated__/FillFields';
import type { Fills_party_tradesConnection_edges_node } from './__generated__/Fills';
export interface FillsTableProps { export type Props = (AgGridReactProps | AgReactUiProps) & {
partyId: string; partyId: string;
datasource: IDatasource; };
}
export const FillsTable = forwardRef<AgGridReact, FillsTableProps>( type AccountsTableValueFormatterParams = Omit<
({ partyId, datasource }, ref) => { ValueFormatterParams,
'data' | 'value'
> & {
data: Fills_party_tradesConnection_edges_node | null;
};
export const FillsTable = forwardRef<AgGridReact, Props>(
({ partyId, ...props }, ref) => {
return ( return (
<AgGrid <AgGrid
ref={ref} ref={ref}
@ -28,8 +39,7 @@ export const FillsTable = forwardRef<AgGridReact, FillsTableProps>(
defaultColDef={{ flex: 1, resizable: true }} defaultColDef={{ flex: 1, resizable: true }}
style={{ width: '100%', height: '100%' }} style={{ width: '100%', height: '100%' }}
getRowId={({ data }) => data?.id} getRowId={({ data }) => data?.id}
rowModelType="infinite" {...props}
datasource={datasource}
> >
<AgGridColumn headerName={t('Market')} field="market.name" /> <AgGridColumn headerName={t('Market')} field="market.name" />
<AgGridColumn <AgGridColumn
@ -69,7 +79,11 @@ export const FillsTable = forwardRef<AgGridReact, FillsTableProps>(
<AgGridColumn <AgGridColumn
headerName={t('Date')} headerName={t('Date')}
field="createdAt" field="createdAt"
valueFormatter={({ value }: ValueFormatterParams) => { valueFormatter={({
value,
}: AccountsTableValueFormatterParams & {
value: Fills_party_tradesConnection_edges_node['createdAt'];
}) => {
if (value === undefined) { if (value === undefined) {
return value; return value;
} }
@ -81,9 +95,14 @@ export const FillsTable = forwardRef<AgGridReact, FillsTableProps>(
} }
); );
const formatPrice = ({ value, data }: ValueFormatterParams) => { const formatPrice = ({
if (value === undefined) { value,
return value; data,
}: AccountsTableValueFormatterParams & {
value?: Fills_party_tradesConnection_edges_node['price'];
}) => {
if (value === undefined || !data) {
return undefined;
} }
const asset = const asset =
data?.market.tradableInstrument.instrument.product.settlementAsset.symbol; data?.market.tradableInstrument.instrument.product.settlementAsset.symbol;
@ -95,9 +114,14 @@ const formatPrice = ({ value, data }: ValueFormatterParams) => {
}; };
const formatSize = (partyId: string) => { const formatSize = (partyId: string) => {
return ({ value, data }: ValueFormatterParams) => { return ({
if (value === undefined) { value,
return value; data,
}: AccountsTableValueFormatterParams & {
value?: Fills_party_tradesConnection_edges_node['size'];
}) => {
if (value === undefined || !data) {
return undefined;
} }
let prefix; let prefix;
if (data?.buyer.id === partyId) { if (data?.buyer.id === partyId) {
@ -114,9 +138,14 @@ const formatSize = (partyId: string) => {
}; };
}; };
const formatTotal = ({ value, data }: ValueFormatterParams) => { const formatTotal = ({
if (value === undefined) { value,
return value; data,
}: AccountsTableValueFormatterParams & {
value?: Fills_party_tradesConnection_edges_node['price'];
}) => {
if (value === undefined || !data) {
return undefined;
} }
const asset = const asset =
data?.market.tradableInstrument.instrument.product.settlementAsset.symbol; data?.market.tradableInstrument.instrument.product.settlementAsset.symbol;
@ -131,7 +160,12 @@ const formatTotal = ({ value, data }: ValueFormatterParams) => {
}; };
const formatRole = (partyId: string) => { const formatRole = (partyId: string) => {
return ({ value, data }: ValueFormatterParams) => { return ({
value,
data,
}: AccountsTableValueFormatterParams & {
value?: Fills_party_tradesConnection_edges_node['aggressor'];
}) => {
if (value === undefined) { if (value === undefined) {
return value; return value;
} }
@ -156,7 +190,12 @@ const formatRole = (partyId: string) => {
}; };
const formatFee = (partyId: string) => { const formatFee = (partyId: string) => {
return ({ value, data }: ValueFormatterParams) => { return ({
value,
data,
}: AccountsTableValueFormatterParams & {
value?: FillFields_market_tradableInstrument_instrument_product;
}) => {
if (value === undefined) { if (value === undefined) {
return value; return value;
} }

View File

@ -1,5 +1,4 @@
import merge from 'lodash/merge'; import merge from 'lodash/merge';
import type { IGetRowsParams } from 'ag-grid-community';
import type { PartialDeep } from 'type-fest'; import type { PartialDeep } from 'type-fest';
import type { import type {
Fills, Fills,
@ -52,7 +51,6 @@ export const generateFills = (override?: PartialDeep<Fills>): Fills => {
id: 'buyer-id', id: 'buyer-id',
tradesConnection: { tradesConnection: {
__typename: 'TradeConnection', __typename: 'TradeConnection',
totalCount: 1,
edges: fills.map((f) => { edges: fills.map((f) => {
return { return {
__typename: 'TradeEdge', __typename: 'TradeEdge',
@ -64,6 +62,8 @@ export const generateFills = (override?: PartialDeep<Fills>): Fills => {
__typename: 'PageInfo', __typename: 'PageInfo',
startCursor: '1', startCursor: '1',
endCursor: '2', endCursor: '2',
hasNextPage: false,
hasPreviousPage: false,
}, },
}, },
__typename: 'Party', __typename: 'Party',
@ -79,7 +79,7 @@ export const generateFill = (
const defaultFill: Fills_party_tradesConnection_edges_node = { const defaultFill: Fills_party_tradesConnection_edges_node = {
__typename: 'Trade', __typename: 'Trade',
id: '0', id: '0',
createdAt: new Date().toISOString(), createdAt: '2005-04-02T19:37:00.000Z',
price: '10000000', price: '10000000',
size: '50000', size: '50000',
buyOrder: 'buy-order', buyOrder: 'buy-order',
@ -133,9 +133,3 @@ export const generateFill = (
return merge(defaultFill, override); return merge(defaultFill, override);
}; };
export const makeGetRows =
(data: Fills_party_tradesConnection_edges_node[]) =>
({ successCallback }: IGetRowsParams) => {
successCallback(data, data.length);
};

View File

@ -1,46 +0,0 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { MarketState, MarketTradingMode } from "@vegaprotocol/types";
// ====================================================
// GraphQL fragment: MarketDataFields
// ====================================================
export interface MarketDataFields_market {
__typename: "Market";
/**
* Market ID
*/
id: string;
/**
* Current state of the market
*/
state: MarketState;
/**
* Current mode of execution of the market
*/
tradingMode: MarketTradingMode;
}
export interface MarketDataFields {
__typename: "MarketData";
/**
* market id of the associated mark price
*/
market: MarketDataFields_market;
/**
* the highest price level on an order book for buy orders.
*/
bestBidPrice: string;
/**
* the lowest price level on an order book for offer orders.
*/
bestOfferPrice: string;
/**
* the mark price (actually an unsigned int)
*/
markPrice: string;
}

View File

@ -1,53 +0,0 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { MarketState, MarketTradingMode } from "@vegaprotocol/types";
// ====================================================
// GraphQL subscription operation: MarketDataSub
// ====================================================
export interface MarketDataSub_marketData_market {
__typename: "Market";
/**
* Market ID
*/
id: string;
/**
* Current state of the market
*/
state: MarketState;
/**
* Current mode of execution of the market
*/
tradingMode: MarketTradingMode;
}
export interface MarketDataSub_marketData {
__typename: "MarketData";
/**
* market id of the associated mark price
*/
market: MarketDataSub_marketData_market;
/**
* the highest price level on an order book for buy orders.
*/
bestBidPrice: string;
/**
* the lowest price level on an order book for offer orders.
*/
bestOfferPrice: string;
/**
* the mark price (actually an unsigned int)
*/
markPrice: string;
}
export interface MarketDataSub {
/**
* Subscribe to the mark price changes
*/
marketData: MarketDataSub_marketData;
}

View File

@ -1,126 +0,0 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { MarketState, MarketTradingMode } from "@vegaprotocol/types";
// ====================================================
// GraphQL query operation: Markets
// ====================================================
export interface Markets_markets_data_market {
__typename: "Market";
/**
* Market ID
*/
id: string;
/**
* Current state of the market
*/
state: MarketState;
/**
* Current mode of execution of the market
*/
tradingMode: MarketTradingMode;
}
export interface Markets_markets_data {
__typename: "MarketData";
/**
* market id of the associated mark price
*/
market: Markets_markets_data_market;
/**
* the highest price level on an order book for buy orders.
*/
bestBidPrice: string;
/**
* the lowest price level on an order book for offer orders.
*/
bestOfferPrice: string;
/**
* the mark price (actually an unsigned int)
*/
markPrice: string;
}
export interface Markets_markets_tradableInstrument_instrument_product_settlementAsset {
__typename: "Asset";
/**
* The symbol of the asset (e.g: GBP)
*/
symbol: string;
}
export interface Markets_markets_tradableInstrument_instrument_product {
__typename: "Future";
/**
* The name of the asset (string)
*/
settlementAsset: Markets_markets_tradableInstrument_instrument_product_settlementAsset;
}
export interface Markets_markets_tradableInstrument_instrument {
__typename: "Instrument";
/**
* A short non necessarily unique code used to easily describe the instrument (e.g: FX:BTCUSD/DEC18) (string)
*/
code: string;
/**
* A reference to or instance of a fully specified product, including all required product parameters for that product (Product union)
*/
product: Markets_markets_tradableInstrument_instrument_product;
}
export interface Markets_markets_tradableInstrument {
__typename: "TradableInstrument";
/**
* An instance of or reference to a fully specified instrument.
*/
instrument: Markets_markets_tradableInstrument_instrument;
}
export interface Markets_markets {
__typename: "Market";
/**
* Market ID
*/
id: string;
/**
* Market full name
*/
name: string;
/**
* decimalPlaces indicates the number of decimal places that an integer must be shifted by in order to get a correct
* number denominated in the currency of the Market. (uint64)
*
* Examples:
* Currency Balance decimalPlaces Real Balance
* GBP 100 0 GBP 100
* GBP 100 2 GBP 1.00
* GBP 100 4 GBP 0.01
* GBP 1 4 GBP 0.0001 ( 0.01p )
*
* GBX (pence) 100 0 GBP 1.00 (100p )
* GBX (pence) 100 2 GBP 0.01 ( 1p )
* GBX (pence) 100 4 GBP 0.0001 ( 0.01p )
* GBX (pence) 1 4 GBP 0.000001 ( 0.0001p)
*/
decimalPlaces: number;
/**
* marketData for the given market
*/
data: Markets_markets_data | null;
/**
* An instance of or reference to a tradable instrument.
*/
tradableInstrument: Markets_markets_tradableInstrument;
}
export interface Markets {
/**
* One or more instruments that are trading on the VEGA network
*/
markets: Markets_markets[] | null;
}

View File

@ -1,3 +1,2 @@
export * from './__generated__';
export * from './landing'; export * from './landing';
export * from './markets-container'; export * from './markets-container';

View File

@ -1,3 +1,4 @@
export * from './MarketDataFields'; export * from './MarketDataFields';
export * from './MarketDataSub'; export * from './MarketDataSub';
export * from './MarketList';
export * from './Markets'; export * from './Markets';

View File

@ -2,4 +2,4 @@ export * from './market-list-table';
export * from './markets-container'; export * from './markets-container';
export * from './markets-data-provider'; export * from './markets-data-provider';
export * from './summary-cell'; export * from './summary-cell';
export * from './__generated__/MarketList'; export * from './__generated__';

View File

@ -1,5 +1,5 @@
import { forwardRef } from 'react'; import { forwardRef } from 'react';
import type { IDatasource, ValueFormatterParams } from 'ag-grid-community'; import type { ValueFormatterParams } from 'ag-grid-community';
import { import {
PriceFlashCell, PriceFlashCell,
addDecimalsFormatNumber, addDecimalsFormatNumber,
@ -8,95 +8,119 @@ import {
} from '@vegaprotocol/react-helpers'; } from '@vegaprotocol/react-helpers';
import { AgGridDynamic as AgGrid } from '@vegaprotocol/ui-toolkit'; import { AgGridDynamic as AgGrid } from '@vegaprotocol/ui-toolkit';
import { AgGridColumn } from 'ag-grid-react'; import { AgGridColumn } from 'ag-grid-react';
import type { AgGridReact } from 'ag-grid-react'; import type {
import type { Markets_markets } from '../__generated__/Markets'; AgGridReact,
AgGridReactProps,
AgReactUiProps,
} from 'ag-grid-react';
import { MarketTradingMode, AuctionTrigger } from '@vegaprotocol/types'; import { MarketTradingMode, AuctionTrigger } from '@vegaprotocol/types';
import type {
Markets_markets,
Markets_markets_data,
} from './__generated__/Markets';
interface MarketListTableProps { type Props = AgGridReactProps | AgReactUiProps;
datasource: IDatasource;
onRowClicked: (marketId: string) => void;
}
export const MarketListTable = forwardRef<AgGridReact, MarketListTableProps>( type MarketListTableValueFormatterParams = Omit<
({ datasource, onRowClicked }, ref) => { ValueFormatterParams,
return ( 'data' | 'value'
<AgGrid > & {
style={{ width: '100%', height: '100%' }} data: Markets_markets;
overlayNoRowsTemplate={t('No markets')} };
rowModelType="infinite"
datasource={datasource} export const MarketListTable = forwardRef<AgGridReact, Props>((props, ref) => {
getRowId={({ data }) => data?.id} return (
ref={ref} <AgGrid
defaultColDef={{ style={{ width: '100%', height: '100%' }}
flex: 1, overlayNoRowsTemplate={t('No markets')}
resizable: true, getRowId={({ data }) => data?.id}
ref={ref}
defaultColDef={{
flex: 1,
resizable: true,
}}
suppressCellFocus={true}
components={{ PriceFlashCell }}
{...props}
>
<AgGridColumn
headerName={t('Market')}
field="tradableInstrument.instrument.code"
/>
<AgGridColumn
headerName={t('Settlement asset')}
field="tradableInstrument.instrument.product.settlementAsset.symbol"
/>
<AgGridColumn
headerName={t('Trading mode')}
field="data"
minWidth={200}
valueFormatter={({
value,
}: MarketListTableValueFormatterParams & {
value?: Markets_markets_data;
}) => {
if (!value) return value;
const { market, trigger } = value;
return market &&
market.tradingMode === MarketTradingMode.MonitoringAuction &&
trigger &&
trigger !== AuctionTrigger.Unspecified
? `${formatLabel(market.tradingMode)} - ${trigger.toLowerCase()}`
: formatLabel(market?.tradingMode);
}} }}
suppressCellFocus={true} />
onRowClicked={({ data }: { data: Markets_markets }) => <AgGridColumn
onRowClicked(data.id) headerName={t('Best bid')}
field="data.bestBidPrice"
type="rightAligned"
cellRenderer="PriceFlashCell"
valueFormatter={({
value,
data,
}: MarketListTableValueFormatterParams & {
value?: Markets_markets_data['bestBidPrice'];
}) =>
value === undefined
? value
: addDecimalsFormatNumber(value, data.decimalPlaces)
} }
components={{ PriceFlashCell }} />
> <AgGridColumn
<AgGridColumn headerName={t('Best offer')}
headerName={t('Market')} field="data.bestOfferPrice"
field="tradableInstrument.instrument.code" type="rightAligned"
/> valueFormatter={({
<AgGridColumn value,
headerName={t('Settlement asset')} data,
field="tradableInstrument.instrument.product.settlementAsset.symbol" }: MarketListTableValueFormatterParams & {
/> value?: Markets_markets_data['bestOfferPrice'];
<AgGridColumn }) =>
headerName={t('Trading mode')} value === undefined
field="data" ? value
minWidth={200} : addDecimalsFormatNumber(value, data.decimalPlaces)
valueFormatter={({ value }: ValueFormatterParams) => { }
if (!value) return value; cellRenderer="PriceFlashCell"
const { market, trigger } = value; />
return market && <AgGridColumn
market.tradingMode === MarketTradingMode.MonitoringAuction && headerName={t('Mark price')}
trigger && field="data.markPrice"
trigger !== AuctionTrigger.Unspecified type="rightAligned"
? `${formatLabel(market.tradingMode)} - ${trigger.toLowerCase()}` cellRenderer="PriceFlashCell"
: formatLabel(market?.tradingMode); valueFormatter={({
}} value,
/> data,
<AgGridColumn }: MarketListTableValueFormatterParams & {
headerName={t('Best bid')} value?: Markets_markets_data['markPrice'];
field="data.bestBidPrice" }) =>
type="rightAligned" value === undefined
cellRenderer="PriceFlashCell" ? value
valueFormatter={({ value, data }: ValueFormatterParams) => : addDecimalsFormatNumber(value, data.decimalPlaces)
value === undefined }
? value />
: addDecimalsFormatNumber(value, data.decimalPlaces) <AgGridColumn headerName={t('Description')} field="name" />
} </AgGrid>
/> );
<AgGridColumn });
headerName={t('Best offer')}
field="data.bestOfferPrice"
type="rightAligned"
valueFormatter={({ value, data }: ValueFormatterParams) =>
value === undefined
? value
: addDecimalsFormatNumber(value, data.decimalPlaces)
}
cellRenderer="PriceFlashCell"
/>
<AgGridColumn
headerName={t('Mark price')}
field="data.markPrice"
type="rightAligned"
cellRenderer="PriceFlashCell"
valueFormatter={({ value, data }: ValueFormatterParams) =>
value === undefined
? value
: addDecimalsFormatNumber(value, data.decimalPlaces)
}
/>
<AgGridColumn headerName={t('Description')} field="name" />
</AgGrid>
);
}
);
export default MarketListTable; export default MarketListTable;

View File

@ -8,7 +8,7 @@ import type { IGetRowsParams } from 'ag-grid-community';
import type { import type {
Markets_markets, Markets_markets,
Markets_markets_data, Markets_markets_data,
} from '../../components/__generated__/Markets'; } from './__generated__/Markets';
import { marketsDataProvider as dataProvider } from './markets-data-provider'; import { marketsDataProvider as dataProvider } from './markets-data-provider';
import { MarketState } from '@vegaprotocol/types'; import { MarketState } from '@vegaprotocol/types';
@ -42,13 +42,15 @@ export const MarketsContainer = () => {
const lastRow = dataRef.current?.length ?? -1; const lastRow = dataRef.current?.length ?? -1;
successCallback(rowsThisBlock, lastRow); successCallback(rowsThisBlock, lastRow);
}; };
return ( return (
<AsyncRenderer loading={loading} error={error} data={data}> <AsyncRenderer loading={loading} error={error} data={data}>
<MarketListTable <MarketListTable
rowModelType="infinite"
datasource={{ getRows }} datasource={{ getRows }}
ref={gridRef} ref={gridRef}
onRowClicked={(id) => push(`/markets/${id}`)} onRowClicked={({ data }: { data: Markets_markets }) =>
push(`/markets/${data.id}`)
}
/> />
</AsyncRenderer> </AsyncRenderer>
); );

View File

@ -3,13 +3,10 @@ import { gql } from '@apollo/client';
import type { import type {
Markets, Markets,
Markets_markets, Markets_markets,
} from '../../components/__generated__/Markets';
import { makeDataProvider } from '@vegaprotocol/react-helpers';
import type {
MarketDataSub, MarketDataSub,
MarketDataSub_marketData, MarketDataSub_marketData,
} from '../../components/__generated__/MarketDataSub'; } from './';
import { makeDataProvider } from '@vegaprotocol/react-helpers';
const MARKET_DATA_FRAGMENT = gql` const MARKET_DATA_FRAGMENT = gql`
fragment MarketDataFields on MarketData { fragment MarketDataFields on MarketData {

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { useApolloClient } from '@apollo/client'; import { useApolloClient } from '@apollo/client';
import type { OperationVariables } from '@apollo/client'; import type { OperationVariables } from '@apollo/client';
import type { Subscribe, Pagination, Load } from '../lib/generic-data-provider'; import type { Subscribe, Load } from '../lib/generic-data-provider';
/** /**
* *
@ -48,9 +48,9 @@ export function useDataProvider<Data, Delta>({
reloadRef.current(force); reloadRef.current(force);
} }
}, []); }, []);
const load = useCallback((pagination: Pagination) => { const load = useCallback<Load<Data>>((...args) => {
if (loadRef.current) { if (loadRef.current) {
return loadRef.current(pagination); return loadRef.current(...args);
} }
return Promise.reject(); return Promise.reject();
}, []); }, []);

View File

@ -0,0 +1,445 @@
import { makeDataProvider, defaultAppend } from './generic-data-provider';
import type {
Query,
UpdateCallback,
Update,
PageInfo,
} from './generic-data-provider';
import type {
ApolloClient,
FetchResult,
SubscriptionOptions,
OperationVariables,
ApolloQueryResult,
QueryOptions,
} from '@apollo/client';
import type { Subscription, Observable } from 'zen-observable-ts';
type Item = {
cursor: string;
node: {
id: string;
};
};
type Data = Item[];
type QueryData = {
data: Data;
pageInfo?: PageInfo;
totalCount?: number;
};
type SubscriptionData = QueryData;
type Delta = Data;
describe('data provider', () => {
const update = jest.fn<
ReturnType<Update<Data, Delta>>,
Parameters<Update<Data, Delta>>
>();
const callback = jest.fn<
ReturnType<UpdateCallback<Data, Delta>>,
Parameters<UpdateCallback<Data, Delta>>
>();
const query: Query<QueryData> = {
kind: 'Document',
definitions: [],
};
const subscriptionQuery: Query<SubscriptionData> = query;
const subscribe = makeDataProvider<QueryData, Data, SubscriptionData, Delta>(
query,
subscriptionQuery,
update,
(r) => r.data,
(r) => r.data
);
const first = 100;
const paginatedSubscribe = makeDataProvider<
QueryData,
Data,
SubscriptionData,
Delta
>(
query,
subscriptionQuery,
update,
(r) => r.data,
(r) => r.data,
{
first,
append: defaultAppend,
getPageInfo: (r) => r?.pageInfo ?? null,
getTotalCount: (r) => r?.totalCount,
}
);
const generateData = (start = 0, size = first) => {
return new Array(size).fill(null).map((v, i) => ({
cursor: (i + start + 1).toString(),
node: {
id: (i + start + 1).toString(),
},
}));
};
const clientSubscribeUnsubscribe = jest.fn();
const clientSubscribeSubscribe = jest.fn<
Subscription,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[(value: FetchResult<SubscriptionData>) => void, (error: any) => void]
>(() => ({
unsubscribe: clientSubscribeUnsubscribe,
closed: false,
}));
const clientSubscribe = jest.fn<
Observable<FetchResult<SubscriptionData>>,
[SubscriptionOptions<OperationVariables, SubscriptionData>]
>(
() =>
({
subscribe: clientSubscribeSubscribe,
} as unknown as Observable<FetchResult<SubscriptionData>>)
);
const clientQueryPromise: {
resolve?: (
value:
| ApolloQueryResult<QueryData>
| PromiseLike<ApolloQueryResult<QueryData>>
) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
reject?: (reason?: any) => void;
} = {};
const clientQuery = jest.fn<
Promise<ApolloQueryResult<QueryData>>,
[QueryOptions<OperationVariables, QueryData>]
>(() => {
return new Promise((resolve, reject) => {
clientQueryPromise.resolve = resolve;
clientQueryPromise.reject = reject;
});
});
const client = {
query: clientQuery,
subscribe: clientSubscribe,
} as unknown as ApolloClient<object>;
const resolveQuery = async (data: QueryData) => {
if (clientQueryPromise.resolve) {
await clientQueryPromise.resolve({
data,
loading: false,
networkStatus: 8,
});
}
};
it('memoize instance and unsubscribe if no subscribers', () => {
const subscription1 = subscribe(jest.fn(), client);
const subscription2 = subscribe(jest.fn(), client);
expect(clientSubscribeSubscribe.mock.calls.length).toEqual(1);
subscription1.unsubscribe();
expect(clientSubscribeUnsubscribe.mock.calls.length).toEqual(0);
subscription2.unsubscribe();
expect(clientSubscribeUnsubscribe.mock.calls.length).toEqual(1);
});
it('calls callback before and after initial fetch', async () => {
callback.mockClear();
const data: Item[] = [];
const subscription = subscribe(callback, client);
expect(callback.mock.calls.length).toBe(1);
expect(callback.mock.calls[0][0].data).toBe(null);
expect(callback.mock.calls[0][0].loading).toBe(true);
await resolveQuery({ data });
expect(callback.mock.calls.length).toBe(2);
expect(callback.mock.calls[1][0].data).toBe(data);
expect(callback.mock.calls[1][0].loading).toBe(false);
subscription.unsubscribe();
});
it('calls update and callback on each update', async () => {
const data: Item[] = [];
const subscription = subscribe(callback, client);
await resolveQuery({ data });
const delta: Item[] = [];
update.mockImplementationOnce((data, delta) => [...data, ...delta]);
// calling onNext from client.subscribe({ query }).subscribe(onNext)
await clientSubscribeSubscribe.mock.calls[
clientSubscribeSubscribe.mock.calls.length - 1
][0]({ data: { data: delta } });
expect(update.mock.calls[update.mock.calls.length - 1][0]).toBe(data);
expect(update.mock.calls[update.mock.calls.length - 1][1]).toBe(delta);
expect(callback.mock.calls[callback.mock.calls.length - 1][0].delta).toBe(
delta
);
subscription.unsubscribe();
});
it("don't calls callback on update if data doesn't", async () => {
callback.mockClear();
const data: Item[] = [];
const subscription = subscribe(callback, client);
await resolveQuery({ data });
const delta: Item[] = [];
update.mockImplementationOnce((data, delta) => data);
const callbackCallsLength = callback.mock.calls.length;
// calling onNext from client.subscribe({ query }).subscribe(onNext)
await clientSubscribeSubscribe.mock.calls[
clientSubscribeSubscribe.mock.calls.length - 1
][0]({ data: { data: delta } });
expect(update.mock.calls[update.mock.calls.length - 1][0]).toBe(data);
expect(update.mock.calls[update.mock.calls.length - 1][1]).toBe(delta);
expect(callback.mock.calls.length).toBe(callbackCallsLength);
subscription.unsubscribe();
});
it('refetch data on reload', async () => {
clientQuery.mockClear();
clientSubscribeUnsubscribe.mockClear();
clientSubscribeSubscribe.mockClear();
const data: Item[] = [];
const subscription = subscribe(callback, client);
await resolveQuery({ data });
subscription.reload();
await resolveQuery({ data });
expect(clientQuery.mock.calls.length).toBe(2);
expect(clientSubscribeSubscribe.mock.calls.length).toBe(1);
expect(clientSubscribeUnsubscribe.mock.calls.length).toBe(0);
subscription.unsubscribe();
});
it('refetch data and restart subscription on reload with force', async () => {
clientQuery.mockClear();
clientSubscribeUnsubscribe.mockClear();
clientSubscribeSubscribe.mockClear();
const data: Item[] = [];
const subscription = subscribe(callback, client);
await resolveQuery({ data });
subscription.reload(true);
await resolveQuery({ data });
expect(clientQuery.mock.calls.length).toBe(2);
expect(clientSubscribeSubscribe.mock.calls.length).toBe(2);
expect(clientSubscribeUnsubscribe.mock.calls.length).toBe(1);
subscription.unsubscribe();
});
it('calls callback on flush', async () => {
callback.mockClear();
const data: Item[] = [];
const subscription = subscribe(callback, client);
await resolveQuery({ data });
const callbackCallsLength = callback.mock.calls.length;
subscription.flush();
expect(callback.mock.calls.length).toBe(callbackCallsLength + 1);
subscription.unsubscribe();
});
it('fills data with nulls if paginaton is enabled', async () => {
callback.mockClear();
const totalCount = 1000;
const data: Item[] = new Array(first).fill(null).map((v, i) => ({
cursor: i.toString(),
node: {
id: i.toString(),
},
}));
const subscription = paginatedSubscribe(callback, client);
await resolveQuery({
data,
totalCount,
pageInfo: {
hasNextPage: true,
},
});
expect(callback.mock.calls[1][0].data?.length).toBe(totalCount);
subscription.unsubscribe();
});
it('loads requested data blocks and inserts data with total count', async () => {
callback.mockClear();
const totalCount = 1000;
const subscription = paginatedSubscribe(callback, client);
await resolveQuery({
data: generateData(),
totalCount,
pageInfo: {
hasNextPage: true,
endCursor: '100',
},
});
// load next page
subscription.load();
let lastQueryArgs =
clientQuery.mock.calls[clientQuery.mock.calls.length - 1][0];
expect(lastQueryArgs?.variables?.pagination).toEqual({
after: '100',
first,
});
await resolveQuery({
data: generateData(100),
pageInfo: {
hasNextPage: true,
endCursor: '200',
},
});
// load page with skip
subscription.load(500, 600);
lastQueryArgs =
clientQuery.mock.calls[clientQuery.mock.calls.length - 1][0];
expect(lastQueryArgs?.variables?.pagination).toEqual({
after: '200',
first,
skip: 300,
});
await resolveQuery({
data: generateData(500),
pageInfo: {
hasNextPage: true,
endCursor: '600',
},
});
// load in the gap
subscription.load(400, 500);
lastQueryArgs =
clientQuery.mock.calls[clientQuery.mock.calls.length - 1][0];
expect(lastQueryArgs?.variables?.pagination).toEqual({
after: '200',
first,
skip: 200,
});
await resolveQuery({
data: generateData(400),
pageInfo: {
hasNextPage: true,
endCursor: '500',
},
});
// load page after last block
subscription.load(700, 800);
lastQueryArgs =
clientQuery.mock.calls[clientQuery.mock.calls.length - 1][0];
expect(lastQueryArgs?.variables?.pagination).toEqual({
after: '600',
first,
skip: 100,
});
await resolveQuery({
data: generateData(700),
pageInfo: {
hasNextPage: true,
endCursor: '800',
},
});
// load last page shorter than expected
subscription.load(950, 1050);
lastQueryArgs =
clientQuery.mock.calls[clientQuery.mock.calls.length - 1][0];
expect(lastQueryArgs?.variables?.pagination).toEqual({
after: '800',
first,
skip: 150,
});
await resolveQuery({
data: generateData(950, 20),
pageInfo: {
hasNextPage: false,
endCursor: '970',
},
});
let lastCallbackArgs = callback.mock.calls[callback.mock.calls.length - 1];
expect(lastCallbackArgs[0].totalCount).toBe(970);
// load next page when pageInfo.hasNextPage === false
const clientQueryCallsLength = clientQuery.mock.calls.length;
subscription.load();
expect(clientQuery.mock.calls.length).toBe(clientQueryCallsLength);
// load last page longer than expected
subscription.load(960, 1000);
lastQueryArgs =
clientQuery.mock.calls[clientQuery.mock.calls.length - 1][0];
expect(lastQueryArgs?.variables?.pagination).toEqual({
after: '960',
first,
});
await resolveQuery({
data: generateData(960, 40),
pageInfo: {
hasNextPage: true,
endCursor: '1000',
},
});
lastCallbackArgs = callback.mock.calls[callback.mock.calls.length - 1];
expect(lastCallbackArgs[0].totalCount).toBe(1000);
subscription.unsubscribe();
});
it('loads requested data blocks and inserts data without totalCount', async () => {
callback.mockClear();
const totalCount = undefined;
const subscription = paginatedSubscribe(callback, client);
await resolveQuery({
data: generateData(),
totalCount,
pageInfo: {
hasNextPage: true,
endCursor: '100',
},
});
let lastCallbackArgs = callback.mock.calls[callback.mock.calls.length - 1];
expect(lastCallbackArgs[0].totalCount).toBe(undefined);
// load next page
subscription.load();
await resolveQuery({
data: generateData(100),
pageInfo: {
hasNextPage: true,
endCursor: '200',
},
});
lastCallbackArgs = callback.mock.calls[callback.mock.calls.length - 1];
expect(lastCallbackArgs[0].totalCount).toBe(undefined);
// load last page
subscription.load();
await resolveQuery({
data: generateData(200, 50),
pageInfo: {
hasNextPage: false,
endCursor: '250',
},
});
lastCallbackArgs = callback.mock.calls[callback.mock.calls.length - 1];
expect(lastCallbackArgs[0].totalCount).toBe(250);
subscription.unsubscribe();
});
it('sets total count when first page has no next page', async () => {
const subscription = paginatedSubscribe(callback, client);
await resolveQuery({
data: generateData(),
pageInfo: {
hasNextPage: false,
endCursor: '100',
},
});
const lastCallbackArgs =
callback.mock.calls[callback.mock.calls.length - 1];
expect(lastCallbackArgs[0].totalCount).toBe(100);
subscription.unsubscribe();
});
});

View File

@ -7,6 +7,7 @@ import type {
} from '@apollo/client'; } from '@apollo/client';
import type { Subscription } from 'zen-observable-ts'; import type { Subscription } from 'zen-observable-ts';
import isEqual from 'lodash/isEqual'; import isEqual from 'lodash/isEqual';
import type { Pagination as PaginationWithoutSkip } from '@vegaprotocol/types';
export interface UpdateCallback<Data, Delta> { export interface UpdateCallback<Data, Delta> {
(arg: { (arg: {
@ -21,15 +22,12 @@ export interface UpdateCallback<Data, Delta> {
} }
export interface Load<Data> { export interface Load<Data> {
(pagination: Pagination): Promise<Data | null>; (start?: number, end?: number): Promise<Data | null>;
} }
export interface Pagination { type Pagination = PaginationWithoutSkip & {
first?: number; skip?: number;
after?: string; };
last?: number;
before?: string;
}
export interface PageInfo { export interface PageInfo {
startCursor?: string; startCursor?: string;
@ -51,7 +49,7 @@ export interface Subscribe<Data, Delta> {
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
type Query<Result> = DocumentNode | TypedDocumentNode<Result, any>; export type Query<Result> = DocumentNode | TypedDocumentNode<Result, any>;
export interface Update<Data, Delta> { export interface Update<Data, Delta> {
(data: Data, delta: Delta, reload: (forceReset?: boolean) => void): Data; (data: Data, delta: Delta, reload: (forceReset?: boolean) => void): Data;
@ -60,13 +58,13 @@ export interface Update<Data, Delta> {
export interface Append<Data> { export interface Append<Data> {
( (
data: Data | null, data: Data | null,
pageInfo: PageInfo,
insertionData: Data | null, insertionData: Data | null,
insertionPageInfo: PageInfo | null, insertionPageInfo: PageInfo | null,
pagination?: Pagination pagination?: Pagination,
totalCount?: number
): { ): {
data: Data | null; data: Data | null;
pageInfo: PageInfo; totalCount?: number;
}; };
} }
@ -86,6 +84,46 @@ interface GetDelta<SubscriptionData, Delta> {
(subscriptionData: SubscriptionData): Delta; (subscriptionData: SubscriptionData): Delta;
} }
export function defaultAppend<Data>(
data: Data | null,
insertionData: Data | null,
insertionPageInfo: PageInfo | null,
pagination?: Pagination,
totalCount?: number
) {
if (data && insertionData && insertionPageInfo) {
if (!(data instanceof Array) || !(insertionData instanceof Array)) {
throw new Error(
'data needs to be instance of { cursor: string }[] when using pagination'
);
}
if (pagination?.after) {
const cursors = data.map((item) => item && item.cursor);
const startIndex = cursors.lastIndexOf(pagination.after);
if (startIndex !== -1) {
const start = startIndex + 1 + (pagination.skip ?? 0);
const end = start + insertionData.length;
let updatedData = [
...data.slice(0, start),
...insertionData,
...data.slice(end),
];
if (!insertionPageInfo.hasNextPage && end !== (totalCount ?? 0)) {
// adjust totalCount if last page is shorter or longer than expected
totalCount = end;
updatedData = updatedData.slice(0, end);
}
return {
data: updatedData,
// increase totalCount if last page is longer than expected
totalCount: totalCount && Math.max(updatedData.length, totalCount),
};
}
}
}
return { data, totalCount };
}
/** /**
* @param subscriptionQuery query that will be used for subscription * @param subscriptionQuery query that will be used for subscription
* @param update function that will be execued on each onNext, it should update data base on delta, it can reload data provider * @param update function that will be execued on each onNext, it should update data base on delta, it can reload data provider
@ -102,7 +140,7 @@ function makeDataProviderInternal<QueryData, Data, SubscriptionData, Delta>(
getDelta: GetDelta<SubscriptionData, Delta>, getDelta: GetDelta<SubscriptionData, Delta>,
pagination?: { pagination?: {
getPageInfo: GetPageInfo<QueryData>; getPageInfo: GetPageInfo<QueryData>;
getTotalCount: GetTotalCount<QueryData>; getTotalCount?: GetTotalCount<QueryData>;
append: Append<Data>; append: Append<Data>;
first: number; first: number;
}, },
@ -125,7 +163,7 @@ function makeDataProviderInternal<QueryData, Data, SubscriptionData, Delta>(
// notify single callback about current state, delta is passes optionally only if notify was invoked onNext // notify single callback about current state, delta is passes optionally only if notify was invoked onNext
const notify = ( const notify = (
callback: UpdateCallback<Data, Delta>, callback: UpdateCallback<Data, Delta>,
dataUpdate?: { delta?: Delta; insertionData?: Data | null } updateData?: { delta?: Delta; insertionData?: Data | null }
) => { ) => {
callback({ callback({
data, data,
@ -133,26 +171,50 @@ function makeDataProviderInternal<QueryData, Data, SubscriptionData, Delta>(
loading, loading,
pageInfo, pageInfo,
totalCount, totalCount,
...dataUpdate, ...updateData,
}); });
}; };
// notify all callbacks // notify all callbacks
const notifyAll = (dataUpdate?: { const notifyAll = (updateData?: {
delta?: Delta; delta?: Delta;
insertionData?: Data | null; insertionData?: Data | null;
}) => { }) => {
callbacks.forEach((callback) => notify(callback, dataUpdate)); callbacks.forEach((callback) => notify(callback, updateData));
}; };
const load = async (params?: Pagination) => { const load = async (start?: number, end?: number) => {
if (!client || !pagination || !pageInfo) { if (!client || !pagination || !pageInfo || !(data instanceof Array)) {
return Promise.reject(); return Promise.reject();
} }
const paginationVariables: Pagination = params ?? { const paginationVariables: Pagination = {
first: pagination.first, first: pagination.first,
after: pageInfo.endCursor, after: pageInfo.endCursor,
}; };
if (start !== undefined) {
if (!start) {
paginationVariables.after = undefined;
} else if (data && data[start - 1]) {
paginationVariables.after = (
data[start - 1] as { cursor: string }
).cursor;
} else {
let skip = 1;
while (!data[start - 1 - skip] && skip <= start) {
skip += 1;
}
paginationVariables.skip = skip;
if (skip === start) {
paginationVariables.after = undefined;
} else {
paginationVariables.after = (
data[start - 1 - skip] as { cursor: string }
).cursor;
}
}
} else if (!pageInfo.hasNextPage) {
return null;
}
const res = await client.query<QueryData>({ const res = await client.query<QueryData>({
query, query,
variables: { variables: {
@ -162,15 +224,18 @@ function makeDataProviderInternal<QueryData, Data, SubscriptionData, Delta>(
fetchPolicy, fetchPolicy,
}); });
const insertionData = getData(res.data); const insertionData = getData(res.data);
const insertionDataPageInfo = pagination.getPageInfo(res.data); const insertionPageInfo = pagination.getPageInfo(res.data);
({ data, pageInfo } = pagination.append( ({ data, totalCount } = pagination.append(
data, data,
pageInfo,
insertionData, insertionData,
insertionDataPageInfo, insertionPageInfo,
paginationVariables paginationVariables,
totalCount
)); ));
totalCount = pagination.getTotalCount(res.data); pageInfo = insertionPageInfo;
totalCount =
(pagination.getTotalCount && pagination.getTotalCount(res.data)) ??
totalCount;
notifyAll({ insertionData }); notifyAll({ insertionData });
return insertionData; return insertionData;
}; };
@ -188,9 +253,23 @@ function makeDataProviderInternal<QueryData, Data, SubscriptionData, Delta>(
fetchPolicy, fetchPolicy,
}); });
data = getData(res.data); data = getData(res.data);
if (pagination) { if (data && pagination) {
if (!(data instanceof Array)) {
throw new Error(
'data needs to be instance of { cursor: string }[] when using pagination'
);
}
pageInfo = pagination.getPageInfo(res.data); pageInfo = pagination.getPageInfo(res.data);
totalCount = pagination.getTotalCount(res.data); if (pageInfo && !pageInfo.hasNextPage) {
totalCount = data.length;
} else {
totalCount =
pagination.getTotalCount && pagination.getTotalCount(res.data);
}
if (data && totalCount && data.length < totalCount) {
data.push(...new Array(totalCount - data.length).fill(null));
}
} }
// if there was some updates received from subscription during initial query loading apply them on just received data // if there was some updates received from subscription during initial query loading apply them on just received data
if (data && updateQueue && updateQueue.length > 0) { if (data && updateQueue && updateQueue.length > 0) {
@ -255,11 +334,11 @@ function makeDataProviderInternal<QueryData, Data, SubscriptionData, Delta>(
if (loading || !data) { if (loading || !data) {
updateQueue.push(delta); updateQueue.push(delta);
} else { } else {
const newData = update(data, delta, reload); const updatedData = update(data, delta, reload);
if (newData === data) { if (updatedData === data) {
return; return;
} }
data = newData; data = updatedData;
notifyAll({ delta }); notifyAll({ delta });
} }
}, },
@ -361,7 +440,7 @@ export function makeDataProvider<QueryData, Data, SubscriptionData, Delta>(
getDelta: GetDelta<SubscriptionData, Delta>, getDelta: GetDelta<SubscriptionData, Delta>,
pagination?: { pagination?: {
getPageInfo: GetPageInfo<QueryData>; getPageInfo: GetPageInfo<QueryData>;
getTotalCount: GetTotalCount<QueryData>; getTotalCount?: GetTotalCount<QueryData>;
append: Append<Data>; append: Append<Data>;
first: number; first: number;
}, },

View File

@ -3,11 +3,13 @@
// @generated // @generated
// This file was automatically generated and should not be edited. // This file was automatically generated and should not be edited.
import { Pagination } from "@vegaprotocol/types";
// ==================================================== // ====================================================
// GraphQL query operation: Trades // GraphQL query operation: Trades
// ==================================================== // ====================================================
export interface Trades_market_trades_market { export interface Trades_market_tradesConnection_edges_node_market {
__typename: "Market"; __typename: "Market";
/** /**
* Market ID * Market ID
@ -38,7 +40,7 @@ export interface Trades_market_trades_market {
positionDecimalPlaces: number; positionDecimalPlaces: number;
} }
export interface Trades_market_trades { export interface Trades_market_tradesConnection_edges_node {
__typename: "Trade"; __typename: "Trade";
/** /**
* The hash of the trade data * The hash of the trade data
@ -59,7 +61,33 @@ export interface Trades_market_trades {
/** /**
* The market the trade occurred on * The market the trade occurred on
*/ */
market: Trades_market_trades_market; market: Trades_market_tradesConnection_edges_node_market;
}
export interface Trades_market_tradesConnection_edges {
__typename: "TradeEdge";
node: Trades_market_tradesConnection_edges_node;
cursor: string;
}
export interface Trades_market_tradesConnection_pageInfo {
__typename: "PageInfo";
startCursor: string;
endCursor: string;
hasNextPage: boolean;
hasPreviousPage: boolean;
}
export interface Trades_market_tradesConnection {
__typename: "TradeConnection";
/**
* The trade in this connection
*/
edges: Trades_market_tradesConnection_edges[];
/**
* The pagination information
*/
pageInfo: Trades_market_tradesConnection_pageInfo;
} }
export interface Trades_market { export interface Trades_market {
@ -68,10 +96,7 @@ export interface Trades_market {
* Market ID * Market ID
*/ */
id: string; id: string;
/** tradesConnection: Trades_market_tradesConnection;
* Trades on a market
*/
trades: Trades_market_trades[] | null;
} }
export interface Trades { export interface Trades {
@ -83,5 +108,5 @@ export interface Trades {
export interface TradesVariables { export interface TradesVariables {
marketId: string; marketId: string;
maxTrades: number; pagination?: Pagination | null;
} }

View File

@ -1,16 +1,22 @@
import { useDataProvider } from '@vegaprotocol/react-helpers'; import { useDataProvider } from '@vegaprotocol/react-helpers';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit'; import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import type { GridApi } from 'ag-grid-community';
import type { AgGridReact } from 'ag-grid-react'; import type { AgGridReact } from 'ag-grid-react';
import { useCallback, useMemo, useRef } from 'react'; import { useCallback, useMemo, useRef } from 'react';
import type {
IGetRowsParams,
BodyScrollEvent,
BodyScrollEndEvent,
} from 'ag-grid-community';
import { import {
MAX_TRADES, MAX_TRADES,
sortTrades,
tradesDataProvider as dataProvider, tradesDataProvider as dataProvider,
} from './trades-data-provider'; } from './trades-data-provider';
import { TradesTable } from './trades-table'; import { TradesTable } from './trades-table';
import type { TradeFields } from './__generated__/TradeFields'; import type { TradeFields } from './__generated__/TradeFields';
import type { TradesVariables } from './__generated__/Trades'; import type {
TradesVariables,
Trades_market_tradesConnection_edges,
} from './__generated__/Trades';
interface TradesContainerProps { interface TradesContainerProps {
marketId: string; marketId: string;
@ -18,51 +24,132 @@ interface TradesContainerProps {
export const TradesContainer = ({ marketId }: TradesContainerProps) => { export const TradesContainer = ({ marketId }: TradesContainerProps) => {
const gridRef = useRef<AgGridReact | null>(null); const gridRef = useRef<AgGridReact | null>(null);
const dataRef = useRef<
(Trades_market_tradesConnection_edges | null)[] | null
>(null);
const totalCountRef = useRef<number | undefined>(undefined);
const newRows = useRef(0);
const scrolledToTop = useRef(true);
const variables = useMemo<TradesVariables>( const variables = useMemo<TradesVariables>(
() => ({ marketId, maxTrades: MAX_TRADES }), () => ({ marketId, maxTrades: MAX_TRADES }),
[marketId] [marketId]
); );
const update = useCallback(({ delta }: { delta: TradeFields[] }) => {
if (!gridRef.current?.api) { const addNewRows = useCallback(() => {
return false; if (newRows.current === 0) {
return;
} }
if (totalCountRef.current !== undefined) {
const incoming = sortTrades(delta); totalCountRef.current += newRows.current;
const currentRows = getAllRows(gridRef.current.api); }
// Create array of trades whose index is now greater than the max so we newRows.current = 0;
// can remove them from the grid if (!gridRef.current?.api) {
const outgoing = [...incoming, ...currentRows].filter( return;
(r, i) => i > MAX_TRADES - 1 }
); gridRef.current.api.refreshInfiniteCache();
gridRef.current.api.applyTransactionAsync({
add: incoming,
remove: outgoing,
addIndex: 0,
});
return true;
}, []); }, []);
const { data, error, loading } = useDataProvider({
const update = useCallback(
({
data,
delta,
}: {
data: (Trades_market_tradesConnection_edges | null)[];
delta: TradeFields[];
}) => {
if (!gridRef.current?.api) {
return false;
}
if (!scrolledToTop.current) {
const createdAt = dataRef.current?.[0]?.node.createdAt;
if (createdAt) {
newRows.current += delta.filter(
(trade) => trade.createdAt > createdAt
).length;
}
}
dataRef.current = data;
gridRef.current.api.refreshInfiniteCache();
return true;
},
[]
);
const insert = useCallback(
({
data,
totalCount,
}: {
data: (Trades_market_tradesConnection_edges | null)[];
totalCount?: number;
}) => {
dataRef.current = data;
totalCountRef.current = totalCount;
return true;
},
[]
);
const { data, error, loading, load, totalCount } = useDataProvider({
dataProvider, dataProvider,
update, update,
insert,
variables, variables,
}); });
totalCountRef.current = totalCount;
dataRef.current = data;
const getRows = async ({
successCallback,
failCallback,
startRow,
endRow,
}: IGetRowsParams) => {
startRow += newRows.current;
endRow += newRows.current;
try {
if (dataRef.current && dataRef.current.indexOf(null) < endRow) {
await load();
}
const rowsThisBlock = dataRef.current
? dataRef.current.slice(startRow, endRow).map((edge) => edge?.node)
: [];
let lastRow = -1;
if (totalCountRef.current !== undefined) {
if (!totalCountRef.current) {
lastRow = 0;
} else if (totalCountRef.current <= endRow) {
lastRow = totalCountRef.current;
}
} else if (rowsThisBlock.length < endRow - startRow) {
lastRow = rowsThisBlock.length;
}
successCallback(rowsThisBlock, lastRow);
} catch (e) {
failCallback();
}
};
const onBodyScrollEnd = (event: BodyScrollEndEvent) => {
if (event.top === 0) {
addNewRows();
}
};
const onBodyScroll = (event: BodyScrollEvent) => {
scrolledToTop.current = event.top <= 0;
};
return ( return (
<AsyncRenderer <AsyncRenderer loading={loading} error={error} data={data}>
loading={loading} <TradesTable
error={error} ref={gridRef}
data={data} rowModelType="infinite"
render={(data) => <TradesTable ref={gridRef} data={data} />} datasource={{ getRows }}
/> onBodyScrollEnd={onBodyScrollEnd}
onBodyScroll={onBodyScroll}
/>
</AsyncRenderer>
); );
}; };
const getAllRows = (api: GridApi) => {
const rows: TradeFields[] = [];
api.forEachNode((node) => {
rows.push(node.data);
});
return rows;
};

View File

@ -1,7 +1,15 @@
import { gql } from '@apollo/client'; import { gql } from '@apollo/client';
import { makeDataProvider } from '@vegaprotocol/react-helpers'; import {
makeDataProvider,
defaultAppend as append,
} from '@vegaprotocol/react-helpers';
import type { PageInfo } from '@vegaprotocol/react-helpers';
import type { TradeFields } from './__generated__/TradeFields'; import type { TradeFields } from './__generated__/TradeFields';
import type { Trades } from './__generated__/Trades'; import type {
Trades,
Trades_market_tradesConnection_edges,
Trades_market_tradesConnection_edges_node,
} from './__generated__/Trades';
import type { TradesSub } from './__generated__/TradesSub'; import type { TradesSub } from './__generated__/TradesSub';
import orderBy from 'lodash/orderBy'; import orderBy from 'lodash/orderBy';
import produce from 'immer'; import produce from 'immer';
@ -24,11 +32,22 @@ const TRADES_FRAGMENT = gql`
export const TRADES_QUERY = gql` export const TRADES_QUERY = gql`
${TRADES_FRAGMENT} ${TRADES_FRAGMENT}
query Trades($marketId: ID!, $maxTrades: Int!) { query Trades($marketId: ID!, $pagination: Pagination) {
market(id: $marketId) { market(id: $marketId) {
id id
trades(last: $maxTrades) { tradesConnection(pagination: $pagination) {
...TradeFields edges {
node {
...TradeFields
}
cursor
}
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
}
} }
} }
} }
@ -43,40 +62,50 @@ export const TRADES_SUB = gql`
} }
`; `;
export const sortTrades = (trades: TradeFields[]) => { const update = (
return orderBy( data: (Trades_market_tradesConnection_edges | null)[],
trades, delta: TradeFields[]
(t) => { ) => {
return new Date(t.createdAt).getTime();
},
'desc'
);
};
const update = (data: TradeFields[], delta: TradeFields[]) => {
return produce(data, (draft) => { return produce(data, (draft) => {
const incoming = sortTrades(delta); orderBy(delta, 'createdAt', 'desc').forEach((node) => {
const index = draft.findIndex((edge) => edge?.node.id === node.id);
// Add new trades to the top if (index !== -1) {
draft.unshift(...incoming); if (draft[index]?.node) {
Object.assign(
// Remove old trades from the bottom draft[index]?.node as Trades_market_tradesConnection_edges_node,
if (draft.length > MAX_TRADES) { node
draft.splice(MAX_TRADES, draft.length - MAX_TRADES); );
} }
} else {
const firstNode = draft[0]?.node;
if (firstNode && node.createdAt >= firstNode.createdAt) {
draft.unshift({ node, cursor: '', __typename: 'TradeEdge' });
}
}
});
}); });
}; };
const getData = (responseData: Trades): TradeFields[] | null => const getData = (
responseData.market ? responseData.market.trades : null; responseData: Trades
): Trades_market_tradesConnection_edges[] | null =>
responseData.market ? responseData.market.tradesConnection.edges : null;
const getDelta = (subscriptionData: TradesSub): TradeFields[] => const getDelta = (subscriptionData: TradesSub): TradeFields[] =>
subscriptionData?.trades || []; subscriptionData?.trades || [];
const getPageInfo = (responseData: Trades): PageInfo | null =>
responseData.market?.tradesConnection.pageInfo || null;
export const tradesDataProvider = makeDataProvider( export const tradesDataProvider = makeDataProvider(
TRADES_QUERY, TRADES_QUERY,
TRADES_SUB, TRADES_SUB,
update, update,
getData, getData,
getDelta getDelta,
{
getPageInfo,
append,
first: 100,
}
); );

View File

@ -19,7 +19,7 @@ const trade: TradeFields = {
it('Correct columns are rendered', async () => { it('Correct columns are rendered', async () => {
await act(async () => { await act(async () => {
render(<TradesTable data={[trade]} />); render(<TradesTable rowData={[trade]} />);
}); });
const expectedHeaders = ['Price', 'Size', 'Created at']; const expectedHeaders = ['Price', 'Size', 'Created at'];
const headers = screen.getAllByRole('columnheader'); const headers = screen.getAllByRole('columnheader');
@ -29,7 +29,7 @@ it('Correct columns are rendered', async () => {
it('Number and data columns are formatted', async () => { it('Number and data columns are formatted', async () => {
await act(async () => { await act(async () => {
render(<TradesTable data={[trade]} />); render(<TradesTable rowData={[trade]} />);
}); });
const cells = screen.getAllByRole('gridcell'); const cells = screen.getAllByRole('gridcell');
@ -51,7 +51,7 @@ it('Price and size columns are formatted', async () => {
size: (Number(trade.size) - 10).toString(), size: (Number(trade.size) - 10).toString(),
}; };
await act(async () => { await act(async () => {
render(<TradesTable data={[trade2, trade]} />); render(<TradesTable rowData={[trade2, trade]} />);
}); });
const cells = screen.getAllByRole('gridcell'); const cells = screen.getAllByRole('gridcell');

View File

@ -1,8 +1,7 @@
import type { AgGridReact } from 'ag-grid-react'; import type { AgGridReact } from 'ag-grid-react';
import { AgGridColumn } from 'ag-grid-react'; import { AgGridColumn } from 'ag-grid-react';
import { forwardRef, useMemo } from 'react'; import { forwardRef } from 'react';
import { AgGridDynamic as AgGrid } from '@vegaprotocol/ui-toolkit'; import { AgGridDynamic as AgGrid } from '@vegaprotocol/ui-toolkit';
import type { TradeFields } from './__generated__/TradeFields';
import { import {
addDecimal, addDecimal,
addDecimalsFormatNumber, addDecimalsFormatNumber,
@ -10,8 +9,9 @@ import {
t, t,
} from '@vegaprotocol/react-helpers'; } from '@vegaprotocol/react-helpers';
import type { CellClassParams, ValueFormatterParams } from 'ag-grid-community'; import type { CellClassParams, ValueFormatterParams } from 'ag-grid-community';
import type { AgGridReactProps, AgReactUiProps } from 'ag-grid-react';
import type { Trades_market_tradesConnection_edges_node } from './__generated__/Trades';
import BigNumber from 'bignumber.js'; import BigNumber from 'bignumber.js';
import { sortTrades } from './trades-data-provider';
export const UP_CLASS = 'text-vega-green'; export const UP_CLASS = 'text-vega-green';
export const DOWN_CLASS = 'text-vega-red'; export const DOWN_CLASS = 'text-vega-red';
@ -37,58 +37,72 @@ const changeCellClass =
return ['font-mono', colorClass].join(' '); return ['font-mono', colorClass].join(' ');
}; };
interface TradesTableProps { type Props = AgGridReactProps | AgReactUiProps;
data: TradeFields[] | null; type TradesTableValueFormatterParams = Omit<
} ValueFormatterParams,
'data' | 'value'
> & {
data: Trades_market_tradesConnection_edges_node | null;
};
export const TradesTable = forwardRef<AgGridReact, TradesTableProps>( export const TradesTable = forwardRef<AgGridReact, Props>((props, ref) => {
({ data }, ref) => { return (
// Sort initial trades <AgGrid
const trades = useMemo(() => { style={{ width: '100%', height: '100%' }}
if (!data) { overlayNoRowsTemplate={t('No trades')}
return null; getRowId={({ data }) => data.id}
} ref={ref}
return sortTrades(data); defaultColDef={{
}, [data]); resizable: true,
}}
return ( {...props}
<AgGrid >
style={{ width: '100%', height: '100%' }} <AgGridColumn
overlayNoRowsTemplate={t('No trades')} headerName={t('Price')}
rowData={trades} field="price"
getRowId={({ data }) => data.id} width={130}
ref={ref} cellClass={changeCellClass('price')}
defaultColDef={{ valueFormatter={({
resizable: true, value,
data,
}: TradesTableValueFormatterParams & {
value: Trades_market_tradesConnection_edges_node['price'];
}) => {
if (!data?.market) {
return null;
}
return addDecimalsFormatNumber(value, data.market.decimalPlaces);
}} }}
> />
<AgGridColumn <AgGridColumn
headerName={t('Price')} headerName={t('Size')}
field="price" field="size"
width={130} width={125}
cellClass={changeCellClass('price')} valueFormatter={({
valueFormatter={({ value, data }: ValueFormatterParams) => { value,
return addDecimalsFormatNumber(value, data.market.decimalPlaces); data,
}} }: TradesTableValueFormatterParams & {
/> value: Trades_market_tradesConnection_edges_node['size'];
<AgGridColumn }) => {
headerName={t('Size')} if (!data?.market) {
field="size" return null;
width={125} }
valueFormatter={({ value, data }: ValueFormatterParams) => { return addDecimal(value, data.market.positionDecimalPlaces);
return addDecimal(value, data.market.positionDecimalPlaces); }}
}} cellClass={changeCellClass('size')}
cellClass={changeCellClass('size')} />
/> <AgGridColumn
<AgGridColumn headerName={t('Created at')}
headerName={t('Created at')} field="createdAt"
field="createdAt" width={170}
width={170} valueFormatter={({
valueFormatter={({ value }: ValueFormatterParams) => { value,
return getDateTimeFormat().format(new Date(value)); }: TradesTableValueFormatterParams & {
}} value: Trades_market_tradesConnection_edges_node['createdAt'];
/> }) => {
</AgGrid> return value && getDateTimeFormat().format(new Date(value));
); }}
} />
); </AgGrid>
);
});

View File

@ -6,7 +6,7 @@
"start": "nx serve", "start": "nx serve",
"build": "nx build", "build": "nx build",
"test": "nx test", "test": "nx test",
"postinstall": "husky install && yarn tsc -b tools/executors/**" "postinstall": "husky install && yarn tsc -b tools/executors/next && yarn tsc -b tools/executors/webpack"
}, },
"engines": { "engines": {
"node": ">=16.14.0" "node": ">=16.14.0"