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:
parent
4fe3c916be
commit
556be89bfd
@ -51,7 +51,6 @@ export const generateFills = (override?: PartialDeep<Fills>): Fills => {
|
||||
id: 'buyer-id',
|
||||
tradesConnection: {
|
||||
__typename: 'TradeConnection',
|
||||
totalCount: 1,
|
||||
edges: fills.map((f) => {
|
||||
return {
|
||||
__typename: 'TradeEdge',
|
||||
@ -63,6 +62,8 @@ export const generateFills = (override?: PartialDeep<Fills>): Fills => {
|
||||
__typename: 'PageInfo',
|
||||
startCursor: '1',
|
||||
endCursor: '2',
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
},
|
||||
},
|
||||
__typename: 'Party',
|
||||
|
@ -1,9 +1,12 @@
|
||||
import merge from 'lodash/merge';
|
||||
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 => {
|
||||
const trades: Trades_market_trades[] = [
|
||||
const trades: Trades_market_tradesConnection_edges_node[] = [
|
||||
{
|
||||
id: 'FFFFBC80005C517A10ACF481F7E6893769471098E696D0CC407F18134044CB16',
|
||||
price: '17116898',
|
||||
@ -44,10 +47,26 @@ export const generateTrades = (override?: PartialDeep<Trades>): Trades => {
|
||||
__typename: 'Trade',
|
||||
},
|
||||
];
|
||||
const defaultResult = {
|
||||
const defaultResult: Trades = {
|
||||
market: {
|
||||
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',
|
||||
},
|
||||
};
|
||||
|
6
libs/fills/src/lib/__generated__/Fills.ts
generated
6
libs/fills/src/lib/__generated__/Fills.ts
generated
@ -206,14 +206,12 @@ export interface Fills_party_tradesConnection_pageInfo {
|
||||
__typename: "PageInfo";
|
||||
startCursor: string;
|
||||
endCursor: string;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
}
|
||||
|
||||
export interface Fills_party_tradesConnection {
|
||||
__typename: "TradeConnection";
|
||||
/**
|
||||
* The total number of trades in this connection
|
||||
*/
|
||||
totalCount: number;
|
||||
/**
|
||||
* The trade in this connection
|
||||
*/
|
||||
|
@ -1,11 +1,16 @@
|
||||
import produce from 'immer';
|
||||
import orderBy from 'lodash/orderBy';
|
||||
import { gql } from '@apollo/client';
|
||||
import { makeDataProvider } from '@vegaprotocol/react-helpers';
|
||||
import type { PageInfo, Pagination } from '@vegaprotocol/react-helpers';
|
||||
import {
|
||||
makeDataProvider,
|
||||
defaultAppend as append,
|
||||
} from '@vegaprotocol/react-helpers';
|
||||
import type { PageInfo } from '@vegaprotocol/react-helpers';
|
||||
import type { FillFields } from './__generated__/FillFields';
|
||||
import type {
|
||||
Fills,
|
||||
Fills_party_tradesConnection_edges,
|
||||
Fills_party_tradesConnection_edges_node,
|
||||
} from './__generated__/Fills';
|
||||
import type { FillsSub } from './__generated__/FillsSub';
|
||||
|
||||
@ -64,7 +69,6 @@ export const FILLS_QUERY = gql`
|
||||
party(id: $partyId) {
|
||||
id
|
||||
tradesConnection(marketId: $marketId, pagination: $pagination) {
|
||||
totalCount
|
||||
edges {
|
||||
node {
|
||||
...FillFields
|
||||
@ -74,6 +78,8 @@ export const FILLS_QUERY = gql`
|
||||
pageInfo {
|
||||
startCursor
|
||||
endCursor
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -90,16 +96,24 @@ export const FILLS_SUB = gql`
|
||||
`;
|
||||
|
||||
const update = (
|
||||
data: Fills_party_tradesConnection_edges[],
|
||||
data: (Fills_party_tradesConnection_edges | null)[],
|
||||
delta: FillFields[]
|
||||
) => {
|
||||
return produce(data, (draft) => {
|
||||
delta.forEach((node) => {
|
||||
const index = draft.findIndex((edge) => edge.node.id === node.id);
|
||||
orderBy(delta, 'createdAt').forEach((node) => {
|
||||
const index = draft.findIndex((edge) => edge?.node.id === node.id);
|
||||
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 {
|
||||
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 =>
|
||||
responseData.party?.tradesConnection.pageInfo || null;
|
||||
|
||||
const getTotalCount = (responseData: Fills): number | undefined =>
|
||||
responseData.party?.tradesConnection.totalCount;
|
||||
|
||||
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(
|
||||
FILLS_QUERY,
|
||||
FILLS_SUB,
|
||||
@ -155,7 +137,6 @@ export const fillsDataProvider = makeDataProvider(
|
||||
getDelta,
|
||||
{
|
||||
getPageInfo,
|
||||
getTotalCount,
|
||||
append,
|
||||
first: 100,
|
||||
}
|
||||
|
@ -3,7 +3,11 @@ import { useCallback, useRef, useMemo } from 'react';
|
||||
import { useDataProvider } from '@vegaprotocol/react-helpers';
|
||||
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
|
||||
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 type { Fills_party_tradesConnection_edges } from './__generated__/Fills';
|
||||
@ -15,14 +19,46 @@ interface FillsManagerProps {
|
||||
|
||||
export const FillsManager = ({ partyId }: FillsManagerProps) => {
|
||||
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 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 }: { data: Fills_party_tradesConnection_edges[] }) => {
|
||||
({
|
||||
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;
|
||||
@ -35,7 +71,7 @@ export const FillsManager = ({ partyId }: FillsManagerProps) => {
|
||||
data,
|
||||
totalCount,
|
||||
}: {
|
||||
data: Fills_party_tradesConnection_edges[];
|
||||
data: (Fills_party_tradesConnection_edges | null)[];
|
||||
totalCount?: number;
|
||||
}) => {
|
||||
dataRef.current = data;
|
||||
@ -48,7 +84,7 @@ export const FillsManager = ({ partyId }: FillsManagerProps) => {
|
||||
const variables = useMemo(() => ({ partyId }), [partyId]);
|
||||
|
||||
const { data, error, loading, load, totalCount } = useDataProvider<
|
||||
Fills_party_tradesConnection_edges[],
|
||||
(Fills_party_tradesConnection_edges | null)[],
|
||||
FillsSub_trades[]
|
||||
>({ dataProvider, update, insert, variables });
|
||||
totalCountRef.current = totalCount;
|
||||
@ -60,15 +96,14 @@ export const FillsManager = ({ partyId }: FillsManagerProps) => {
|
||||
startRow,
|
||||
endRow,
|
||||
}: IGetRowsParams) => {
|
||||
startRow += newRows.current;
|
||||
endRow += newRows.current;
|
||||
try {
|
||||
if (dataRef.current && dataRef.current.length < endRow) {
|
||||
await load({
|
||||
first: endRow - startRow,
|
||||
after: dataRef.current[dataRef.current.length - 1].cursor,
|
||||
});
|
||||
if (dataRef.current && dataRef.current.indexOf(null) < endRow) {
|
||||
await load();
|
||||
}
|
||||
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;
|
||||
if (totalCountRef.current !== undefined) {
|
||||
@ -77,6 +112,8 @@ export const FillsManager = ({ partyId }: FillsManagerProps) => {
|
||||
} else if (totalCountRef.current <= endRow) {
|
||||
lastRow = totalCountRef.current;
|
||||
}
|
||||
} else if (rowsThisBlock.length < endRow - startRow) {
|
||||
lastRow = rowsThisBlock.length;
|
||||
}
|
||||
successCallback(rowsThisBlock, lastRow);
|
||||
} 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 (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
@ -4,7 +4,7 @@ import { Side } from '@vegaprotocol/types';
|
||||
import type { PartialDeep } from 'type-fest';
|
||||
|
||||
import { FillsTable } from './fills-table';
|
||||
import { generateFill, makeGetRows } from './test-helpers';
|
||||
import { generateFill } from './test-helpers';
|
||||
import type { FillFields } from './__generated__/FillFields';
|
||||
|
||||
const waitForGridToBeInTheDOM = () => {
|
||||
@ -47,12 +47,7 @@ describe('FillsTable', () => {
|
||||
});
|
||||
|
||||
it('correct columns are rendered', async () => {
|
||||
render(
|
||||
<FillsTable
|
||||
partyId="party-id"
|
||||
datasource={{ getRows: makeGetRows([generateFill()]) }}
|
||||
/>
|
||||
);
|
||||
render(<FillsTable partyId="party-id" rowData={[generateFill()]} />);
|
||||
await waitForGridToBeInTheDOM();
|
||||
await waitForDataToHaveLoaded();
|
||||
|
||||
@ -84,12 +79,7 @@ describe('FillsTable', () => {
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<FillsTable
|
||||
partyId={partyId}
|
||||
datasource={{ getRows: makeGetRows([buyerFill]) }}
|
||||
/>
|
||||
);
|
||||
render(<FillsTable partyId={partyId} rowData={[buyerFill]} />);
|
||||
await waitForGridToBeInTheDOM();
|
||||
await waitForDataToHaveLoaded();
|
||||
|
||||
@ -126,12 +116,7 @@ describe('FillsTable', () => {
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<FillsTable
|
||||
partyId={partyId}
|
||||
datasource={{ getRows: makeGetRows([buyerFill]) }}
|
||||
/>
|
||||
);
|
||||
render(<FillsTable partyId={partyId} rowData={[buyerFill]} />);
|
||||
await waitForGridToBeInTheDOM();
|
||||
await waitForDataToHaveLoaded();
|
||||
|
||||
@ -163,10 +148,7 @@ describe('FillsTable', () => {
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<FillsTable
|
||||
partyId={partyId}
|
||||
datasource={{ getRows: makeGetRows([takerFill]) }}
|
||||
/>
|
||||
<FillsTable partyId={partyId} rowData={[takerFill]} />
|
||||
);
|
||||
await waitForGridToBeInTheDOM();
|
||||
await waitForDataToHaveLoaded();
|
||||
@ -184,12 +166,7 @@ describe('FillsTable', () => {
|
||||
aggressor: Side.Buy,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<FillsTable
|
||||
partyId={partyId}
|
||||
datasource={{ getRows: makeGetRows([makerFill]) }}
|
||||
/>
|
||||
);
|
||||
rerender(<FillsTable partyId={partyId} rowData={[makerFill]} />);
|
||||
await waitForGridToBeInTheDOM();
|
||||
await waitForDataToHaveLoaded();
|
||||
|
||||
|
@ -1,22 +1,397 @@
|
||||
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 { 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 {
|
||||
component: FillsTable,
|
||||
title: 'FillsTable',
|
||||
} as Meta;
|
||||
|
||||
const Template: Story<FillsTableProps> = (args) => <FillsTable {...args} />;
|
||||
|
||||
const Template: Story<Props> = (args) => <FillsTable {...args} />;
|
||||
export const Default = Template.bind({});
|
||||
|
||||
const createdAt = new Date('2005-04-02 21:37:00').getTime();
|
||||
const fills = generateFills();
|
||||
Default.args = {
|
||||
partyId: 'party-id',
|
||||
datasource: {
|
||||
getRows: makeGetRows(
|
||||
fills.party?.tradesConnection.edges.map((e) => e.node) || []
|
||||
),
|
||||
},
|
||||
rowData: 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({});
|
||||
|
@ -6,21 +6,32 @@ import {
|
||||
getDateTimeFormat,
|
||||
t,
|
||||
} from '@vegaprotocol/react-helpers';
|
||||
import { Side } from '@vegaprotocol/types';
|
||||
import { AgGridColumn } from 'ag-grid-react';
|
||||
import { AgGridDynamic as AgGrid } from '@vegaprotocol/ui-toolkit';
|
||||
import { forwardRef } from 'react';
|
||||
import type { FillFields } from './__generated__/FillFields';
|
||||
import type { ValueFormatterParams, IDatasource } from 'ag-grid-community';
|
||||
import type { ValueFormatterParams } from 'ag-grid-community';
|
||||
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;
|
||||
datasource: IDatasource;
|
||||
}
|
||||
};
|
||||
|
||||
export const FillsTable = forwardRef<AgGridReact, FillsTableProps>(
|
||||
({ partyId, datasource }, ref) => {
|
||||
type AccountsTableValueFormatterParams = Omit<
|
||||
ValueFormatterParams,
|
||||
'data' | 'value'
|
||||
> & {
|
||||
data: Fills_party_tradesConnection_edges_node | null;
|
||||
};
|
||||
|
||||
export const FillsTable = forwardRef<AgGridReact, Props>(
|
||||
({ partyId, ...props }, ref) => {
|
||||
return (
|
||||
<AgGrid
|
||||
ref={ref}
|
||||
@ -28,8 +39,7 @@ export const FillsTable = forwardRef<AgGridReact, FillsTableProps>(
|
||||
defaultColDef={{ flex: 1, resizable: true }}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
getRowId={({ data }) => data?.id}
|
||||
rowModelType="infinite"
|
||||
datasource={datasource}
|
||||
{...props}
|
||||
>
|
||||
<AgGridColumn headerName={t('Market')} field="market.name" />
|
||||
<AgGridColumn
|
||||
@ -69,7 +79,11 @@ export const FillsTable = forwardRef<AgGridReact, FillsTableProps>(
|
||||
<AgGridColumn
|
||||
headerName={t('Date')}
|
||||
field="createdAt"
|
||||
valueFormatter={({ value }: ValueFormatterParams) => {
|
||||
valueFormatter={({
|
||||
value,
|
||||
}: AccountsTableValueFormatterParams & {
|
||||
value: Fills_party_tradesConnection_edges_node['createdAt'];
|
||||
}) => {
|
||||
if (value === undefined) {
|
||||
return value;
|
||||
}
|
||||
@ -81,9 +95,14 @@ export const FillsTable = forwardRef<AgGridReact, FillsTableProps>(
|
||||
}
|
||||
);
|
||||
|
||||
const formatPrice = ({ value, data }: ValueFormatterParams) => {
|
||||
if (value === undefined) {
|
||||
return value;
|
||||
const formatPrice = ({
|
||||
value,
|
||||
data,
|
||||
}: AccountsTableValueFormatterParams & {
|
||||
value?: Fills_party_tradesConnection_edges_node['price'];
|
||||
}) => {
|
||||
if (value === undefined || !data) {
|
||||
return undefined;
|
||||
}
|
||||
const asset =
|
||||
data?.market.tradableInstrument.instrument.product.settlementAsset.symbol;
|
||||
@ -95,9 +114,14 @@ const formatPrice = ({ value, data }: ValueFormatterParams) => {
|
||||
};
|
||||
|
||||
const formatSize = (partyId: string) => {
|
||||
return ({ value, data }: ValueFormatterParams) => {
|
||||
if (value === undefined) {
|
||||
return value;
|
||||
return ({
|
||||
value,
|
||||
data,
|
||||
}: AccountsTableValueFormatterParams & {
|
||||
value?: Fills_party_tradesConnection_edges_node['size'];
|
||||
}) => {
|
||||
if (value === undefined || !data) {
|
||||
return undefined;
|
||||
}
|
||||
let prefix;
|
||||
if (data?.buyer.id === partyId) {
|
||||
@ -114,9 +138,14 @@ const formatSize = (partyId: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
const formatTotal = ({ value, data }: ValueFormatterParams) => {
|
||||
if (value === undefined) {
|
||||
return value;
|
||||
const formatTotal = ({
|
||||
value,
|
||||
data,
|
||||
}: AccountsTableValueFormatterParams & {
|
||||
value?: Fills_party_tradesConnection_edges_node['price'];
|
||||
}) => {
|
||||
if (value === undefined || !data) {
|
||||
return undefined;
|
||||
}
|
||||
const asset =
|
||||
data?.market.tradableInstrument.instrument.product.settlementAsset.symbol;
|
||||
@ -131,7 +160,12 @@ const formatTotal = ({ value, data }: ValueFormatterParams) => {
|
||||
};
|
||||
|
||||
const formatRole = (partyId: string) => {
|
||||
return ({ value, data }: ValueFormatterParams) => {
|
||||
return ({
|
||||
value,
|
||||
data,
|
||||
}: AccountsTableValueFormatterParams & {
|
||||
value?: Fills_party_tradesConnection_edges_node['aggressor'];
|
||||
}) => {
|
||||
if (value === undefined) {
|
||||
return value;
|
||||
}
|
||||
@ -156,7 +190,12 @@ const formatRole = (partyId: string) => {
|
||||
};
|
||||
|
||||
const formatFee = (partyId: string) => {
|
||||
return ({ value, data }: ValueFormatterParams) => {
|
||||
return ({
|
||||
value,
|
||||
data,
|
||||
}: AccountsTableValueFormatterParams & {
|
||||
value?: FillFields_market_tradableInstrument_instrument_product;
|
||||
}) => {
|
||||
if (value === undefined) {
|
||||
return value;
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import merge from 'lodash/merge';
|
||||
import type { IGetRowsParams } from 'ag-grid-community';
|
||||
import type { PartialDeep } from 'type-fest';
|
||||
import type {
|
||||
Fills,
|
||||
@ -52,7 +51,6 @@ export const generateFills = (override?: PartialDeep<Fills>): Fills => {
|
||||
id: 'buyer-id',
|
||||
tradesConnection: {
|
||||
__typename: 'TradeConnection',
|
||||
totalCount: 1,
|
||||
edges: fills.map((f) => {
|
||||
return {
|
||||
__typename: 'TradeEdge',
|
||||
@ -64,6 +62,8 @@ export const generateFills = (override?: PartialDeep<Fills>): Fills => {
|
||||
__typename: 'PageInfo',
|
||||
startCursor: '1',
|
||||
endCursor: '2',
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
},
|
||||
},
|
||||
__typename: 'Party',
|
||||
@ -79,7 +79,7 @@ export const generateFill = (
|
||||
const defaultFill: Fills_party_tradesConnection_edges_node = {
|
||||
__typename: 'Trade',
|
||||
id: '0',
|
||||
createdAt: new Date().toISOString(),
|
||||
createdAt: '2005-04-02T19:37:00.000Z',
|
||||
price: '10000000',
|
||||
size: '50000',
|
||||
buyOrder: 'buy-order',
|
||||
@ -133,9 +133,3 @@ export const generateFill = (
|
||||
|
||||
return merge(defaultFill, override);
|
||||
};
|
||||
|
||||
export const makeGetRows =
|
||||
(data: Fills_party_tradesConnection_edges_node[]) =>
|
||||
({ successCallback }: IGetRowsParams) => {
|
||||
successCallback(data, data.length);
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -1,3 +1,2 @@
|
||||
export * from './__generated__';
|
||||
export * from './landing';
|
||||
export * from './markets-container';
|
||||
|
@ -1,3 +1,4 @@
|
||||
export * from './MarketDataFields';
|
||||
export * from './MarketDataSub';
|
||||
export * from './MarketList';
|
||||
export * from './Markets';
|
@ -2,4 +2,4 @@ export * from './market-list-table';
|
||||
export * from './markets-container';
|
||||
export * from './markets-data-provider';
|
||||
export * from './summary-cell';
|
||||
export * from './__generated__/MarketList';
|
||||
export * from './__generated__';
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { forwardRef } from 'react';
|
||||
import type { IDatasource, ValueFormatterParams } from 'ag-grid-community';
|
||||
import type { ValueFormatterParams } from 'ag-grid-community';
|
||||
import {
|
||||
PriceFlashCell,
|
||||
addDecimalsFormatNumber,
|
||||
@ -8,95 +8,119 @@ import {
|
||||
} from '@vegaprotocol/react-helpers';
|
||||
import { AgGridDynamic as AgGrid } from '@vegaprotocol/ui-toolkit';
|
||||
import { AgGridColumn } from 'ag-grid-react';
|
||||
import type { AgGridReact } from 'ag-grid-react';
|
||||
import type { Markets_markets } from '../__generated__/Markets';
|
||||
import type {
|
||||
AgGridReact,
|
||||
AgGridReactProps,
|
||||
AgReactUiProps,
|
||||
} from 'ag-grid-react';
|
||||
import { MarketTradingMode, AuctionTrigger } from '@vegaprotocol/types';
|
||||
import type {
|
||||
Markets_markets,
|
||||
Markets_markets_data,
|
||||
} from './__generated__/Markets';
|
||||
|
||||
interface MarketListTableProps {
|
||||
datasource: IDatasource;
|
||||
onRowClicked: (marketId: string) => void;
|
||||
}
|
||||
type Props = AgGridReactProps | AgReactUiProps;
|
||||
|
||||
export const MarketListTable = forwardRef<AgGridReact, MarketListTableProps>(
|
||||
({ datasource, onRowClicked }, ref) => {
|
||||
return (
|
||||
<AgGrid
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
overlayNoRowsTemplate={t('No markets')}
|
||||
rowModelType="infinite"
|
||||
datasource={datasource}
|
||||
getRowId={({ data }) => data?.id}
|
||||
ref={ref}
|
||||
defaultColDef={{
|
||||
flex: 1,
|
||||
resizable: true,
|
||||
type MarketListTableValueFormatterParams = Omit<
|
||||
ValueFormatterParams,
|
||||
'data' | 'value'
|
||||
> & {
|
||||
data: Markets_markets;
|
||||
};
|
||||
|
||||
export const MarketListTable = forwardRef<AgGridReact, Props>((props, ref) => {
|
||||
return (
|
||||
<AgGrid
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
overlayNoRowsTemplate={t('No markets')}
|
||||
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 }) =>
|
||||
onRowClicked(data.id)
|
||||
/>
|
||||
<AgGridColumn
|
||||
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
|
||||
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 }: ValueFormatterParams) => {
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
<AgGridColumn
|
||||
headerName={t('Best bid')}
|
||||
field="data.bestBidPrice"
|
||||
type="rightAligned"
|
||||
cellRenderer="PriceFlashCell"
|
||||
valueFormatter={({ value, data }: ValueFormatterParams) =>
|
||||
value === undefined
|
||||
? value
|
||||
: addDecimalsFormatNumber(value, data.decimalPlaces)
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
);
|
||||
/>
|
||||
<AgGridColumn
|
||||
headerName={t('Best offer')}
|
||||
field="data.bestOfferPrice"
|
||||
type="rightAligned"
|
||||
valueFormatter={({
|
||||
value,
|
||||
data,
|
||||
}: MarketListTableValueFormatterParams & {
|
||||
value?: Markets_markets_data['bestOfferPrice'];
|
||||
}) =>
|
||||
value === undefined
|
||||
? value
|
||||
: addDecimalsFormatNumber(value, data.decimalPlaces)
|
||||
}
|
||||
cellRenderer="PriceFlashCell"
|
||||
/>
|
||||
<AgGridColumn
|
||||
headerName={t('Mark price')}
|
||||
field="data.markPrice"
|
||||
type="rightAligned"
|
||||
cellRenderer="PriceFlashCell"
|
||||
valueFormatter={({
|
||||
value,
|
||||
data,
|
||||
}: MarketListTableValueFormatterParams & {
|
||||
value?: Markets_markets_data['markPrice'];
|
||||
}) =>
|
||||
value === undefined
|
||||
? value
|
||||
: addDecimalsFormatNumber(value, data.decimalPlaces)
|
||||
}
|
||||
/>
|
||||
<AgGridColumn headerName={t('Description')} field="name" />
|
||||
</AgGrid>
|
||||
);
|
||||
});
|
||||
|
||||
export default MarketListTable;
|
||||
|
@ -8,7 +8,7 @@ import type { IGetRowsParams } from 'ag-grid-community';
|
||||
import type {
|
||||
Markets_markets,
|
||||
Markets_markets_data,
|
||||
} from '../../components/__generated__/Markets';
|
||||
} from './__generated__/Markets';
|
||||
import { marketsDataProvider as dataProvider } from './markets-data-provider';
|
||||
import { MarketState } from '@vegaprotocol/types';
|
||||
|
||||
@ -42,13 +42,15 @@ export const MarketsContainer = () => {
|
||||
const lastRow = dataRef.current?.length ?? -1;
|
||||
successCallback(rowsThisBlock, lastRow);
|
||||
};
|
||||
|
||||
return (
|
||||
<AsyncRenderer loading={loading} error={error} data={data}>
|
||||
<MarketListTable
|
||||
rowModelType="infinite"
|
||||
datasource={{ getRows }}
|
||||
ref={gridRef}
|
||||
onRowClicked={(id) => push(`/markets/${id}`)}
|
||||
onRowClicked={({ data }: { data: Markets_markets }) =>
|
||||
push(`/markets/${data.id}`)
|
||||
}
|
||||
/>
|
||||
</AsyncRenderer>
|
||||
);
|
||||
|
@ -3,13 +3,10 @@ import { gql } from '@apollo/client';
|
||||
import type {
|
||||
Markets,
|
||||
Markets_markets,
|
||||
} from '../../components/__generated__/Markets';
|
||||
import { makeDataProvider } from '@vegaprotocol/react-helpers';
|
||||
|
||||
import type {
|
||||
MarketDataSub,
|
||||
MarketDataSub_marketData,
|
||||
} from '../../components/__generated__/MarketDataSub';
|
||||
} from './';
|
||||
import { makeDataProvider } from '@vegaprotocol/react-helpers';
|
||||
|
||||
const MARKET_DATA_FRAGMENT = gql`
|
||||
fragment MarketDataFields on MarketData {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useApolloClient } 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);
|
||||
}
|
||||
}, []);
|
||||
const load = useCallback((pagination: Pagination) => {
|
||||
const load = useCallback<Load<Data>>((...args) => {
|
||||
if (loadRef.current) {
|
||||
return loadRef.current(pagination);
|
||||
return loadRef.current(...args);
|
||||
}
|
||||
return Promise.reject();
|
||||
}, []);
|
||||
|
445
libs/react-helpers/src/lib/generic-data-provider.spec.ts
Normal file
445
libs/react-helpers/src/lib/generic-data-provider.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
@ -7,6 +7,7 @@ import type {
|
||||
} from '@apollo/client';
|
||||
import type { Subscription } from 'zen-observable-ts';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import type { Pagination as PaginationWithoutSkip } from '@vegaprotocol/types';
|
||||
|
||||
export interface UpdateCallback<Data, Delta> {
|
||||
(arg: {
|
||||
@ -21,15 +22,12 @@ export interface UpdateCallback<Data, Delta> {
|
||||
}
|
||||
|
||||
export interface Load<Data> {
|
||||
(pagination: Pagination): Promise<Data | null>;
|
||||
(start?: number, end?: number): Promise<Data | null>;
|
||||
}
|
||||
|
||||
export interface Pagination {
|
||||
first?: number;
|
||||
after?: string;
|
||||
last?: number;
|
||||
before?: string;
|
||||
}
|
||||
type Pagination = PaginationWithoutSkip & {
|
||||
skip?: number;
|
||||
};
|
||||
|
||||
export interface PageInfo {
|
||||
startCursor?: string;
|
||||
@ -51,7 +49,7 @@ export interface Subscribe<Data, Delta> {
|
||||
}
|
||||
|
||||
// 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> {
|
||||
(data: Data, delta: Delta, reload: (forceReset?: boolean) => void): Data;
|
||||
@ -60,13 +58,13 @@ export interface Update<Data, Delta> {
|
||||
export interface Append<Data> {
|
||||
(
|
||||
data: Data | null,
|
||||
pageInfo: PageInfo,
|
||||
insertionData: Data | null,
|
||||
insertionPageInfo: PageInfo | null,
|
||||
pagination?: Pagination
|
||||
pagination?: Pagination,
|
||||
totalCount?: number
|
||||
): {
|
||||
data: Data | null;
|
||||
pageInfo: PageInfo;
|
||||
totalCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@ -86,6 +84,46 @@ interface GetDelta<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 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>,
|
||||
pagination?: {
|
||||
getPageInfo: GetPageInfo<QueryData>;
|
||||
getTotalCount: GetTotalCount<QueryData>;
|
||||
getTotalCount?: GetTotalCount<QueryData>;
|
||||
append: Append<Data>;
|
||||
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
|
||||
const notify = (
|
||||
callback: UpdateCallback<Data, Delta>,
|
||||
dataUpdate?: { delta?: Delta; insertionData?: Data | null }
|
||||
updateData?: { delta?: Delta; insertionData?: Data | null }
|
||||
) => {
|
||||
callback({
|
||||
data,
|
||||
@ -133,26 +171,50 @@ function makeDataProviderInternal<QueryData, Data, SubscriptionData, Delta>(
|
||||
loading,
|
||||
pageInfo,
|
||||
totalCount,
|
||||
...dataUpdate,
|
||||
...updateData,
|
||||
});
|
||||
};
|
||||
|
||||
// notify all callbacks
|
||||
const notifyAll = (dataUpdate?: {
|
||||
const notifyAll = (updateData?: {
|
||||
delta?: Delta;
|
||||
insertionData?: Data | null;
|
||||
}) => {
|
||||
callbacks.forEach((callback) => notify(callback, dataUpdate));
|
||||
callbacks.forEach((callback) => notify(callback, updateData));
|
||||
};
|
||||
|
||||
const load = async (params?: Pagination) => {
|
||||
if (!client || !pagination || !pageInfo) {
|
||||
const load = async (start?: number, end?: number) => {
|
||||
if (!client || !pagination || !pageInfo || !(data instanceof Array)) {
|
||||
return Promise.reject();
|
||||
}
|
||||
const paginationVariables: Pagination = params ?? {
|
||||
const paginationVariables: Pagination = {
|
||||
first: pagination.first,
|
||||
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>({
|
||||
query,
|
||||
variables: {
|
||||
@ -162,15 +224,18 @@ function makeDataProviderInternal<QueryData, Data, SubscriptionData, Delta>(
|
||||
fetchPolicy,
|
||||
});
|
||||
const insertionData = getData(res.data);
|
||||
const insertionDataPageInfo = pagination.getPageInfo(res.data);
|
||||
({ data, pageInfo } = pagination.append(
|
||||
const insertionPageInfo = pagination.getPageInfo(res.data);
|
||||
({ data, totalCount } = pagination.append(
|
||||
data,
|
||||
pageInfo,
|
||||
insertionData,
|
||||
insertionDataPageInfo,
|
||||
paginationVariables
|
||||
insertionPageInfo,
|
||||
paginationVariables,
|
||||
totalCount
|
||||
));
|
||||
totalCount = pagination.getTotalCount(res.data);
|
||||
pageInfo = insertionPageInfo;
|
||||
totalCount =
|
||||
(pagination.getTotalCount && pagination.getTotalCount(res.data)) ??
|
||||
totalCount;
|
||||
notifyAll({ insertionData });
|
||||
return insertionData;
|
||||
};
|
||||
@ -188,9 +253,23 @@ function makeDataProviderInternal<QueryData, Data, SubscriptionData, Delta>(
|
||||
fetchPolicy,
|
||||
});
|
||||
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);
|
||||
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 (data && updateQueue && updateQueue.length > 0) {
|
||||
@ -255,11 +334,11 @@ function makeDataProviderInternal<QueryData, Data, SubscriptionData, Delta>(
|
||||
if (loading || !data) {
|
||||
updateQueue.push(delta);
|
||||
} else {
|
||||
const newData = update(data, delta, reload);
|
||||
if (newData === data) {
|
||||
const updatedData = update(data, delta, reload);
|
||||
if (updatedData === data) {
|
||||
return;
|
||||
}
|
||||
data = newData;
|
||||
data = updatedData;
|
||||
notifyAll({ delta });
|
||||
}
|
||||
},
|
||||
@ -361,7 +440,7 @@ export function makeDataProvider<QueryData, Data, SubscriptionData, Delta>(
|
||||
getDelta: GetDelta<SubscriptionData, Delta>,
|
||||
pagination?: {
|
||||
getPageInfo: GetPageInfo<QueryData>;
|
||||
getTotalCount: GetTotalCount<QueryData>;
|
||||
getTotalCount?: GetTotalCount<QueryData>;
|
||||
append: Append<Data>;
|
||||
first: number;
|
||||
},
|
||||
|
41
libs/trades/src/lib/__generated__/Trades.ts
generated
41
libs/trades/src/lib/__generated__/Trades.ts
generated
@ -3,11 +3,13 @@
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
import { Pagination } from "@vegaprotocol/types";
|
||||
|
||||
// ====================================================
|
||||
// GraphQL query operation: Trades
|
||||
// ====================================================
|
||||
|
||||
export interface Trades_market_trades_market {
|
||||
export interface Trades_market_tradesConnection_edges_node_market {
|
||||
__typename: "Market";
|
||||
/**
|
||||
* Market ID
|
||||
@ -38,7 +40,7 @@ export interface Trades_market_trades_market {
|
||||
positionDecimalPlaces: number;
|
||||
}
|
||||
|
||||
export interface Trades_market_trades {
|
||||
export interface Trades_market_tradesConnection_edges_node {
|
||||
__typename: "Trade";
|
||||
/**
|
||||
* The hash of the trade data
|
||||
@ -59,7 +61,33 @@ export interface Trades_market_trades {
|
||||
/**
|
||||
* 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 {
|
||||
@ -68,10 +96,7 @@ export interface Trades_market {
|
||||
* Market ID
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Trades on a market
|
||||
*/
|
||||
trades: Trades_market_trades[] | null;
|
||||
tradesConnection: Trades_market_tradesConnection;
|
||||
}
|
||||
|
||||
export interface Trades {
|
||||
@ -83,5 +108,5 @@ export interface Trades {
|
||||
|
||||
export interface TradesVariables {
|
||||
marketId: string;
|
||||
maxTrades: number;
|
||||
pagination?: Pagination | null;
|
||||
}
|
||||
|
@ -1,16 +1,22 @@
|
||||
import { useDataProvider } from '@vegaprotocol/react-helpers';
|
||||
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
|
||||
import type { GridApi } from 'ag-grid-community';
|
||||
import type { AgGridReact } from 'ag-grid-react';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type {
|
||||
IGetRowsParams,
|
||||
BodyScrollEvent,
|
||||
BodyScrollEndEvent,
|
||||
} from 'ag-grid-community';
|
||||
import {
|
||||
MAX_TRADES,
|
||||
sortTrades,
|
||||
tradesDataProvider as dataProvider,
|
||||
} from './trades-data-provider';
|
||||
import { TradesTable } from './trades-table';
|
||||
import type { TradeFields } from './__generated__/TradeFields';
|
||||
import type { TradesVariables } from './__generated__/Trades';
|
||||
import type {
|
||||
TradesVariables,
|
||||
Trades_market_tradesConnection_edges,
|
||||
} from './__generated__/Trades';
|
||||
|
||||
interface TradesContainerProps {
|
||||
marketId: string;
|
||||
@ -18,51 +24,132 @@ interface TradesContainerProps {
|
||||
|
||||
export const TradesContainer = ({ marketId }: TradesContainerProps) => {
|
||||
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>(
|
||||
() => ({ marketId, maxTrades: MAX_TRADES }),
|
||||
[marketId]
|
||||
);
|
||||
const update = useCallback(({ delta }: { delta: TradeFields[] }) => {
|
||||
if (!gridRef.current?.api) {
|
||||
return false;
|
||||
|
||||
const addNewRows = useCallback(() => {
|
||||
if (newRows.current === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const incoming = sortTrades(delta);
|
||||
const currentRows = getAllRows(gridRef.current.api);
|
||||
// Create array of trades whose index is now greater than the max so we
|
||||
// can remove them from the grid
|
||||
const outgoing = [...incoming, ...currentRows].filter(
|
||||
(r, i) => i > MAX_TRADES - 1
|
||||
);
|
||||
|
||||
gridRef.current.api.applyTransactionAsync({
|
||||
add: incoming,
|
||||
remove: outgoing,
|
||||
addIndex: 0,
|
||||
});
|
||||
|
||||
return true;
|
||||
if (totalCountRef.current !== undefined) {
|
||||
totalCountRef.current += newRows.current;
|
||||
}
|
||||
newRows.current = 0;
|
||||
if (!gridRef.current?.api) {
|
||||
return;
|
||||
}
|
||||
gridRef.current.api.refreshInfiniteCache();
|
||||
}, []);
|
||||
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,
|
||||
update,
|
||||
insert,
|
||||
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 (
|
||||
<AsyncRenderer
|
||||
loading={loading}
|
||||
error={error}
|
||||
data={data}
|
||||
render={(data) => <TradesTable ref={gridRef} data={data} />}
|
||||
/>
|
||||
<AsyncRenderer loading={loading} error={error} data={data}>
|
||||
<TradesTable
|
||||
ref={gridRef}
|
||||
rowModelType="infinite"
|
||||
datasource={{ getRows }}
|
||||
onBodyScrollEnd={onBodyScrollEnd}
|
||||
onBodyScroll={onBodyScroll}
|
||||
/>
|
||||
</AsyncRenderer>
|
||||
);
|
||||
};
|
||||
|
||||
const getAllRows = (api: GridApi) => {
|
||||
const rows: TradeFields[] = [];
|
||||
api.forEachNode((node) => {
|
||||
rows.push(node.data);
|
||||
});
|
||||
return rows;
|
||||
};
|
||||
|
@ -1,7 +1,15 @@
|
||||
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 { 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 orderBy from 'lodash/orderBy';
|
||||
import produce from 'immer';
|
||||
@ -24,11 +32,22 @@ const TRADES_FRAGMENT = gql`
|
||||
|
||||
export const TRADES_QUERY = gql`
|
||||
${TRADES_FRAGMENT}
|
||||
query Trades($marketId: ID!, $maxTrades: Int!) {
|
||||
query Trades($marketId: ID!, $pagination: Pagination) {
|
||||
market(id: $marketId) {
|
||||
id
|
||||
trades(last: $maxTrades) {
|
||||
...TradeFields
|
||||
tradesConnection(pagination: $pagination) {
|
||||
edges {
|
||||
node {
|
||||
...TradeFields
|
||||
}
|
||||
cursor
|
||||
}
|
||||
pageInfo {
|
||||
startCursor
|
||||
endCursor
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -43,40 +62,50 @@ export const TRADES_SUB = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
export const sortTrades = (trades: TradeFields[]) => {
|
||||
return orderBy(
|
||||
trades,
|
||||
(t) => {
|
||||
return new Date(t.createdAt).getTime();
|
||||
},
|
||||
'desc'
|
||||
);
|
||||
};
|
||||
|
||||
const update = (data: TradeFields[], delta: TradeFields[]) => {
|
||||
const update = (
|
||||
data: (Trades_market_tradesConnection_edges | null)[],
|
||||
delta: TradeFields[]
|
||||
) => {
|
||||
return produce(data, (draft) => {
|
||||
const incoming = sortTrades(delta);
|
||||
|
||||
// Add new trades to the top
|
||||
draft.unshift(...incoming);
|
||||
|
||||
// Remove old trades from the bottom
|
||||
if (draft.length > MAX_TRADES) {
|
||||
draft.splice(MAX_TRADES, draft.length - MAX_TRADES);
|
||||
}
|
||||
orderBy(delta, 'createdAt', 'desc').forEach((node) => {
|
||||
const index = draft.findIndex((edge) => edge?.node.id === node.id);
|
||||
if (index !== -1) {
|
||||
if (draft[index]?.node) {
|
||||
Object.assign(
|
||||
draft[index]?.node as Trades_market_tradesConnection_edges_node,
|
||||
node
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const firstNode = draft[0]?.node;
|
||||
if (firstNode && node.createdAt >= firstNode.createdAt) {
|
||||
draft.unshift({ node, cursor: '', __typename: 'TradeEdge' });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const getData = (responseData: Trades): TradeFields[] | null =>
|
||||
responseData.market ? responseData.market.trades : null;
|
||||
const getData = (
|
||||
responseData: Trades
|
||||
): Trades_market_tradesConnection_edges[] | null =>
|
||||
responseData.market ? responseData.market.tradesConnection.edges : null;
|
||||
|
||||
const getDelta = (subscriptionData: TradesSub): TradeFields[] =>
|
||||
subscriptionData?.trades || [];
|
||||
|
||||
const getPageInfo = (responseData: Trades): PageInfo | null =>
|
||||
responseData.market?.tradesConnection.pageInfo || null;
|
||||
|
||||
export const tradesDataProvider = makeDataProvider(
|
||||
TRADES_QUERY,
|
||||
TRADES_SUB,
|
||||
update,
|
||||
getData,
|
||||
getDelta
|
||||
getDelta,
|
||||
{
|
||||
getPageInfo,
|
||||
append,
|
||||
first: 100,
|
||||
}
|
||||
);
|
||||
|
@ -19,7 +19,7 @@ const trade: TradeFields = {
|
||||
|
||||
it('Correct columns are rendered', async () => {
|
||||
await act(async () => {
|
||||
render(<TradesTable data={[trade]} />);
|
||||
render(<TradesTable rowData={[trade]} />);
|
||||
});
|
||||
const expectedHeaders = ['Price', 'Size', 'Created at'];
|
||||
const headers = screen.getAllByRole('columnheader');
|
||||
@ -29,7 +29,7 @@ it('Correct columns are rendered', async () => {
|
||||
|
||||
it('Number and data columns are formatted', async () => {
|
||||
await act(async () => {
|
||||
render(<TradesTable data={[trade]} />);
|
||||
render(<TradesTable rowData={[trade]} />);
|
||||
});
|
||||
|
||||
const cells = screen.getAllByRole('gridcell');
|
||||
@ -51,7 +51,7 @@ it('Price and size columns are formatted', async () => {
|
||||
size: (Number(trade.size) - 10).toString(),
|
||||
};
|
||||
await act(async () => {
|
||||
render(<TradesTable data={[trade2, trade]} />);
|
||||
render(<TradesTable rowData={[trade2, trade]} />);
|
||||
});
|
||||
|
||||
const cells = screen.getAllByRole('gridcell');
|
||||
|
@ -1,8 +1,7 @@
|
||||
import type { AgGridReact } 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 type { TradeFields } from './__generated__/TradeFields';
|
||||
import {
|
||||
addDecimal,
|
||||
addDecimalsFormatNumber,
|
||||
@ -10,8 +9,9 @@ import {
|
||||
t,
|
||||
} from '@vegaprotocol/react-helpers';
|
||||
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 { sortTrades } from './trades-data-provider';
|
||||
|
||||
export const UP_CLASS = 'text-vega-green';
|
||||
export const DOWN_CLASS = 'text-vega-red';
|
||||
@ -37,58 +37,72 @@ const changeCellClass =
|
||||
return ['font-mono', colorClass].join(' ');
|
||||
};
|
||||
|
||||
interface TradesTableProps {
|
||||
data: TradeFields[] | null;
|
||||
}
|
||||
type Props = AgGridReactProps | AgReactUiProps;
|
||||
type TradesTableValueFormatterParams = Omit<
|
||||
ValueFormatterParams,
|
||||
'data' | 'value'
|
||||
> & {
|
||||
data: Trades_market_tradesConnection_edges_node | null;
|
||||
};
|
||||
|
||||
export const TradesTable = forwardRef<AgGridReact, TradesTableProps>(
|
||||
({ data }, ref) => {
|
||||
// Sort initial trades
|
||||
const trades = useMemo(() => {
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
return sortTrades(data);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<AgGrid
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
overlayNoRowsTemplate={t('No trades')}
|
||||
rowData={trades}
|
||||
getRowId={({ data }) => data.id}
|
||||
ref={ref}
|
||||
defaultColDef={{
|
||||
resizable: true,
|
||||
export const TradesTable = forwardRef<AgGridReact, Props>((props, ref) => {
|
||||
return (
|
||||
<AgGrid
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
overlayNoRowsTemplate={t('No trades')}
|
||||
getRowId={({ data }) => data.id}
|
||||
ref={ref}
|
||||
defaultColDef={{
|
||||
resizable: true,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<AgGridColumn
|
||||
headerName={t('Price')}
|
||||
field="price"
|
||||
width={130}
|
||||
cellClass={changeCellClass('price')}
|
||||
valueFormatter={({
|
||||
value,
|
||||
data,
|
||||
}: TradesTableValueFormatterParams & {
|
||||
value: Trades_market_tradesConnection_edges_node['price'];
|
||||
}) => {
|
||||
if (!data?.market) {
|
||||
return null;
|
||||
}
|
||||
return addDecimalsFormatNumber(value, data.market.decimalPlaces);
|
||||
}}
|
||||
>
|
||||
<AgGridColumn
|
||||
headerName={t('Price')}
|
||||
field="price"
|
||||
width={130}
|
||||
cellClass={changeCellClass('price')}
|
||||
valueFormatter={({ value, data }: ValueFormatterParams) => {
|
||||
return addDecimalsFormatNumber(value, data.market.decimalPlaces);
|
||||
}}
|
||||
/>
|
||||
<AgGridColumn
|
||||
headerName={t('Size')}
|
||||
field="size"
|
||||
width={125}
|
||||
valueFormatter={({ value, data }: ValueFormatterParams) => {
|
||||
return addDecimal(value, data.market.positionDecimalPlaces);
|
||||
}}
|
||||
cellClass={changeCellClass('size')}
|
||||
/>
|
||||
<AgGridColumn
|
||||
headerName={t('Created at')}
|
||||
field="createdAt"
|
||||
width={170}
|
||||
valueFormatter={({ value }: ValueFormatterParams) => {
|
||||
return getDateTimeFormat().format(new Date(value));
|
||||
}}
|
||||
/>
|
||||
</AgGrid>
|
||||
);
|
||||
}
|
||||
);
|
||||
/>
|
||||
<AgGridColumn
|
||||
headerName={t('Size')}
|
||||
field="size"
|
||||
width={125}
|
||||
valueFormatter={({
|
||||
value,
|
||||
data,
|
||||
}: TradesTableValueFormatterParams & {
|
||||
value: Trades_market_tradesConnection_edges_node['size'];
|
||||
}) => {
|
||||
if (!data?.market) {
|
||||
return null;
|
||||
}
|
||||
return addDecimal(value, data.market.positionDecimalPlaces);
|
||||
}}
|
||||
cellClass={changeCellClass('size')}
|
||||
/>
|
||||
<AgGridColumn
|
||||
headerName={t('Created at')}
|
||||
field="createdAt"
|
||||
width={170}
|
||||
valueFormatter={({
|
||||
value,
|
||||
}: TradesTableValueFormatterParams & {
|
||||
value: Trades_market_tradesConnection_edges_node['createdAt'];
|
||||
}) => {
|
||||
return value && getDateTimeFormat().format(new Date(value));
|
||||
}}
|
||||
/>
|
||||
</AgGrid>
|
||||
);
|
||||
});
|
||||
|
@ -6,7 +6,7 @@
|
||||
"start": "nx serve",
|
||||
"build": "nx build",
|
||||
"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": {
|
||||
"node": ">=16.14.0"
|
||||
|
Loading…
Reference in New Issue
Block a user