chore(trading): filtering and sorting for ledger entries (#2944)

Co-authored-by: Matthew Russell <mattrussell36@gmail.com>
This commit is contained in:
Maciek 2023-02-24 08:40:31 +01:00 committed by GitHub
parent 5bde096977
commit c29087cc96
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 353 additions and 163 deletions

View File

@ -45,8 +45,9 @@ export const FillsManager = ({
datasource={{ getRows }}
onBodyScrollEnd={onBodyScrollEnd}
onBodyScroll={onBodyScroll}
noRowsOverlayComponent={() => null}
onMarketClick={onMarketClick}
suppressLoadingOverlay
suppressNoRowsOverlay
/>
<div className="pointer-events-none absolute inset-0">
<AsyncRenderer

View File

@ -2,10 +2,8 @@ import type { Asset } from '@vegaprotocol/assets';
import { assetsProvider } from '@vegaprotocol/assets';
import type { Market } from '@vegaprotocol/market-list';
import { marketsProvider } from '@vegaprotocol/market-list';
import type { PageInfo } from '@vegaprotocol/react-helpers';
import { makeInfiniteScrollGetRows } from '@vegaprotocol/react-helpers';
import {
defaultAppend as append,
makeDataProvider,
makeDerivedDataProvider,
useDataProvider,
@ -27,13 +25,15 @@ import type {
import { LedgerEntriesDocument } from './__generated__/LedgerEntries';
export type LedgerEntry = LedgerEntryFragment & {
id: number;
asset: Asset | null | undefined;
marketSender: Market | null | undefined;
marketReceiver: Market | null | undefined;
};
export type AggregatedLedgerEntriesEdge = Schema.AggregatedLedgerEntriesEdge;
export type AggregatedLedgerEntriesNode = AggregatedLedgerEntriesEdge & {
node: LedgerEntry;
};
const getData = (responseData: LedgerEntriesQuery | null) => {
return responseData?.ledgerEntries?.edges || [];
@ -94,33 +94,29 @@ export const update = (
});
};
const getPageInfo = (responseData: LedgerEntriesQuery): PageInfo | null =>
responseData.ledgerEntries?.pageInfo || null;
const ledgerEntriesOnlyProvider = makeDataProvider({
query: LedgerEntriesDocument,
getData,
getDelta: getData,
update,
pagination: {
getPageInfo,
append,
first: 100,
},
additionalContext: {
isEnlargedTimeout: true,
},
});
export const ledgerEntriesProvider = makeDerivedDataProvider<
(AggregatedLedgerEntriesEdge | null)[],
AggregatedLedgerEntriesEdge[],
AggregatedLedgerEntriesNode[],
AggregatedLedgerEntriesNode[],
LedgerEntriesQueryVariables
>(
[ledgerEntriesOnlyProvider, assetsProvider, marketsProvider],
[
ledgerEntriesOnlyProvider,
(callback, client) => assetsProvider(callback, client),
marketsProvider,
],
([entries, assets, markets]) => {
return entries.map((edge: AggregatedLedgerEntriesEdge) => {
const entry = edge?.node;
const entry = edge.node;
const asset = assets.find((asset: Asset) => asset.id === entry.assetId);
const marketSender = markets.find(
(market: Market) => market.id === entry.fromAccountMarketId
@ -148,21 +144,22 @@ export const useLedgerEntriesDataProvider = ({
filter,
gridRef,
}: Props) => {
const dataRef = useRef<(AggregatedLedgerEntriesEdge | null)[] | null>(null);
const dataRef = useRef<AggregatedLedgerEntriesEdge[] | null>(null);
const totalCountRef = useRef<number>();
const variables = useMemo<LedgerEntriesQueryVariables>(
() => ({
partyId,
dateRange: filter?.vegaTime?.value,
fromAccountType: filter?.fromAccountType?.value ?? null,
toAccountType: filter?.toAccountType?.value ?? null,
pagination: {
first: 5000,
},
}),
[partyId, filter]
[partyId, filter?.vegaTime?.value]
);
const update = useCallback(
({ data }: { data: (AggregatedLedgerEntriesEdge | null)[] | null }) => {
({ data }: { data: AggregatedLedgerEntriesEdge[] | null }) => {
return updateGridData(dataRef, data, gridRef);
},
[gridRef]
@ -173,7 +170,7 @@ export const useLedgerEntriesDataProvider = ({
data,
totalCount,
}: {
data: (AggregatedLedgerEntriesEdge | null)[] | null;
data: AggregatedLedgerEntriesEdge[] | null;
totalCount?: number;
}) => {
totalCountRef.current = totalCount;

View File

@ -3,7 +3,9 @@ import type * as Schema from '@vegaprotocol/types';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import type { FilterChangedEvent } from 'ag-grid-community';
import type { AgGridReact } from 'ag-grid-react';
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { subDays, formatRFC3339 } from 'date-fns';
import type { AggregatedLedgerEntriesNode } from './ledger-entries-data-provider';
import { useLedgerEntriesDataProvider } from './ledger-entries-data-provider';
import { LedgerTable } from './ledger-table';
import type * as Types from '@vegaprotocol/types';
@ -15,43 +17,43 @@ export interface Filter {
fromAccountType?: { value: Types.AccountType[] };
toAccountType?: { value: Types.AccountType[] };
}
type LedgerManagerProps = { partyId: string };
export const LedgerManager = ({ partyId }: LedgerManagerProps) => {
const defaultFilter = {
vegaTime: {
value: { start: formatRFC3339(subDays(Date.now(), 7)) },
},
};
export const LedgerManager = ({ partyId }: { partyId: string }) => {
const gridRef = useRef<AgGridReact | null>(null);
const [filter, setFilter] = useState<Filter | undefined>();
const [filter, setFilter] = useState<Filter>(defaultFilter);
const [dataCount, setDataCount] = useState(0);
const { data, error, loading, getRows, reload } =
useLedgerEntriesDataProvider({
partyId,
filter,
gridRef,
});
const { data, error, loading, reload } = useLedgerEntriesDataProvider({
partyId,
filter,
gridRef,
});
const onFilterChanged = useCallback(
(event: FilterChangedEvent) => {
const updatedFilter = event.api.getFilterModel();
if (Object.keys(updatedFilter).length) {
setFilter(updatedFilter);
} else if (filter) {
setFilter(undefined);
}
},
[filter]
);
const getRowId = useCallback(
({ data }: { data: Types.AggregatedLedgerEntry }) =>
`${data.vegaTime}-${data.fromAccountPartyId}-${data.toAccountPartyId}`,
const onFilterChanged = useCallback((event: FilterChangedEvent) => {
const updatedFilter = { ...defaultFilter, ...event.api.getFilterModel() };
setFilter(updatedFilter);
}, []);
const extractNodesDecorator = useCallback(
(data: AggregatedLedgerEntriesNode[] | null, loading: boolean) =>
data && !loading ? data.map((item) => item.node) : null,
[]
);
const extractedData = extractNodesDecorator(data, loading);
useEffect(() => {
setDataCount(gridRef.current?.api?.getModel().getRowCount() ?? 0);
}, [extractedData]);
return (
<div className="h-full relative">
<LedgerTable
ref={gridRef}
rowModelType="infinite"
datasource={{ getRows }}
rowData={extractedData}
onFilterChanged={onFilterChanged}
getRowId={getRowId}
/>
<div className="pointer-events-none absolute inset-0">
<AsyncRenderer
@ -59,7 +61,7 @@ export const LedgerManager = ({ partyId }: LedgerManagerProps) => {
error={error}
data={data}
noDataMessage={t('No entries')}
noDataCondition={(data) => !(data && data.length)}
noDataCondition={() => !dataCount}
reload={reload}
/>
</div>

View File

@ -22,6 +22,7 @@ import {
} from '@vegaprotocol/types';
import type { LedgerEntry } from './ledger-entries-data-provider';
import { forwardRef } from 'react';
import { formatRFC3339, subDays } from 'date-fns';
export const TransferTooltipCellComponent = ({
value,
@ -35,6 +36,11 @@ export const TransferTooltipCellComponent = ({
);
};
const defaultRangeFilter = { start: formatRFC3339(subDays(Date.now(), 7)) };
const dateRangeFilterParams = {
maxNextDays: 0,
defaultRangeFilter,
};
type LedgerEntryProps = TypedDataAgGrid<LedgerEntry>;
export const LedgerTable = forwardRef<AgGridReact, LedgerEntryProps>(
@ -42,17 +48,20 @@ export const LedgerTable = forwardRef<AgGridReact, LedgerEntryProps>(
return (
<AgGrid
style={{ width: '100%', height: '100%' }}
overlayNoRowsTemplate={t('No entries')}
ref={ref}
getRowId={({ data }) => data.id}
tooltipShowDelay={500}
defaultColDef={{
flex: 1,
resizable: true,
sortable: true,
tooltipComponent: TransferTooltipCellComponent,
filterParams: { buttons: ['reset'] },
filterParams: {
...dateRangeFilterParams,
buttons: ['reset'],
},
}}
suppressLoadingOverlay
suppressNoRowsOverlay
{...props}
>
<AgGridColumn
@ -201,6 +210,7 @@ export const LedgerTable = forwardRef<AgGridReact, LedgerEntryProps>(
}: VegaValueFormatterParams<LedgerEntry, 'vegaTime'>) =>
value ? getDateTimeFormat().format(fromNanoSeconds(value)) : '-'
}
filterParams={dateRangeFilterParams}
filter={DateRangeFilter}
/>
</AgGrid>

View File

@ -152,6 +152,8 @@ export const OrderListManager = ({
isReadOnly={isReadOnly}
hasActiveOrder={hasActiveOrder}
blockLoadDebounceMillis={100}
suppressLoadingOverlay
suppressNoRowsOverlay
/>
<div className="pointer-events-none absolute inset-0">
<AsyncRenderer

View File

@ -1,4 +1,5 @@
/* eslint-disable */
process.env.TZ = 'UTC';
export default {
displayName: 'react-helpers',
preset: '../../jest.preset.js',

View File

@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react';
import type { DateRangeFilterProps } from './date-range-filter';
import { DateRangeFilter } from './date-range-filter';
const commonProps = {
filterChangedCallback: jest.fn(),
};
describe('DateRangeFilter', () => {
it('should be properly rendered', async () => {
const defaultRangeFilter = {
start: '2023-02-14T13:53:01+01:00',
end: '2023-02-21T13:53:01+01:00',
};
const displayStartValue = '2023-02-14T12:53:01.000';
const displayEndValue = '2023-02-21T12:53:01.000';
render(
<DateRangeFilter
{...(commonProps as unknown as DateRangeFilterProps)}
defaultRangeFilter={defaultRangeFilter}
/>
);
expect(screen.getByLabelText('Start')).toHaveValue(displayStartValue);
expect(screen.getByLabelText('End')).toHaveValue(displayEndValue);
expect(commonProps.filterChangedCallback).toHaveBeenCalled();
});
});

View File

@ -1,113 +1,259 @@
import type { ChangeEvent } from 'react';
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import type * as Schema from '@vegaprotocol/types';
import { forwardRef, useImperativeHandle, useState } from 'react';
import type { IDoesFilterPassParams, IFilterParams } from 'ag-grid-community';
import { formatForInput } from '../format/date';
import {
isBefore,
subDays,
addDays,
differenceInDays,
formatRFC3339,
min,
max,
isValid,
} from 'date-fns';
import { t } from '../i18n';
import { formatForInput } from '../format/date';
const defaultFilterValue: Schema.DateRange = {};
export interface DateRangeFilterProps extends IFilterParams {
defaultRangeFilter?: Schema.DateRange;
maxSubDays?: number;
maxNextDays?: number;
maxDaysRange?: number;
}
export const DateRangeFilter = forwardRef((props: IFilterParams, ref) => {
const [value, setValue] = useState<Schema.DateRange>(defaultFilterValue);
export const DateRangeFilter = forwardRef(
(props: DateRangeFilterProps, ref) => {
const defaultDates = props?.defaultRangeFilter || defaultFilterValue;
const [value, setValue] = useState<Schema.DateRange>(defaultDates);
const [error, setError] = useState<string>('');
const [minStartDate, maxStartDate, minEndDate, maxEndDate] = useMemo(() => {
const minStartDate =
props?.maxSubDays !== undefined
? formatForInput(subDays(Date.now(), props.maxSubDays))
: '';
const maxStartDate =
props?.maxNextDays !== undefined
? formatForInput(addDays(Date.now(), props.maxNextDays))
: '';
const minEndDate =
value.start && props?.maxDaysRange !== undefined
? formatForInput(new Date(value.start))
: minStartDate || value.start
? formatForInput(new Date(value.start))
: '';
const endDateCandidates = [];
if (props.maxNextDays !== undefined) {
endDateCandidates.push(addDays(new Date(), props.maxNextDays));
}
if (props.maxDaysRange !== undefined && value.start) {
endDateCandidates.push(
addDays(new Date(value.start), props.maxDaysRange)
);
}
const maxEndDate = endDateCandidates.length
? formatForInput(min(endDateCandidates))
: maxStartDate;
return [minStartDate, maxStartDate, minEndDate, maxEndDate];
}, [props.maxSubDays, props.maxDaysRange, props.maxNextDays, value.start]);
// expose AG Grid Filter Lifecycle callbacks
useImperativeHandle(ref, () => {
return {
doesFilterPass(params: IDoesFilterPassParams) {
const { api, colDef, column, columnApi, context } = props;
const { node } = params;
const rowValue = props.valueGetter({
api,
colDef,
column,
columnApi,
context,
data: node.data,
getValue: (field) => node.data[field],
node,
});
if (
value.start &&
rowValue &&
new Date(rowValue) <= new Date(value.start)
) {
return false;
}
if (
value.end &&
rowValue &&
new Date(rowValue) >= new Date(value.end)
) {
return false;
}
return true;
},
// expose AG Grid Filter Lifecycle callbacks
useImperativeHandle(ref, () => {
return {
doesFilterPass(params: IDoesFilterPassParams) {
const { api, colDef, column, columnApi, context } = props;
const { node } = params;
const rowValue = props.valueGetter({
api,
colDef,
column,
columnApi,
context,
data: node.data,
getValue: (field) => node.data[field],
node,
});
if (
value.start &&
rowValue &&
new Date(rowValue) <= new Date(value.start)
) {
return false;
}
if (
value.end &&
rowValue &&
new Date(rowValue) >= new Date(value.end)
) {
return false;
}
return true;
},
isFilterActive() {
return value.start || value.end;
},
isFilterActive() {
return value.start || value.end;
},
getModel() {
if (!this.isFilterActive()) {
return null;
}
getModel() {
if (!this.isFilterActive()) {
return null;
}
return { value };
},
return { value };
},
setModel(model?: { value: Schema.DateRange } | null) {
setValue(model?.value || defaultFilterValue);
},
};
});
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
setValue({
...value,
[event.target.name]:
event.target.value &&
new Date(event.target.value).toISOString().replace('Z', '000000Z'),
setModel(model?: { value: Schema.DateRange } | null) {
setValue(
model?.value || props?.defaultRangeFilter || defaultFilterValue
);
},
};
});
};
const validate = (
name: string,
timeValue: Date,
update?: Schema.DateRange
) => {
if (
props.maxSubDays !== undefined &&
isBefore(new Date(timeValue), subDays(Date.now(), props.maxSubDays + 1))
) {
setError(
t(
'The earliest data that can be queried is %s days ago.',
String(props.maxSubDays)
)
);
return false;
}
if (props?.maxDaysRange !== undefined) {
const contrvalue =
name === 'start'
? update?.end || value.end
: update?.start || value.start;
if (
Math.abs(
differenceInDays(new Date(timeValue), new Date(contrvalue))
) > props.maxDaysRange
) {
setError(
t(
'The maximum time range that can be queried is %s days.',
String(props.maxDaysRange)
)
);
return false;
}
}
setError('');
return true;
};
useEffect(() => {
props?.filterChangedCallback();
}, [value, props]);
const checkForEndDate = (
endDate: Date | undefined,
startDate: Date | undefined
) => {
const endDateCandidates: Date[] = [];
if (props.maxDaysRange !== undefined && isValid(startDate)) {
endDateCandidates.push(addDays(startDate as Date, props.maxDaysRange));
}
if (props.maxNextDays !== undefined) {
endDateCandidates.push(addDays(Date.now(), props.maxNextDays));
}
if (isValid(endDate)) {
endDateCandidates.push(endDate as Date);
}
return endDate && startDate
? formatRFC3339(max([startDate, min(endDateCandidates)]))
: undefined;
};
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
const { value: dateValue, name } = event.target;
const date = new Date(dateValue || defaultDates[name as 'start' | 'end']);
let update = { [name]: isValid(date) ? formatRFC3339(date) : undefined };
const startCheckDate = name === 'start' ? date : new Date(value.start);
const endCheckDate =
name === 'start'
? new Date(value.end)
: isValid(date)
? date
: new Date(maxEndDate);
const endDate = isValid(endCheckDate) ? endCheckDate : undefined;
const startDate = isValid(startCheckDate) ? startCheckDate : undefined;
update = { ...update, end: checkForEndDate(endDate, startDate) };
const start = (value.start && formatForInput(new Date(value.start))) || '';
const end = (value.end && formatForInput(new Date(value.end))) || '';
return (
<div className="ag-filter-body-wrapper">
<fieldset className="ag-simple-filter-body-wrapper">
<label className="block" key="start">
<span className="block">{t('Start')}</span>
<input
type="datetime-local"
name="start"
value={start}
onChange={onChange}
/>
</label>
<label className="block" key="end">
<span className="block">{t('End')}</span>
<input
type="datetime-local"
name="end"
value={end}
onChange={onChange}
/>
</label>
</fieldset>
<div className="ag-filter-apply-panel">
<button
type="button"
className="ag-standard-button ag-filter-apply-panel-button"
onClick={() => setValue(defaultFilterValue)}
if (validate(name, date, update)) {
setValue((curr) => ({
...curr,
...update,
}));
}
};
useEffect(() => {
props?.filterChangedCallback();
}, [value, props]);
const notification = useMemo(() => {
const not = error ? (
<div
className="text-sm flex items-center first-letter:uppercase mt-2 border-danger text-danger"
role="alert"
>
Reset
</button>
{error}
</div>
) : null;
return (
<div className="ag-filter-apply-panel flex min-h-[2rem]">{not}</div>
);
}, [error]);
const start = (value.start && formatForInput(new Date(value.start))) || '';
const end = (value.end && formatForInput(new Date(value.end))) || '';
return (
<div className="ag-filter-body-wrapper inline-block min-w-fit">
{notification}
<div className="ag-filter-apply-panel">
<fieldset className="ag-simple-filter-body-wrapper">
<label className="block" key="start">
<span className="block mb-1">{t('Start')}</span>
<input
type="datetime-local"
name="start"
value={start || ''}
onChange={onChange}
min={minStartDate}
max={maxStartDate}
/>
</label>
</fieldset>
<fieldset className="ag-simple-filter-body-wrapper">
<label className="block" key="end">
<span className="block mb-1">{t('End')}</span>
<input
type="datetime-local"
name="end"
value={end || ''}
onChange={onChange}
min={minEndDate}
max={maxEndDate}
/>
</label>
</fieldset>
</div>
<div className="ag-filter-apply-panel">
<button
type="button"
className="ag-standard-button ag-filter-apply-panel-button"
onClick={() => {
setError('');
setValue(defaultDates);
}}
>
{t('Reset')}
</button>
</div>
</div>
</div>
);
});
);
}
);

View File

@ -6,6 +6,7 @@ import React, {
useState,
} from 'react';
import type { IDoesFilterPassParams, IFilterParams } from 'ag-grid-community';
import { t } from '../i18n';
export const SetFilter = forwardRef((props: IFilterParams, ref) => {
const [value, setValue] = useState<string[]>([]);
@ -16,18 +17,19 @@ export const SetFilter = forwardRef((props: IFilterParams, ref) => {
doesFilterPass(params: IDoesFilterPassParams) {
const { api, colDef, column, columnApi, context } = props;
const { node } = params;
return (
props.valueGetter({
api,
colDef,
column,
columnApi,
context,
data: node.data,
getValue: (field) => node.data[field],
node,
}) === value
);
const getValue = props.valueGetter({
api,
colDef,
column,
columnApi,
context,
data: node.data,
getValue: (field) => node.data[field],
node,
});
return Array.isArray(value)
? value.includes(getValue)
: getValue === value;
},
isFilterActive() {
@ -83,7 +85,7 @@ export const SetFilter = forwardRef((props: IFilterParams, ref) => {
className="ag-standard-button ag-filter-apply-panel-button"
onClick={() => setValue([])}
>
Reset
{t('Reset')}
</button>
</div>
</div>