Feat/152 Trade list MVP (#217)

* add trades lib with data provider

* add trades table and cell color logic

* ensure we only show last 50 rows

* add test for table columns and formatting

* update trades table to get cells using col-id

* fix linting

* use default function param for fetchpolicy
This commit is contained in:
Matthew Russell 2022-04-07 06:41:57 -07:00 committed by GitHub
parent 41303190f8
commit 98c1fc82f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 684 additions and 16 deletions

View File

@ -5,10 +5,12 @@ import { useState } from 'react';
import { GridTab, GridTabs } from './grid-tabs'; import { GridTab, GridTabs } from './grid-tabs';
import { DealTicketContainer } from '@vegaprotocol/deal-ticket'; import { DealTicketContainer } from '@vegaprotocol/deal-ticket';
import { OrderListContainer } from '@vegaprotocol/order-list'; import { OrderListContainer } from '@vegaprotocol/order-list';
import { TradesContainer } from '@vegaprotocol/trades';
import { Splash } from '@vegaprotocol/ui-toolkit'; import { Splash } from '@vegaprotocol/ui-toolkit';
import { PositionsContainer } from '@vegaprotocol/positions'; import { PositionsContainer } from '@vegaprotocol/positions';
import type { Market_market } from './__generated__/Market'; import type { Market_market } from './__generated__/Market';
import { t } from '@vegaprotocol/react-helpers'; import { t } from '@vegaprotocol/react-helpers';
import { AccountsContainer } from '@vegaprotocol/accounts';
const Chart = () => ( const Chart = () => (
<Splash> <Splash>
@ -20,16 +22,6 @@ const Orderbook = () => (
<p>{t('Orderbook')}</p> <p>{t('Orderbook')}</p>
</Splash> </Splash>
); );
const Collateral = () => (
<Splash>
<p>{t('Collateral')}</p>
</Splash>
);
const Trades = () => (
<Splash>
<p>{t('Trades')}</p>
</Splash>
);
const TradingViews = { const TradingViews = {
Chart: Chart, Chart: Chart,
@ -37,8 +29,8 @@ const TradingViews = {
Orderbook: Orderbook, Orderbook: Orderbook,
Orders: OrderListContainer, Orders: OrderListContainer,
Positions: PositionsContainer, Positions: PositionsContainer,
Collateral: Collateral, Accounts: AccountsContainer,
Trades: Trades, Trades: TradesContainer,
}; };
type TradingView = keyof typeof TradingViews; type TradingView = keyof typeof TradingViews;
@ -50,7 +42,7 @@ interface TradeGridProps {
export const TradeGrid = ({ market }: TradeGridProps) => { export const TradeGrid = ({ market }: TradeGridProps) => {
const wrapperClasses = classNames( const wrapperClasses = classNames(
'h-full max-h-full', 'h-full max-h-full',
'grid gap-[1px] grid-cols-[1fr_325px_325px] grid-rows-[min-content_1fr_200px]', 'grid gap-[1px] grid-cols-[1fr_375px_460px] grid-rows-[min-content_1fr_200px]',
'bg-black-10 dark:bg-white-10', 'bg-black-10 dark:bg-white-10',
'text-ui' 'text-ui'
); );
@ -70,7 +62,7 @@ export const TradeGrid = ({ market }: TradeGridProps) => {
<TradeGridChild className="row-start-1 row-end-3"> <TradeGridChild className="row-start-1 row-end-3">
<GridTabs group="trade"> <GridTabs group="trade">
<GridTab id="trades" name={t('Trades')}> <GridTab id="trades" name={t('Trades')}>
<TradingViews.Trades /> <TradingViews.Trades marketId={market.id} />
</GridTab> </GridTab>
<GridTab id="orderbook" name={t('Orderbook')}> <GridTab id="orderbook" name={t('Orderbook')}>
<TradingViews.Orderbook /> <TradingViews.Orderbook />
@ -85,8 +77,8 @@ export const TradeGrid = ({ market }: TradeGridProps) => {
<GridTab id="positions" name={t('Positions')}> <GridTab id="positions" name={t('Positions')}>
<TradingViews.Positions /> <TradingViews.Positions />
</GridTab> </GridTab>
<GridTab id="collateral" name={t('Collateral')}> <GridTab id="accounts" name={t('Accounts')}>
<TradingViews.Collateral /> <TradingViews.Accounts />
</GridTab> </GridTab>
</GridTabs> </GridTabs>
</TradeGridChild> </TradeGridChild>

View File

@ -86,6 +86,7 @@ function makeDataProviderInternal<QueryData, Data, SubscriptionData, Delta>(
.subscribe<SubscriptionData>({ .subscribe<SubscriptionData>({
query: subscriptionQuery, query: subscriptionQuery,
variables, variables,
fetchPolicy,
}) })
.subscribe(({ data: subscriptionData }) => { .subscribe(({ data: subscriptionData }) => {
if (!subscriptionData) { if (!subscriptionData) {

12
libs/trades/.babelrc Normal file
View File

@ -0,0 +1,12 @@
{
"presets": [
[
"@nrwl/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}

View File

@ -0,0 +1,18 @@
{
"extends": ["plugin:@nrwl/nx/react", "../../.eslintrc.json"],
"ignorePatterns": ["!**/*", "__generated__"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}

7
libs/trades/README.md Normal file
View File

@ -0,0 +1,7 @@
# trades
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test trades` to execute the unit tests via [Jest](https://jestjs.io).

View File

@ -0,0 +1,10 @@
module.exports = {
displayName: 'trades',
preset: '../../jest.preset.js',
transform: {
'^.+\\.[tj]sx?$': 'babel-jest',
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
coverageDirectory: '../../coverage/libs/trades',
setupFilesAfterEnv: ['./src/setup-tests.ts'],
};

4
libs/trades/package.json Normal file
View File

@ -0,0 +1,4 @@
{
"name": "@vegaprotocol/trades",
"version": "0.0.1"
}

43
libs/trades/project.json Normal file
View File

@ -0,0 +1,43 @@
{
"root": "libs/trades",
"sourceRoot": "libs/trades/src",
"projectType": "library",
"tags": [],
"targets": {
"build": {
"executor": "@nrwl/web:rollup",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/libs/trades",
"tsConfig": "libs/trades/tsconfig.lib.json",
"project": "libs/trades/package.json",
"entryFile": "libs/trades/src/index.ts",
"external": ["react/jsx-runtime"],
"rollupConfig": "@nrwl/react/plugins/bundle-rollup",
"compiler": "babel",
"assets": [
{
"glob": "libs/trades/README.md",
"input": ".",
"output": "."
}
]
}
},
"lint": {
"executor": "@nrwl/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["libs/trades/**/*.{ts,tsx,js,jsx}"]
}
},
"test": {
"executor": "@nrwl/jest:jest",
"outputs": ["coverage/libs/trades"],
"options": {
"jestConfig": "libs/trades/jest.config.js",
"passWithNoTests": true
}
}
}
}

1
libs/trades/src/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './lib/trades-container';

View File

@ -0,0 +1,57 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL fragment: TradeFields
// ====================================================
export interface TradeFields_market {
__typename: "Market";
/**
* Market ID
*/
id: 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;
}
export interface TradeFields {
__typename: "Trade";
/**
* The hash of the trade data
*/
id: string;
/**
* The price of the trade (probably initially the passive order price, other determination algorithms are possible though) (uint64)
*/
price: string;
/**
* The number of contracts trades, will always be <= the remaining size of both orders immediately before the trade (uint64)
*/
size: string;
/**
* RFC3339Nano time for when the trade occurred
*/
createdAt: string;
/**
* The market the trade occurred on
*/
market: TradeFields_market;
}

View File

@ -0,0 +1,81 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL query operation: Trades
// ====================================================
export interface Trades_market_trades_market {
__typename: "Market";
/**
* Market ID
*/
id: 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;
}
export interface Trades_market_trades {
__typename: "Trade";
/**
* The hash of the trade data
*/
id: string;
/**
* The price of the trade (probably initially the passive order price, other determination algorithms are possible though) (uint64)
*/
price: string;
/**
* The number of contracts trades, will always be <= the remaining size of both orders immediately before the trade (uint64)
*/
size: string;
/**
* RFC3339Nano time for when the trade occurred
*/
createdAt: string;
/**
* The market the trade occurred on
*/
market: Trades_market_trades_market;
}
export interface Trades_market {
__typename: "Market";
/**
* Market ID
*/
id: string;
/**
* Trades on a market
*/
trades: Trades_market_trades[] | null;
}
export interface Trades {
/**
* An instrument that is trading on the VEGA network
*/
market: Trades_market | null;
}
export interface TradesVariables {
marketId: string;
maxTrades: number;
}

View File

@ -0,0 +1,68 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL subscription operation: TradesSub
// ====================================================
export interface TradesSub_trades_market {
__typename: "Market";
/**
* Market ID
*/
id: 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;
}
export interface TradesSub_trades {
__typename: "Trade";
/**
* The hash of the trade data
*/
id: string;
/**
* The price of the trade (probably initially the passive order price, other determination algorithms are possible though) (uint64)
*/
price: string;
/**
* The number of contracts trades, will always be <= the remaining size of both orders immediately before the trade (uint64)
*/
size: string;
/**
* RFC3339Nano time for when the trade occurred
*/
createdAt: string;
/**
* The market the trade occurred on
*/
market: TradesSub_trades_market;
}
export interface TradesSub {
/**
* Subscribe to the trades updates
*/
trades: TradesSub_trades[] | null;
}
export interface TradesSubVariables {
marketId: string;
}

View File

@ -0,0 +1,65 @@
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 {
MAX_TRADES,
sortTrades,
tradesDataProvider,
} from './trades-data-provider';
import { TradesTable } from './trades-table';
import type { TradeFields } from './__generated__/TradeFields';
import type { TradesVariables } from './__generated__/Trades';
interface TradesContainerProps {
marketId: string;
}
export const TradesContainer = ({ marketId }: TradesContainerProps) => {
const gridRef = useRef<AgGridReact | null>(null);
const variables = useMemo<TradesVariables>(
() => ({ marketId, maxTrades: MAX_TRADES }),
[marketId]
);
const update = useCallback((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
);
gridRef.current.api.applyTransactionAsync({
add: incoming,
remove: outgoing,
addIndex: 0,
});
return true;
}, []);
const { data, error, loading } = useDataProvider(
tradesDataProvider,
update,
variables
);
return (
<AsyncRenderer loading={loading} error={error} data={data}>
{(data) => <TradesTable ref={gridRef} data={data} />}
</AsyncRenderer>
);
};
const getAllRows = (api: GridApi) => {
const rows: TradeFields[] = [];
api.forEachNode((node) => {
rows.push(node.data);
});
return rows;
};

View File

@ -0,0 +1,78 @@
import { gql } from '@apollo/client';
import { makeDataProvider } from '@vegaprotocol/react-helpers';
import type { TradeFields } from './__generated__/TradeFields';
import type { Trades } from './__generated__/Trades';
import type { TradesSub } from './__generated__/TradesSub';
import orderBy from 'lodash/orderBy';
export const MAX_TRADES = 50;
const TRADES_FRAGMENT = gql`
fragment TradeFields on Trade {
id
price
size
createdAt
market {
id
decimalPlaces
}
}
`;
export const TRADES_QUERY = gql`
${TRADES_FRAGMENT}
query Trades($marketId: ID!, $maxTrades: Int!) {
market(id: $marketId) {
id
trades(last: $maxTrades) {
...TradeFields
}
}
}
`;
export const TRADES_SUB = gql`
${TRADES_FRAGMENT}
subscription TradesSub($marketId: ID!) {
trades(marketId: $marketId) {
...TradeFields
}
}
`;
export const sortTrades = (trades: TradeFields[]) => {
return orderBy(
trades,
(t) => {
return new Date(t.createdAt).getTime();
},
'desc'
);
};
const update = (draft: TradeFields[], delta: TradeFields[]) => {
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);
}
};
const getData = (responseData: Trades): TradeFields[] | null =>
responseData.market ? responseData.market.trades : null;
const getDelta = (subscriptionData: TradesSub): TradeFields[] =>
subscriptionData?.trades || [];
export const tradesDataProvider = makeDataProvider(
TRADES_QUERY,
TRADES_SUB,
update,
getData,
getDelta
);

View File

@ -0,0 +1,74 @@
import { act, render, screen } from '@testing-library/react';
import { getDateTimeFormat } from '@vegaprotocol/react-helpers';
import { DOWN_CLASS, TradesTable, UP_CLASS } from './trades-table';
import type { TradeFields } from './__generated__/TradeFields';
const trade: TradeFields = {
__typename: 'Trade',
id: 'trade-id',
price: '111122200',
size: '20',
createdAt: new Date('2022-04-06T19:00:00').toISOString(),
market: {
__typename: 'Market',
id: 'market-id',
decimalPlaces: 2,
},
};
test('Correct columns are rendered', async () => {
await act(async () => {
render(<TradesTable data={[trade]} />);
});
const expectedHeaders = ['Price', 'Size', 'Created at'];
const headers = screen.getAllByRole('columnheader');
expect(headers).toHaveLength(expectedHeaders.length);
expect(headers.map((h) => h.textContent?.trim())).toEqual(expectedHeaders);
});
test('Columns are formatted', async () => {
await act(async () => {
render(<TradesTable data={[trade]} />);
});
const cells = screen.getAllByRole('gridcell');
const expectedValues = [
'1,111,222.00',
'20',
getDateTimeFormat().format(new Date(trade.createdAt)),
];
cells.forEach((cell, i) => {
expect(cell).toHaveTextContent(expectedValues[i]);
});
});
test('Columns are formatted', async () => {
const trade2 = {
...trade,
id: 'trade-id-2',
price: (Number(trade.price) + 10).toString(),
size: (Number(trade.size) - 10).toString(),
};
await act(async () => {
render(<TradesTable data={[trade2, trade]} />);
});
const cells = screen.getAllByRole('gridcell');
const priceCells = cells.filter(
(cell) => cell.getAttribute('col-id') === 'price'
);
const sizeCells = cells.filter(
(cell) => cell.getAttribute('col-id') === 'size'
);
// For first trade price should have green class and size should have red class
// row 1
expect(priceCells[0]).toHaveClass(UP_CLASS);
expect(priceCells[1]).not.toHaveClass(DOWN_CLASS);
expect(priceCells[1]).not.toHaveClass(UP_CLASS);
expect(sizeCells[0]).toHaveClass(DOWN_CLASS);
expect(sizeCells[1]).not.toHaveClass(DOWN_CLASS);
expect(sizeCells[1]).not.toHaveClass(UP_CLASS);
});

View File

@ -0,0 +1,88 @@
import type { AgGridReact } from 'ag-grid-react';
import { AgGridColumn } from 'ag-grid-react';
import { forwardRef, useMemo } from 'react';
import { AgGridDynamic as AgGrid } from '@vegaprotocol/ui-toolkit';
import type { TradeFields } from './__generated__/TradeFields';
import {
formatNumber,
getDateTimeFormat,
t,
} from '@vegaprotocol/react-helpers';
import type { CellClassParams, ValueFormatterParams } from 'ag-grid-community';
import BigNumber from 'bignumber.js';
import { sortTrades } from './trades-data-provider';
export const UP_CLASS = 'text-vega-green';
export const DOWN_CLASS = 'text-vega-pink';
const changeCellClass =
(dataKey: string) =>
({ api, value, node }: CellClassParams) => {
const rowIndex = node?.rowIndex;
let colorClass = '';
if (typeof rowIndex === 'number') {
const prevRowNode = api.getModel().getRow(rowIndex + 1);
const prevValue = prevRowNode?.data[dataKey];
const valueNum = new BigNumber(value);
if (valueNum.isGreaterThan(prevValue)) {
colorClass = UP_CLASS;
} else if (valueNum.isLessThan(prevValue)) {
colorClass = DOWN_CLASS;
}
}
return ['font-mono', colorClass].join(' ');
};
interface TradesTableProps {
data: TradeFields[] | null;
}
export const TradesTable = forwardRef<AgGridReact, TradesTableProps>(
({ data }, ref) => {
// Sort intial trades
const trades = useMemo(() => {
if (!data) {
return null;
}
return sortTrades(data);
}, [data]);
return (
<AgGrid
style={{ width: '100%', height: '100%' }}
overlayNoRowsTemplate={t('No trades')}
rowData={trades}
getRowNodeId={(data) => data.id}
ref={ref}
defaultColDef={{
flex: 1,
resizable: true,
}}
>
<AgGridColumn
headerName={t('Price')}
field="price"
cellClass={changeCellClass('price')}
valueFormatter={({ value, data }: ValueFormatterParams) => {
return formatNumber(value, data.market.decimalPlaces);
}}
/>
<AgGridColumn
headerName={t('Size')}
field="size"
cellClass={changeCellClass('size')}
/>
<AgGridColumn
headerName={t('Created at')}
field="createdAt"
valueFormatter={({ value }: ValueFormatterParams) => {
return getDateTimeFormat().format(new Date(value));
}}
/>
</AgGrid>
);
}
);

View File

@ -0,0 +1 @@
import '@testing-library/jest-dom';

25
libs/trades/tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"allowJs": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@ -0,0 +1,22 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["node"]
},
"files": [
"../../node_modules/@nrwl/react/typings/cssmodule.d.ts",
"../../node_modules/@nrwl/react/typings/image.d.ts"
],
"exclude": [
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx"
],
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]
}

View File

@ -0,0 +1,19 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"]
},
"include": [
"**/*.test.ts",
"**/*.spec.ts",
"**/*.test.tsx",
"**/*.spec.tsx",
"**/*.test.js",
"**/*.spec.js",
"**/*.test.jsx",
"**/*.spec.jsx",
"**/*.d.ts"
]
}

View File

@ -27,6 +27,7 @@
"@vegaprotocol/tailwindcss-config": [ "@vegaprotocol/tailwindcss-config": [
"libs/tailwindcss-config/src/index.js" "libs/tailwindcss-config/src/index.js"
], ],
"@vegaprotocol/trades": ["libs/trades/src/index.ts"],
"@vegaprotocol/types": ["libs/types/src/index.ts"], "@vegaprotocol/types": ["libs/types/src/index.ts"],
"@vegaprotocol/ui-toolkit": ["libs/ui-toolkit/src/index.ts"], "@vegaprotocol/ui-toolkit": ["libs/ui-toolkit/src/index.ts"],
"@vegaprotocol/wallet": ["libs/wallet/src/index.ts"], "@vegaprotocol/wallet": ["libs/wallet/src/index.ts"],

View File

@ -16,6 +16,7 @@
"stats": "apps/stats", "stats": "apps/stats",
"stats-e2e": "apps/stats-e2e", "stats-e2e": "apps/stats-e2e",
"tailwindcss-config": "libs/tailwindcss-config", "tailwindcss-config": "libs/tailwindcss-config",
"trades": "libs/trades",
"trading": "apps/trading", "trading": "apps/trading",
"trading-e2e": "apps/trading-e2e", "trading-e2e": "apps/trading-e2e",
"types": "libs/types", "types": "libs/types",