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',
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',

View File

@ -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',
},
};

View File

@ -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
*/

View File

@ -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,17 +96,25 @@ 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 {
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,
}

View File

@ -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>
);
};

View File

@ -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();

View File

@ -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({});

View File

@ -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;
}

View File

@ -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);
};

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 './markets-container';

View File

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

View File

@ -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__';

View File

@ -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,23 +8,31 @@ 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) => {
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')}
rowModelType="infinite"
datasource={datasource}
getRowId={({ data }) => data?.id}
ref={ref}
defaultColDef={{
@ -32,10 +40,8 @@ export const MarketListTable = forwardRef<AgGridReact, MarketListTableProps>(
resizable: true,
}}
suppressCellFocus={true}
onRowClicked={({ data }: { data: Markets_markets }) =>
onRowClicked(data.id)
}
components={{ PriceFlashCell }}
{...props}
>
<AgGridColumn
headerName={t('Market')}
@ -49,7 +55,11 @@ export const MarketListTable = forwardRef<AgGridReact, MarketListTableProps>(
headerName={t('Trading mode')}
field="data"
minWidth={200}
valueFormatter={({ value }: ValueFormatterParams) => {
valueFormatter={({
value,
}: MarketListTableValueFormatterParams & {
value?: Markets_markets_data;
}) => {
if (!value) return value;
const { market, trigger } = value;
return market &&
@ -65,7 +75,12 @@ export const MarketListTable = forwardRef<AgGridReact, MarketListTableProps>(
field="data.bestBidPrice"
type="rightAligned"
cellRenderer="PriceFlashCell"
valueFormatter={({ value, data }: ValueFormatterParams) =>
valueFormatter={({
value,
data,
}: MarketListTableValueFormatterParams & {
value?: Markets_markets_data['bestBidPrice'];
}) =>
value === undefined
? value
: addDecimalsFormatNumber(value, data.decimalPlaces)
@ -75,7 +90,12 @@ export const MarketListTable = forwardRef<AgGridReact, MarketListTableProps>(
headerName={t('Best offer')}
field="data.bestOfferPrice"
type="rightAligned"
valueFormatter={({ value, data }: ValueFormatterParams) =>
valueFormatter={({
value,
data,
}: MarketListTableValueFormatterParams & {
value?: Markets_markets_data['bestOfferPrice'];
}) =>
value === undefined
? value
: addDecimalsFormatNumber(value, data.decimalPlaces)
@ -87,7 +107,12 @@ export const MarketListTable = forwardRef<AgGridReact, MarketListTableProps>(
field="data.markPrice"
type="rightAligned"
cellRenderer="PriceFlashCell"
valueFormatter={({ value, data }: ValueFormatterParams) =>
valueFormatter={({
value,
data,
}: MarketListTableValueFormatterParams & {
value?: Markets_markets_data['markPrice'];
}) =>
value === undefined
? value
: addDecimalsFormatNumber(value, data.decimalPlaces)
@ -96,7 +121,6 @@ export const MarketListTable = forwardRef<AgGridReact, MarketListTableProps>(
<AgGridColumn headerName={t('Description')} field="name" />
</AgGrid>
);
}
);
});
export default MarketListTable;

View File

@ -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>
);

View File

@ -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 {

View File

@ -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();
}, []);

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';
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;
},

View File

@ -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;
}

View File

@ -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[] }) => {
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: (Trades_market_tradesConnection_edges | null)[];
delta: TradeFields[];
}) => {
if (!gridRef.current?.api) {
return false;
}
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
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;
},
[]
);
gridRef.current.api.applyTransactionAsync({
add: incoming,
remove: outgoing,
addIndex: 0,
});
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 } = useDataProvider({
},
[]
);
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;
};

View File

@ -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,12 +32,23 @@ 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) {
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,
}
);

View File

@ -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');

View File

@ -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,37 +37,40 @@ const changeCellClass =
return ['font-mono', colorClass].join(' ');
};
interface TradesTableProps {
data: TradeFields[] | null;
}
export const TradesTable = forwardRef<AgGridReact, TradesTableProps>(
({ data }, ref) => {
// Sort initial trades
const trades = useMemo(() => {
if (!data) {
return null;
}
return sortTrades(data);
}, [data]);
type Props = AgGridReactProps | AgReactUiProps;
type TradesTableValueFormatterParams = Omit<
ValueFormatterParams,
'data' | 'value'
> & {
data: Trades_market_tradesConnection_edges_node | null;
};
export const TradesTable = forwardRef<AgGridReact, Props>((props, ref) => {
return (
<AgGrid
style={{ width: '100%', height: '100%' }}
overlayNoRowsTemplate={t('No trades')}
rowData={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 }: ValueFormatterParams) => {
valueFormatter={({
value,
data,
}: TradesTableValueFormatterParams & {
value: Trades_market_tradesConnection_edges_node['price'];
}) => {
if (!data?.market) {
return null;
}
return addDecimalsFormatNumber(value, data.market.decimalPlaces);
}}
/>
@ -75,7 +78,15 @@ export const TradesTable = forwardRef<AgGridReact, TradesTableProps>(
headerName={t('Size')}
field="size"
width={125}
valueFormatter={({ value, data }: ValueFormatterParams) => {
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')}
@ -84,11 +95,14 @@ export const TradesTable = forwardRef<AgGridReact, TradesTableProps>(
headerName={t('Created at')}
field="createdAt"
width={170}
valueFormatter={({ value }: ValueFormatterParams) => {
return getDateTimeFormat().format(new Date(value));
valueFormatter={({
value,
}: TradesTableValueFormatterParams & {
value: Trades_market_tradesConnection_edges_node['createdAt'];
}) => {
return value && getDateTimeFormat().format(new Date(value));
}}
/>
</AgGrid>
);
}
);
});

View File

@ -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"