feat: [console-lite] - abstract components for portfolio page (#1306)

* feat: [console-lite] - abstract components for portfolio page

* feat: [console-lite] - abstract components for portfolio page - improvements

* feat: [console-lite] - use abstract list with accounts manager

* feat: [console-lite] - use abstract list with positions

* feat: [console-lite] - use abstract list with orders

* feat: [console-lite] - use abstract list with fills

* feat: [console-lite] - fix failings linters

* feat: [console-lite] - fix failings e2e test

* feat: [console-lite] - fix failings e2e test

* feat: [console-lite] - improve some css

* feat: [console-lite] - a bunch of fixes for positions

* feat: [console-lite] - tweaks of columns confs, bunch of e2e tests

* feat: [console-lite] - abstract components for portfolio page - after review feedback fixes

* feat: [console-lite] - abstract components for portfolio page - after review feedback fixes

* feat: [console-lite] - abstract components for portfolio page - add missing asset datails dialog

* feat: [console-lite] - portfolio abstractions - add fills hook unit tests

* feat: [console-lite] - portfolio abstractions - add orders hook unit tests

* feat: [console-lite] - portfolio abstractions - fix lint error

* feat: [console-lite] - abstract components for portfolio page -fix failings tests

* feat: [console-lite] - abstract components for portfolio page -fix failings tests

* feat: [console-lite] - abstract components for portfolio page - fix failings lint check

* feat: [console-lite] - abstract components for portfolio page - next fix

* feat: [console-lite] - abstract components for portfolio page - improve some int test

Co-authored-by: maciek <maciek@vegaprotocol.io>
This commit is contained in:
macqbat 2022-09-22 13:09:12 +02:00 committed by GitHub
parent d95bfb60ea
commit 5c4af868a7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 2889 additions and 525 deletions

View File

@ -2,6 +2,12 @@ import {
connectVegaWallet, connectVegaWallet,
disconnectVegaWallet, disconnectVegaWallet,
} from '../support/connect-wallet'; } from '../support/connect-wallet';
import { aliasQuery } from '@vegaprotocol/cypress';
import { generatePositions } from '../support/mocks/generate-positions';
import { generateAccounts } from '../support/mocks/generate-accounts';
import { generateOrders } from '../support/mocks/generate-orders';
import { generateFills } from '../support/mocks/generate-fills';
import { generateFillsMarkets } from '../support/mocks/generate-markets';
describe('Portfolio page', () => { describe('Portfolio page', () => {
afterEach(() => { afterEach(() => {
@ -19,19 +25,91 @@ describe('Portfolio page', () => {
it('certain tabs should exist', () => { it('certain tabs should exist', () => {
cy.visit('/portfolio'); cy.visit('/portfolio');
connectVegaWallet(); connectVegaWallet();
cy.getByTestId('Assets').should('exist');
cy.getByTestId('tab-assets').should('exist');
cy.getByTestId('Positions').click(); cy.getByTestId('assets').click();
cy.getByTestId('tab-positions').should('exist'); cy.location('pathname').should('eq', '/portfolio/assets');
cy.getByTestId('Orders').click(); cy.getByTestId('positions').click();
cy.getByTestId('tab-orders').should('exist'); cy.location('pathname').should('eq', '/portfolio/positions');
cy.getByTestId('Fills').click(); cy.getByTestId('orders').click();
cy.getByTestId('tab-fills').should('exist'); cy.location('pathname').should('eq', '/portfolio/orders');
cy.getByTestId('Deposits').click(); cy.getByTestId('fills').click();
cy.getByTestId('tab-deposits').should('exist'); cy.location('pathname').should('eq', '/portfolio/fills');
cy.getByTestId('deposits').click();
cy.location('pathname').should('eq', '/portfolio/deposits');
});
describe('Assets view', () => {
beforeEach(() => {
cy.mockGQL((req) => {
aliasQuery(req, 'Positions', generatePositions());
aliasQuery(req, 'Accounts', generateAccounts());
});
cy.visit('/portfolio/assets');
connectVegaWallet();
});
it('data should be properly rendered', () => {
cy.get('.ag-center-cols-container .ag-row').should('have.length', 5);
cy.get(
'.ag-center-cols-container [row-id="ACCOUNT_TYPE_GENERAL-asset-id-null"]'
)
.find('button')
.click();
cy.getByTestId('dialog-title').should(
'have.text',
'Asset details - tEURO'
);
cy.getByTestId('dialog-close').click();
});
});
describe('Positions view', () => {
beforeEach(() => {
cy.mockGQL((req) => {
aliasQuery(req, 'Positions', generatePositions());
aliasQuery(req, 'Accounts', generateAccounts());
});
cy.visit('/portfolio/positions');
connectVegaWallet();
});
it('data should be properly rendered', () => {
cy.getByTestId('positions-asset-tDAI').should('exist');
cy.getByTestId('positions-asset-tEURO').should('exist');
});
});
describe('Orders view', () => {
beforeEach(() => {
cy.mockGQL((req) => {
aliasQuery(req, 'Orders', generateOrders());
aliasQuery(req, 'Markets', generateFillsMarkets());
});
cy.visit('/portfolio/orders');
connectVegaWallet();
});
it('data should be properly rendered', () => {
cy.get('.ag-center-cols-container .ag-row').should('have.length', 5);
});
});
describe('Fills view', () => {
beforeEach(() => {
cy.mockGQL((req) => {
aliasQuery(req, 'Fills', generateFills());
aliasQuery(req, 'Markets', generateFillsMarkets());
});
cy.visit('/portfolio/fills');
connectVegaWallet();
});
it('data should be properly rendered', () => {
cy.get('.ag-center-cols-container .ag-row').should('have.length', 4);
});
}); });
}); });

View File

@ -0,0 +1,108 @@
import merge from 'lodash/merge';
import type { AccountsQuery } from '@vegaprotocol/accounts';
import { AccountType } from '@vegaprotocol/types';
import type { PartialDeep } from 'type-fest';
export const generateAccounts = (
override?: PartialDeep<AccountsQuery>
): AccountsQuery => {
const defaultAccounts: AccountsQuery = {
party: {
__typename: 'Party',
id: Cypress.env('VEGA_PUBLIC_KEY'),
accounts: [
{
__typename: 'Account',
type: AccountType.ACCOUNT_TYPE_GENERAL,
balance: '100000000',
market: null,
asset: {
__typename: 'Asset',
id: 'asset-id',
symbol: 'tEURO',
decimals: 5,
},
},
{
__typename: 'Account',
type: AccountType.ACCOUNT_TYPE_GENERAL,
balance: '100000000',
market: {
id: '0604e8c918655474525e1a95367902266ade70d318c2c908f0cca6e3d11dcb13',
tradableInstrument: {
__typename: 'TradableInstrument',
instrument: {
__typename: 'Instrument',
name: 'AAVEDAI Monthly (30 Jun 2022)',
},
},
__typename: 'Market',
},
asset: {
__typename: 'Asset',
id: 'asset-id-2',
symbol: 'tDAI',
decimals: 5,
},
},
{
__typename: 'Account',
type: AccountType.ACCOUNT_TYPE_MARGIN,
balance: '1000',
market: {
__typename: 'Market',
tradableInstrument: {
__typename: 'TradableInstrument',
instrument: {
__typename: 'Instrument',
name: '',
},
},
id: '5a4b0b9e9c0629f0315ec56fcb7bd444b0c6e4da5ec7677719d502626658a376',
},
asset: {
__typename: 'Asset',
id: 'asset-id',
symbol: 'tEURO',
decimals: 5,
},
},
{
__typename: 'Account',
type: AccountType.ACCOUNT_TYPE_MARGIN,
balance: '1000',
market: {
__typename: 'Market',
tradableInstrument: {
__typename: 'TradableInstrument',
instrument: {
__typename: 'Instrument',
name: '',
},
},
id: 'c9f5acd348796011c075077e4d58d9b7f1689b7c1c8e030a5e886b83aa96923d',
},
asset: {
__typename: 'Asset',
id: 'asset-id-2',
symbol: 'tDAI',
decimals: 5,
},
},
{
__typename: 'Account',
type: AccountType.ACCOUNT_TYPE_GENERAL,
balance: '100000000',
market: null,
asset: {
__typename: 'Asset',
id: 'asset-0',
symbol: 'AST0',
decimals: 5,
},
},
],
},
};
return merge(defaultAccounts, override);
};

View File

@ -0,0 +1,114 @@
import type {
Fills,
Fills_party_tradesConnection_edges_node,
} from '@vegaprotocol/fills';
import { Side } from '@vegaprotocol/types';
import merge from 'lodash/merge';
import type { PartialDeep } from 'type-fest';
export const generateFills = (override?: PartialDeep<Fills>): Fills => {
const fills: Fills_party_tradesConnection_edges_node[] = [
generateFill({
buyer: {
id: Cypress.env('VEGA_PUBLIC_KEY'),
},
}),
generateFill({
id: '1',
seller: {
id: Cypress.env('VEGA_PUBLIC_KEY'),
},
aggressor: Side.SIDE_SELL,
buyerFee: {
infrastructureFee: '5000',
},
market: {
id: 'market-1',
},
}),
generateFill({
id: '2',
seller: {
id: Cypress.env('VEGA_PUBLIC_KEY'),
},
aggressor: Side.SIDE_BUY,
}),
generateFill({
id: '3',
aggressor: Side.SIDE_SELL,
market: {
id: 'market-2',
},
buyer: {
id: Cypress.env('VEGA_PUBLIC_KEY'),
},
}),
];
const defaultResult: Fills = {
party: {
id: 'buyer-id',
tradesConnection: {
__typename: 'TradeConnection',
edges: fills.map((f) => {
return {
__typename: 'TradeEdge',
node: f,
cursor: '3',
};
}),
pageInfo: {
__typename: 'PageInfo',
startCursor: '1',
endCursor: '2',
hasNextPage: false,
hasPreviousPage: false,
},
},
__typename: 'Party',
},
};
return merge(defaultResult, override);
};
export const generateFill = (
override?: PartialDeep<Fills_party_tradesConnection_edges_node>
) => {
const defaultFill: Fills_party_tradesConnection_edges_node = {
__typename: 'Trade',
id: '0',
createdAt: new Date().toISOString(),
price: '10000000',
size: '50000',
buyOrder: 'buy-order',
sellOrder: 'sell-order',
aggressor: Side.SIDE_BUY,
buyer: {
__typename: 'Party',
id: 'buyer-id',
},
seller: {
__typename: 'Party',
id: 'seller-id',
},
buyerFee: {
__typename: 'TradeFee',
makerFee: '100',
infrastructureFee: '100',
liquidityFee: '100',
},
sellerFee: {
__typename: 'TradeFee',
makerFee: '200',
infrastructureFee: '200',
liquidityFee: '200',
},
market: {
__typename: 'Market',
id: 'market-0',
},
};
return merge(defaultFill, override);
};

View File

@ -1103,3 +1103,16 @@ export const generateMarkets = (override?): Markets => {
return merge(defaultResult, override); return merge(defaultResult, override);
}; };
export const generateFillsMarkets = () => {
const marketIds = ['market-0', 'market-1', 'market-2', 'market-4'];
return {
marketsConnection: {
__typename: 'MarketConnection',
edges: marketIds.map((id) => ({
__typename: 'MarketEdge',
node: { ...protoMarket, id },
})),
},
};
};

View File

@ -0,0 +1,139 @@
import merge from 'lodash/merge';
import type { PartialDeep } from 'type-fest';
import type {
Orders,
Orders_party_ordersConnection_edges_node,
} from '@vegaprotocol/orders';
import {
OrderStatus,
OrderTimeInForce,
OrderType,
Side,
} from '@vegaprotocol/types';
export const generateOrders = (override?: PartialDeep<Orders>): Orders => {
const orders: Orders_party_ordersConnection_edges_node[] = [
{
__typename: 'Order',
id: '066468C06549101DAF7BC51099E1412A0067DC08C246B7D8013C9D0CBF1E8EE7',
market: {
__typename: 'Market',
id: 'market-0',
},
size: '10',
type: OrderType.TYPE_LIMIT,
status: OrderStatus.STATUS_FILLED,
side: Side.SIDE_BUY,
remaining: '0',
price: '20000000',
timeInForce: OrderTimeInForce.TIME_IN_FORCE_GTC,
createdAt: new Date(2020, 1, 30).toISOString(),
updatedAt: null,
expiresAt: null,
rejectionReason: null,
},
{
__typename: 'Order',
id: '48DB6767E4E4E0F649C5A13ABFADE39F8451C27DA828DAF14B7A1E8E5EBDAD99',
market: {
__typename: 'Market',
id: 'market-1',
},
size: '1',
type: OrderType.TYPE_LIMIT,
status: OrderStatus.STATUS_FILLED,
side: Side.SIDE_BUY,
remaining: '0',
price: '100',
timeInForce: OrderTimeInForce.TIME_IN_FORCE_GTC,
createdAt: new Date(2020, 1, 29).toISOString(),
updatedAt: null,
expiresAt: null,
rejectionReason: null,
},
{
__typename: 'Order',
id: '4e93702990712c41f6995fcbbd94f60bb372ad12d64dfa7d96d205c49f790336',
market: {
__typename: 'Market',
id: 'market-2',
},
size: '1',
type: OrderType.TYPE_LIMIT,
status: OrderStatus.STATUS_FILLED,
side: Side.SIDE_BUY,
remaining: '0',
price: '20000',
timeInForce: OrderTimeInForce.TIME_IN_FORCE_GTC,
createdAt: new Date(2020, 1, 28).toISOString(),
updatedAt: null,
expiresAt: null,
rejectionReason: null,
},
{
__typename: 'Order',
id: '94737d2bafafa4bc3b80a56ef084ae52a983b91aa067c31e243c61a0f962a836',
market: {
__typename: 'Market',
id: 'market-3',
},
size: '1',
type: OrderType.TYPE_LIMIT,
status: OrderStatus.STATUS_ACTIVE,
side: Side.SIDE_BUY,
remaining: '0',
price: '100000',
timeInForce: OrderTimeInForce.TIME_IN_FORCE_GTC,
createdAt: new Date(2020, 1, 27).toISOString(),
updatedAt: null,
expiresAt: null,
rejectionReason: null,
},
{
__typename: 'Order',
id: '94aead3ca92dc932efcb503631b03a410e2a5d4606cae6083e2406dc38e52f78',
market: {
__typename: 'Market',
id: 'market-3',
},
size: '10',
type: OrderType.TYPE_LIMIT,
status: OrderStatus.STATUS_PARTIALLY_FILLED,
side: Side.SIDE_SELL,
remaining: '3',
price: '100000',
timeInForce: OrderTimeInForce.TIME_IN_FORCE_GTC,
createdAt: new Date(2020, 1, 27).toISOString(),
updatedAt: null,
expiresAt: null,
rejectionReason: null,
},
];
const defaultResult: Orders = {
party: {
id: Cypress.env('VEGA_PUBLIC_KEY'),
ordersConnection: {
__typename: 'OrderConnection',
edges: orders.map((f) => {
return {
__typename: 'OrderEdge',
node: f,
cursor: f.id,
};
}),
pageInfo: {
__typename: 'PageInfo',
startCursor:
'066468C06549101DAF7BC51099E1412A0067DC08C246B7D8013C9D0CBF1E8EE7',
endCursor:
'94737d2bafafa4bc3b80a56ef084ae52a983b91aa067c31e243c61a0f962a836',
hasNextPage: false,
hasPreviousPage: false,
},
},
__typename: 'Party',
},
};
return merge(defaultResult, override);
};

View File

@ -0,0 +1,191 @@
import merge from 'lodash/merge';
import type { PartialDeep } from 'type-fest';
import type {
Positions,
Positions_party_positionsConnection_edges_node,
} from '@vegaprotocol/positions';
import { MarketTradingMode } from '@vegaprotocol/types';
export const generatePositions = (
override?: PartialDeep<Positions>
): Positions => {
const nodes: Positions_party_positionsConnection_edges_node[] = [
{
__typename: 'Position',
realisedPNL: '0',
openVolume: '6',
unrealisedPNL: '895000',
averageEntryPrice: '1129935',
updatedAt: '2022-07-28T15:09:34.441143Z',
marginsConnection: {
__typename: 'MarginConnection',
edges: [
{
__typename: 'MarginEdge',
node: {
__typename: 'MarginLevels',
maintenanceLevel: '0',
searchLevel: '0',
initialLevel: '0',
collateralReleaseLevel: '0',
market: {
__typename: 'Market',
id: 'c9f5acd348796011c075077e4d58d9b7f1689b7c1c8e030a5e886b83aa96923d',
},
asset: {
__typename: 'Asset',
symbol: 'tDAI',
},
},
},
],
},
market: {
id: 'c9f5acd348796011c075077e4d58d9b7f1689b7c1c8e030a5e886b83aa96923d',
tradingMode: MarketTradingMode.TRADING_MODE_CONTINUOUS,
data: {
markPrice: '17588787',
__typename: 'MarketData',
market: {
__typename: 'Market',
id: 'c9f5acd348796011c075077e4d58d9b7f1689b7c1c8e030a5e886b83aa96923d',
},
},
decimalPlaces: 5,
positionDecimalPlaces: 0,
tradableInstrument: {
instrument: {
name: 'UNIDAI Monthly (30 Jun 2022)',
__typename: 'Instrument',
},
__typename: 'TradableInstrument',
},
__typename: 'Market',
},
},
{
__typename: 'Position',
realisedPNL: '100',
openVolume: '20',
unrealisedPNL: '895000',
averageEntryPrice: '8509338',
updatedAt: '2022-07-28T15:09:34.441143Z',
marginsConnection: {
__typename: 'MarginConnection',
edges: [
{
__typename: 'MarginEdge',
node: {
__typename: 'MarginLevels',
maintenanceLevel: '0',
searchLevel: '0',
initialLevel: '0',
collateralReleaseLevel: '0',
market: {
__typename: 'Market',
id: '0604e8c918655474525e1a95367902266ade70d318c2c908f0cca6e3d11dcb13',
},
asset: {
__typename: 'Asset',
symbol: 'tDAI',
},
},
},
],
},
market: {
id: '0604e8c918655474525e1a95367902266ade70d318c2c908f0cca6e3d11dcb13',
tradingMode: MarketTradingMode.TRADING_MODE_CONTINUOUS,
data: {
markPrice: '8649338',
__typename: 'MarketData',
market: {
__typename: 'Market',
id: '0604e8c918655474525e1a95367902266ade70d318c2c908f0cca6e3d11dcb13',
},
},
decimalPlaces: 5,
positionDecimalPlaces: 0,
tradableInstrument: {
instrument: {
name: 'AAVEDAI Monthly (30 Jun 2022)',
__typename: 'Instrument',
},
__typename: 'TradableInstrument',
},
__typename: 'Market',
},
},
{
realisedPNL: '0',
openVolume: '1',
unrealisedPNL: '-22519',
averageEntryPrice: '84400088',
updatedAt: '2022-07-28T14:53:54.725477Z',
marginsConnection: {
__typename: 'MarginConnection',
edges: [
{
__typename: 'MarginEdge',
node: {
__typename: 'MarginLevels',
maintenanceLevel: '0',
searchLevel: '0',
initialLevel: '0',
collateralReleaseLevel: '0',
market: {
__typename: 'Market',
id: '5a4b0b9e9c0629f0315ec56fcb7bd444b0c6e4da5ec7677719d502626658a376',
},
asset: {
__typename: 'Asset',
symbol: 'tEURO',
},
},
},
],
},
market: {
id: '5a4b0b9e9c0629f0315ec56fcb7bd444b0c6e4da5ec7677719d502626658a376',
tradingMode: MarketTradingMode.TRADING_MODE_CONTINUOUS,
data: {
markPrice: '84377569',
__typename: 'MarketData',
market: {
__typename: 'Market',
id: '5a4b0b9e9c0629f0315ec56fcb7bd444b0c6e4da5ec7677719d502626658a376',
},
},
decimalPlaces: 5,
positionDecimalPlaces: 0,
tradableInstrument: {
instrument: {
name: 'Tesla Quarterly (30 Jun 2022)',
__typename: 'Instrument',
},
__typename: 'TradableInstrument',
},
__typename: 'Market',
},
__typename: 'Position',
},
];
const defaultResult: Positions = {
party: {
__typename: 'Party',
id: Cypress.env('VEGA_PUBLIC_KEY'),
positionsConnection: {
__typename: 'PositionConnection',
edges: nodes.map((node) => {
return {
__typename: 'PositionEdge',
node,
};
}),
},
},
};
return merge(defaultResult, override);
};

View File

@ -0,0 +1,107 @@
import React, {
forwardRef,
useCallback,
useContext,
useMemo,
useRef,
} from 'react';
import classNames from 'classnames';
import type { AgGridReact } from 'ag-grid-react';
import { AgGridDynamic as AgGrid } from '@vegaprotocol/ui-toolkit';
import {
t,
ThemeContext,
useScreenDimensions,
} from '@vegaprotocol/react-helpers';
import type {
GridOptions,
GetRowIdParams,
TabToNextCellParams,
CellKeyDownEvent,
FullWidthCellKeyDownEvent,
} from 'ag-grid-community';
import * as constants from '../simple-market-list/constants';
interface Props<T> extends GridOptions {
data?: T[];
handleRowClicked?: (event: { data: T }) => void;
components?: Record<string, unknown>;
classNamesParam?: string | string[];
}
const ConsoleLiteGrid = <T extends { id?: string }>(
{ data, handleRowClicked, getRowId, classNamesParam, ...props }: Props<T>,
ref?: React.Ref<AgGridReact>
) => {
const { isMobile, screenSize } = useScreenDimensions();
const gridRef = useRef<AgGridReact | null>(null);
const theme = useContext(ThemeContext);
const handleOnGridReady = useCallback(() => {
(
(ref as React.RefObject<AgGridReact>) || gridRef
).current?.api?.sizeColumnsToFit();
}, [gridRef, ref]);
const onTabToNextCell = useCallback((params: TabToNextCellParams) => {
const {
api,
previousCellPosition: { rowIndex },
} = params;
const rowCount = api.getDisplayedRowCount();
if (rowCount <= rowIndex + 1) {
return null;
}
return { ...params.previousCellPosition, rowIndex: rowIndex + 1 };
}, []);
const getRowIdLocal = useCallback(({ data }: GetRowIdParams) => data.id, []);
const onCellKeyDown = useCallback(
(
params: (CellKeyDownEvent | FullWidthCellKeyDownEvent) & {
event: KeyboardEvent;
}
) => {
const { event: { key = '' } = {}, data } = params;
if (key === 'Enter') {
handleRowClicked?.({ data });
}
},
[handleRowClicked]
);
const shouldSuppressHorizontalScroll = useMemo(() => {
return !isMobile && constants.LARGE_SCREENS.includes(screenSize);
}, [isMobile, screenSize]);
return (
<AgGrid
className={classNames(classNamesParam)}
rowData={data}
rowHeight={60}
customThemeParams={
theme === 'dark'
? constants.agGridDarkVariables
: constants.agGridLightVariables
}
onGridReady={handleOnGridReady}
onRowClicked={handleRowClicked}
rowClass={isMobile ? 'mobile' : ''}
rowClassRules={constants.ROW_CLASS_RULES}
ref={ref || gridRef}
overlayNoRowsTemplate={t('No data to display')}
suppressContextMenu
getRowId={getRowId || getRowIdLocal}
suppressMovableColumns
suppressRowTransform
onCellKeyDown={onCellKeyDown}
tabToNextCell={onTabToNextCell}
suppressHorizontalScroll={shouldSuppressHorizontalScroll}
{...props}
/>
);
};
const ConsoleLiteGridForwarder = forwardRef(ConsoleLiteGrid) as <
T extends { id?: string }
>(
p: Props<T> & { ref?: React.Ref<AgGridReact> }
) => React.ReactElement;
export default ConsoleLiteGridForwarder;

View File

@ -0,0 +1 @@
export { default as ConsoleLiteGrid } from './console-lite-grid';

View File

@ -0,0 +1,103 @@
import classNames from 'classnames';
import React, { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
export interface Item {
name: string;
id: string;
url?: string;
isActive?: boolean;
color?: string;
cssClass?: string;
}
interface Props {
active?: string;
cssClass?: string[];
items: Item[];
'data-testid'?: string;
'aria-label'?: string;
}
const MenuItem = ({ id, name, url, isActive, cssClass }: Item): JSX.Element => {
if (!url) {
return <span data-testid={id}>{name}</span>;
}
return (
<Link
to={url}
aria-label={name}
className={classNames('pl-0 hover:opacity-75', cssClass, {
active: isActive,
})}
data-testid={id}
>
{name}
</Link>
);
};
const findActive = (active?: string, items?: Item[]) => {
if (!active && items?.length) {
return 0;
}
return items?.length
? items.findIndex((item) => item.id === active) || 0
: -1;
};
export const HorizontalMenu = ({
items,
active,
'data-testid': dataTestId,
'aria-label': ariaLabel,
}: Props) => {
const [activeNumber, setActiveNumber] = useState<number>(
findActive(active, items)
);
const slideContRef = useRef<HTMLUListElement | null>(null);
const [sliderStyles, setSliderStyles] = useState<Record<string, string>>({});
useEffect(() => {
setActiveNumber(findActive(active, items));
}, [active, items]);
useEffect(() => {
const contStyles = (
slideContRef.current as HTMLUListElement
).getBoundingClientRect();
const selectedStyles = (slideContRef.current as HTMLUListElement).children[
activeNumber
]?.getBoundingClientRect();
const styles: Record<string, string> = selectedStyles
? {
backgroundColor: items[activeNumber].color || '',
width: `${selectedStyles.width}px`,
left: `${selectedStyles.left - contStyles.left}px`,
}
: {};
setSliderStyles(styles);
}, [activeNumber, slideContRef, items]);
return items.length ? (
<ul
ref={slideContRef}
className="grid grid-flow-col auto-cols-min gap-4 relative pb-2 mb-2"
data-testid={dataTestId}
aria-label={ariaLabel}
>
{items.map((item, i) => (
<li key={item.id} className="md:mr-2 whitespace-nowrap">
<MenuItem {...item} isActive={i === activeNumber} />
</li>
))}
<li
className="absolute bottom-0 h-[2px] transition-left duration-300 dark:bg-white bg-black"
key="slider"
style={sliderStyles}
/>
</ul>
) : null;
};
export default HorizontalMenu;

View File

@ -0,0 +1,2 @@
export { default as HorizontalMenu } from './horizontal-menu';
export type { Item as HorizontalMenuItem } from './horizontal-menu';

View File

@ -0,0 +1,63 @@
import { useMemo, useRef } from 'react';
import type { AgGridReact } from 'ag-grid-react';
import { PriceCell, useDataProvider } from '@vegaprotocol/react-helpers';
import type {
AccountFieldsFragment,
AccountEventsSubscription,
} from '@vegaprotocol/accounts';
import {
accountsDataProvider,
accountsManagerUpdate,
getId,
} from '@vegaprotocol/accounts';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import {
AssetDetailsDialog,
useAssetDetailsDialogStore,
} from '@vegaprotocol/assets';
import { ConsoleLiteGrid } from '../../console-lite-grid';
import { useAccountColumnDefinitions } from '.';
interface AccountObj extends AccountFieldsFragment {
id: string;
}
interface Props {
partyId: string;
}
const AccountsManager = ({ partyId }: Props) => {
const {
isAssetDetailsDialogOpen,
assetDetailsDialogSymbol,
setAssetDetailsDialogOpen,
} = useAssetDetailsDialogStore();
const gridRef = useRef<AgGridReact | null>(null);
const variables = useMemo(() => ({ partyId }), [partyId]);
const update = accountsManagerUpdate(gridRef);
const { data, error, loading } = useDataProvider<
AccountFieldsFragment[],
AccountEventsSubscription['accounts']
>({ dataProvider: accountsDataProvider, update, variables });
const { columnDefs, defaultColDef } = useAccountColumnDefinitions();
return (
<>
<AsyncRenderer loading={loading} error={error} data={data}>
<ConsoleLiteGrid<AccountObj>
data={data as AccountObj[]}
columnDefs={columnDefs}
defaultColDef={defaultColDef}
components={{ PriceCell }}
getRowId={({ data }) => getId(data)}
/>
</AsyncRenderer>
<AssetDetailsDialog
assetSymbol={assetDetailsDialogSymbol}
open={isAssetDetailsDialogOpen}
onChange={(open) => setAssetDetailsDialogOpen(open)}
/>
</>
);
};
export default AccountsManager;

View File

@ -0,0 +1,2 @@
export { default as AccountManager } from './accounts';
export { default as useAccountColumnDefinitions } from './use-column-definitions';

View File

@ -0,0 +1,113 @@
import React, { useMemo } from 'react';
import { addDecimalsFormatNumber, t } from '@vegaprotocol/react-helpers';
import type { SummaryRow } from '@vegaprotocol/react-helpers';
import type { AccountFieldsFragment } from '@vegaprotocol/accounts';
import { useAssetDetailsDialogStore } from '@vegaprotocol/assets';
import type {
ColDef,
GroupCellRendererParams,
ValueFormatterParams,
} from 'ag-grid-community';
import type { AccountType } from '@vegaprotocol/types';
import { AccountTypeMapping } from '@vegaprotocol/types';
interface AccountsTableValueFormatterParams extends ValueFormatterParams {
data: AccountFieldsFragment;
}
const comparator = (
valueA: string,
valueB: string,
nodeA: { data: AccountFieldsFragment & SummaryRow },
nodeB: { data: AccountFieldsFragment & SummaryRow },
isInverted: boolean
) => {
if (valueA < valueB) {
return -1;
}
if (valueA > valueB) {
return 1;
}
if (nodeA.data.__summaryRow) {
return isInverted ? -1 : 1;
}
if (nodeB.data.__summaryRow) {
return isInverted ? 1 : -1;
}
return 0;
};
const useAccountColumnDefinitions = () => {
const { setAssetDetailsDialogOpen, setAssetDetailsDialogSymbol } =
useAssetDetailsDialogStore();
const columnDefs: ColDef[] = useMemo(() => {
return [
{
colId: 'account-asset',
headerName: t('Asset'),
field: 'asset.symbol',
comparator,
headerClass: 'uppercase justify-start',
cellClass: 'uppercase flex h-full items-center md:pl-4',
cellRenderer: ({ value }: GroupCellRendererParams) =>
value && value.length > 0 ? (
<div className="md:pl-4 grid h-full items-center" title={value}>
<div className="truncate min-w-0">
<button
className="hover:underline"
onClick={() => {
setAssetDetailsDialogOpen(true);
setAssetDetailsDialogSymbol(value);
}}
>
{value}
</button>
</div>
</div>
) : (
''
),
},
{
colId: 'type',
headerName: t('Type'),
field: 'type',
cellClass: 'uppercase !flex h-full items-center',
valueFormatter: ({ value }: ValueFormatterParams) =>
value ? AccountTypeMapping[value as AccountType] : '-',
},
{
colId: 'market',
headerName: t('Market'),
cellClass: 'uppercase !flex h-full items-center',
field: 'market.tradableInstrument.instrument.name',
valueFormatter: "value || '—'",
},
{
colId: 'balance',
headerName: t('Balance'),
field: 'balance',
cellClass: 'uppercase !flex h-full items-center',
cellRenderer: 'PriceCell',
valueFormatter: ({ value, data }: AccountsTableValueFormatterParams) =>
addDecimalsFormatNumber(value, data.asset.decimals),
},
];
}, [setAssetDetailsDialogOpen, setAssetDetailsDialogSymbol]);
const defaultColDef = useMemo(() => {
return {
sortable: true,
unSortIcon: true,
headerClass: 'uppercase',
editable: false,
};
}, []);
return { columnDefs, defaultColDef };
};
export default useAccountColumnDefinitions;

View File

@ -0,0 +1,35 @@
import { t } from '@vegaprotocol/react-helpers';
export const PORTFOLIO_ASSETS = 'assets';
export const PORTFOLIO_POSITIONS = 'positions';
export const PORTFOLIO_ORDERS = 'orders';
export const PORTFOLIO_FILLS = 'fills';
export const PORTFOLIO_DEPOSITS = 'deposits';
export const PORTFOLIO_ITEMS = [
{
name: t('Assets'),
id: PORTFOLIO_ASSETS,
url: `/portfolio/${PORTFOLIO_ASSETS}`,
},
{
name: t('Positions'),
id: PORTFOLIO_POSITIONS,
url: `/portfolio/${PORTFOLIO_POSITIONS}`,
},
{
name: t('Orders'),
id: PORTFOLIO_ORDERS,
url: `/portfolio/${PORTFOLIO_ORDERS}`,
},
{
name: t('Fills'),
id: PORTFOLIO_FILLS,
url: `/portfolio/${PORTFOLIO_FILLS}`,
},
{
name: t('Deposits'),
id: PORTFOLIO_DEPOSITS,
url: `/portfolio/${PORTFOLIO_DEPOSITS}`,
},
];

View File

@ -0,0 +1,49 @@
import { useRef } from 'react';
import type { AgGridReact } from 'ag-grid-react';
import type { TradeWithMarket } from '@vegaprotocol/fills';
import { useFillsList } from '@vegaprotocol/fills';
import type { BodyScrollEndEvent, BodyScrollEvent } from 'ag-grid-community';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { ConsoleLiteGrid } from '../../console-lite-grid';
import useColumnDefinitions from './use-column-definitions';
interface Props {
partyId: string;
}
const FillsManager = ({ partyId }: Props) => {
const { columnDefs, defaultColDef } = useColumnDefinitions({ partyId });
const gridRef = useRef<AgGridReact | null>(null);
const scrolledToTop = useRef(true);
const { data, error, loading, addNewRows, getRows } = useFillsList({
partyId,
gridRef,
scrolledToTop,
});
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}>
<ConsoleLiteGrid<TradeWithMarket>
ref={gridRef}
rowModelType="infinite"
datasource={{ getRows }}
onBodyScrollEnd={onBodyScrollEnd}
onBodyScroll={onBodyScroll}
columnDefs={columnDefs}
defaultColDef={defaultColDef}
/>
</AsyncRenderer>
);
};
export default FillsManager;

View File

@ -0,0 +1 @@
export { default as FillsManager } from './fills';

View File

@ -0,0 +1,190 @@
import { useMemo } from 'react';
import type { ColDef, ValueFormatterParams } from 'ag-grid-community';
import type { TradeWithMarket } from '@vegaprotocol/fills';
import classNames from 'classnames';
import {
addDecimal,
addDecimalsFormatNumber,
formatNumber,
getDateTimeFormat,
negativeClassNames,
positiveClassNames,
t,
} from '@vegaprotocol/react-helpers';
import BigNumber from 'bignumber.js';
import { Side } from '@vegaprotocol/types';
interface Props {
partyId: string;
}
const useColumnDefinitions = ({ partyId }: Props) => {
const columnDefs: ColDef[] = useMemo(() => {
return [
{
colId: 'market',
headerName: t('Market'),
field: 'market.tradableInstrument.instrument.name',
cellClass: '!flex h-full items-center !md:pl-4',
minWidth: 150,
},
{
colId: 'size',
headerName: t('Size'),
headerClass: 'uppercase',
field: 'size',
width: 100,
cellClass: ({ data }: { data: TradeWithMarket }) => {
return classNames('!flex h-full items-center justify-center', {
[positiveClassNames]: data?.buyer.id === partyId,
[negativeClassNames]: data?.seller.id,
});
},
valueFormatter: ({ value, data }: ValueFormatterParams) => {
if (value && data?.market) {
let prefix;
if (data.buyer.id === partyId) {
prefix = '+';
} else if (data.seller.id) {
prefix = '-';
}
const size = addDecimalsFormatNumber(
value,
data.market.positionDecimalPlaces
);
return `${prefix}${size}`;
}
return '-';
},
},
{
colId: 'value',
headerName: t('Value'),
headerClass: 'uppercase text-center',
cellClass: '!flex h-full items-center',
field: 'price',
valueFormatter: ({ value, data }: ValueFormatterParams) => {
if (value && data?.market) {
const asset =
data.market.tradableInstrument.instrument.product.settlementAsset
.symbol;
const valueFormatted = addDecimalsFormatNumber(
value,
data.market.decimalPlaces
);
return `${valueFormatted} ${asset}`;
}
return '-';
},
type: 'rightAligned',
},
{
colId: 'filledvalue',
headerName: t('Filled value'),
headerClass: 'uppercase text-center',
cellClass: '!flex h-full items-center',
field: 'price',
valueFormatter: ({ value, data }: ValueFormatterParams) => {
if (value && data?.market) {
const asset =
data.market.tradableInstrument.instrument.product.settlementAsset
.symbol;
const size = new BigNumber(
addDecimal(data.size, data.market.positionDecimalPlaces)
);
const price = new BigNumber(
addDecimal(value, data.market.decimalPlaces)
);
const total = size.times(price).toString();
const valueFormatted = formatNumber(
total,
data?.market.decimalPlaces
);
return `${valueFormatted} ${asset}`;
}
return '-';
},
type: 'rightAligned',
},
{
colId: 'role',
headerName: t('Role'),
field: 'aggressor',
width: 100,
valueFormatter: ({ value, data }: ValueFormatterParams) => {
if (value && data) {
const taker = t('Taker');
const maker = t('Maker');
if (data?.buyer.id === partyId) {
if (value === Side.SIDE_BUY) {
return taker;
} else {
return maker;
}
} else if (data?.seller.id === partyId) {
if (value === Side.SIDE_SELL) {
return taker;
} else {
return maker;
}
}
}
return '-';
},
},
{
colId: 'fee',
headerName: t('Fee'),
headerClass: 'uppercase text-center',
cellClass: '!flex h-full items-center',
field: 'market.tradableInstrument.instrument.product',
valueFormatter: ({ value, data }: ValueFormatterParams) => {
if (value && data) {
const asset = value.settlementAsset;
let feesObj;
if (data.buyer.id === partyId) {
feesObj = data.buyerFee;
} else if (data?.seller.id === partyId) {
feesObj = data?.sellerFee;
} else {
return '-';
}
const fee = new BigNumber(feesObj.makerFee)
.plus(feesObj.infrastructureFee)
.plus(feesObj.liquidityFee);
const totalFees = addDecimalsFormatNumber(
fee.toString(),
asset.decimals
);
return `${totalFees} ${asset.symbol}`;
}
return '-';
},
type: 'rightAligned',
},
{
colId: 'date',
headerName: t('Date'),
field: 'createdAt',
valueFormatter: ({ value }: ValueFormatterParams) => {
return value ? getDateTimeFormat().format(new Date(value)) : '-';
},
},
];
}, [partyId]);
const defaultColDef = useMemo(() => {
return {
sortable: true,
unSortIcon: true,
headerClass: 'uppercase',
cellClass: '!flex h-full items-center',
};
}, []);
return { columnDefs, defaultColDef };
};
export default useColumnDefinitions;

View File

@ -0,0 +1 @@
export { default as OrdersManager } from './orders';

View File

@ -0,0 +1,96 @@
import { useRef, useState } from 'react';
import type { AgGridReact } from 'ag-grid-react';
import type { BodyScrollEndEvent, BodyScrollEvent } from 'ag-grid-community';
import type { OrderWithMarket } from '@vegaprotocol/orders';
import {
useOrderCancel,
useOrderListData,
useOrderEdit,
OrderFeedback,
getCancelDialogTitle,
getCancelDialogIntent,
getEditDialogTitle,
OrderEditDialog,
} from '@vegaprotocol/orders';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { ConsoleLiteGrid } from '../../console-lite-grid';
import useColumnDefinitions from './use-column-definitions';
interface Props {
partyId: string;
}
const OrdersManager = ({ partyId }: Props) => {
const [editOrder, setEditOrder] = useState<OrderWithMarket | null>(null);
const orderCancel = useOrderCancel();
const orderEdit = useOrderEdit(editOrder);
const { columnDefs, defaultColDef } = useColumnDefinitions({
setEditOrder,
orderCancel,
});
const gridRef = useRef<AgGridReact | null>(null);
const scrolledToTop = useRef(true);
const { data, error, loading, addNewRows, getRows } = useOrderListData({
partyId,
gridRef,
scrolledToTop,
});
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}>
<ConsoleLiteGrid<OrderWithMarket>
ref={gridRef}
rowModelType="infinite"
datasource={{ getRows }}
onBodyScrollEnd={onBodyScrollEnd}
onBodyScroll={onBodyScroll}
columnDefs={columnDefs}
defaultColDef={defaultColDef}
/>
<orderCancel.Dialog
title={getCancelDialogTitle(orderCancel.cancelledOrder?.status)}
intent={getCancelDialogIntent(orderCancel.cancelledOrder?.status)}
>
<OrderFeedback
transaction={orderCancel.transaction}
order={orderCancel.cancelledOrder}
/>
</orderCancel.Dialog>
<orderEdit.Dialog
title={getEditDialogTitle(orderEdit.updatedOrder?.status)}
>
<OrderFeedback
transaction={orderEdit.transaction}
order={orderEdit.updatedOrder}
/>
</orderEdit.Dialog>
{editOrder && (
<OrderEditDialog
isOpen={Boolean(editOrder)}
onChange={(isOpen) => {
if (!isOpen) setEditOrder(null);
}}
order={editOrder}
onSubmit={(fields) => {
setEditOrder(null);
orderEdit.edit({ price: fields.entryPrice });
}}
/>
)}
</AsyncRenderer>
);
};
export default OrdersManager;

View File

@ -0,0 +1,287 @@
import { useMemo } from 'react';
import type {
ColDef,
ValueFormatterParams,
ICellRendererParams,
} from 'ag-grid-community';
import {
addDecimal,
getDateTimeFormat,
negativeClassNames,
positiveClassNames,
t,
} from '@vegaprotocol/react-helpers';
import type {
Orders_party_ordersConnection_edges_node,
OrderWithMarket,
CancelOrderArgs,
} from '@vegaprotocol/orders';
import { isOrderActive } from '@vegaprotocol/orders';
import {
OrderRejectionReasonMapping,
OrderStatus,
OrderType,
OrderStatusMapping,
OrderTypeMapping,
Side,
OrderTimeInForce,
OrderTimeInForceMapping,
} from '@vegaprotocol/types';
import BigNumber from 'bignumber.js';
import { Button } from '@vegaprotocol/ui-toolkit';
type StatusKey = keyof typeof OrderStatusMapping;
type RejectReasonKey = keyof typeof OrderRejectionReasonMapping;
type OrderTimeKey = keyof typeof OrderTimeInForceMapping;
interface Props {
setEditOrder: (order: OrderWithMarket) => void;
orderCancel: {
cancel: (args: CancelOrderArgs) => void;
[key: string]: unknown;
};
}
const useColumnDefinitions = ({ setEditOrder, orderCancel }: Props) => {
const columnDefs: ColDef[] = useMemo(() => {
return [
{
colId: 'market',
headerName: t('Market'),
field: 'market.tradableInstrument.instrument.code',
headerClass: 'uppercase justify-start',
cellClass: '!flex h-full items-center !md:pl-4',
},
{
colId: 'size',
headerName: t('Size'),
field: 'size',
headerClass: 'uppercase',
cellClass: 'font-mono !flex h-full items-center',
width: 80,
cellClassRules: {
[positiveClassNames]: ({
data,
}: {
data: Orders_party_ordersConnection_edges_node;
}) => data?.side === Side.SIDE_BUY,
[negativeClassNames]: ({
data,
}: {
data: Orders_party_ordersConnection_edges_node;
}) => data?.side === Side.SIDE_SELL,
},
valueFormatter: ({ value, data }: ValueFormatterParams) => {
if (value && data && data.market) {
const prefix = data
? data.side === Side.SIDE_BUY
? '+'
: '-'
: '';
return (
prefix + addDecimal(value, data.market.positionDecimalPlaces)
);
}
return '-';
},
},
{
colId: 'type',
field: 'type',
width: 80,
valueFormatter: ({
value,
}: ValueFormatterParams & {
value?: Orders_party_ordersConnection_edges_node['type'];
}) => OrderTypeMapping[value as OrderType],
},
{
colId: 'status',
field: 'status',
cellClass: 'text-center font-mono !flex h-full items-center',
valueFormatter: ({
value,
data,
}: ValueFormatterParams & {
value?: StatusKey;
}) => {
if (value && data && data.market) {
if (value === OrderStatus.STATUS_REJECTED) {
return `${OrderStatusMapping[value as StatusKey]}: ${
data.rejectionReason &&
OrderRejectionReasonMapping[
data.rejectionReason as RejectReasonKey
]
}`;
}
return OrderStatusMapping[value as StatusKey] as string;
}
return '-';
},
},
{
colId: 'filled',
headerName: t('Filled'),
headerClass: 'uppercase',
field: 'remaining',
cellClass: 'font-mono text-center !flex h-full items-center',
width: 80,
valueFormatter: ({
data,
value,
}: ValueFormatterParams & {
value?: Orders_party_ordersConnection_edges_node['remaining'];
}) => {
if (value && data && data.market) {
const dps = data.market.positionDecimalPlaces;
const size = new BigNumber(data.size);
const remaining = new BigNumber(value);
const fills = size.minus(remaining);
return `${addDecimal(fills.toString(), dps)}/${addDecimal(
size.toString(),
dps
)}`;
}
return '-';
},
},
{
colId: 'price',
field: 'price',
type: 'rightAligned',
width: 125,
headerClass: 'uppercase text-right',
cellClass: 'font-mono text-right !flex h-full items-center',
valueFormatter: ({
value,
data,
}: ValueFormatterParams & {
value?: Orders_party_ordersConnection_edges_node['price'];
}) => {
if (
value === undefined ||
!data ||
!data.market ||
data.type === OrderType.TYPE_MARKET
) {
return '-';
}
return addDecimal(value, data.market.decimalPlaces);
},
},
{
colId: 'timeInForce',
field: 'timeInForce',
minWidth: 120,
valueFormatter: ({
value,
data,
}: ValueFormatterParams & {
value?: OrderTimeKey;
}) => {
if (value && data?.market) {
if (
value === OrderTimeInForce.TIME_IN_FORCE_GTT &&
data.expiresAt
) {
const expiry = getDateTimeFormat().format(
new Date(data.expiresAt)
);
return `${
OrderTimeInForceMapping[value as OrderTimeKey]
}: ${expiry}`;
}
return OrderTimeInForceMapping[value as OrderTimeKey];
}
return '-';
},
},
{
colId: 'createdat',
field: 'createdAt',
valueFormatter: ({
value,
}: ValueFormatterParams & {
value?: Orders_party_ordersConnection_edges_node['createdAt'];
}) => {
return value ? getDateTimeFormat().format(new Date(value)) : value;
},
},
{
colId: 'updated',
field: 'updatedAt',
cellClass: '!flex h-full items-center justify-center',
valueFormatter: ({
value,
}: ValueFormatterParams & {
value?: Orders_party_ordersConnection_edges_node['updatedAt'];
}) => {
return value ? getDateTimeFormat().format(new Date(value)) : '-';
},
},
{
colId: 'edit',
field: 'edit',
width: 75,
cellRenderer: ({ data }: ICellRendererParams) => {
if (!data) return null;
if (isOrderActive(data.status)) {
return (
<Button
data-testid="edit"
onClick={() => {
setEditOrder(data);
}}
size="xs"
>
{t('Edit')}
</Button>
);
}
return null;
},
},
{
colId: 'cancel',
field: 'cancel',
minWidth: 130,
cellRenderer: ({ data }: ICellRendererParams) => {
if (!data) return null;
if (isOrderActive(data.status)) {
return (
<Button
size="xs"
data-testid="cancel"
onClick={() => {
if (data.market) {
orderCancel.cancel({
orderId: data.id,
marketId: data.market.id,
});
}
}}
>
Cancel
</Button>
);
}
return null;
},
},
];
}, [orderCancel, setEditOrder]);
const defaultColDef = useMemo(() => {
return {
sortable: true,
unSortIcon: true,
headerClass: 'uppercase',
cellClass: '!flex h-full items-center',
};
}, []);
return { columnDefs, defaultColDef };
};
export default useColumnDefinitions;

View File

@ -1,40 +1,54 @@
import * as React from 'react'; import React, { useMemo } from 'react';
import { AccountsContainer } from '@vegaprotocol/accounts'; import { AccountManager } from './accounts';
import { t } from '@vegaprotocol/react-helpers';
import { useVegaWallet } from '@vegaprotocol/wallet'; import { useVegaWallet } from '@vegaprotocol/wallet';
import { Tabs, Tab } from '@vegaprotocol/ui-toolkit';
import { OrderListContainer } from '@vegaprotocol/orders';
import { PositionsContainer } from '@vegaprotocol/positions';
import { FillsContainer } from '@vegaprotocol/fills';
import ConnectWallet from '../wallet-connector'; import ConnectWallet from '../wallet-connector';
import { DepositContainer } from '../deposits'; import { DepositContainer } from '../deposits';
import { useParams } from 'react-router-dom';
import { HorizontalMenu } from '../horizontal-menu';
import * as constants from './constants';
import { PositionsManager } from './positions';
import { OrdersManager } from './orders';
import { FillsManager } from './fills';
type RouterParams = {
module?: string;
};
export const Portfolio = () => { export const Portfolio = () => {
const { keypair } = useVegaWallet(); const { keypair } = useVegaWallet();
if (!keypair) { const params = useParams<RouterParams>();
return (
<section className="xl:w-1/2"> const module = useMemo(() => {
<ConnectWallet /> if (!keypair) {
</section> return (
); <section className="xl:w-1/2">
} <ConnectWallet />
</section>
);
}
switch (params?.module) {
case constants.PORTFOLIO_ASSETS:
default:
return <AccountManager partyId={keypair.pub} />;
case constants.PORTFOLIO_POSITIONS:
return <PositionsManager partyId={keypair.pub} />;
case constants.PORTFOLIO_ORDERS:
return <OrdersManager partyId={keypair.pub} />;
case constants.PORTFOLIO_FILLS:
return <FillsManager partyId={keypair.pub} />;
case constants.PORTFOLIO_DEPOSITS:
return <DepositContainer />;
}
}, [params?.module, keypair]);
return ( return (
<Tabs> <div className="h-full p-4 md:p-6 grid grid-rows-[min-content_1fr]">
<Tab id="assets" name={t('Assets')}> <HorizontalMenu
<AccountsContainer /> active={params?.module}
</Tab> items={constants.PORTFOLIO_ITEMS}
<Tab id="positions" name={t('Positions')}> />
<PositionsContainer /> {module}
</Tab> </div>
<Tab id="orders" name={t('Orders')}>
<OrderListContainer />
</Tab>
<Tab id="fills" name={t('Fills')}>
<FillsContainer />
</Tab>
<Tab id="deposits" name={t('Deposits')}>
<DepositContainer />
</Tab>
</Tabs>
); );
}; };

View File

@ -0,0 +1 @@
export { default as PositionsManager } from './positions';

View File

@ -0,0 +1,58 @@
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { useRef } from 'react';
import type { AgGridReact } from 'ag-grid-react';
import type { Position } from '@vegaprotocol/positions';
import { PriceFlashCell, t } from '@vegaprotocol/react-helpers';
import { AssetBalance } from '@vegaprotocol/accounts';
import { usePositionsData } from '@vegaprotocol/positions';
import { ConsoleLiteGrid } from '../../console-lite-grid';
import useColumnDefinitions from './use-column-definitions';
interface Props {
partyId: string;
assetSymbol: string;
}
const getRowId = ({ data }: { data: Position }) => data.marketId;
const PositionsAsset = ({ partyId, assetSymbol }: Props) => {
const gridRef = useRef<AgGridReact | null>(null);
const { data, error, loading, getRows } = usePositionsData({
partyId,
assetSymbol,
gridRef,
});
const { columnDefs, defaultColDef } = useColumnDefinitions();
return (
<AsyncRenderer loading={loading} error={error} data={data}>
<div
data-testid={`positions-asset-${assetSymbol}`}
className="flex justify-between items-center px-4 pt-3 pb-1"
>
<h4>
{assetSymbol} {t('markets')}
</h4>
<div className="text-sm text-neutral-500 dark:text-neutral-300">
{assetSymbol} {t('balance')}:
<span data-testid="balance" className="pl-1 font-mono">
<AssetBalance partyId={partyId} assetSymbol={assetSymbol} />
</span>
</div>
</div>
<ConsoleLiteGrid<Position & { id: undefined }>
ref={gridRef}
domLayout="autoHeight"
classNamesParam="h-auto"
columnDefs={columnDefs}
defaultColDef={defaultColDef}
getRowId={getRowId}
rowModelType={data?.length ? 'infinite' : 'clientSide'}
data={data?.length ? undefined : []}
datasource={{ getRows }}
components={{ PriceFlashCell }}
/>
</AsyncRenderer>
);
};
export default PositionsAsset;

View File

@ -0,0 +1,32 @@
import { t } from '@vegaprotocol/react-helpers';
import { usePositionsAssets } from '@vegaprotocol/positions';
import { AsyncRenderer, Splash } from '@vegaprotocol/ui-toolkit';
import PositionsAsset from './positions-asset';
interface Props {
partyId: string;
}
const Positions = ({ partyId }: Props) => {
const { data, error, loading, assetSymbols } = usePositionsAssets({
partyId,
});
return (
<AsyncRenderer loading={loading} error={error} data={data}>
{assetSymbols && assetSymbols.length && (
<div className="w-full, h-max">
{assetSymbols?.map((assetSymbol) => (
<PositionsAsset
key={assetSymbol}
partyId={partyId}
assetSymbol={assetSymbol}
/>
))}
</div>
)}
{assetSymbols?.length === 0 && <Splash>{t('No data to display')}</Splash>}
</AsyncRenderer>
);
};
export default Positions;

View File

@ -0,0 +1,291 @@
import { useMemo } from 'react';
import {
addDecimalsFormatNumber,
formatNumber,
getDateTimeFormat,
PriceFlashCell,
signedNumberCssClassRules,
t,
} from '@vegaprotocol/react-helpers';
import type {
PositionsTableValueFormatterParams,
Position,
} from '@vegaprotocol/positions';
import { AmountCell, ProgressBarCell } from '@vegaprotocol/positions';
import type {
CellRendererSelectorResult,
ICellRendererParams,
ValueGetterParams,
GroupCellRendererParams,
ColDef,
} from 'ag-grid-community';
import { MarketTradingMode } from '@vegaprotocol/types';
import { Intent } from '@vegaprotocol/ui-toolkit';
const EmptyCell = () => '';
const useColumnDefinitions = () => {
const columnDefs: ColDef[] = useMemo(() => {
return [
{
colId: 'market',
headerName: t('Market'),
headerClass: 'uppercase justify-start',
cellClass: '!flex h-full items-center !md:pl-4',
field: 'marketName',
cellRenderer: ({ value }: GroupCellRendererParams) => {
if (!value) {
return null;
}
const valueFormatted: [string, string?] = (() => {
// split market name into two parts, 'Part1 (Part2)' or 'Part1 - Part2'
const matches = value.match(/^(.*)(\((.*)\)| - (.*))\s*$/);
if (matches && matches[1] && matches[3]) {
return [matches[1].trim(), matches[3].trim()];
}
return [value];
})();
if (valueFormatted && valueFormatted[1]) {
return (
<div className="leading-tight">
<div>{valueFormatted[0]}</div>
<div>{valueFormatted[1]}</div>
</div>
);
}
return valueFormatted ? valueFormatted[0] : null;
},
},
{
colId: 'amount',
headerName: t('Amount'),
headerClass: 'uppercase',
cellClass: '!flex h-full items-center',
field: 'openVolume',
valueGetter: ({ node, data }: ValueGetterParams) => {
return node?.rowPinned ? data?.notional : data?.openVolume;
},
type: 'rightAligned',
cellRendererSelector: (
params: ICellRendererParams
): CellRendererSelectorResult => {
return {
component: params.node.rowPinned ? PriceFlashCell : AmountCell,
};
},
valueFormatter: ({
value,
data,
node,
}: PositionsTableValueFormatterParams & {
value: Position['openVolume'];
}) => {
let ret;
if (value && data) {
ret = node?.rowPinned
? addDecimalsFormatNumber(value, data.decimals)
: data;
}
// FIXME this column needs refactoring
return ret as unknown as string;
},
},
{
colId: 'markprice',
headerName: t('Mark price'),
headerClass: 'uppercase',
cellClass: '!flex h-full items-center justify-center',
field: 'markPrice',
cellRendererSelector: (
params: ICellRendererParams
): CellRendererSelectorResult => {
return {
component: params.node.rowPinned ? EmptyCell : PriceFlashCell,
};
},
valueFormatter: ({
value,
data,
node,
}: PositionsTableValueFormatterParams & {
value: Position['markPrice'];
}) => {
if (
data &&
value &&
node?.rowPinned &&
data.marketTradingMode ===
MarketTradingMode.TRADING_MODE_OPENING_AUCTION
) {
return addDecimalsFormatNumber(
value.toString(),
data.marketDecimalPlaces
);
}
return '-';
},
},
{
colId: 'entryprice',
headerName: t('Entry price'),
headerClass: 'uppercase',
cellClass: '!flex h-full items-center',
field: 'averageEntryPrice',
headerComponentParams: {
template:
'<div class="ag-cell-label-container" role="presentation">' +
` <span>${t('Liquidation price (est)')}</span>` +
' <span ref="eText" class="ag-header-cell-text"></span>' +
'</div>',
},
cellRenderer: ({ node, data }: GroupCellRendererParams) => {
const valueFormatted =
data && !node?.rowPinned
? (() => {
const min = BigInt(data.averageEntryPrice);
const max = BigInt(data.liquidationPrice);
const mid = BigInt(data.markPrice);
const range = max - min;
return {
low: addDecimalsFormatNumber(
min.toString(),
data.marketDecimalPlaces
),
high: addDecimalsFormatNumber(
max.toString(),
data.marketDecimalPlaces
),
value: range
? Number(((mid - min) * BigInt(100)) / range)
: 0,
intent: data.lowMarginLevel ? Intent.Warning : undefined,
};
})()
: undefined;
return node.rowPinned ? (
''
) : (
<ProgressBarCell valueFormatted={valueFormatted} />
);
},
},
{
colId: 'leverage',
headerName: t('Leverage'),
headerClass: 'uppercase',
cellClass: '!flex h-full items-center justify-center',
field: 'currentLeverage',
type: 'rightAligned',
cellRendererSelector: (
params: ICellRendererParams
): CellRendererSelectorResult => {
return {
component: params.node.rowPinned ? EmptyCell : PriceFlashCell,
};
},
valueFormatter: ({
value,
node,
}: PositionsTableValueFormatterParams & {
value: Position['currentLeverage'];
}) => (value === undefined ? '' : formatNumber(value.toString(), 1)),
},
{
colId: 'marginallocated',
headerName: t('Margin allocated'),
headerClass: 'uppercase',
cellClass: '!flex h-full flex-col justify-center',
field: 'capitalUtilisation',
type: 'rightAligned',
cellRenderer: ({ value, node, data }: GroupCellRendererParams) => {
const valueFormatted =
data && value
? (() => {
return {
low: `${formatNumber(value, 2)}%`,
high: addDecimalsFormatNumber(
data.totalBalance,
data.decimals
),
value: Number(value),
};
})()
: undefined;
return node.rowPinned ? (
''
) : (
<ProgressBarCell valueFormatted={valueFormatted} />
);
},
},
{
colId: 'realisedpnl',
headerName: t('Realised PNL'),
headerClass: 'uppercase',
cellClass: '!flex h-full items-center',
field: 'realisedPNL',
type: 'rightAligned',
cellClassRules: signedNumberCssClassRules,
valueFormatter: ({
value,
data,
}: PositionsTableValueFormatterParams & {
value: Position['realisedPNL'];
}) =>
value === undefined
? ''
: addDecimalsFormatNumber(value.toString(), data.decimals),
cellRenderer: 'PriceFlashCell',
headerTooltip: t('P&L excludes any fees paid.'),
},
{
colId: 'unrealisedpnl',
headerName: t('Unrealised PNL'),
headerClass: 'uppercase',
cellClass: '!flex h-full items-center',
field: 'unrealisedPNL',
type: 'rightAligned',
cellClassRules: signedNumberCssClassRules,
valueFormatter: ({
value,
data,
}: PositionsTableValueFormatterParams & {
value: Position['unrealisedPNL'];
}) =>
value === undefined
? ''
: addDecimalsFormatNumber(value.toString(), data.decimals),
cellRenderer: 'PriceFlashCell',
},
{
colId: 'updated',
headerName: t('Updated'),
headerClass: 'uppercase',
cellClass: '!flex h-full items-center',
field: 'updatedAt',
type: 'rightAligned',
valueFormatter: ({
value,
}: PositionsTableValueFormatterParams & {
value: Position['updatedAt'];
}) => {
if (!value) {
return '';
}
return getDateTimeFormat().format(new Date(value));
},
},
];
}, []);
const defaultColDef = useMemo(() => {
return {
sortable: true,
unSortIcon: true,
headerClass: 'uppercase',
cellClass: '!flex h-full items-center',
};
}, []);
return { columnDefs, defaultColDef };
};
export default useColumnDefinitions;

View File

@ -1,9 +1,9 @@
import { t } from '@vegaprotocol/react-helpers'; import { t } from '@vegaprotocol/react-helpers';
import { themelite as theme } from '@vegaprotocol/tailwindcss-config'; import { themelite as theme } from '@vegaprotocol/tailwindcss-config';
import { MarketState } from '@vegaprotocol/types'; import { MarketState } from '@vegaprotocol/types';
import { IS_MARKET_TRADABLE } from '../../constants';
import colors from 'tailwindcss/colors'; import colors from 'tailwindcss/colors';
import type { Market } from '@vegaprotocol/market-list'; import type { Market } from '@vegaprotocol/market-list';
import { IS_MARKET_TRADABLE } from '../../constants';
export const STATES_FILTER = [ export const STATES_FILTER = [
{ value: 'all', text: t('All') }, { value: 'all', text: t('All') },
@ -198,4 +198,11 @@ export const ROW_CLASS_RULES = {
export const LARGE_SCREENS = ['xl', 'xxl']; export const LARGE_SCREENS = ['xl', 'xxl'];
export const ALL_PRODUCTS_ITEM = {
name: t('All Markets'),
id: 'allmarkets',
cssClass: 'text-pink',
color: theme.colors.pink,
};
export const AG_GRID_CONTAINER_STYLES = { width: '100%', height: '100%' }; export const AG_GRID_CONTAINER_STYLES = { width: '100%', height: '100%' };

View File

@ -1,25 +1,14 @@
import { useCallback, useContext, useEffect, useMemo, useRef } from 'react'; import React, { useCallback, useEffect, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import type { AgGridReact } from 'ag-grid-react'; import type { AgGridReact } from 'ag-grid-react';
import { AgGridDynamic as AgGrid } from '@vegaprotocol/ui-toolkit';
import { useScreenDimensions } from '@vegaprotocol/react-helpers'; import { useScreenDimensions } from '@vegaprotocol/react-helpers';
import { t } from '@vegaprotocol/react-helpers';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit'; import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { ThemeContext } from '@vegaprotocol/react-helpers';
import type { MarketState } from '@vegaprotocol/types'; import type { MarketState } from '@vegaprotocol/types';
import useMarketsFilterData from './use-markets-filter-data'; import useMarketsFilterData from './use-markets-filter-data';
import useColumnDefinitions from './use-column-definitions'; import useColumnDefinitions from './use-column-definitions';
import * as constants from './constants';
import SimpleMarketToolbar from './simple-market-toolbar'; import SimpleMarketToolbar from './simple-market-toolbar';
import { IS_MARKET_TRADABLE } from '../../constants'; import { IS_MARKET_TRADABLE } from '../../constants';
import type { import { ConsoleLiteGrid } from '../console-lite-grid';
CellKeyDownEvent,
FullWidthCellKeyDownEvent,
} from 'ag-grid-community/dist/lib/events';
import type {
GetRowIdParams,
TabToNextCellParams,
} from 'ag-grid-community/dist/lib/entities/iCallbackParams';
import type { Market, MarketsListData } from '@vegaprotocol/market-list'; import type { Market, MarketsListData } from '@vegaprotocol/market-list';
import { useMarketList } from '@vegaprotocol/market-list'; import { useMarketList } from '@vegaprotocol/market-list';
@ -34,10 +23,9 @@ export type RouterParams = Partial<{
}>; }>;
const SimpleMarketList = () => { const SimpleMarketList = () => {
const { isMobile, screenSize } = useScreenDimensions(); const { isMobile } = useScreenDimensions();
const navigate = useNavigate(); const navigate = useNavigate();
const params = useParams<RouterParams>(); const params = useParams<RouterParams>();
const theme = useContext(ThemeContext);
const statusesRef = useRef<Record<string, MarketState | ''>>({}); const statusesRef = useRef<Record<string, MarketState | ''>>({});
const gridRef = useRef<AgGridReact | null>(null); const gridRef = useRef<AgGridReact | null>(null);
@ -63,8 +51,6 @@ const SimpleMarketList = () => {
const { columnDefs, defaultColDef } = useColumnDefinitions({ isMobile }); const { columnDefs, defaultColDef } = useColumnDefinitions({ isMobile });
const getRowId = useCallback(({ data }: GetRowIdParams) => data.id, []);
const handleRowClicked = useCallback( const handleRowClicked = useCallback(
({ data }: { data: Market }) => { ({ data }: { data: Market }) => {
if (IS_MARKET_TRADABLE(data)) { if (IS_MARKET_TRADABLE(data)) {
@ -74,65 +60,16 @@ const SimpleMarketList = () => {
[navigate] [navigate]
); );
const onTabToNextCell = useCallback((params: TabToNextCellParams) => {
const {
api,
previousCellPosition: { rowIndex },
} = params;
const rowCount = api.getDisplayedRowCount();
if (rowCount <= rowIndex + 1) {
return null;
}
return { ...params.previousCellPosition, rowIndex: rowIndex + 1 };
}, []);
const onCellKeyDown = useCallback(
(
params: (CellKeyDownEvent | FullWidthCellKeyDownEvent) & {
event: KeyboardEvent;
}
) => {
const { event: { key = '' } = {}, data } = params;
if (key === 'Enter') {
handleRowClicked({ data });
}
},
[handleRowClicked]
);
const shouldSuppressHorizontalScroll = useMemo(() => {
return !isMobile && constants.LARGE_SCREENS.includes(screenSize);
}, [isMobile, screenSize]);
return ( return (
<div className="h-full p-4 md:p-6 grid grid-rows-[min-content,1fr]"> <div className="h-full p-4 md:p-6 grid grid-rows-[min-content,1fr]">
<SimpleMarketToolbar data={data?.markets || []} /> <SimpleMarketToolbar data={data?.markets || []} />
<AsyncRenderer loading={loading} error={error} data={localData}> <AsyncRenderer loading={loading} error={error} data={localData}>
<AgGrid <ConsoleLiteGrid<MarketWithPercentChange>
className="mb-32 min-h-[300px] w-full" classNamesParam="mb-32 min-h-[300px]"
style={constants.AG_GRID_CONTAINER_STYLES}
defaultColDef={defaultColDef}
columnDefs={columnDefs} columnDefs={columnDefs}
rowData={localData} data={localData}
rowHeight={60} defaultColDef={defaultColDef}
customThemeParams={ handleRowClicked={handleRowClicked}
theme === 'dark'
? constants.agGridDarkVariables
: constants.agGridLightVariables
}
onGridReady={handleOnGridReady}
onRowClicked={handleRowClicked}
rowClass={isMobile ? 'mobile' : ''}
rowClassRules={constants.ROW_CLASS_RULES}
ref={gridRef}
overlayNoRowsTemplate={t('No data to display')}
suppressContextMenu
getRowId={getRowId}
suppressMovableColumns
suppressRowTransform
onCellKeyDown={onCellKeyDown}
tabToNextCell={onTabToNextCell}
suppressHorizontalScroll={shouldSuppressHorizontalScroll}
/> />
</AsyncRenderer> </AsyncRenderer>
</div> </div>

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { useNavigate, useParams, Link } from 'react-router-dom'; import { useNavigate, useParams, Link } from 'react-router-dom';
import { import {
@ -7,7 +7,6 @@ import {
} from '@radix-ui/react-dropdown-menu'; } from '@radix-ui/react-dropdown-menu';
import { IconNames } from '@blueprintjs/icons'; import { IconNames } from '@blueprintjs/icons';
import { t } from '@vegaprotocol/react-helpers'; import { t } from '@vegaprotocol/react-helpers';
import { themelite as theme } from '@vegaprotocol/tailwindcss-config';
import { import {
DropdownMenuCheckboxItem, DropdownMenuCheckboxItem,
DropdownMenuContent, DropdownMenuContent,
@ -16,8 +15,10 @@ import {
} from '@vegaprotocol/ui-toolkit'; } from '@vegaprotocol/ui-toolkit';
import { MarketState } from '@vegaprotocol/types'; import { MarketState } from '@vegaprotocol/types';
import useMarketFiltersData from '../../hooks/use-markets-filter'; import useMarketFiltersData from '../../hooks/use-markets-filter';
import { STATES_FILTER } from './constants';
import type { Markets_marketsConnection_edges_node } from '@vegaprotocol/market-list'; import type { Markets_marketsConnection_edges_node } from '@vegaprotocol/market-list';
import { HorizontalMenu } from '../horizontal-menu';
import type { HorizontalMenuItem } from '../horizontal-menu';
import * as constants from './constants';
interface Props { interface Props {
data: Markets_marketsConnection_edges_node[]; data: Markets_marketsConnection_edges_node[];
@ -28,37 +29,6 @@ const SimpleMarketToolbar = ({ data }: Props) => {
const params = useParams(); const params = useParams();
const { products, assetsPerProduct } = useMarketFiltersData(data); const { products, assetsPerProduct } = useMarketFiltersData(data);
const [isOpen, setOpen] = useState(false); const [isOpen, setOpen] = useState(false);
const [activeNumber, setActiveNumber] = useState(
products?.length ? products.indexOf(params.product || '') + 1 : -1
);
const [sliderStyles, setSliderStyles] = useState<Record<string, string>>({});
const slideContRef = useRef<HTMLUListElement | null>(null);
useEffect(() => {
if (products.length) {
setActiveNumber(products.indexOf(params.product || '') + 1);
} else {
setActiveNumber(-1);
}
}, [params, products, setActiveNumber]);
useEffect(() => {
const contStyles = (
slideContRef.current as HTMLUListElement
).getBoundingClientRect();
const selectedStyles = (slideContRef.current as HTMLUListElement).children[
activeNumber
]?.getBoundingClientRect();
const styles: Record<string, string> = selectedStyles
? {
backgroundColor: activeNumber ? '' : theme.colors.pink,
width: `${selectedStyles.width}px`,
left: `${selectedStyles.left - contStyles.left}px`,
}
: {};
setSliderStyles(styles);
}, [activeNumber, slideContRef]);
const onStateChange = useCallback( const onStateChange = useCallback(
(activeState: string) => { (activeState: string) => {
@ -74,53 +44,33 @@ const SimpleMarketToolbar = ({ data }: Props) => {
[params, navigate] [params, navigate]
); );
const productItems = useMemo(() => {
const currentState = params.state || MarketState.STATE_ACTIVE;
const noStateSkip = currentState !== MarketState.STATE_ACTIVE;
const items: HorizontalMenuItem[] = [
{
...constants.ALL_PRODUCTS_ITEM,
url: `/markets${noStateSkip ? '/' + currentState : ''}`,
},
];
products.forEach((product) =>
items.push({
name: product,
id: product,
url: `/markets/${currentState}/${product}`,
})
);
return items;
}, [params, products]);
return ( return (
<div className="w-full max-w-full mb-2 md:mb-8 font-alpha"> <div className="w-full max-w-full mb-2 md:mb-8 font-alpha">
<ul <HorizontalMenu
ref={slideContRef} active={params.product || constants.ALL_PRODUCTS_ITEM.id}
className="grid grid-flow-col auto-cols-min gap-4 relative pb-2 mb-2" items={productItems}
data-testid="market-products-menu" data-testid="market-products-menu"
aria-label={t('Product type')} aria-label={t('Product type')}
> />
<li key="all-markets" className="md:mr-2 whitespace-nowrap">
<Link
to={`/markets${
params.state && params.state !== MarketState.STATE_ACTIVE
? '/' + params.state
: ''
}`}
aria-label={t('All markets')}
className={classNames('pl-0 text-pink hover:opacity-75', {
active: !activeNumber,
})}
>
{t('All Markets')}
</Link>
</li>
{products.map((product, i) => (
<li key={`${product}-${i}`} className="mx-2 whitespace-nowrap">
<Link
to={`/markets/${
params.state || MarketState.STATE_ACTIVE
}/${product}`}
className={classNames(
'hover:opacity-75 text-black dark:text-white',
{
active: activeNumber - 1 === i,
}
)}
aria-label={product}
>
{product}
</Link>
</li>
))}
<li
className="absolute bottom-0 h-[2px] transition-left duration-300 dark:bg-white bg-black"
key="slider"
style={sliderStyles}
/>
</ul>
<div className="grid gap-4 pb-2 mt-2 md:mt-6 md:grid-cols-[min-content,min-content,1fr]"> <div className="grid gap-4 pb-2 mt-2 md:mt-6 md:grid-cols-[min-content,min-content,1fr]">
<div className="pb-2"> <div className="pb-2">
<DropdownMenu open={isOpen} onOpenChange={(open) => setOpen(open)}> <DropdownMenu open={isOpen} onOpenChange={(open) => setOpen(open)}>
@ -130,7 +80,7 @@ const SimpleMarketToolbar = ({ data }: Props) => {
onClick={() => setOpen(!isOpen)} onClick={() => setOpen(!isOpen)}
> >
<div className="w-full justify-between uppercase inline-flex items-center justify-center box-border"> <div className="w-full justify-between uppercase inline-flex items-center justify-center box-border">
{STATES_FILTER.find( {constants.STATES_FILTER.find(
(state) => (state) =>
state.value === params.state || state.value === params.state ||
(!params.state && state.value === MarketState.STATE_ACTIVE) (!params.state && state.value === MarketState.STATE_ACTIVE)
@ -147,7 +97,7 @@ const SimpleMarketToolbar = ({ data }: Props) => {
</div> </div>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
{STATES_FILTER.map(({ value, text }) => ( {constants.STATES_FILTER.map(({ value, text }) => (
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
className="uppercase text-ui" className="uppercase text-ui"
key={value} key={value}
@ -169,7 +119,7 @@ const SimpleMarketToolbar = ({ data }: Props) => {
<div className="hidden md:block text-deemphasise dark:text-midGrey"> <div className="hidden md:block text-deemphasise dark:text-midGrey">
| |
</div> </div>
{activeNumber > 0 && ( {params.product && (
<ul <ul
className="md:gap-x-6 gap-x-4 gap-y-1 pb-2 md:ml-2 flex flex-wrap" className="md:gap-x-6 gap-x-4 gap-y-1 pb-2 md:ml-2 flex flex-wrap"
data-testid="market-assets-menu" data-testid="market-assets-menu"
@ -189,7 +139,7 @@ const SimpleMarketToolbar = ({ data }: Props) => {
{t('All')} {t('All')}
</Link> </Link>
</li> </li>
{assetsPerProduct[products[activeNumber - 1]]?.map((asset) => ( {assetsPerProduct[params?.product]?.map((asset) => (
<li key={asset}> <li key={asset}>
<Link <Link
to={`/markets/${params.state}/${params.product}/${asset}`} to={`/markets/${params.state}/${params.product}/${asset}`}

View File

@ -51,6 +51,12 @@ export const routerConfig = [
name: 'Portfolio', name: 'Portfolio',
text: t('Portfolio'), text: t('Portfolio'),
element: <Portfolio />, element: <Portfolio />,
children: [
{
path: ':module',
element: <Portfolio />,
},
],
icon: 'portfolio', icon: 'portfolio',
isNavItem: true, isNavItem: true,
}, },

View File

@ -73,8 +73,11 @@ describe('orders', () => {
}); });
it('partially filled orders should not show close/edit buttons', () => { it('partially filled orders should not show close/edit buttons', () => {
const partiallyFilledId =
'94aead3ca92dc932efcb503631b03a410e2a5d4606cae6083e2406dc38e52f78';
cy.getByTestId('tab-orders').should('be.visible'); cy.getByTestId('tab-orders').should('be.visible');
cy.get('[row-index="4"]').within(() => { cy.get(`[row-id="${partiallyFilledId}"]`).within(() => {
cy.get(`[col-id='${orderStatus}']`).should( cy.get(`[col-id='${orderStatus}']`).should(
'have.text', 'have.text',
'PartiallyFilled' 'PartiallyFilled'
@ -95,12 +98,21 @@ describe('orders', () => {
]; ];
cy.getByTestId('tab-orders') cy.getByTestId('tab-orders')
.get(`[col-id='${orderSymbol}']`) .get(`.ag-center-cols-container [col-id='${orderSymbol}']`)
.should('have.length.at.least', 4) .should('have.length.at.least', 5)
.each(($symbol, index) => { .then(($symbols) => {
if (index != 0) { const symbolNames: string[] = [];
cy.wrap($symbol).should('have.text', expectedOrderList[index - 1]); cy.wrap($symbols)
} .each(($symbol) => {
cy.wrap($symbol)
.invoke('text')
.then((text) => {
symbolNames.push(text);
});
})
.then(() => {
expect(symbolNames).to.include.ordered.members(expectedOrderList);
});
}); });
}); });
}); });

View File

@ -1,4 +1,4 @@
import { useRef, useCallback, useMemo } from 'react'; import React, { useRef, useMemo } from 'react';
import { produce } from 'immer'; import { produce } from 'immer';
import merge from 'lodash/merge'; import merge from 'lodash/merge';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit'; import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
@ -20,52 +20,53 @@ interface AccountsManagerProps {
partyId: string; partyId: string;
} }
export const accountsManagerUpdate =
(gridRef: React.RefObject<AgGridReact>) =>
({ delta: deltas }: { delta: AccountEventsSubscription['accounts'] }) => {
const update: AccountFieldsFragment[] = [];
const add: AccountFieldsFragment[] = [];
if (!gridRef.current?.api) {
return false;
}
const api = gridRef.current.api;
deltas.forEach((delta) => {
const rowNode = api.getRowNode(getId(delta));
if (rowNode) {
const updatedData = produce<AccountFieldsFragment>(
rowNode.data,
(draft: AccountFieldsFragment) => {
merge(draft, delta);
}
);
if (updatedData !== rowNode.data) {
update.push(updatedData);
}
} else {
// #TODO handle new account (or leave it to data provider to handle it)
}
});
if (update.length || add.length) {
gridRef.current.api.applyTransactionAsync({
update,
add,
addIndex: 0,
});
}
if (add.length) {
addSummaryRows(
gridRef.current.api,
gridRef.current.columnApi,
getGroupId,
getGroupSummaryRow
);
}
return true;
};
export const AccountsManager = ({ partyId }: AccountsManagerProps) => { export const AccountsManager = ({ partyId }: AccountsManagerProps) => {
const gridRef = useRef<AgGridReact | null>(null); const gridRef = useRef<AgGridReact | null>(null);
const variables = useMemo(() => ({ partyId }), [partyId]); const variables = useMemo(() => ({ partyId }), [partyId]);
const update = useCallback( const update = accountsManagerUpdate(gridRef);
({ delta: deltas }: { delta: AccountEventsSubscription['accounts'] }) => {
const update: AccountFieldsFragment[] = [];
const add: AccountFieldsFragment[] = [];
if (!gridRef.current?.api) {
return false;
}
const api = gridRef.current.api;
deltas.forEach((delta) => {
const rowNode = api.getRowNode(getId(delta));
if (rowNode) {
const updatedData = produce<AccountFieldsFragment>(
rowNode.data,
(draft: AccountFieldsFragment) => {
merge(draft, delta);
}
);
if (updatedData !== rowNode.data) {
update.push(updatedData);
}
} else {
// #TODO handle new account (or leave it to data provider to handle it)
}
});
if (update.length || add.length) {
gridRef.current.api.applyTransactionAsync({
update,
add,
addIndex: 0,
});
}
if (add.length) {
addSummaryRows(
gridRef.current.api,
gridRef.current.columnApi,
getGroupId,
getGroupSummaryRow
);
}
return true;
},
[gridRef]
);
const { data, error, loading } = useDataProvider< const { data, error, loading } = useDataProvider<
AccountFieldsFragment[], AccountFieldsFragment[],
AccountEventsSubscription['accounts'] AccountEventsSubscription['accounts']

View File

@ -1,4 +1,6 @@
export * from './lib/fills-container'; export * from './lib/fills-container';
export * from './lib/use-fills-list';
export * from './lib/fills-data-provider';
export * from './lib/__generated__/FillFields'; export * from './lib/__generated__/FillFields';
export * from './lib/__generated__/Fills'; export * from './lib/__generated__/Fills';
export * from './lib/__generated__/FillsSub'; export * from './lib/__generated__/FillsSub';

View File

@ -1,18 +1,9 @@
import type { AgGridReact } from 'ag-grid-react'; import type { AgGridReact } from 'ag-grid-react';
import { useCallback, useRef, useMemo } from 'react'; import { useRef } from 'react';
import {
useDataProvider,
makeInfiniteScrollGetRows,
} from '@vegaprotocol/react-helpers';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit'; import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { FillsTable } from './fills-table'; import { FillsTable } from './fills-table';
import type { BodyScrollEvent, BodyScrollEndEvent } from 'ag-grid-community'; import type { BodyScrollEvent, BodyScrollEndEvent } from 'ag-grid-community';
import { useFillsList } from './use-fills-list';
import type {
TradeWithMarket,
TradeWithMarketEdge,
} from './fills-data-provider';
import { fillsWithMarketProvider } from './fills-data-provider';
interface FillsManagerProps { interface FillsManagerProps {
partyId: string; partyId: string;
@ -20,81 +11,12 @@ interface FillsManagerProps {
export const FillsManager = ({ partyId }: FillsManagerProps) => { export const FillsManager = ({ partyId }: FillsManagerProps) => {
const gridRef = useRef<AgGridReact | null>(null); const gridRef = useRef<AgGridReact | null>(null);
const dataRef = useRef<(TradeWithMarketEdge | null)[] | null>(null);
const totalCountRef = useRef<number | undefined>(undefined);
const newRows = useRef(0);
const scrolledToTop = useRef(true); const scrolledToTop = useRef(true);
const { data, error, loading, addNewRows, getRows } = useFillsList({
const addNewRows = useCallback(() => { partyId,
if (newRows.current === 0) { gridRef,
return; scrolledToTop,
} });
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: (TradeWithMarketEdge | null)[] | null;
delta: TradeWithMarket[];
}) => {
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: (TradeWithMarketEdge | null)[] | null;
totalCount?: number;
}) => {
dataRef.current = data;
totalCountRef.current = totalCount;
return true;
},
[]
);
const variables = useMemo(() => ({ partyId }), [partyId]);
const { data, error, loading, load, totalCount } = useDataProvider<
(TradeWithMarketEdge | null)[],
TradeWithMarket[]
>({ dataProvider: fillsWithMarketProvider, update, insert, variables });
totalCountRef.current = totalCount;
dataRef.current = data;
const getRows = makeInfiniteScrollGetRows<TradeWithMarketEdge>(
newRows,
dataRef,
totalCountRef,
load
);
const onBodyScrollEnd = (event: BodyScrollEndEvent) => { const onBodyScrollEnd = (event: BodyScrollEndEvent) => {
if (event.top === 0) { if (event.top === 0) {

View File

@ -0,0 +1,93 @@
import type { AgGridReact } from 'ag-grid-react';
import { MockedProvider } from '@apollo/client/testing';
import { renderHook } from '@testing-library/react';
import { useFillsList } from './use-fills-list';
import type { TradeWithMarketEdge } from './fills-data-provider';
let mockData = null;
let mockDataProviderData = {
data: mockData as (TradeWithMarketEdge | null)[] | null,
error: undefined,
loading: true,
};
let updateMock: jest.Mock;
const mockDataProvider = jest.fn((args) => {
updateMock = args.update;
return mockDataProviderData;
});
jest.mock('@vegaprotocol/react-helpers', () => ({
...jest.requireActual('@vegaprotocol/react-helpers'),
useDataProvider: jest.fn((args) => mockDataProvider(args)),
}));
describe('useFillsList Hook', () => {
const mockRefreshAgGridApi = jest.fn();
const partyId = 'partyId';
const gridRef = {
current: {
api: {
refreshInfiniteCache: mockRefreshAgGridApi,
},
} as unknown as AgGridReact,
};
const scrolledToTop = {
current: false,
};
afterEach(() => {
jest.clearAllMocks();
});
it('should return proper dataProvider results', () => {
const { result } = renderHook(
() => useFillsList({ partyId, gridRef, scrolledToTop }),
{
wrapper: MockedProvider,
}
);
expect(result.current).toMatchObject({
data: null,
error: undefined,
loading: true,
addNewRows: expect.any(Function),
getRows: expect.any(Function),
});
});
it('return proper mocked results', () => {
mockData = [
{
node: {
id: 'data_id_1',
},
} as unknown as TradeWithMarketEdge,
{
node: {
id: 'data_id_2',
},
} as unknown as TradeWithMarketEdge,
];
mockDataProviderData = {
...mockDataProviderData,
data: mockData,
loading: false,
};
const { result } = renderHook(
() => useFillsList({ partyId, gridRef, scrolledToTop }),
{
wrapper: MockedProvider,
}
);
expect(result.current).toMatchObject({
data: mockData,
error: undefined,
loading: false,
addNewRows: expect.any(Function),
getRows: expect.any(Function),
});
expect(mockRefreshAgGridApi).not.toHaveBeenCalled();
updateMock({ data: {} });
expect(mockRefreshAgGridApi).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,96 @@
import type { RefObject } from 'react';
import type { AgGridReact } from 'ag-grid-react';
import { useCallback, useMemo, useRef } from 'react';
import {
makeInfiniteScrollGetRows,
useDataProvider,
} from '@vegaprotocol/react-helpers';
import type {
TradeWithMarket,
TradeWithMarketEdge,
} from './fills-data-provider';
import { fillsWithMarketProvider } from './fills-data-provider';
interface Props {
partyId: string;
gridRef: RefObject<AgGridReact>;
scrolledToTop: RefObject<boolean>;
}
export const useFillsList = ({ partyId, gridRef, scrolledToTop }: Props) => {
const dataRef = useRef<(TradeWithMarketEdge | null)[] | null>(null);
const totalCountRef = useRef<number | undefined>(undefined);
const newRows = useRef(0);
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();
}, [gridRef]);
const update = useCallback(
({
data,
delta,
}: {
data: (TradeWithMarketEdge | null)[] | null;
delta: TradeWithMarket[];
}) => {
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;
},
[gridRef, scrolledToTop]
);
const insert = useCallback(
({
data,
totalCount,
}: {
data: (TradeWithMarketEdge | null)[] | null;
totalCount?: number;
}) => {
dataRef.current = data;
totalCountRef.current = totalCount;
return true;
},
[]
);
const variables = useMemo(() => ({ partyId }), [partyId]);
const { data, error, loading, load, totalCount } = useDataProvider<
(TradeWithMarketEdge | null)[],
TradeWithMarket[]
>({ dataProvider: fillsWithMarketProvider, update, insert, variables });
totalCountRef.current = totalCount;
dataRef.current = data;
const getRows = makeInfiniteScrollGetRows<TradeWithMarketEdge>(
newRows,
dataRef,
totalCountRef,
load
);
return { data, error, loading, addNewRows, getRows };
};

View File

@ -8,7 +8,7 @@ export const marketProvider = makeDerivedDataProvider<Market, never>(
([markets], variables) => { ([markets], variables) => {
if (markets) { if (markets) {
const market = (markets as Market[]).find( const market = (markets as Market[]).find(
(market) => market.id === variables?.marketId (market) => market.id === variables?.['marketId']
); );
if (market) { if (market) {
return market; return market;

View File

@ -1 +1,2 @@
export * from './order-list-manager'; export * from './order-list-manager';
export * from './use-order-list-data';

View File

@ -1,14 +1,10 @@
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit'; import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { import { useRef } from 'react';
useDataProvider,
makeInfiniteScrollGetRows,
} from '@vegaprotocol/react-helpers';
import { useCallback, useMemo, useRef } from 'react';
import type { BodyScrollEvent, BodyScrollEndEvent } from 'ag-grid-community'; import type { BodyScrollEvent, BodyScrollEndEvent } from 'ag-grid-community';
import type { AgGridReact } from 'ag-grid-react'; import type { AgGridReact } from 'ag-grid-react';
import { OrderList, ordersWithMarketProvider } from '../'; import { OrderList } from '../';
import type { OrderWithMarketEdge, OrderWithMarket } from '../'; import { useOrderListData } from './use-order-list-data';
interface OrderListManagerProps { interface OrderListManagerProps {
partyId: string; partyId: string;
@ -16,82 +12,13 @@ interface OrderListManagerProps {
export const OrderListManager = ({ partyId }: OrderListManagerProps) => { export const OrderListManager = ({ partyId }: OrderListManagerProps) => {
const gridRef = useRef<AgGridReact | null>(null); const gridRef = useRef<AgGridReact | null>(null);
const dataRef = useRef<(OrderWithMarketEdge | null)[] | null>(null);
const totalCountRef = useRef<number | undefined>(undefined);
const newRows = useRef(0);
const scrolledToTop = useRef(true); const scrolledToTop = useRef(true);
const variables = useMemo(() => ({ partyId }), [partyId]);
const addNewRows = useCallback(() => { const { data, error, loading, addNewRows, getRows } = useOrderListData({
if (newRows.current === 0) { partyId,
return; gridRef,
} scrolledToTop,
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: (OrderWithMarketEdge | null)[];
delta: OrderWithMarket[];
}) => {
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: (OrderWithMarketEdge | null)[];
totalCount?: number;
}) => {
dataRef.current = data;
totalCountRef.current = totalCount;
return true;
},
[]
);
const { data, error, loading, load, totalCount } = useDataProvider({
dataProvider: ordersWithMarketProvider,
update,
insert,
variables,
}); });
totalCountRef.current = totalCount;
dataRef.current = data;
const getRows = makeInfiniteScrollGetRows<OrderWithMarketEdge>(
newRows,
dataRef,
totalCountRef,
load
);
const onBodyScrollEnd = (event: BodyScrollEndEvent) => { const onBodyScrollEnd = (event: BodyScrollEndEvent) => {
if (event.top === 0) { if (event.top === 0) {

View File

@ -0,0 +1,163 @@
import type { AgGridReact } from 'ag-grid-react';
import { MockedProvider } from '@apollo/client/testing';
import { renderHook, waitFor } from '@testing-library/react';
import { useOrderListData } from './use-order-list-data';
import type { Orders_party_ordersConnection_edges } from '../order-data-provider/__generated__/Orders';
import type { IGetRowsParams } from 'ag-grid-community';
const loadMock = jest.fn();
let mockData = null;
let mockDataProviderData = {
data: mockData as (Orders_party_ordersConnection_edges | null)[] | null,
error: undefined,
loading: true,
load: loadMock,
totalCount: 0,
};
let updateMock: jest.Mock;
const mockDataProvider = jest.fn((args) => {
updateMock = args.update;
return mockDataProviderData;
});
jest.mock('@vegaprotocol/react-helpers', () => ({
...jest.requireActual('@vegaprotocol/react-helpers'),
useDataProvider: jest.fn((args) => mockDataProvider(args)),
}));
describe('useOrderListData Hook', () => {
const mockRefreshAgGridApi = jest.fn();
const partyId = 'partyId';
const gridRef = {
current: {
api: {
refreshInfiniteCache: mockRefreshAgGridApi,
},
} as unknown as AgGridReact,
};
const scrolledToTop = {
current: false,
};
afterEach(() => {
jest.clearAllMocks();
});
it('should return proper dataProvider results', () => {
const { result } = renderHook(
() => useOrderListData({ partyId, gridRef, scrolledToTop }),
{
wrapper: MockedProvider,
}
);
expect(result.current).toMatchObject({
data: null,
error: undefined,
loading: true,
addNewRows: expect.any(Function),
getRows: expect.any(Function),
});
});
it('return proper mocked results', () => {
mockData = [
{
node: {
id: 'data_id_1',
createdAt: 1,
},
} as unknown as Orders_party_ordersConnection_edges,
{
node: {
id: 'data_id_2',
createdAt: 2,
},
} as unknown as Orders_party_ordersConnection_edges,
];
mockDataProviderData = {
...mockDataProviderData,
data: mockData,
loading: false,
};
const { result } = renderHook(
() => useOrderListData({ partyId, gridRef, scrolledToTop }),
{
wrapper: MockedProvider,
}
);
expect(result.current).toMatchObject({
data: mockData,
error: undefined,
loading: false,
addNewRows: expect.any(Function),
getRows: expect.any(Function),
});
expect(mockRefreshAgGridApi).not.toHaveBeenCalled();
updateMock({ data: [], delta: [] });
expect(mockRefreshAgGridApi).toHaveBeenCalled();
});
it('methods for pagination should works', async () => {
const successCallback = jest.fn();
mockData = [
{
node: {
id: 'data_id_1',
createdAt: 1,
},
} as unknown as Orders_party_ordersConnection_edges,
{
node: {
id: 'data_id_2',
createdAt: 2,
},
} as unknown as Orders_party_ordersConnection_edges,
];
mockDataProviderData = {
...mockDataProviderData,
data: mockData,
loading: false,
totalCount: 4,
};
const mockDelta = [
{
node: {
id: 'data_id_3',
createdAt: 3,
},
} as unknown as Orders_party_ordersConnection_edges,
{
node: {
id: 'data_id_4',
createdAt: 4,
},
} as unknown as Orders_party_ordersConnection_edges,
];
const mockNextData = [...mockData, ...mockDelta];
const { result } = renderHook(
() => useOrderListData({ partyId, gridRef, scrolledToTop }),
{
wrapper: MockedProvider,
}
);
updateMock({ data: mockNextData, delta: mockDelta });
const getRowsParams = {
successCallback,
failCallback: jest.fn(),
startRow: 2,
endRow: 4,
} as unknown as IGetRowsParams;
await waitFor(async () => {
await result.current.getRows(getRowsParams);
});
expect(loadMock).toHaveBeenCalled();
expect(successCallback).toHaveBeenLastCalledWith(
mockDelta.map((item) => item.node),
4
);
});
});

View File

@ -0,0 +1,99 @@
import { useCallback, useMemo, useRef } from 'react';
import type { RefObject } from 'react';
import type { AgGridReact } from 'ag-grid-react';
import {
makeInfiniteScrollGetRows,
useDataProvider,
} from '@vegaprotocol/react-helpers';
import { ordersWithMarketProvider } from '../';
import type { OrderWithMarketEdge, OrderWithMarket } from '../';
interface Props {
partyId: string;
gridRef: RefObject<AgGridReact>;
scrolledToTop: RefObject<boolean>;
}
export const useOrderListData = ({
partyId,
gridRef,
scrolledToTop,
}: Props) => {
const dataRef = useRef<(OrderWithMarketEdge | null)[] | null>(null);
const totalCountRef = useRef<number | undefined>(undefined);
const newRows = useRef(0);
const variables = useMemo(() => ({ partyId }), [partyId]);
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();
}, [gridRef]);
const update = useCallback(
({
data,
delta,
}: {
data: (OrderWithMarketEdge | null)[];
delta: OrderWithMarket[];
}) => {
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;
},
[gridRef, scrolledToTop]
);
const insert = useCallback(
({
data,
totalCount,
}: {
data: (OrderWithMarketEdge | null)[];
totalCount?: number;
}) => {
dataRef.current = data;
totalCountRef.current = totalCount;
return true;
},
[]
);
const { data, error, loading, load, totalCount } = useDataProvider({
dataProvider: ordersWithMarketProvider,
update,
insert,
variables,
});
totalCountRef.current = totalCount;
dataRef.current = data;
const getRows = makeInfiniteScrollGetRows<OrderWithMarketEdge>(
newRows,
dataRef,
totalCountRef,
load
);
return { loading, error, data, addNewRows, getRows };
};

View File

@ -1,2 +1,3 @@
export * from './order-list.stories'; export * from './order-list.stories';
export * from './order-list'; export * from './order-list';
export * from './order-edit-dialog';

View File

@ -320,7 +320,7 @@ export const OrderListTable = forwardRef<AgGridReact, OrderListTableProps>(
/** /**
* Check if an order is active to determine if it can be edited or cancelled * Check if an order is active to determine if it can be edited or cancelled
*/ */
const isOrderActive = (status: OrderStatus) => { export const isOrderActive = (status: OrderStatus) => {
return ![ return ![
OrderStatus.STATUS_CANCELLED, OrderStatus.STATUS_CANCELLED,
OrderStatus.STATUS_REJECTED, OrderStatus.STATUS_REJECTED,
@ -331,7 +331,9 @@ const isOrderActive = (status: OrderStatus) => {
].includes(status); ].includes(status);
}; };
const getEditDialogTitle = (status?: OrderStatus): string | undefined => { export const getEditDialogTitle = (
status?: OrderStatus
): string | undefined => {
if (!status) { if (!status) {
return; return;
} }
@ -358,7 +360,9 @@ const getEditDialogTitle = (status?: OrderStatus): string | undefined => {
} }
}; };
const getCancelDialogIntent = (status?: OrderStatus): Intent | undefined => { export const getCancelDialogIntent = (
status?: OrderStatus
): Intent | undefined => {
if (!status) { if (!status) {
return; return;
} }
@ -371,7 +375,9 @@ const getCancelDialogIntent = (status?: OrderStatus): Intent | undefined => {
} }
}; };
const getCancelDialogTitle = (status?: OrderStatus): string | undefined => { export const getCancelDialogTitle = (
status?: OrderStatus
): string | undefined => {
if (!status) { if (!status) {
return; return;
} }

View File

@ -3,3 +3,4 @@ export * from './order-event-query';
export * from './use-order-cancel'; export * from './use-order-cancel';
export * from './use-order-submit'; export * from './use-order-submit';
export * from './use-order-validation'; export * from './use-order-validation';
export * from './use-order-edit';

View File

@ -4,7 +4,7 @@ import type { OrderEvent_busEvents_event_Order } from './';
import * as Sentry from '@sentry/react'; import * as Sentry from '@sentry/react';
import { useOrderEvent } from './use-order-event'; import { useOrderEvent } from './use-order-event';
interface CancelOrderArgs { export interface CancelOrderArgs {
orderId: string; orderId: string;
marketId: string; marketId: string;
} }

View File

@ -5,3 +5,5 @@ export * from './lib/positions-data-providers';
export * from './lib/positions-table'; export * from './lib/positions-table';
export * from './lib/use-close-position'; export * from './lib/use-close-position';
export * from './lib/use-position-event'; export * from './lib/use-position-event';
export * from './lib/use-positions-data';
export * from './lib/use-positions-assets';

View File

@ -1,21 +1,14 @@
import { useCallback, useMemo, useRef } from 'react'; import { useCallback } from 'react';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit'; import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { useDataProvider } from '@vegaprotocol/react-helpers';
import { positionsMetricsDataProvider as dataProvider } from './positions-data-providers';
import type { Position } from './positions-data-providers'; import type { Position } from './positions-data-providers';
import { Positions } from './positions'; import { Positions } from './positions';
import { useClosePosition } from '../'; import { useClosePosition, usePositionsAssets } from '../';
interface PositionsManagerProps { interface PositionsManagerProps {
partyId: string; partyId: string;
} }
const getSymbols = (positions: Position[]) =>
Array.from(new Set(positions.map((position) => position.assetSymbol))).sort();
export const PositionsManager = ({ partyId }: PositionsManagerProps) => { export const PositionsManager = ({ partyId }: PositionsManagerProps) => {
const variables = useMemo(() => ({ partyId }), [partyId]);
const assetSymbols = useRef<string[] | undefined>();
const { submit, Dialog } = useClosePosition(); const { submit, Dialog } = useClosePosition();
const onClose = useCallback( const onClose = useCallback(
(position: Position) => { (position: Position) => {
@ -23,37 +16,22 @@ export const PositionsManager = ({ partyId }: PositionsManagerProps) => {
}, },
[submit] [submit]
); );
const update = useCallback(({ data }: { data: Position[] | null }) => {
if (data?.length) { const { data, error, loading, assetSymbols } = usePositionsAssets({
const newAssetSymbols = getSymbols(data); partyId,
if (
!newAssetSymbols.every(
(symbol) =>
assetSymbols.current && assetSymbols.current.includes(symbol)
)
) {
return false;
}
}
return true;
}, []);
const { data, error, loading } = useDataProvider<Position[], never>({
dataProvider,
update,
variables,
}); });
return ( return (
<> <>
<AsyncRenderer loading={loading} error={error} data={assetSymbols}> <AsyncRenderer loading={loading} error={error} data={data}>
{data && {assetSymbols?.map((assetSymbol) => (
getSymbols(data)?.map((assetSymbol) => ( <Positions
<Positions partyId={partyId}
partyId={partyId} assetSymbol={assetSymbol}
assetSymbol={assetSymbol} key={assetSymbol}
key={assetSymbol} onClose={onClose}
onClose={onClose} />
/> ))}
))}
</AsyncRenderer> </AsyncRenderer>
<Dialog> <Dialog>
<p>Your position was not closed! This is still not implemented. </p> <p>Your position was not closed! This is still not implemented. </p>

View File

@ -41,7 +41,7 @@ interface Props extends AgGridReactProps {
style?: CSSProperties; style?: CSSProperties;
} }
type PositionsTableValueFormatterParams = Omit< export type PositionsTableValueFormatterParams = Omit<
ValueFormatterParams, ValueFormatterParams,
'data' | 'value' 'data' | 'value'
> & { > & {
@ -83,7 +83,7 @@ export const ProgressBarCell = ({ valueFormatted }: PriceCellProps) => {
<ProgressBar <ProgressBar
value={valueFormatted.value} value={valueFormatted.value}
intent={valueFormatted.intent} intent={valueFormatted.intent}
className="mt-2" className="mt-2 w-full"
/> />
</> </>
) : null; ) : null;

View File

@ -1,85 +1,27 @@
import { useRef, useCallback, useMemo, memo } from 'react'; import { useRef, memo } from 'react';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit'; import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { BigNumber } from 'bignumber.js'; import { t } from '@vegaprotocol/react-helpers';
import { t, toBigNum, useDataProvider } from '@vegaprotocol/react-helpers';
import type { AgGridReact } from 'ag-grid-react'; import type { AgGridReact } from 'ag-grid-react';
import filter from 'lodash/filter';
import PositionsTable from './positions-table'; import PositionsTable from './positions-table';
import type { GetRowsParams } from './positions-table';
import { positionsMetricsDataProvider as dataProvider } from './positions-data-providers';
import { AssetBalance } from '@vegaprotocol/accounts';
import type { Position } from './positions-data-providers'; import type { Position } from './positions-data-providers';
import { AssetBalance } from '@vegaprotocol/accounts';
import { usePositionsData } from './use-positions-data';
interface PositionsProps { interface PositionsProps {
partyId: string; partyId: string;
assetSymbol: string; assetSymbol: string;
onClose: (position: Position) => void; onClose: (position: Position) => void;
} }
const getSummaryRow = (positions: Position[]) => {
const summaryRow = {
notional: new BigNumber(0),
realisedPNL: BigInt(0),
unrealisedPNL: BigInt(0),
};
positions.forEach((position) => {
summaryRow.notional = summaryRow.notional.plus(
toBigNum(position.notional, position.marketDecimalPlaces)
);
summaryRow.realisedPNL += BigInt(position.realisedPNL);
summaryRow.unrealisedPNL += BigInt(position.unrealisedPNL);
});
const decimals = positions[0]?.decimals || 0;
return {
marketName: t('Total'),
// we are using asset decimals instead of market decimals because each market can have different decimals
notional: summaryRow.notional
.multipliedBy(10 ** decimals)
.toFixed()
.toString(),
realisedPNL: summaryRow.realisedPNL.toString(),
unrealisedPNL: summaryRow.unrealisedPNL.toString(),
decimals,
};
};
export const Positions = memo( export const Positions = memo(
({ partyId, assetSymbol, onClose }: PositionsProps) => { ({ partyId, assetSymbol, onClose }: PositionsProps) => {
const gridRef = useRef<AgGridReact | null>(null); const gridRef = useRef<AgGridReact | null>(null);
const variables = useMemo(() => ({ partyId }), [partyId]); const { data, error, loading, getRows } = usePositionsData({
const dataRef = useRef<Position[] | null>(null); partyId,
const update = useCallback( assetSymbol,
({ data }: { data: Position[] | null }) => { gridRef,
if (!gridRef.current?.api) {
return false;
}
dataRef.current = filter(data, { assetSymbol });
gridRef.current.api.refreshInfiniteCache();
return true;
},
[assetSymbol]
);
const { data, error, loading } = useDataProvider<Position[], never>({
dataProvider,
update,
variables,
}); });
dataRef.current = filter(data, { assetSymbol });
const getRows = async ({
successCallback,
startRow,
endRow,
}: GetRowsParams) => {
const rowsThisBlock = dataRef.current
? dataRef.current.slice(startRow, endRow)
: [];
const lastRow = dataRef.current?.length ?? -1;
successCallback(rowsThisBlock, lastRow);
if (gridRef.current?.api) {
gridRef.current.api.setPinnedBottomRowData([
getSummaryRow(rowsThisBlock),
]);
}
};
return ( return (
<AsyncRenderer loading={loading} error={error} data={data}> <AsyncRenderer loading={loading} error={error} data={data}>
<div className="flex justify-between items-center px-4 pt-3 pb-1"> <div className="flex justify-between items-center px-4 pt-3 pb-1">

View File

@ -0,0 +1,41 @@
import { useCallback, useMemo, useRef } from 'react';
import { useDataProvider } from '@vegaprotocol/react-helpers';
import type { Position } from './positions-data-providers';
import { positionsMetricsDataProvider as dataProvider } from './positions-data-providers';
interface Props {
partyId: string;
}
const getSymbols = (positions: Position[]) =>
Array.from(new Set(positions.map((position) => position.assetSymbol))).sort();
export const usePositionsAssets = ({ partyId }: Props) => {
const variables = useMemo(() => ({ partyId }), [partyId]);
const assetSymbols = useRef<string[] | undefined>();
const update = useCallback(({ data }: { data: Position[] | null }) => {
if (data?.length) {
const newAssetSymbols = getSymbols(data);
if (
!newAssetSymbols.every(
(symbol) =>
assetSymbols.current && assetSymbols.current.includes(symbol)
)
) {
assetSymbols.current = newAssetSymbols;
return false;
}
}
return true;
}, []);
const { data, error, loading } = useDataProvider<Position[], never>({
dataProvider,
update,
variables,
});
if (!assetSymbols.current && data) {
assetSymbols.current = getSymbols(data);
}
return { data, error, loading, assetSymbols: assetSymbols.current };
};

View File

@ -0,0 +1,86 @@
import { useCallback, useMemo, useRef } from 'react';
import type { RefObject } from 'react';
import { BigNumber } from 'bignumber.js';
import type { AgGridReact } from 'ag-grid-react';
import type { GetRowsParams } from './positions-table';
import type { Position } from './positions-data-providers';
import { positionsMetricsDataProvider as dataProvider } from './positions-data-providers';
import filter from 'lodash/filter';
import { t, toBigNum, useDataProvider } from '@vegaprotocol/react-helpers';
interface Props {
partyId: string;
assetSymbol?: string;
gridRef: RefObject<AgGridReact>;
}
const getSummaryRow = (positions: Position[]) => {
const summaryRow = {
notional: new BigNumber(0),
realisedPNL: BigInt(0),
unrealisedPNL: BigInt(0),
};
positions.forEach((position) => {
summaryRow.notional = summaryRow.notional.plus(
toBigNum(position.notional, position.marketDecimalPlaces)
);
summaryRow.realisedPNL += BigInt(position.realisedPNL);
summaryRow.unrealisedPNL += BigInt(position.unrealisedPNL);
});
const decimals = positions[0]?.decimals || 0;
return {
marketName: t('Total'),
// we are using asset decimals instead of market decimals because each market can have different decimals
notional: summaryRow.notional
.multipliedBy(10 ** decimals)
.toFixed()
.toString(),
realisedPNL: summaryRow.realisedPNL.toString(),
unrealisedPNL: summaryRow.unrealisedPNL.toString(),
decimals,
};
};
export const usePositionsData = ({ partyId, assetSymbol, gridRef }: Props) => {
const variables = useMemo(() => ({ partyId }), [partyId]);
const dataRef = useRef<Position[] | null>(null);
const update = useCallback(
({ data }: { data: Position[] | null }) => {
if (!gridRef.current?.api) {
return false;
}
dataRef.current = assetSymbol ? filter(data, { assetSymbol }) : data;
gridRef.current.api.refreshInfiniteCache();
return true;
},
[assetSymbol, gridRef]
);
const { data, error, loading } = useDataProvider<Position[], never>({
dataProvider,
update,
variables,
});
dataRef.current = assetSymbol ? filter(data, { assetSymbol }) : data;
const getRows = async ({
successCallback,
startRow,
endRow,
}: GetRowsParams) => {
const rowsThisBlock = dataRef.current
? dataRef.current.slice(startRow, endRow)
: [];
const lastRow = dataRef.current?.length ?? -1;
successCallback(rowsThisBlock, lastRow);
if (gridRef.current?.api) {
gridRef.current.api.setPinnedBottomRowData([
getSummaryRow(rowsThisBlock),
]);
}
};
return {
data,
error,
loading,
getRows,
};
};

View File

@ -2,10 +2,12 @@ export const positiveClassNames = 'text-vega-green-dark dark:text-vega-green';
export const negativeClassNames = 'text-vega-red-dark dark:text-vega-red'; export const negativeClassNames = 'text-vega-red-dark dark:text-vega-red';
const isPositive = ({ value }: { value: string | bigint | number }) => const isPositive = ({ value }: { value: string | bigint | number }) =>
value && ((typeof value === 'string' && !value.startsWith('-')) || value > 0); !!value &&
((typeof value === 'string' && !value.startsWith('-')) || value > 0);
const isNegative = ({ value }: { value: string | bigint | number }) => const isNegative = ({ value }: { value: string | bigint | number }) =>
value && ((typeof value === 'string' && value.startsWith('-')) || value < 0); !!value &&
((typeof value === 'string' && value.startsWith('-')) || value < 0);
export const signedNumberCssClass = (value: string | bigint | number) => { export const signedNumberCssClass = (value: string | bigint | number) => {
if (isPositive({ value })) { if (isPositive({ value })) {