feat(trading): show full history of orders, fills and trades (#5105)

This commit is contained in:
Bartłomiej Głownia 2023-10-27 15:21:26 +02:00 committed by GitHub
parent fbafc726a4
commit c440abc77d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 219 additions and 83 deletions

View File

@ -3,7 +3,12 @@ import throttle from 'lodash/throttle';
import isEqualWith from 'lodash/isEqualWith'; import isEqualWith from 'lodash/isEqualWith';
import { useApolloClient } from '@apollo/client'; import { useApolloClient } from '@apollo/client';
import type { OperationVariables } from '@apollo/client'; import type { OperationVariables } from '@apollo/client';
import type { Subscribe, Load, UpdateCallback } from './generic-data-provider'; import type {
Subscribe,
Load,
UpdateCallback,
PageInfo,
} from './generic-data-provider';
import { variablesIsEqualCustomizer } from './generic-data-provider'; import { variablesIsEqualCustomizer } from './generic-data-provider';
export interface useDataProviderParams< export interface useDataProviderParams<
@ -12,13 +17,23 @@ export interface useDataProviderParams<
Variables extends OperationVariables | undefined = undefined Variables extends OperationVariables | undefined = undefined
> { > {
dataProvider: Subscribe<Data, Delta, Variables>; dataProvider: Subscribe<Data, Delta, Variables>;
update?: ({ delta, data }: { delta?: Delta; data: Data | null }) => boolean; update?: ({
delta,
data,
pageInfo,
}: {
delta?: Delta;
data: Data | null;
pageInfo: PageInfo | null;
}) => boolean;
insert?: ({ insert?: ({
insertionData, insertionData,
data, data,
pageInfo,
}: { }: {
insertionData?: Data | null; insertionData?: Data | null;
data: Data | null; data: Data | null;
pageInfo: PageInfo | null;
}) => boolean; }) => boolean;
variables: Variables; variables: Variables;
skipUpdates?: boolean; skipUpdates?: boolean;
@ -30,7 +45,7 @@ export interface useDataProviderParams<
* @param dataProvider subscribe function created by makeDataProvider * @param dataProvider subscribe function created by makeDataProvider
* @param update optional function called on each delta received in subscription, if returns true updated data will be not passed from hook (component handles updates internally) * @param update optional function called on each delta received in subscription, if returns true updated data will be not passed from hook (component handles updates internally)
* @param variables optional * @param variables optional
* @returns state: data, loading, error, methods: flush (pass updated data to update function without delta), restart: () => void}}; * @returns state: data, loading, pageInfo, error, methods: flush (pass updated data to update function without delta), restart: () => void}};
*/ */
export const useDataProvider = < export const useDataProvider = <
Data, Data,
@ -48,6 +63,7 @@ export const useDataProvider = <
const [data, setData] = useState<Data | null>(null); const [data, setData] = useState<Data | null>(null);
const [loading, setLoading] = useState<boolean>(!skip); const [loading, setLoading] = useState<boolean>(!skip);
const [error, setError] = useState<Error | undefined>(undefined); const [error, setError] = useState<Error | undefined>(undefined);
const [pageInfo, setPageInfo] = useState<PageInfo | null>(null);
const flushRef = useRef<(() => void) | undefined>(undefined); const flushRef = useRef<(() => void) | undefined>(undefined);
const reloadRef = useRef<((force?: boolean) => void) | undefined>(undefined); const reloadRef = useRef<((force?: boolean) => void) | undefined>(undefined);
const loadRef = useRef<Load<Data> | undefined>(undefined); const loadRef = useRef<Load<Data> | undefined>(undefined);
@ -93,6 +109,7 @@ export const useDataProvider = <
isInsert, isInsert,
isUpdate, isUpdate,
loaded, loaded,
pageInfo,
} = args; } = args;
setError(error); setError(error);
setLoading(!loaded && loading); setLoading(!loaded && loading);
@ -104,21 +121,22 @@ export const useDataProvider = <
(skipUpdatesRef.current || (skipUpdatesRef.current ||
(!skipUpdatesRef.current && (!skipUpdatesRef.current &&
updateRef.current && updateRef.current &&
updateRef.current({ delta, data }))) updateRef.current({ delta, data, pageInfo })))
) { ) {
return; return;
} }
if ( if (
isInsert && isInsert &&
insertRef.current && insertRef.current &&
insertRef.current({ insertionData, data }) insertRef.current({ insertionData, data, pageInfo })
) { ) {
return; return;
} }
} }
setData(data); setData(data);
setPageInfo(pageInfo);
if (!loading && !isUpdate && updateRef.current) { if (!loading && !isUpdate && updateRef.current) {
updateRef.current({ data }); updateRef.current({ data, pageInfo });
} }
}, []); }, []);
@ -136,15 +154,13 @@ export const useDataProvider = <
useEffect(() => { useEffect(() => {
setData(null); setData(null);
setPageInfo(null);
setError(undefined); setError(undefined);
if (updateRef.current) { if (updateRef.current) {
updateRef.current({ data: null }); updateRef.current({ data: null, pageInfo: null });
} }
if (skip) { if (skip) {
setLoading(false); setLoading(false);
if (updateRef.current) {
updateRef.current({ data: null });
}
return; return;
} }
setLoading(true); setLoading(true);
@ -165,6 +181,7 @@ export const useDataProvider = <
}, [client, dataProvider, callback, variables, skip]); }, [client, dataProvider, callback, variables, skip]);
return { return {
data, data,
pageInfo,
loading, loading,
error, error,
flush, flush,

View File

@ -109,7 +109,7 @@ describe('DealTicket', () => {
variables: { variables: {
partyId: 'pubKey', partyId: 'pubKey',
filter: { liveOnly: true }, filter: { liveOnly: true },
pagination: { first: 5000 }, pagination: { first: 1000 },
}, },
}, },
result: { result: {

View File

@ -42,8 +42,12 @@ fragment FillEdge on TradeEdge {
cursor cursor
} }
query Fills($filter: TradesFilter, $pagination: Pagination) { query Fills(
trades(filter: $filter, pagination: $pagination) { $filter: TradesFilter
$pagination: Pagination
$dateRange: DateRange
) {
trades(filter: $filter, dateRange: $dateRange, pagination: $pagination) {
edges { edges {
...FillEdge ...FillEdge
} }

View File

@ -12,6 +12,7 @@ export type FillEdgeFragment = { __typename?: 'TradeEdge', cursor: string, node:
export type FillsQueryVariables = Types.Exact<{ export type FillsQueryVariables = Types.Exact<{
filter?: Types.InputMaybe<Types.TradesFilter>; filter?: Types.InputMaybe<Types.TradesFilter>;
pagination?: Types.InputMaybe<Types.Pagination>; pagination?: Types.InputMaybe<Types.Pagination>;
dateRange?: Types.InputMaybe<Types.DateRange>;
}>; }>;
@ -95,8 +96,8 @@ export const FillUpdateFieldsFragmentDoc = gql`
} }
${TradeFeeFieldsFragmentDoc}`; ${TradeFeeFieldsFragmentDoc}`;
export const FillsDocument = gql` export const FillsDocument = gql`
query Fills($filter: TradesFilter, $pagination: Pagination) { query Fills($filter: TradesFilter, $pagination: Pagination, $dateRange: DateRange) {
trades(filter: $filter, pagination: $pagination) { trades(filter: $filter, dateRange: $dateRange, pagination: $pagination) {
edges { edges {
...FillEdge ...FillEdge
} }
@ -124,6 +125,7 @@ export const FillsDocument = gql`
* variables: { * variables: {
* filter: // value for 'filter' * filter: // value for 'filter'
* pagination: // value for 'pagination' * pagination: // value for 'pagination'
* dateRange: // value for 'dateRange'
* }, * },
* }); * });
*/ */

View File

@ -1,11 +1,12 @@
import type { AgGridReact } from 'ag-grid-react'; import type { AgGridReact } from 'ag-grid-react';
import { useRef } from 'react'; import { useCallback, useRef, useState } from 'react';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { FillsTable } from './fills-table'; import { FillsTable } from './fills-table';
import type { useDataGridEvents } from '@vegaprotocol/datagrid'; import type { useDataGridEvents } from '@vegaprotocol/datagrid';
import { useDataProvider } from '@vegaprotocol/data-provider'; import { useDataProvider } from '@vegaprotocol/data-provider';
import type * as Schema from '@vegaprotocol/types'; import type * as Schema from '@vegaprotocol/types';
import { fillsWithMarketProvider } from './fills-data-provider'; import { fillsWithMarketProvider } from './fills-data-provider';
import { TradingButton as Button } from '@vegaprotocol/ui-toolkit';
interface FillsManagerProps { interface FillsManagerProps {
partyId: string; partyId: string;
@ -22,26 +23,60 @@ export const FillsManager = ({
const filter: Schema.TradesFilter | Schema.TradesSubscriptionFilter = { const filter: Schema.TradesFilter | Schema.TradesSubscriptionFilter = {
partyIds: [partyId], partyIds: [partyId],
}; };
const { data, error } = useDataProvider({ const [hasDisplayedRow, setHasDisplayedRow] = useState<boolean | undefined>(
dataProvider: fillsWithMarketProvider, undefined
update: ({ data }) => { );
if (data?.length && gridRef.current?.api) { const { onFilterChanged, ...props } = gridProps || {};
gridRef.current?.api.setRowData(data); const onRowDataUpdated = useCallback(
return true; ({ api }: { api: AgGridReact['api'] }) => {
} setHasDisplayedRow(!!api.getDisplayedRowCount());
return false;
}, },
[]
);
const { data, error, load, pageInfo } = useDataProvider({
dataProvider: fillsWithMarketProvider,
variables: { filter }, variables: { filter },
}); });
return ( return (
<FillsTable <div className="flex flex-col h-full">
ref={gridRef} <FillsTable
rowData={data} ref={gridRef}
partyId={partyId} rowData={data}
onMarketClick={onMarketClick} onFilterChanged={(event) => {
overlayNoRowsTemplate={error ? error.message : t('No fills')} onRowDataUpdated(event);
{...gridProps} onFilterChanged(event);
/> }}
onRowDataUpdated={onRowDataUpdated}
partyId={partyId}
onMarketClick={onMarketClick}
overlayNoRowsTemplate={error ? error.message : t('No fills')}
{...props}
/>
<div className="flex justify-between border-t border-default p-1 items-center">
<div className="text-xs">
{t(
'Depending on data node retention you may not be able see the "full" history'
)}
</div>
<div className="flex text-xs items-center">
{data?.length && !pageInfo?.hasNextPage
? t('all %s items loaded', [data.length.toString()])
: t('%s items loaded', [
data?.length ? data.length.toString() : ' ',
])}
{pageInfo?.hasNextPage ? (
<Button size="extra-small" className="ml-1" onClick={() => load()}>
{t('Load more')}
</Button>
) : null}
</div>
{data?.length && hasDisplayedRow === false ? (
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-xs">
{t('No fills matching selected filters')}
</div>
) : null}
</div>
</div>
); );
}; };

View File

@ -20,6 +20,7 @@ import {
negativeClassNames, negativeClassNames,
MarketNameCell, MarketNameCell,
COL_DEFS, COL_DEFS,
DateRangeFilter,
} from '@vegaprotocol/datagrid'; } from '@vegaprotocol/datagrid';
import type { import type {
VegaValueFormatterParams, VegaValueFormatterParams,
@ -120,6 +121,7 @@ export const FillsTable = forwardRef<AgGridReact, Props>(
}, },
{ {
headerName: t('Date'), headerName: t('Date'),
filter: DateRangeFilter,
field: 'createdAt', field: 'createdAt',
valueFormatter: ({ valueFormatter: ({
value, value,

View File

@ -166,7 +166,7 @@ export const ordersProvider = makeDataProvider<
pagination: { pagination: {
getPageInfo, getPageInfo,
append, append,
first: 5000, first: 1000,
}, },
resetDelay: 1000, resetDelay: 1000,
additionalContext: { isEnlargedTimeout: true }, additionalContext: { isEnlargedTimeout: true },

View File

@ -27,6 +27,7 @@ describe('OrderListManager', () => {
flush: jest.fn(), flush: jest.fn(),
reload: jest.fn(), reload: jest.fn(),
load: jest.fn(), load: jest.fn(),
pageInfo: null,
}); });
render(generateJsx()); render(generateJsx());
@ -45,6 +46,7 @@ describe('OrderListManager', () => {
flush: jest.fn(), flush: jest.fn(),
reload: jest.fn(), reload: jest.fn(),
load: jest.fn(), load: jest.fn(),
pageInfo: null,
}); });
render(generateJsx()); render(generateJsx());
@ -62,6 +64,7 @@ describe('OrderListManager', () => {
flush: jest.fn(), flush: jest.fn(),
reload: jest.fn(), reload: jest.fn(),
load: jest.fn(), load: jest.fn(),
pageInfo: null,
}); });
render( render(

View File

@ -1,7 +1,6 @@
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { useCallback, useRef, useState, useEffect } from 'react'; import { useCallback, useRef, useState, useEffect } from 'react';
import type { AgGridReact } from 'ag-grid-react'; import type { AgGridReact } from 'ag-grid-react';
import type { FilterChangedEvent } from 'ag-grid-community';
import { OrderListTable } from '../order-list'; import { OrderListTable } from '../order-list';
import type { useDataGridEvents } from '@vegaprotocol/datagrid'; import type { useDataGridEvents } from '@vegaprotocol/datagrid';
import { useDataProvider } from '@vegaprotocol/data-provider'; import { useDataProvider } from '@vegaprotocol/data-provider';
@ -12,7 +11,7 @@ import type { OrderTxUpdateFieldsFragment } from '@vegaprotocol/web3';
import { OrderEditDialog } from '../order-list/order-edit-dialog'; import { OrderEditDialog } from '../order-list/order-edit-dialog';
import type { Order } from '../order-data-provider'; import type { Order } from '../order-data-provider';
import { OrderViewDialog } from '../order-list/order-view-dialog'; import { OrderViewDialog } from '../order-list/order-view-dialog';
import { Splash } from '@vegaprotocol/ui-toolkit'; import { Splash, TradingButton as Button } from '@vegaprotocol/ui-toolkit';
export enum Filter { export enum Filter {
'Open' = 'Open', 'Open' = 'Open',
@ -48,33 +47,11 @@ export const OrderListManager = ({
? { partyId, filter: { liveOnly: true } } ? { partyId, filter: { liveOnly: true } }
: { partyId }; : { partyId };
const { data, error } = useDataProvider({ const { data, error, pageInfo, load } = useDataProvider({
dataProvider: ordersWithMarketProvider, dataProvider: ordersWithMarketProvider,
variables, variables,
update: ({ data }) => {
if (data && gridRef.current?.api) {
gridRef.current.api.setRowData(data);
return true;
}
return false;
},
}); });
const onFilterChanged = useCallback(
(event: FilterChangedEvent) => {
gridProps?.onFilterChanged?.(event);
if (event.api) {
const isEmpty = event.api.getDisplayedRowCount() === 0;
if (isEmpty) {
event.api.showNoRowsOverlay();
} else {
event.api.hideOverlay();
}
}
},
[gridProps]
);
useEffect(() => { useEffect(() => {
if (!data || !data.length) { if (!data || !data.length) {
gridRef.current?.api?.showNoRowsOverlay(); gridRef.current?.api?.showNoRowsOverlay();
@ -96,6 +73,17 @@ export const OrderListManager = ({
[create] [create]
); );
const [hasDisplayedRow, setHasDisplayedRow] = useState<boolean | undefined>(
undefined
);
const { onFilterChanged, ...props } = gridProps || {};
const onRowDataUpdated = useCallback(
({ api }: { api: AgGridReact['api'] }) => {
setHasDisplayedRow(!!api.getDisplayedRowCount());
},
[]
);
if (error) { if (error) {
return <Splash>{t(`Something went wrong: ${error.message}`)}</Splash>; return <Splash>{t(`Something went wrong: ${error.message}`)}</Splash>;
} }
@ -103,20 +91,60 @@ export const OrderListManager = ({
return ( return (
<> <>
<div className="h-full relative"> <div className="h-full relative">
<OrderListTable <div className="flex flex-col h-full">
rowData={data} <OrderListTable
ref={gridRef} rowData={data}
filter={filter} ref={gridRef}
onCancel={cancel} filter={filter}
onEdit={setEditOrder} onCancel={cancel}
onView={setViewOrder} onEdit={setEditOrder}
onMarketClick={onMarketClick} onView={setViewOrder}
onOrderTypeClick={onOrderTypeClick} onMarketClick={onMarketClick}
isReadOnly={isReadOnly} onOrderTypeClick={onOrderTypeClick}
overlayNoRowsTemplate={noRowsMessage || t('No orders')} onFilterChanged={(event) => {
{...gridProps} onRowDataUpdated(event);
onFilterChanged={onFilterChanged} if (onFilterChanged) {
/> onFilterChanged(event);
}
}}
onRowDataUpdated={onRowDataUpdated}
isReadOnly={isReadOnly}
overlayNoRowsTemplate={noRowsMessage || t('No orders')}
{...props}
/>
<div className="flex justify-between border-t border-default p-1 items-center">
<div className="text-xs">
{variables.filter?.liveOnly
? null
: t(
'Depending on data node retention you may not be able see the "full" history'
)}
</div>
{data ? (
<div className="flex text-xs items-center">
{data?.length && !pageInfo?.hasNextPage
? t('all %s items loaded', [data.length.toString()])
: t('%s items loaded', [
data?.length ? data.length.toString() : ' ',
])}
{pageInfo?.hasNextPage ? (
<Button
size="extra-small"
className="ml-1"
onClick={() => load()}
>
{t('Load more')}
</Button>
) : null}
</div>
) : null}
{data?.length && hasDisplayedRow === false ? (
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-xs">
{t('No orders matching selected filters')}
</div>
) : null}
</div>
</div>
</div> </div>
{editOrder && ( {editOrder && (
<OrderEditDialog <OrderEditDialog

View File

@ -30,6 +30,7 @@ describe('StopOrdersManager', () => {
flush: jest.fn(), flush: jest.fn(),
reload: jest.fn(), reload: jest.fn(),
load: jest.fn(), load: jest.fn(),
pageInfo: null,
}); });
await act(async () => { await act(async () => {
render(generateJsx()); render(generateJsx());

View File

@ -4,10 +4,13 @@ import { TradesTable } from './trades-table';
import { useDealTicketFormValues } from '@vegaprotocol/deal-ticket'; import { useDealTicketFormValues } from '@vegaprotocol/deal-ticket';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import type { useDataGridEvents } from '@vegaprotocol/datagrid'; import type { useDataGridEvents } from '@vegaprotocol/datagrid';
import { useCallback, useState } from 'react';
import type { AgGridReact } from 'ag-grid-react';
import { TradingButton as Button } from '@vegaprotocol/ui-toolkit';
interface TradesContainerProps { interface TradesContainerProps {
marketId: string; marketId: string;
gridProps?: ReturnType<typeof useDataGridEvents>; gridProps: ReturnType<typeof useDataGridEvents>;
} }
export const TradesManager = ({ export const TradesManager = ({
@ -16,19 +19,60 @@ export const TradesManager = ({
}: TradesContainerProps) => { }: TradesContainerProps) => {
const update = useDealTicketFormValues((state) => state.updateAll); const update = useDealTicketFormValues((state) => state.updateAll);
const { data, error } = useDataProvider({ const { data, error, load, pageInfo } = useDataProvider({
dataProvider: tradesWithMarketProvider, dataProvider: tradesWithMarketProvider,
variables: { marketId }, variables: { marketId },
}); });
const [hasDisplayedRow, setHasDisplayedRow] = useState<boolean | undefined>(
undefined
);
const { onFilterChanged, ...props } = gridProps || {};
const onRowDataUpdated = useCallback(
({ api }: { api: AgGridReact['api'] }) => {
setHasDisplayedRow(!!api.getDisplayedRowCount());
},
[]
);
return ( return (
<TradesTable <div className="flex flex-col h-full">
rowData={data} <TradesTable
onClick={(price?: string) => { rowData={data}
update(marketId, { price }); onClick={(price?: string) => {
}} update(marketId, { price });
overlayNoRowsTemplate={error ? error.message : t('No trades')} }}
{...gridProps} onFilterChanged={(event) => {
/> onRowDataUpdated(event);
onFilterChanged(event);
}}
onRowDataUpdated={onRowDataUpdated}
overlayNoRowsTemplate={error ? error.message : t('No trades')}
{...props}
/>
<div className="flex justify-between border-t border-default p-1 items-center">
<div className="text-xs">
{t(
'Depending on data node retention you may not be able see the "full" history'
)}
</div>
<div className="flex text-xs items-center">
{data?.length && !pageInfo?.hasNextPage
? t('all %s items loaded', [data.length.toString()])
: t('%s items loaded', [
data?.length ? data.length.toString() : ' ',
])}
{pageInfo?.hasNextPage ? (
<Button size="extra-small" className="ml-1" onClick={() => load()}>
{t('Load more')}
</Button>
) : null}
</div>
{data?.length && hasDisplayedRow === false ? (
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-xs">
{t('No trades matching selected filters')}
</div>
) : null}
</div>
</div>
); );
}; };