Merge pull request #157 from vegaprotocol/feature/128-positions-table

Feature/128 positions table
This commit is contained in:
Bartłomiej Głownia 2022-03-29 19:41:19 +02:00 committed by GitHub
commit 65bb99ea72
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1438 additions and 219 deletions

View File

@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen, act } from '@testing-library/react';
import { Web3Container } from './web3-container';
const defaultHookValue = {
@ -17,13 +17,15 @@ jest.mock('@web3-react/core', () => {
};
});
test('Prompt to connect opens dialog', () => {
test('Prompt to connect opens dialog', async () => {
mockHookValue = defaultHookValue;
await act(async () => {
render(
<Web3Container>
<div>Child</div>
</Web3Container>
);
});
expect(screen.queryByText('Child')).not.toBeInTheDocument();
expect(screen.queryByTestId('web3-connector-list')).not.toBeInTheDocument();
@ -32,33 +34,35 @@ test('Prompt to connect opens dialog', () => {
expect(screen.getByTestId('web3-connector-list')).toBeInTheDocument();
});
test('Error message is shown', () => {
test('Error message is shown', async () => {
const message = 'Opps! An error';
mockHookValue = { ...defaultHookValue, error: new Error(message) };
await act(async () => {
render(
<Web3Container>
<div>Child</div>
</Web3Container>
);
});
expect(screen.queryByText('Child')).not.toBeInTheDocument();
expect(screen.getByText(`Something went wrong: ${message}`));
});
test('Chain id matches app configuration', () => {
test('Chain id matches app configuration', async () => {
const expectedChainId = 4;
mockHookValue = {
...defaultHookValue,
isActive: true,
chainId: expectedChainId,
};
await act(async () => {
render(
<Web3Container>
<div>Child</div>
</Web3Container>
);
});
expect(screen.queryByText('Child')).not.toBeInTheDocument();
expect(screen.getByText(`This app only works on chain ID: 3`));

View File

@ -1,56 +1,38 @@
import { useState, useEffect, useRef } from 'react';
import { useRef, useCallback } from 'react';
import { produce } from 'immer';
import merge from 'lodash/merge';
import { useApolloClient } from '@apollo/client';
import { useRouter } from 'next/router';
import { AsyncRenderer } from '../../components/async-renderer';
import { MarketListTable, getRowNodeId } from '@vegaprotocol/market-list';
import {
Markets_markets,
Markets_markets_data,
MarketsDataProviderCallbackArg,
marketsDataProvider,
} from '@vegaprotocol/graphql';
import { useDataProvider } from '@vegaprotocol/react-helpers';
import type { AgGridReact } from 'ag-grid-react';
const Markets = () => {
const { pathname, push } = useRouter();
const [markets, setMarkets] = useState<Markets_markets[] | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<Error>();
const client = useApolloClient();
const gridRef = useRef<AgGridReact | null>(null);
const initialized = useRef<boolean>(false);
useEffect(() => {
return marketsDataProvider(
client,
({ data, error, loading, delta }: MarketsDataProviderCallbackArg) => {
setError(error);
setLoading(loading);
if (!error && !loading) {
if (!initialized.current || !gridRef.current) {
initialized.current = true;
setMarkets(data);
} else {
const update = useCallback(
(delta: Markets_markets_data) => {
const update: Markets_markets[] = [];
const add: Markets_markets[] = [];
// split into updates and adds
if (!gridRef.current || !delta) return;
if (!gridRef.current) {
return false;
}
const rowNode = gridRef.current.api.getRowNode(
getRowNodeId(delta.market)
);
if (rowNode) {
const updatedData = produce(
const updatedData = produce<Markets_markets_data>(
rowNode.data.data,
(draft: Markets_markets_data) => merge(draft, delta)
);
if (updatedData !== rowNode.data.data) {
update.push({ ...rowNode.data, data: delta });
update.push({ ...rowNode.data, data: updatedData });
}
}
// @TODO - else add new market
@ -61,18 +43,21 @@ const Markets = () => {
addIndex: 0,
});
}
}
}
}
return true;
},
[gridRef]
);
}, [client, initialized]);
const { data, error, loading } = useDataProvider<
Markets_markets,
Markets_markets_data
>(marketsDataProvider, update);
return (
<AsyncRenderer loading={loading} error={error} data={markets}>
<AsyncRenderer loading={loading} error={error} data={data}>
{(data) => (
<MarketListTable
ref={gridRef}
markets={data}
data={data}
onRowClicked={(id) =>
push(`${pathname}/${id}?portfolio=orders&trade=orderbook`)
}

View File

@ -0,0 +1,71 @@
import { useRef, useCallback, useMemo } from 'react';
import { produce } from 'immer';
import merge from 'lodash/merge';
import { useRouter } from 'next/router';
import { AsyncRenderer } from '../../components/async-renderer';
import { PositionsTable, getRowNodeId } from '@vegaprotocol/positions';
import { useDataProvider } from '@vegaprotocol/react-helpers';
import {
Positions_party_positions,
PositionSubscribe_positions,
positionsDataProvider,
} from '@vegaprotocol/graphql';
import { useVegaWallet } from '@vegaprotocol/wallet';
import type { AgGridReact } from 'ag-grid-react';
export const Positions = () => {
const { pathname, push } = useRouter();
const gridRef = useRef<AgGridReact | null>(null);
const { keypair } = useVegaWallet();
const variables = useMemo(() => ({ partyId: keypair?.pub }), [keypair]);
const update = useCallback(
(delta: PositionSubscribe_positions) => {
const update: Positions_party_positions[] = [];
const add: Positions_party_positions[] = [];
if (!gridRef.current) {
return false;
}
const rowNode = gridRef.current.api.getRowNode(getRowNodeId(delta));
if (rowNode) {
const updatedData = produce<Positions_party_positions>(
rowNode.data,
(draft: Positions_party_positions) => {
merge(draft, delta);
}
);
if (updatedData !== rowNode.data) {
update.push(updatedData);
}
} else {
add.push(delta);
}
if (update.length || add.length) {
gridRef.current.api.applyTransactionAsync({
update,
add,
addIndex: 0,
});
}
return true;
},
[gridRef]
);
const { data, error, loading } = useDataProvider<
Positions_party_positions,
PositionSubscribe_positions
>(positionsDataProvider, update, variables);
return (
<AsyncRenderer loading={loading} error={error} data={data}>
{(data) => (
<PositionsTable
ref={gridRef}
data={data}
onRowClicked={(id) =>
push(`${pathname}/${id}?portfolio=orders&trade=orderbook`)
}
/>
)}
</AsyncRenderer>
);
};

View File

@ -6,6 +6,7 @@ import { GridTab, GridTabs } from './grid-tabs';
import { DealTicketContainer } from '../../components/deal-ticket-container';
import { OrderListContainer } from '../..//components/order-list-container';
import { Splash } from '@vegaprotocol/ui-toolkit';
import { Positions } from './positions';
const Chart = () => (
<Splash>
@ -17,11 +18,6 @@ const Orderbook = () => (
<p>Orderbook</p>
</Splash>
);
const Positions = () => (
<Splash>
<p>Positions</p>
</Splash>
);
const Collateral = () => (
<Splash>
<p>Collateral</p>

View File

@ -4,6 +4,6 @@ module.exports = {
name: 'vega',
url: process.env.NX_VEGA_URL,
},
includes: ['../../{apps,lib}/**/*.{ts,tsx,js,jsx,graphql}'],
includes: ['../../{apps,libs}/**/*.{ts,tsx,js,jsx,graphql}'],
},
};

View File

@ -0,0 +1,167 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { MarketTradingMode } from "./globalTypes";
// ====================================================
// GraphQL fragment: PositionDetails
// ====================================================
export interface PositionDetails_market_data_market {
__typename: "Market";
/**
* Market ID
*/
id: string;
}
export interface PositionDetails_market_data {
__typename: "MarketData";
/**
* the mark price (actually an unsigned int)
*/
markPrice: string;
/**
* what state the market is in (auction, continuous etc)
*/
marketTradingMode: MarketTradingMode;
/**
* market id of the associated mark price
*/
market: PositionDetails_market_data_market;
}
export interface PositionDetails_market_tradableInstrument_instrument_metadata {
__typename: "InstrumentMetadata";
/**
* An arbitrary list of tags to associated to associate to the Instrument (string list)
*/
tags: string[] | null;
}
export interface PositionDetails_market_tradableInstrument_instrument_product_settlementAsset {
__typename: "Asset";
/**
* The id of the asset
*/
id: string;
/**
* The symbol of the asset (e.g: GBP)
*/
symbol: string;
/**
* The full name of the asset (e.g: Great British Pound)
*/
name: string;
/**
* The precision of the asset
*/
decimals: number;
}
export interface PositionDetails_market_tradableInstrument_instrument_product {
__typename: "Future";
/**
* The name of the asset (string)
*/
settlementAsset: PositionDetails_market_tradableInstrument_instrument_product_settlementAsset;
/**
* String representing the quote (e.g. BTCUSD -> USD is quote)
*/
quoteName: string;
}
export interface PositionDetails_market_tradableInstrument_instrument {
__typename: "Instrument";
/**
* Uniquely identify an instrument across all instruments available on Vega (string)
*/
id: string;
/**
* Full and fairly descriptive name for the instrument
*/
name: string;
/**
* Metadata for this instrument
*/
metadata: PositionDetails_market_tradableInstrument_instrument_metadata;
/**
* A short non necessarily unique code used to easily describe the instrument (e.g: FX:BTCUSD/DEC18) (string)
*/
code: string;
/**
* A reference to or instance of a fully specified product, including all required product parameters for that product (Product union)
*/
product: PositionDetails_market_tradableInstrument_instrument_product;
}
export interface PositionDetails_market_tradableInstrument {
__typename: "TradableInstrument";
/**
* An instance of or reference to a fully specified instrument.
*/
instrument: PositionDetails_market_tradableInstrument_instrument;
}
export interface PositionDetails_market {
__typename: "Market";
/**
* Market ID
*/
id: string;
/**
* Market full name
*/
name: string;
/**
* marketData for the given market
*/
data: PositionDetails_market_data | null;
/**
* decimalPlaces indicates the number of decimal places that an integer must be shifted by in order to get a correct
* number denominated in the currency of the Market. (uint64)
*
* Examples:
* Currency Balance decimalPlaces Real Balance
* GBP 100 0 GBP 100
* GBP 100 2 GBP 1.00
* GBP 100 4 GBP 0.01
* GBP 1 4 GBP 0.0001 ( 0.01p )
*
* GBX (pence) 100 0 GBP 1.00 (100p )
* GBX (pence) 100 2 GBP 0.01 ( 1p )
* GBX (pence) 100 4 GBP 0.0001 ( 0.01p )
* GBX (pence) 1 4 GBP 0.000001 ( 0.0001p)
*/
decimalPlaces: number;
/**
* An instance of or reference to a tradable instrument.
*/
tradableInstrument: PositionDetails_market_tradableInstrument;
}
export interface PositionDetails {
__typename: "Position";
/**
* Realised Profit and Loss (int64)
*/
realisedPNL: string;
/**
* Open volume (uint64)
*/
openVolume: string;
/**
* Unrealised Profit and Loss (int64)
*/
unrealisedPNL: string;
/**
* Average entry price for this position
*/
averageEntryPrice: string;
/**
* Market relating to this position
*/
market: PositionDetails_market;
}

View File

@ -0,0 +1,178 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { MarketTradingMode } from "./globalTypes";
// ====================================================
// GraphQL subscription operation: PositionSubscribe
// ====================================================
export interface PositionSubscribe_positions_market_data_market {
__typename: "Market";
/**
* Market ID
*/
id: string;
}
export interface PositionSubscribe_positions_market_data {
__typename: "MarketData";
/**
* the mark price (actually an unsigned int)
*/
markPrice: string;
/**
* what state the market is in (auction, continuous etc)
*/
marketTradingMode: MarketTradingMode;
/**
* market id of the associated mark price
*/
market: PositionSubscribe_positions_market_data_market;
}
export interface PositionSubscribe_positions_market_tradableInstrument_instrument_metadata {
__typename: "InstrumentMetadata";
/**
* An arbitrary list of tags to associated to associate to the Instrument (string list)
*/
tags: string[] | null;
}
export interface PositionSubscribe_positions_market_tradableInstrument_instrument_product_settlementAsset {
__typename: "Asset";
/**
* The id of the asset
*/
id: string;
/**
* The symbol of the asset (e.g: GBP)
*/
symbol: string;
/**
* The full name of the asset (e.g: Great British Pound)
*/
name: string;
/**
* The precision of the asset
*/
decimals: number;
}
export interface PositionSubscribe_positions_market_tradableInstrument_instrument_product {
__typename: "Future";
/**
* The name of the asset (string)
*/
settlementAsset: PositionSubscribe_positions_market_tradableInstrument_instrument_product_settlementAsset;
/**
* String representing the quote (e.g. BTCUSD -> USD is quote)
*/
quoteName: string;
}
export interface PositionSubscribe_positions_market_tradableInstrument_instrument {
__typename: "Instrument";
/**
* Uniquely identify an instrument across all instruments available on Vega (string)
*/
id: string;
/**
* Full and fairly descriptive name for the instrument
*/
name: string;
/**
* Metadata for this instrument
*/
metadata: PositionSubscribe_positions_market_tradableInstrument_instrument_metadata;
/**
* A short non necessarily unique code used to easily describe the instrument (e.g: FX:BTCUSD/DEC18) (string)
*/
code: string;
/**
* A reference to or instance of a fully specified product, including all required product parameters for that product (Product union)
*/
product: PositionSubscribe_positions_market_tradableInstrument_instrument_product;
}
export interface PositionSubscribe_positions_market_tradableInstrument {
__typename: "TradableInstrument";
/**
* An instance of or reference to a fully specified instrument.
*/
instrument: PositionSubscribe_positions_market_tradableInstrument_instrument;
}
export interface PositionSubscribe_positions_market {
__typename: "Market";
/**
* Market ID
*/
id: string;
/**
* Market full name
*/
name: string;
/**
* marketData for the given market
*/
data: PositionSubscribe_positions_market_data | null;
/**
* decimalPlaces indicates the number of decimal places that an integer must be shifted by in order to get a correct
* number denominated in the currency of the Market. (uint64)
*
* Examples:
* Currency Balance decimalPlaces Real Balance
* GBP 100 0 GBP 100
* GBP 100 2 GBP 1.00
* GBP 100 4 GBP 0.01
* GBP 1 4 GBP 0.0001 ( 0.01p )
*
* GBX (pence) 100 0 GBP 1.00 (100p )
* GBX (pence) 100 2 GBP 0.01 ( 1p )
* GBX (pence) 100 4 GBP 0.0001 ( 0.01p )
* GBX (pence) 1 4 GBP 0.000001 ( 0.0001p)
*/
decimalPlaces: number;
/**
* An instance of or reference to a tradable instrument.
*/
tradableInstrument: PositionSubscribe_positions_market_tradableInstrument;
}
export interface PositionSubscribe_positions {
__typename: "Position";
/**
* Realised Profit and Loss (int64)
*/
realisedPNL: string;
/**
* Open volume (uint64)
*/
openVolume: string;
/**
* Unrealised Profit and Loss (int64)
*/
unrealisedPNL: string;
/**
* Average entry price for this position
*/
averageEntryPrice: string;
/**
* Market relating to this position
*/
market: PositionSubscribe_positions_market;
}
export interface PositionSubscribe {
/**
* Subscribe to the positions updates
*/
positions: PositionSubscribe_positions;
}
export interface PositionSubscribeVariables {
partyId: string;
}

View File

@ -0,0 +1,190 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { MarketTradingMode } from "./globalTypes";
// ====================================================
// GraphQL query operation: Positions
// ====================================================
export interface Positions_party_positions_market_data_market {
__typename: "Market";
/**
* Market ID
*/
id: string;
}
export interface Positions_party_positions_market_data {
__typename: "MarketData";
/**
* the mark price (actually an unsigned int)
*/
markPrice: string;
/**
* what state the market is in (auction, continuous etc)
*/
marketTradingMode: MarketTradingMode;
/**
* market id of the associated mark price
*/
market: Positions_party_positions_market_data_market;
}
export interface Positions_party_positions_market_tradableInstrument_instrument_metadata {
__typename: "InstrumentMetadata";
/**
* An arbitrary list of tags to associated to associate to the Instrument (string list)
*/
tags: string[] | null;
}
export interface Positions_party_positions_market_tradableInstrument_instrument_product_settlementAsset {
__typename: "Asset";
/**
* The id of the asset
*/
id: string;
/**
* The symbol of the asset (e.g: GBP)
*/
symbol: string;
/**
* The full name of the asset (e.g: Great British Pound)
*/
name: string;
/**
* The precision of the asset
*/
decimals: number;
}
export interface Positions_party_positions_market_tradableInstrument_instrument_product {
__typename: "Future";
/**
* The name of the asset (string)
*/
settlementAsset: Positions_party_positions_market_tradableInstrument_instrument_product_settlementAsset;
/**
* String representing the quote (e.g. BTCUSD -> USD is quote)
*/
quoteName: string;
}
export interface Positions_party_positions_market_tradableInstrument_instrument {
__typename: "Instrument";
/**
* Uniquely identify an instrument across all instruments available on Vega (string)
*/
id: string;
/**
* Full and fairly descriptive name for the instrument
*/
name: string;
/**
* Metadata for this instrument
*/
metadata: Positions_party_positions_market_tradableInstrument_instrument_metadata;
/**
* A short non necessarily unique code used to easily describe the instrument (e.g: FX:BTCUSD/DEC18) (string)
*/
code: string;
/**
* A reference to or instance of a fully specified product, including all required product parameters for that product (Product union)
*/
product: Positions_party_positions_market_tradableInstrument_instrument_product;
}
export interface Positions_party_positions_market_tradableInstrument {
__typename: "TradableInstrument";
/**
* An instance of or reference to a fully specified instrument.
*/
instrument: Positions_party_positions_market_tradableInstrument_instrument;
}
export interface Positions_party_positions_market {
__typename: "Market";
/**
* Market ID
*/
id: string;
/**
* Market full name
*/
name: string;
/**
* marketData for the given market
*/
data: Positions_party_positions_market_data | null;
/**
* decimalPlaces indicates the number of decimal places that an integer must be shifted by in order to get a correct
* number denominated in the currency of the Market. (uint64)
*
* Examples:
* Currency Balance decimalPlaces Real Balance
* GBP 100 0 GBP 100
* GBP 100 2 GBP 1.00
* GBP 100 4 GBP 0.01
* GBP 1 4 GBP 0.0001 ( 0.01p )
*
* GBX (pence) 100 0 GBP 1.00 (100p )
* GBX (pence) 100 2 GBP 0.01 ( 1p )
* GBX (pence) 100 4 GBP 0.0001 ( 0.01p )
* GBX (pence) 1 4 GBP 0.000001 ( 0.0001p)
*/
decimalPlaces: number;
/**
* An instance of or reference to a tradable instrument.
*/
tradableInstrument: Positions_party_positions_market_tradableInstrument;
}
export interface Positions_party_positions {
__typename: "Position";
/**
* Realised Profit and Loss (int64)
*/
realisedPNL: string;
/**
* Open volume (uint64)
*/
openVolume: string;
/**
* Unrealised Profit and Loss (int64)
*/
unrealisedPNL: string;
/**
* Average entry price for this position
*/
averageEntryPrice: string;
/**
* Market relating to this position
*/
market: Positions_party_positions_market;
}
export interface Positions_party {
__typename: "Party";
/**
* Party identifier
*/
id: string;
/**
* Trading positions relating to a party
*/
positions: Positions_party_positions[] | null;
}
export interface Positions {
/**
* An entity that is trading on the VEGA network
*/
party: Positions_party | null;
}
export interface PositionsVariables {
partyId: string;
}

View File

@ -0,0 +1,199 @@
import { produce } from 'immer';
import type { Draft } from 'immer';
import type {
ApolloClient,
DocumentNode,
FetchPolicy,
TypedDocumentNode,
OperationVariables,
} from '@apollo/client';
import type { Subscription } from 'zen-observable-ts';
import isEqual from 'lodash/isEqual';
export interface UpdateCallback<Data, Delta> {
(arg: {
data: Data[] | null;
error?: Error;
loading: boolean;
delta?: Delta;
}): void;
}
export interface Subscribe<Data, Delta> {
(
callback: UpdateCallback<Data, Delta>,
client: ApolloClient<object>,
variables?: OperationVariables
): () => void;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Query<Result> = DocumentNode | TypedDocumentNode<Result, any>;
interface Update<Data, Delta> {
(draft: Draft<Data>[], delta: Delta): void;
}
interface GetData<QueryData, Data> {
(subscriptionData: QueryData): Data[] | null;
}
interface GetDelta<SubscriptionData, Delta> {
(subscriptionData: SubscriptionData): Delta;
}
function makeDataProviderInternal<QueryData, Data, SubscriptionData, Delta>(
query: Query<QueryData>,
subscriptionQuery: Query<SubscriptionData>,
update: Update<Data, Delta>,
getData: GetData<QueryData, Data>,
getDelta: GetDelta<SubscriptionData, Delta>,
fetchPolicy: FetchPolicy = 'no-cache'
): Subscribe<Data, Delta> {
const callbacks: UpdateCallback<Data, Delta>[] = [];
const updateQueue: Delta[] = [];
let variables: OperationVariables | undefined = undefined;
let data: Data[] | null = null;
let error: Error | undefined = undefined;
let loading = false;
let client: ApolloClient<object> | undefined = undefined;
let subscription: Subscription | undefined = undefined;
const notify = (callback: UpdateCallback<Data, Delta>, delta?: Delta) => {
callback({
data,
error,
loading,
delta,
});
};
const notifyAll = (delta?: Delta) => {
callbacks.forEach((callback) => notify(callback, delta));
};
const initialize = async () => {
if (subscription) {
return;
}
loading = true;
error = undefined;
notifyAll();
if (!client) {
return;
}
subscription = client
.subscribe<SubscriptionData>({
query: subscriptionQuery,
variables,
})
.subscribe(({ data: subscriptionData }) => {
if (!subscriptionData) {
return;
}
const delta = getDelta(subscriptionData);
if (loading || !data) {
updateQueue.push(delta);
} else {
const newData = produce(data, (draft) => {
update(draft, delta);
});
if (newData === data) {
return;
}
data = newData;
notifyAll(delta);
}
});
try {
const res = await client.query<QueryData>({
query,
variables,
fetchPolicy,
});
data = getData(res.data);
if (data && updateQueue && updateQueue.length > 0) {
data = produce(data, (draft) => {
while (updateQueue.length) {
const delta = updateQueue.shift();
if (delta) {
update(draft, delta);
}
}
});
}
} catch (e) {
error = e as Error;
subscription.unsubscribe();
subscription = undefined;
} finally {
loading = false;
notifyAll();
}
};
const unsubscribe = (callback: UpdateCallback<Data, Delta>) => {
callbacks.splice(callbacks.indexOf(callback), 1);
if (callbacks.length === 0) {
if (subscription) {
subscription.unsubscribe();
subscription = undefined;
}
data = null;
error = undefined;
loading = false;
}
};
return (callback, c, v) => {
callbacks.push(callback);
if (callbacks.length === 1) {
client = c;
variables = v;
initialize();
} else {
notify(callback);
}
return () => unsubscribe(callback);
};
}
const memoize = <Data, Delta>(
fn: (variables?: OperationVariables) => Subscribe<Data, Delta>
) => {
const cache: {
subscribe: Subscribe<Data, Delta>;
variables?: OperationVariables;
}[] = [];
return (variables?: OperationVariables) => {
const cached = cache.find((c) => isEqual(c.variables, variables));
if (cached) {
return cached.subscribe;
}
const subscribe = fn(variables);
cache.push({ subscribe, variables });
return subscribe;
};
};
export function makeDataProvider<QueryData, Data, SubscriptionData, Delta>(
query: Query<QueryData>,
subscriptionQuery: Query<SubscriptionData>,
update: Update<Data, Delta>,
getData: GetData<QueryData, Data>,
getDelta: GetDelta<SubscriptionData, Delta>,
fetchPolicy: FetchPolicy = 'no-cache'
): Subscribe<Data, Delta> {
const getInstance = memoize<Data, Delta>((variables) =>
makeDataProviderInternal(
query,
subscriptionQuery,
update,
getData,
getDelta,
fetchPolicy
)
);
return (callback, client, variables) =>
getInstance(variables)(callback, client, variables);
}

View File

@ -1 +1,3 @@
export * from './markets-data-provider';
export * from './positions-data-provider';
export type { Subscribe } from './generic-data-provider';

View File

@ -1,8 +1,6 @@
import { gql } from '@apollo/client';
import { produce } from 'immer';
import type { ApolloClient } from '@apollo/client';
import type { Subscription } from 'zen-observable-ts';
import { Markets, Markets_markets } from '../__generated__/Markets';
import { makeDataProvider } from './generic-data-provider';
import {
MarketDataSub,
@ -57,137 +55,21 @@ const MARKET_DATA_SUB = gql`
}
`;
export interface MarketsDataProviderCallbackArg {
data: Markets_markets[] | null;
error?: Error;
loading: boolean;
delta?: MarketDataSub_marketData;
}
export interface MarketsDataProviderCallback {
(arg: MarketsDataProviderCallbackArg): void;
}
const callbacks: MarketsDataProviderCallback[] = [];
const updateQueue: MarketDataSub_marketData[] = [];
let data: Markets_markets[] | null = null;
let error: Error | undefined = undefined;
let loading = false;
let client: ApolloClient<object> | undefined = undefined;
let subscription: Subscription | undefined = undefined;
const notify = (
callback: MarketsDataProviderCallback,
delta?: MarketDataSub_marketData
) => {
callback({
data,
error,
loading,
delta,
});
};
const notifyAll = (delta?: MarketDataSub_marketData) => {
callbacks.forEach((callback) => notify(callback, delta));
};
const update = (
draft: Markets_markets[] | null,
delta: MarketDataSub_marketData
) => {
if (!draft) {
return;
}
const update = (draft: Markets_markets[], delta: MarketDataSub_marketData) => {
const index = draft.findIndex((m) => m.id === delta.market.id);
if (index !== -1) {
draft[index].data = delta;
}
// @TODO - else push new market to draft
};
const getData = (responseData: Markets): Markets_markets[] | null =>
responseData.markets;
const getDelta = (subscriptionData: MarketDataSub): MarketDataSub_marketData =>
subscriptionData.marketData;
const initialize = async () => {
if (subscription) {
return;
}
loading = true;
error = undefined;
notifyAll();
if (!client) {
return;
}
subscription = client
.subscribe<MarketDataSub>({
query: MARKET_DATA_SUB,
})
.subscribe(({ data: delta }) => {
if (!delta) {
return;
}
if (loading) {
updateQueue.push(delta.marketData);
} else {
const newData = produce(data, (draft) => {
update(draft, delta.marketData);
});
if (newData === data) {
return;
}
data = newData;
notifyAll(delta.marketData);
}
});
try {
const res = await client.query<Markets>({
query: MARKETS_QUERY,
});
data = res.data.markets;
if (updateQueue && updateQueue.length > 0) {
data = produce(data, (draft) => {
while (updateQueue.length) {
const delta = updateQueue.shift();
if (delta) {
update(draft, delta);
}
}
});
}
} catch (e) {
error = e as Error;
subscription.unsubscribe();
subscription = undefined;
} finally {
loading = false;
notifyAll();
}
};
const unsubscribe = (callback: MarketsDataProviderCallback) => {
callbacks.splice(callbacks.indexOf(callback), 1);
if (callbacks.length === 0) {
if (subscription) {
subscription.unsubscribe();
subscription = undefined;
}
data = null;
error = undefined;
loading = false;
}
};
export const marketsDataProvider = (
c: ApolloClient<object>,
callback: MarketsDataProviderCallback
) => {
if (!client) {
client = c;
}
callbacks.push(callback);
if (callbacks.length === 1) {
initialize();
} else {
notify(callback);
}
return () => unsubscribe(callback);
};
export const marketsDataProvider = makeDataProvider<
Markets,
Markets_markets,
MarketDataSub,
MarketDataSub_marketData
>(MARKETS_QUERY, MARKET_DATA_SUB, update, getData, getDelta);

View File

@ -0,0 +1,98 @@
import { gql } from '@apollo/client';
import {
Positions,
Positions_party_positions,
} from '../__generated__/Positions';
import { makeDataProvider } from './generic-data-provider';
import {
PositionSubscribe,
PositionSubscribe_positions,
} from '../__generated__/PositionSubscribe';
const POSITIONS_FRAGMENT = gql`
fragment PositionDetails on Position {
realisedPNL
openVolume
unrealisedPNL
averageEntryPrice
market {
id
name
data {
markPrice
marketTradingMode
market {
id
}
}
decimalPlaces
tradableInstrument {
instrument {
id
name
metadata {
tags
}
code
product {
... on Future {
settlementAsset {
id
symbol
name
decimals
}
quoteName
}
}
}
}
}
}
`;
const POSITION_QUERY = gql`
${POSITIONS_FRAGMENT}
query Positions($partyId: ID!) {
party(id: $partyId) {
id
positions {
...PositionDetails
}
}
}
`;
export const POSITIONS_SUB = gql`
${POSITIONS_FRAGMENT}
subscription PositionSubscribe($partyId: ID!) {
positions(partyId: $partyId) {
...PositionDetails
}
}
`;
const update = (
draft: Positions_party_positions[],
delta: PositionSubscribe_positions
) => {
const index = draft.findIndex((m) => m.market.id === delta.market.id);
if (index !== -1) {
draft[index] = delta;
} else {
draft.push(delta);
}
};
const getData = (responseData: Positions): Positions_party_positions[] | null =>
responseData.party ? responseData.party.positions : null;
const getDelta = (
subscriptionData: PositionSubscribe
): PositionSubscribe_positions => subscriptionData.positions;
export const positionsDataProvider = makeDataProvider<
Positions,
Positions_party_positions,
PositionSubscribe,
PositionSubscribe_positions
>(POSITION_QUERY, POSITIONS_SUB, update, getData, getDelta);

View File

@ -2,10 +2,10 @@ export * from './__generated__/AssetsQuery';
export * from './__generated__/globalTypes';
export * from './__generated__/Guess';
export * from './__generated__/Market';
export * from './__generated__/MarketDataFields';
export * from './__generated__/MarketDataSub';
export * from './__generated__/Markets';
export * from './__generated__/MarketsQuery';
export * from './__generated__/MarketDataSub';
export * from './__generated__/MarketDataFields';
export * from './__generated__/NetworkParametersQuery';
export * from './__generated__/NodesQuery';
export * from './__generated__/OrderEvent';
@ -13,6 +13,9 @@ export * from './__generated__/OrderFields';
export * from './__generated__/Orders';
export * from './__generated__/OrderSub';
export * from './__generated__/PartyAssetsQuery';
export * from './__generated__/PositionDetails';
export * from './__generated__/Positions';
export * from './__generated__/PositionSubscribe';
export * from './__generated__/ProposalsQuery';
export * from './data-providers';

View File

@ -7,19 +7,19 @@ import { AgGridColumn } from 'ag-grid-react';
import type { AgGridReact } from 'ag-grid-react';
interface MarketListTableProps {
markets: Markets_markets[] | null;
data: Markets_markets[] | null;
onRowClicked: (marketId: string) => void;
}
export const getRowNodeId = (data: { id: string }) => data.id;
export const MarketListTable = forwardRef<AgGridReact, MarketListTableProps>(
({ markets, onRowClicked }, ref) => {
({ data, onRowClicked }, ref) => {
return (
<AgGrid
style={{ width: '100%', height: '100%' }}
overlayNoRowsTemplate="No markets"
rowData={markets}
rowData={data}
getRowNodeId={getRowNodeId}
ref={ref}
defaultColDef={{

6
libs/positions/.babelrc Normal file
View File

@ -0,0 +1,6 @@
{
"presets": [
"@nrwl/next/babel"
],
"plugins": []
}

View File

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

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

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

View File

@ -0,0 +1,15 @@
module.exports = {
displayName: 'positions',
preset: '../../jest.preset.js',
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json',
},
},
transform: {
'^.+\\.[tj]sx?$': 'ts-jest',
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
coverageDirectory: '../../coverage/libs/positions',
setupFilesAfterEnv: ['./src/setup-tests.ts'],
};

View File

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

View File

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

View File

@ -0,0 +1 @@
export * from './lib/positions-table';

View File

@ -0,0 +1,101 @@
import { act, render, screen } from '@testing-library/react';
import PositionsTable from './positions-table';
import {
Positions_party_positions,
MarketTradingMode,
} from '@vegaprotocol/graphql';
const singleRow: Positions_party_positions = {
realisedPNL: '5',
openVolume: '100',
unrealisedPNL: '895000',
averageEntryPrice: '1129935',
market: {
id: 'b7010da9dbe7fbab2b74d9d5642fc4a8a0ca93ef803d21fa60c2cacd0416bba0',
name: 'UNIDAI Monthly (30 Jun 2022)',
data: {
markPrice: '1138885',
marketTradingMode: MarketTradingMode.Continuous,
__typename: 'MarketData',
market: { __typename: 'Market', id: '123' },
},
decimalPlaces: 5,
tradableInstrument: {
instrument: {
id: '',
name: 'UNIDAI Monthly (30 Jun 2022)',
metadata: {
tags: [
'formerly:3C58ED2A4A6C5D7E',
'base:UNI',
'quote:DAI',
'class:fx/crypto',
'monthly',
'sector:defi',
],
__typename: 'InstrumentMetadata',
},
code: 'UNIDAI.MF21',
product: {
settlementAsset: {
id: '6d9d35f657589e40ddfb448b7ad4a7463b66efb307527fedd2aa7df1bbd5ea61',
symbol: 'tDAI',
name: 'tDAI TEST',
decimals: 5,
__typename: 'Asset',
},
quoteName: 'DAI',
__typename: 'Future',
},
__typename: 'Instrument',
},
__typename: 'TradableInstrument',
},
__typename: 'Market',
},
__typename: 'Position',
};
const singleRowData = [singleRow];
const onRowClicked = jest.fn;
test('should render successfully', async () => {
await act(async () => {
const { baseElement } = render(
<PositionsTable data={[]} onRowClicked={onRowClicked} />
);
expect(baseElement).toBeTruthy();
});
});
test('Render correct columns', async () => {
await act(async () => {
render(<PositionsTable data={singleRowData} onRowClicked={onRowClicked} />);
});
const headers = screen.getAllByRole('columnheader');
expect(headers).toHaveLength(5);
expect(headers.map((h) => h.textContent?.trim())).toEqual([
'Market',
'Amount',
'Average Entry Price',
'Mark Price',
'Realised PNL',
]);
});
test('Correct formatting applied', async () => {
await act(async () => {
render(<PositionsTable data={singleRowData} onRowClicked={onRowClicked} />);
});
const cells = screen.getAllByRole('gridcell');
const expectedValues = [
singleRow.market.tradableInstrument.instrument.code,
'+100',
'11.29935',
'11.38885',
'+5',
];
cells.forEach((cell, i) => {
expect(cell).toHaveTextContent(expectedValues[i]);
});
expect(cells[cells.length - 1]).toHaveClass('color-vega-green');
});

View File

@ -0,0 +1,132 @@
import { forwardRef, useMemo } from 'react';
import type { ValueFormatterParams } from 'ag-grid-community';
import {
PriceCell,
formatNumber,
volumePrefix,
addDecimal,
} from '@vegaprotocol/react-helpers';
import { AgGridDynamic as AgGrid } from '@vegaprotocol/ui-toolkit';
import { AgGridColumn } from 'ag-grid-react';
import type { AgGridReact } from 'ag-grid-react';
import compact from 'lodash/compact';
import {
Positions_party_positions,
MarketTradingMode,
} from '@vegaprotocol/graphql';
interface PositionsTableProps {
data: Positions_party_positions[] | null;
onRowClicked: (marketId: string) => void;
}
export const getRowNodeId = (data: { market: { id: string } }) =>
data.market.id;
const sortByName = (
a: Positions_party_positions,
b: Positions_party_positions
) => {
if (
a.market.tradableInstrument.instrument.name <
b.market.tradableInstrument.instrument.name
) {
return -1;
}
if (
a.market.tradableInstrument.instrument.name >
b.market.tradableInstrument.instrument.name
) {
return 1;
}
return 0;
};
interface PositionsTableValueFormatterParams extends ValueFormatterParams {
data: Positions_party_positions;
}
export const PositionsTable = forwardRef<AgGridReact, PositionsTableProps>(
({ data, onRowClicked }, ref) => {
const sortedData = useMemo(() => {
return compact(data).sort(sortByName);
}, [data]);
return (
<AgGrid
style={{ width: '100%', height: '100%' }}
overlayNoRowsTemplate="No positions"
rowData={sortedData}
getRowNodeId={getRowNodeId}
ref={ref}
defaultColDef={{
flex: 1,
resizable: true,
}}
onRowClicked={({ data }: { data: Positions_party_positions }) =>
onRowClicked(getRowNodeId(data))
}
components={{ PriceCell }}
>
<AgGridColumn
headerName="Market"
field="market.tradableInstrument.instrument.code"
/>
<AgGridColumn
headerName="Amount"
field="openVolume"
valueFormatter={({ value }: PositionsTableValueFormatterParams) =>
volumePrefix(value)
}
/>
<AgGridColumn
headerName="Average Entry Price"
field="averageEntryPrice"
cellRenderer="PriceCell"
valueFormatter={({
value,
data,
}: PositionsTableValueFormatterParams) =>
formatNumber(value, data.market.decimalPlaces)
}
/>
<AgGridColumn
headerName="Mark Price"
field="market.data.markPrice"
type="rightAligned"
cellRenderer="PriceCell"
valueFormatter={({
value,
data,
}: PositionsTableValueFormatterParams) => {
if (
data.market.data?.marketTradingMode ===
MarketTradingMode.OpeningAuction
) {
return '-';
}
return addDecimal(value, data.market.decimalPlaces);
}}
/>
<AgGridColumn
headerName="Realised PNL"
field="realisedPNL"
type="rightAligned"
cellClassRules={{
'color-vega-green': ({ value }: { value: string }) =>
Number(value) > 0,
'color-vega-red': ({ value }: { value: string }) =>
Number(value) < 0,
}}
valueFormatter={({ value }: ValueFormatterParams) =>
volumePrefix(value)
}
cellRenderer="PriceCell"
/>
</AgGrid>
);
}
);
export default PositionsTable;

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
export * from './use-apply-grid-transaction';
export * from './use-data-provider';
export * from './use-theme-switcher';

View File

@ -0,0 +1,33 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useApolloClient } from '@apollo/client';
import type { OperationVariables } from '@apollo/client';
import type { Subscribe } from '@vegaprotocol/graphql';
export function useDataProvider<Data, Delta>(
dataProvider: Subscribe<Data, Delta>,
update?: (delta: Delta) => boolean,
variables?: OperationVariables
) {
const client = useApolloClient();
const [data, setData] = useState<Data[] | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<Error | undefined>(undefined);
const initialized = useRef<boolean>(false);
const callback = useCallback(
({ data, error, loading, delta }) => {
setError(error);
setLoading(loading);
if (!error && !loading) {
if (!initialized.current || !delta || !update || !update(delta)) {
initialized.current = true;
setData(data);
}
}
},
[update]
);
useEffect(() => {
return dataProvider(callback, client, variables);
}, [client, initialized, dataProvider, callback, variables]);
return { data, loading, error };
}

View File

@ -5,5 +5,4 @@ export * from './lib/format';
export * from './lib/grid-cells';
export * from './lib/storage';
export * from './hooks/use-apply-grid-transaction';
export * from './hooks/use-theme-switcher';
export * from './hooks';

View File

@ -7,6 +7,19 @@ const getUserLocale = () => 'default';
export const splitAt = (index: number) => (x: string) =>
[x.slice(0, index), x.slice(index)];
/**
* Returns a number prefixed with either a '-' or a '+'. The open volume field
* already comes with a '-' if negative so we only need to actually prefix if
* its a positive value
*/
export function volumePrefix(value: string): string {
if (value === '0' || value.startsWith('-')) {
return value;
}
return '+' + value;
}
export const getTimeFormat = once(
() =>
new Intl.DateTimeFormat(getUserLocale(), {

View File

@ -5,16 +5,16 @@ import * as React from 'react';
import { PriceCell } from './price-cell';
describe('<PriceCell />', () => {
it('Displayes formatted value', () => {
it('Displays formatted value', () => {
render(<PriceCell value={100} valueFormatted="100.00" />);
expect(screen.getByTestId('price')).toHaveTextContent('100.00');
});
it('Displayes 0', () => {
it('Displays 0', () => {
render(<PriceCell value={0} valueFormatted="0.00" />);
expect(screen.getByTestId('price')).toHaveTextContent('0.00');
});
it('Displayes - if value is not a number', () => {
it('Displays - if value is not a number', () => {
render(<PriceCell value={null} valueFormatted="" />);
expect(screen.getByTestId('price')).toHaveTextContent('-');
});

View File

@ -20,6 +20,7 @@
"@vegaprotocol/market-list": ["libs/market-list/src/index.ts"],
"@vegaprotocol/network-stats": ["libs/network-stats/src/index.ts"],
"@vegaprotocol/order-list": ["libs/order-list/src/index.ts"],
"@vegaprotocol/positions": ["libs/positions/src/index.ts"],
"@vegaprotocol/react-helpers": ["libs/react-helpers/src/index.ts"],
"@vegaprotocol/tailwindcss-config": [
"libs/tailwindcss-config/src/index.js"

View File

@ -8,6 +8,7 @@
"market-list": "libs/market-list",
"network-stats": "libs/network-stats",
"order-list": "libs/order-list",
"positions": "libs/positions",
"react-helpers": "libs/react-helpers",
"stats": "apps/stats",
"stats-e2e": "apps/stats-e2e",