feat(#847): collateral table, add breakdown (#1442)

* feat: #847 show progress bar, margin accounts, no used/deposited

* feat: #847 add collateral tables

* fix: #847 add deposit asset type and fix tests

* feat: #847 show deposited value, avaliable and percentage used

* fix: #847 add styling fixes

* fix: #847 add deposit new asset button

* fix: #847 remove disabledSelect
to fix withdraw and deposit dialogs

* fix: #847 remove global reward from incoming - needs to be party specific

* fix: #847 integration tests

* fix: #847 default select deposit & withdraw

* fix: #847 default select deposit & withdraw

* fix: #847 pass asset id as default value

* fix:  #847 use only bigint no bignumber, remove NaN check

* fix:  #847 update deposit-form.spec.tsx

* fix: revert update on account fields

* feat: add storybook set up

* chore: ignore apollo errors - to be reverted after API will be fixed

* fix: container moved, progress bar in helpers

* fix: #847 UI tweaks around accounts container

* feat: #847 added useDepositAsset and useWithdrawAsset

* fix: #847 fix progress bar in accounts and positions

* feat: #847 add storybook

* fix: #847 added tooltip and updated filtering

* chore: #847 add get account data test

* fix: #847 fix lint and type in account story

* fix:  #847 update data provider

* fix: #847 fix get account data dry & lp link

* fix: #847 fix breakdown table test

* fix: #847 account data provider test

* fix: #847 remove deposit new asset button - subscription does not display a
sset data

* fix: #847 add defaultValue in select otherwise default is not set up

* feat: #847 update data provider update method and tables

* fix: #847 fix accounts tests

* fix: #847 remove unused getRows

* fix: add decimals

* fix: #847 fix imports

* fix: update ids

* Update apps/trading/pages/liquidity/[marketId].page.tsx

* fix: #847 accounts update method check delta

* fix: #847 use vega value formatters and cell renderers

* fix: #847 fix imports

* fix: #847 handle new account else block comment

* fix: accounts and breakdown tables

* fix(#847): account data provider improvments

* fix: #847 fix formatters null check and add param

* fix: #847 fix withdraw test and mock the hook

* fix: #847 fix console lite grid select market test

* fix: console lite build

* fix: revert withdraw limits

* fix: remove redundant waitFor use vega cell renderer

* fix: breakdown display only use accounts

* fix: breakdown display only use accounts

* fix: updated accounts table

* fix: move update inside try useWithdrawAsset

* fix: update trading-accounts test

* fix: portfolio-page.test.ts

Co-authored-by: Bartłomiej Głownia <bglownia@gmail.com>
This commit is contained in:
m.ray 2022-09-30 01:40:44 +01:00 committed by GitHub
parent 835bf36393
commit 907a4e256e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 1940 additions and 630 deletions

View File

@ -56,7 +56,7 @@ describe('Portfolio page', { tags: '@smoke' }, () => {
});
it('data should be properly rendered', () => {
cy.get('.ag-center-cols-container .ag-row').should('have.length', 5);
cy.get('.ag-center-cols-container .ag-row').should('have.length', 3);
cy.get(
'.ag-center-cols-container [row-id="ACCOUNT_TYPE_GENERAL-asset-id-null"]'
)

View File

@ -105,9 +105,7 @@ const ConsoleLiteGrid = <T extends { id?: string }>(
);
};
const ConsoleLiteGridForwarder = forwardRef(ConsoleLiteGrid) as <
T extends { id?: string }
>(
const ConsoleLiteGridForwarder = forwardRef(ConsoleLiteGrid) as <T>(
p: Props<T> & { ref?: React.Ref<AgGridReact> }
) => React.ReactElement;

View File

@ -1,16 +1,10 @@
import { useMemo, useRef } from 'react';
import { useMemo, useRef, useCallback } from 'react';
import { useOutletContext } from 'react-router-dom';
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 type { AccountFields } from '@vegaprotocol/accounts';
import { aggregatedAccountsDataProvider, getId } from '@vegaprotocol/accounts';
import type { IGetRowsParams } from 'ag-grid-community';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import {
AssetDetailsDialog,
@ -20,20 +14,43 @@ import { NO_DATA_MESSAGE } from '../../../constants';
import { ConsoleLiteGrid } from '../../console-lite-grid';
import { useAccountColumnDefinitions } from '.';
interface AccountObj extends AccountFieldsFragment {
id: string;
}
const AccountsManager = () => {
const { partyId = '' } = useOutletContext<{ partyId: string }>();
const { isOpen, symbol, setOpen } = useAssetDetailsDialogStore();
const gridRef = useRef<AgGridReact | null>(null);
const dataRef = useRef<AccountFields[] | null>(null);
const variables = useMemo(() => ({ partyId }), [partyId]);
const update = useMemo(() => accountsManagerUpdate(gridRef), []);
const { data, error, loading } = useDataProvider<
AccountFieldsFragment[],
AccountEventsSubscription['accounts']
>({ dataProvider: accountsDataProvider, update, variables });
const update = useCallback(
({ data }: { data: AccountFields[] | null }) => {
if (!gridRef.current?.api) {
return false;
}
if (dataRef.current?.length) {
dataRef.current = data;
gridRef.current.api.refreshInfiniteCache();
return true;
}
return false;
},
[gridRef]
);
const { data, error, loading } = useDataProvider<AccountFields[], never>({
dataProvider: aggregatedAccountsDataProvider,
update,
variables,
});
dataRef.current = data;
const getRows = async ({
successCallback,
startRow,
endRow,
}: IGetRowsParams) => {
const rowsThisBlock = dataRef.current
? dataRef.current.slice(startRow, endRow)
: [];
const lastRow = dataRef.current?.length ?? -1;
successCallback(rowsThisBlock, lastRow);
};
const { columnDefs, defaultColDef } = useAccountColumnDefinitions();
return (
<>
@ -43,8 +60,11 @@ const AccountsManager = () => {
data={data}
noDataMessage={NO_DATA_MESSAGE}
>
<ConsoleLiteGrid<AccountObj>
rowData={data as AccountObj[]}
<ConsoleLiteGrid<AccountFields>
rowData={data?.length ? undefined : []}
rowModelType={data?.length ? 'infinite' : 'clientSide'}
ref={gridRef}
datasource={{ getRows }}
columnDefs={columnDefs}
defaultColDef={defaultColDef}
components={{ PriceCell }}

View File

@ -1,45 +1,9 @@
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 type { AccountFields } 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;
};
import type { ColDef, GroupCellRendererParams } from 'ag-grid-community';
import type { VegaValueFormatterParams } from '@vegaprotocol/ui-toolkit';
const useAccountColumnDefinitions = () => {
const { open } = useAssetDetailsDialogStore();
@ -49,7 +13,6 @@ const useAccountColumnDefinitions = () => {
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) =>
@ -71,27 +34,25 @@ const useAccountColumnDefinitions = () => {
),
},
{
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',
colId: 'deposited',
headerName: t('Deposited'),
field: 'deposited',
cellRenderer: 'PriceCell',
valueFormatter: ({ value, data }: AccountsTableValueFormatterParams) =>
valueFormatter: ({
value,
data,
}: VegaValueFormatterParams<AccountFields, 'deposited'>) =>
addDecimalsFormatNumber(value, data.asset.decimals),
},
{
colId: 'used',
headerName: t('Used'),
field: 'used',
cellRenderer: 'PriceCell',
valueFormatter: ({
value,
data,
}: VegaValueFormatterParams<AccountFields, 'used'>) =>
addDecimalsFormatNumber(value, data.asset.decimals),
},
];

View File

@ -11,7 +11,7 @@ import type {
PositionsTableValueFormatterParams,
Position,
} from '@vegaprotocol/positions';
import { AmountCell, ProgressBarCell } from '@vegaprotocol/positions';
import { AmountCell } from '@vegaprotocol/positions';
import type {
CellRendererSelectorResult,
ICellRendererParams,
@ -20,7 +20,7 @@ import type {
ColDef,
} from 'ag-grid-community';
import { MarketTradingMode } from '@vegaprotocol/types';
import { Intent } from '@vegaprotocol/ui-toolkit';
import { Intent, ProgressBarCell } from '@vegaprotocol/ui-toolkit';
const EmptyCell = () => '';

View File

@ -70,6 +70,7 @@ const SimpleMarketList = () => {
rowData={localData}
defaultColDef={defaultColDef}
handleRowClicked={handleRowClicked}
getRowId={({ data }) => data.id}
/>
</AsyncRenderer>
</div>

View File

@ -28,19 +28,13 @@ describe('accounts', { tags: '@smoke' }, () => {
cy.getByTestId('tab-accounts')
.should('be.visible')
.get(tradingAccountRowId)
.find('[col-id="type"]')
.should('have.text', 'General');
.find('[col-id="breakdown"]')
.should('have.text', 'Collateral breakdown');
cy.getByTestId('tab-accounts')
.should('be.visible')
.get(tradingAccountRowId)
.find('[col-id="market.tradableInstrument.instrument.name"]')
.should('have.text', '—');
cy.getByTestId('tab-accounts')
.should('be.visible')
.get(tradingAccountRowId)
.find('[col-id="balance"]')
.find('[col-id="deposited"]')
.should('have.text', '1,000.00000');
});
});

View File

@ -19,9 +19,7 @@ describe('collateral', { tags: '@smoke' }, () => {
it('renders collateral', () => {
connectVegaWallet();
cy.getByTestId(collateralTab).click();
cy.get(assetSymbolColumn).each(($symbol) => {
cy.wrap($symbol).invoke('text').should('not.be.empty');
});
cy.getByTestId(assetSymbolColumn).first().click();
cy.get(assetTypeColumn).should('contain.text', 'General');
cy.get(assetMarketName).should(
'contain.text',

View File

@ -70,7 +70,7 @@ describe('withdraw', { tags: '@smoke' }, () => {
it('can set amount using use maximum button', () => {
cy.get(assetSelectField).select('Asset 0');
cy.getByTestId(useMaximumAmount).click();
cy.get(amountField).should('have.value', '1000.00000');
cy.get(amountField).should('have.value', '1,000.00000');
});
it('triggers transaction when submitted', () => {
@ -87,7 +87,7 @@ describe('withdraw', { tags: '@smoke' }, () => {
cy.getByTestId('balance-available')
.should('contain.text', 'Balance available')
.find('td')
.should('have.text', '1000');
.should('have.text', '1,000');
cy.getByTestId('withdrawal-threshold')
.should('contain.text', 'Delayed withdrawal threshold')
.find('td')

View File

@ -1,16 +1,22 @@
import { LiquidityTable, useLiquidityProvision } from '@vegaprotocol/liquidity';
import { addDecimalsFormatNumber, t } from '@vegaprotocol/react-helpers';
import { LiquidityProvisionStatus } from '@vegaprotocol/types';
import { AsyncRenderer, Tab, Tabs } from '@vegaprotocol/ui-toolkit';
import {
AsyncRenderer,
Tab,
Tabs,
Link as UiToolkitLink,
} from '@vegaprotocol/ui-toolkit';
import { useVegaWallet } from '@vegaprotocol/wallet';
import type { AgGridReact } from 'ag-grid-react';
import { Header, HeaderStat } from '../../components/header';
import { useRouter } from 'next/router';
import { useRef, useMemo } from 'react';
import { tooltipMapping } from '@vegaprotocol/market-info';
import Link from 'next/link';
const LiquidityPage = ({ id }: { id?: string }) => {
const { query, push } = useRouter();
const { query } = useRouter();
const { keypair } = useVegaWallet();
const gridRef = useRef<AgGridReact | null>(null);
@ -69,9 +75,11 @@ const LiquidityPage = ({ id }: { id?: string }) => {
<div className="h-full grid grid-rows-[min-content_1fr]">
<Header
title={
<button onClick={() => push(`/markets/${marketId}`)}>{`${name} ${t(
'liquidity provision'
)}`}</button>
<Link href={`/markets/${marketId}`} passHref={true}>
<UiToolkitLink>
{`${name} ${t('liquidity provision')}`}
</UiToolkitLink>
</Link>
}
>
<HeaderStat

View File

@ -13,7 +13,6 @@ import AutoSizer from 'react-virtualized-auto-sizer';
import { useState } from 'react';
import type { ReactNode } from 'react';
import type { Market_market } from './__generated__/Market';
import { AccountsContainer } from '@vegaprotocol/accounts';
import { DepthChartContainer } from '@vegaprotocol/market-depth';
import { CandlesChartContainer } from '@vegaprotocol/candles-chart';
import {
@ -41,6 +40,7 @@ import {
} from '@vegaprotocol/types';
import { TradingModeTooltip } from '../../components/trading-mode-tooltip';
import { Header, HeaderStat } from '../../components/header';
import { AccountsContainer } from '../portfolio/accounts-container';
const TradingViews = {
Candles: CandlesChartContainer,

View File

@ -0,0 +1,89 @@
import { useState } from 'react';
import { Dialog } from '@vegaprotocol/ui-toolkit';
import { t } from '@vegaprotocol/react-helpers';
import { WithdrawalDialogs } from '@vegaprotocol/withdraws';
import { Web3Container } from '@vegaprotocol/web3';
import { DepositContainer } from '@vegaprotocol/deposits';
import { useAssetDetailsDialogStore } from '@vegaprotocol/assets';
import { Splash } from '@vegaprotocol/ui-toolkit';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { AccountManager } from '@vegaprotocol/accounts';
export const AccountsContainer = () => {
const { keypair } = useVegaWallet();
const [depositDialog, setDepositDialog] = useState(false);
if (!keypair) {
return (
<Splash>
<p>{t('Please connect Vega wallet')}</p>
</Splash>
);
}
return (
<Web3Container>
<div className="h-full">
<AssetAccountTable partyId={keypair.pub} />
<DepositDialog
depositDialog={depositDialog}
setDepositDialog={setDepositDialog}
/>
</div>
</Web3Container>
);
};
export const AssetAccountTable = ({ partyId }: { partyId: string }) => {
const [withdrawDialog, setWithdrawDialog] = useState(false);
const [depositDialog, setDepositDialog] = useState(false);
const { open: openAssetDetailsDialog } = useAssetDetailsDialogStore();
const [assetId, setAssetId] = useState<string>();
return (
<>
<AccountManager
partyId={partyId}
onClickAsset={(value) => {
value && openAssetDetailsDialog(value);
}}
onClickWithdraw={(assetId) => {
setWithdrawDialog(true);
setAssetId(assetId);
}}
onClickDeposit={(assetId) => {
setDepositDialog(true);
setAssetId(assetId);
}}
/>
<WithdrawalDialogs
assetId={assetId}
withdrawDialog={withdrawDialog}
setWithdrawDialog={setWithdrawDialog}
/>
<DepositDialog
assetId={assetId}
depositDialog={depositDialog}
setDepositDialog={setDepositDialog}
/>
</>
);
};
export interface DepositDialogProps {
assetId?: string;
depositDialog: boolean;
setDepositDialog: (open: boolean) => void;
}
export const DepositDialog = ({
assetId,
depositDialog,
setDepositDialog,
}: DepositDialogProps) => {
return (
<Dialog open={depositDialog} onChange={setDepositDialog}>
<h1 className="text-2xl mb-4">{t('Deposit')}</h1>
<DepositContainer assetId={assetId} />
</Dialog>
);
};

View File

@ -1,7 +1,6 @@
import { t, titlefy } from '@vegaprotocol/react-helpers';
import { PositionsContainer } from '@vegaprotocol/positions';
import { OrderListContainer } from '@vegaprotocol/orders';
import { AccountsContainer } from '@vegaprotocol/accounts';
import { ResizableGridPanel, Tab, Tabs } from '@vegaprotocol/ui-toolkit';
import { WithdrawalsContainer } from './withdrawals-container';
import { FillsContainer } from '@vegaprotocol/fills';
@ -12,6 +11,7 @@ import { DepositsContainer } from './deposits-container';
import { ResizableGrid } from '@vegaprotocol/ui-toolkit';
import { LayoutPriority } from 'allotment';
import { useGlobalStore } from '../../stores';
import { AccountsContainer } from './accounts-container';
const Portfolio = () => {
const { update } = useGlobalStore((store) => ({

View File

@ -0,0 +1,28 @@
const rootMain = require('../../../.storybook/main');
module.exports = {
...rootMain,
core: { ...rootMain.core, builder: 'webpack5' },
stories: [
...rootMain.stories,
'../src/lib/**/*.stories.mdx',
'../src/lib/**/*.stories.@(js|jsx|ts|tsx)',
],
addons: [
...rootMain.addons,
'@nrwl/react/plugins/storybook',
'storybook-addon-themes',
],
webpackFinal: async (config, { configType }) => {
// apply any global webpack configs that might have been specified in .storybook/main.js
if (rootMain.webpackFinal) {
config = await rootMain.webpackFinal(config, { configType });
}
// add your own webpack tweaks if needed
return config;
},
};

View File

@ -0,0 +1 @@
<link rel="stylesheet" href="https://static.vega.xyz/fonts.css" />

View File

@ -0,0 +1,50 @@
import './styles.scss';
import { ThemeContext } from '@vegaprotocol/react-helpers';
import { useEffect, useState } from 'react';
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
backgrounds: { disable: true },
themes: {
default: 'dark',
list: [
{ name: 'dark', class: ['dark', 'bg-black'], color: '#000' },
{ name: 'light', class: '', color: '#FFF' },
],
},
};
export const decorators = [
(Story, context) => {
// storybook-addon-themes doesn't seem to provide the current selected
// theme in context, we need to provide it in JS as some components
// rely on it for rendering
const [theme, setTheme] = useState(context.parameters.themes.default);
useEffect(() => {
const observer = new MutationObserver((mutationList) => {
if (mutationList.length) {
const body = mutationList[0].target;
if (body.classList.contains('dark')) {
setTheme('dark');
} else {
setTheme('light');
}
}
});
observer.observe(document.body, { attributes: true });
return () => {
observer.disconnect();
};
}, []);
return (
<div style={{ width: '100%', height: 500 }}>
<ThemeContext.Provider value={theme}>
<Story />
</ThemeContext.Provider>
</div>
);
},
];

View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -0,0 +1,20 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"emitDecoratorMetadata": true,
"outDir": ""
},
"files": [
"../../../node_modules/@nrwl/react/typings/styled-jsx.d.ts",
"../../../node_modules/@nrwl/react/typings/cssmodule.d.ts",
"../../../node_modules/@nrwl/react/typings/image.d.ts"
],
"exclude": [
"../**/*.spec.ts",
"../**/*.spec.js",
"../**/*.spec.tsx",
"../**/*.spec.jsx",
"jest.config.ts"
],
"include": ["../src/**/*", "*.js"]
}

View File

@ -38,6 +38,37 @@
"jestConfig": "libs/accounts/jest.config.ts",
"passWithNoTests": true
}
},
"storybook": {
"executor": "@nrwl/storybook:storybook",
"options": {
"uiFramework": "@storybook/react",
"port": 4400,
"config": {
"configFolder": "libs/accounts/.storybook"
}
},
"configurations": {
"ci": {
"quiet": true
}
}
},
"build-storybook": {
"executor": "@nrwl/storybook:build",
"outputs": ["{options.outputPath}"],
"options": {
"uiFramework": "@storybook/react",
"outputPath": "dist/storybook/accounts",
"config": {
"configFolder": "libs/accounts/.storybook"
}
},
"configurations": {
"ci": {
"quiet": true
}
}
}
}
}

View File

@ -0,0 +1,111 @@
import { Schema as Types } from '@vegaprotocol/types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type AccountFieldsFragment = { __typename?: 'Account', type: Types.AccountType, balance: string, market?: { __typename?: 'Market', id: string, tradableInstrument: { __typename?: 'TradableInstrument', instrument: { __typename?: 'Instrument', name: string } } } | null, asset: { __typename?: 'Asset', id: string, symbol: string, decimals: number } };
export type AccountsQueryVariables = Types.Exact<{
partyId: Types.Scalars['ID'];
}>;
export type AccountsQuery = { __typename?: 'Query', party?: { __typename?: 'Party', id: string, accounts?: Array<{ __typename?: 'Account', type: Types.AccountType, balance: string, market?: { __typename?: 'Market', id: string, tradableInstrument: { __typename?: 'TradableInstrument', instrument: { __typename?: 'Instrument', name: string } } } | null, asset: { __typename?: 'Asset', id: string, symbol: string, decimals: number } }> | null } | null };
export type AccountEventsSubscriptionVariables = Types.Exact<{
partyId: Types.Scalars['ID'];
}>;
export type AccountEventsSubscription = { __typename?: 'Subscription', accounts: Array<{ __typename?: 'AccountUpdate', type: Types.AccountType, balance: string, assetId: string, marketId?: string | null }> };
export const AccountFieldsFragmentDoc = gql`
fragment AccountFields on Account {
type
balance
market {
id
tradableInstrument {
instrument {
name
}
}
}
asset {
id
symbol
decimals
}
}
`;
export const AccountsDocument = gql`
query Accounts($partyId: ID!) {
party(id: $partyId) {
id
accounts {
...AccountFields
}
}
}
${AccountFieldsFragmentDoc}`;
/**
* __useAccountsQuery__
*
* To run a query within a React component, call `useAccountsQuery` and pass it any options that fit your needs.
* When your component renders, `useAccountsQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useAccountsQuery({
* variables: {
* partyId: // value for 'partyId'
* },
* });
*/
export function useAccountsQuery(baseOptions: Apollo.QueryHookOptions<AccountsQuery, AccountsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<AccountsQuery, AccountsQueryVariables>(AccountsDocument, options);
}
export function useAccountsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<AccountsQuery, AccountsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<AccountsQuery, AccountsQueryVariables>(AccountsDocument, options);
}
export type AccountsQueryHookResult = ReturnType<typeof useAccountsQuery>;
export type AccountsLazyQueryHookResult = ReturnType<typeof useAccountsLazyQuery>;
export type AccountsQueryResult = Apollo.QueryResult<AccountsQuery, AccountsQueryVariables>;
export const AccountEventsDocument = gql`
subscription AccountEvents($partyId: ID!) {
accounts(partyId: $partyId) {
type
balance
assetId
marketId
}
}
`;
/**
* __useAccountEventsSubscription__
*
* To run a query within a React component, call `useAccountEventsSubscription` and pass it any options that fit your needs.
* When your component renders, `useAccountEventsSubscription` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the subscription, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useAccountEventsSubscription({
* variables: {
* partyId: // value for 'partyId'
* },
* });
*/
export function useAccountEventsSubscription(baseOptions: Apollo.SubscriptionHookOptions<AccountEventsSubscription, AccountEventsSubscriptionVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useSubscription<AccountEventsSubscription, AccountEventsSubscriptionVariables>(AccountEventsDocument, options);
}
export type AccountEventsSubscriptionHookResult = ReturnType<typeof useAccountEventsSubscription>;
export type AccountEventsSubscriptionResult = Apollo.SubscriptionResult<AccountEventsSubscription>;

View File

@ -108,4 +108,4 @@ export function useAccountEventsSubscription(baseOptions: Apollo.SubscriptionHoo
return Apollo.useSubscription<AccountEventsSubscription, AccountEventsSubscriptionVariables>(AccountEventsDocument, options);
}
export type AccountEventsSubscriptionHookResult = ReturnType<typeof useAccountEventsSubscription>;
export type AccountEventsSubscriptionResult = Apollo.SubscriptionResult<AccountEventsSubscription>;
export type AccountEventsSubscriptionResult = Apollo.SubscriptionResult<AccountEventsSubscription>;

View File

@ -1,18 +0,0 @@
import { t } from '@vegaprotocol/react-helpers';
import { Splash } from '@vegaprotocol/ui-toolkit';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { AccountsManager } from './accounts-manager';
export const AccountsContainer = () => {
const { keypair } = useVegaWallet();
if (!keypair) {
return (
<Splash>
<p>{t('Please connect Vega wallet')}</p>
</Splash>
);
}
return <AccountsManager partyId={keypair.pub} />;
};

View File

@ -0,0 +1,243 @@
import { AccountType } from '@vegaprotocol/types';
import type { AccountFields } from './accounts-data-provider';
import { getAccountData } from './accounts-data-provider';
import type { AccountFieldsFragment } from './__generated___/Accounts';
describe('getAccountData', () => {
it('should return the correct aggregated data', () => {
const data = getAccountData(accounts);
expect(data).toEqual(accountResult);
});
});
const accounts: AccountFieldsFragment[] = [
{
__typename: 'Account',
type: AccountType.ACCOUNT_TYPE_MARGIN,
balance: '2781397',
market: {
__typename: 'Market',
id: 'd90fd7c746286625504d7a3f5f420a280875acd3cd611676d9e70acc675f4540',
tradableInstrument: {
__typename: 'TradableInstrument',
instrument: {
__typename: 'Instrument',
name: 'Tesla Quarterly (30 Jun 2022)',
},
},
},
asset: {
__typename: 'Asset',
id: '8b52d4a3a4b0ffe733cddbc2b67be273816cfeb6ca4c8b339bac03ffba08e4e4',
symbol: 'tEURO',
decimals: 5,
},
},
{
__typename: 'Account',
type: AccountType.ACCOUNT_TYPE_MARGIN,
balance: '406922',
market: {
__typename: 'Market',
id: '9c1ee71959e566c484fcea796513137f8a02219cca2e973b7ae72dc29d099581',
tradableInstrument: {
__typename: 'TradableInstrument',
instrument: {
__typename: 'Instrument',
name: 'AAVEDAI Monthly (30 Jun 2022)',
},
},
},
asset: {
__typename: 'Asset',
id: '6d9d35f657589e40ddfb448b7ad4a7463b66efb307527fedd2aa7df1bbd5ea61',
symbol: 'tDAI',
decimals: 5,
},
},
{
__typename: 'Account',
type: AccountType.ACCOUNT_TYPE_GENERAL,
balance: '10001000000',
market: null,
asset: {
__typename: 'Asset',
id: 'XYZalpha',
symbol: 'XYZalpha',
decimals: 5,
},
},
{
__typename: 'Account',
type: AccountType.ACCOUNT_TYPE_GENERAL,
balance: '1990351587',
market: null,
asset: {
__typename: 'Asset',
id: '993ed98f4f770d91a796faab1738551193ba45c62341d20597df70fea6704ede',
symbol: 'tUSDC',
decimals: 5,
},
},
{
__typename: 'Account',
type: AccountType.ACCOUNT_TYPE_GENERAL,
balance: '2996218603',
market: null,
asset: {
__typename: 'Asset',
id: '8b52d4a3a4b0ffe733cddbc2b67be273816cfeb6ca4c8b339bac03ffba08e4e4',
symbol: 'tEURO',
decimals: 5,
},
},
{
__typename: 'Account',
type: AccountType.ACCOUNT_TYPE_GENERAL,
balance: '5000593078',
market: null,
asset: {
__typename: 'Asset',
id: '6d9d35f657589e40ddfb448b7ad4a7463b66efb307527fedd2aa7df1bbd5ea61',
symbol: 'tDAI',
decimals: 5,
},
},
{
__typename: 'Account',
type: AccountType.ACCOUNT_TYPE_GENERAL,
balance: '4000000000000001006031',
market: null,
asset: {
__typename: 'Asset',
id: '5cfa87844724df6069b94e4c8a6f03af21907d7bc251593d08e4251043ee9f7c',
symbol: 'tBTC',
decimals: 5,
},
},
];
const accountResult: AccountFields[] = [
{
asset: {
__typename: 'Asset',
decimals: 5,
id: '5cfa87844724df6069b94e4c8a6f03af21907d7bc251593d08e4251043ee9f7c',
symbol: 'tBTC',
},
available: '4000000000000001006031',
balance: '4000000000000001006031',
breakdown: [],
deposited: '4000000000000001006031',
type: AccountType.ACCOUNT_TYPE_GENERAL,
used: '0',
},
{
asset: {
__typename: 'Asset',
decimals: 5,
id: '6d9d35f657589e40ddfb448b7ad4a7463b66efb307527fedd2aa7df1bbd5ea61',
symbol: 'tDAI',
},
available: '5000593078',
balance: '5000593078',
breakdown: [
{
__typename: 'Account',
asset: {
__typename: 'Asset',
decimals: 5,
id: '6d9d35f657589e40ddfb448b7ad4a7463b66efb307527fedd2aa7df1bbd5ea61',
symbol: 'tDAI',
},
available: '5000593078',
balance: '406922',
deposited: '5001000000',
market: {
__typename: 'Market',
id: '9c1ee71959e566c484fcea796513137f8a02219cca2e973b7ae72dc29d099581',
tradableInstrument: {
__typename: 'TradableInstrument',
instrument: {
__typename: 'Instrument',
name: 'AAVEDAI Monthly (30 Jun 2022)',
},
},
},
type: AccountType.ACCOUNT_TYPE_MARGIN,
used: '406922',
},
],
deposited: '5001000000',
type: AccountType.ACCOUNT_TYPE_GENERAL,
used: '406922',
},
{
asset: {
__typename: 'Asset',
decimals: 5,
id: '8b52d4a3a4b0ffe733cddbc2b67be273816cfeb6ca4c8b339bac03ffba08e4e4',
symbol: 'tEURO',
},
available: '2996218603',
balance: '2996218603',
breakdown: [
{
__typename: 'Account',
asset: {
__typename: 'Asset',
decimals: 5,
id: '8b52d4a3a4b0ffe733cddbc2b67be273816cfeb6ca4c8b339bac03ffba08e4e4',
symbol: 'tEURO',
},
available: '2996218603',
balance: '2781397',
deposited: '2999000000',
market: {
__typename: 'Market',
id: 'd90fd7c746286625504d7a3f5f420a280875acd3cd611676d9e70acc675f4540',
tradableInstrument: {
__typename: 'TradableInstrument',
instrument: {
__typename: 'Instrument',
name: 'Tesla Quarterly (30 Jun 2022)',
},
},
},
type: AccountType.ACCOUNT_TYPE_MARGIN,
used: '2781397',
},
],
deposited: '2999000000',
type: AccountType.ACCOUNT_TYPE_GENERAL,
used: '2781397',
},
{
asset: {
__typename: 'Asset',
decimals: 5,
id: '993ed98f4f770d91a796faab1738551193ba45c62341d20597df70fea6704ede',
symbol: 'tUSDC',
},
available: '1990351587',
balance: '1990351587',
breakdown: [],
deposited: '1990351587',
type: AccountType.ACCOUNT_TYPE_GENERAL,
used: '0',
},
{
asset: {
__typename: 'Asset',
decimals: 5,
id: 'XYZalpha',
symbol: 'XYZalpha',
},
available: '10001000000',
balance: '10001000000',
breakdown: [],
deposited: '10001000000',
type: AccountType.ACCOUNT_TYPE_GENERAL,
used: '0',
},
];

View File

@ -9,7 +9,11 @@ import type {
AccountsQuery,
AccountEventsSubscription,
} from './__generated___/Accounts';
import { makeDataProvider } from '@vegaprotocol/react-helpers';
import {
makeDataProvider,
makeDerivedDataProvider,
} from '@vegaprotocol/react-helpers';
import { AccountType } from '@vegaprotocol/types';
function isAccount(
account:
@ -67,3 +71,77 @@ export const accountsDataProvider = makeDataProvider<
getData,
getDelta,
});
export interface AccountFields extends AccountFieldsFragment {
available: string;
used: string;
deposited: string;
balance: string;
breakdown?: AccountFields[];
}
const USE_ACCOUNT_TYPES = [
AccountType.ACCOUNT_TYPE_MARGIN,
AccountType.ACCOUNT_TYPE_BOND,
AccountType.ACCOUNT_TYPE_FEES_INFRASTRUCTURE,
AccountType.ACCOUNT_TYPE_FEES_LIQUIDITY,
AccountType.ACCOUNT_TYPE_FEES_MAKER,
AccountType.ACCOUNT_TYPE_PENDING_TRANSFERS,
];
const getAssetIds = (data: AccountFieldsFragment[]) =>
Array.from(new Set(data.map((a) => a.asset.id))).sort();
const getTotalBalance = (accounts: AccountFieldsFragment[]) =>
accounts.reduce((acc, a) => acc + BigInt(a.balance), BigInt(0));
export const getAccountData = (
data: AccountFieldsFragment[]
): AccountFields[] => {
return getAssetIds(data).map((assetId) => {
const accounts = data.filter((a) => a.asset.id === assetId);
return accounts && getAssetAccountAggregation(accounts, assetId);
});
};
const getAssetAccountAggregation = (
accountList: AccountFieldsFragment[],
assetId: string
): AccountFields => {
const accounts = accountList.filter((a) => a.asset.id === assetId);
const available = getTotalBalance(
accounts.filter((a) => a.type === AccountType.ACCOUNT_TYPE_GENERAL)
);
const used = getTotalBalance(
accounts.filter((a) => USE_ACCOUNT_TYPES.includes(a.type))
);
const balanceAccount: AccountFields = {
asset: accounts[0].asset,
balance: available.toString(),
type: AccountType.ACCOUNT_TYPE_GENERAL,
available: available.toString(),
used: used.toString(),
deposited: (available + used).toString(),
};
const breakdown = accounts
.filter((a) => USE_ACCOUNT_TYPES.includes(a.type))
.map((a) => ({
...a,
asset: accounts[0].asset,
deposited: balanceAccount.deposited,
available: balanceAccount.available,
used: a.balance,
}));
return { ...balanceAccount, breakdown };
};
export const aggregatedAccountsDataProvider = makeDerivedDataProvider<
AccountFields[],
never
>(
[accountsDataProvider],
(parts) => parts[0] && getAccountData(parts[0] as AccountFieldsFragment[])
);

View File

@ -1,79 +1,75 @@
import React, { useRef, useMemo } from 'react';
import { produce } from 'immer';
import merge from 'lodash/merge';
import type { Asset } from '@vegaprotocol/react-helpers';
import { useDataProvider } from '@vegaprotocol/react-helpers';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { useDataProvider, addSummaryRows } from '@vegaprotocol/react-helpers';
import type {
AccountFieldsFragment,
AccountEventsSubscription,
} from './__generated___/Accounts';
import type { AgGridReact } from 'ag-grid-react';
import {
AccountsTable,
getGroupId,
getGroupSummaryRow,
} from './accounts-table';
import { accountsDataProvider, getId } from './accounts-data-provider';
import { useRef, useMemo, useCallback } from 'react';
import type { AccountFields } from './accounts-data-provider';
import { aggregatedAccountsDataProvider } from './accounts-data-provider';
import type { GetRowsParams } from './accounts-table';
import { AccountTable } from './accounts-table';
interface AccountsManagerProps {
interface AccountManagerProps {
partyId: string;
onClickAsset: (asset?: string | Asset) => void;
onClickWithdraw?: (assetId?: string) => void;
onClickDeposit?: (assetId?: string) => void;
}
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 AccountManager = ({
onClickAsset,
onClickWithdraw,
onClickDeposit,
partyId,
}: AccountManagerProps) => {
const gridRef = useRef<AgGridReact | null>(null);
const dataRef = useRef<AccountFields[] | null>(null);
const variables = useMemo(() => ({ partyId }), [partyId]);
const update = useMemo(() => accountsManagerUpdate(gridRef), []);
const { data, error, loading } = useDataProvider<
AccountFieldsFragment[],
AccountEventsSubscription['accounts']
>({ dataProvider: accountsDataProvider, update, variables });
const update = useCallback(
({ data }: { data: AccountFields[] | null }) => {
if (!gridRef.current?.api) {
return false;
}
if (dataRef.current?.length) {
dataRef.current = data;
gridRef.current.api.refreshInfiniteCache();
return true;
}
return false;
},
[gridRef]
);
const { data, error, loading } = useDataProvider<AccountFields[], never>({
dataProvider: aggregatedAccountsDataProvider,
update,
variables,
});
dataRef.current = 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);
};
return (
<AsyncRenderer loading={loading} error={error} data={data}>
<AccountsTable ref={gridRef} data={data} />
{data && (
<AccountTable
rowModelType={data?.length ? 'infinite' : 'clientSide'}
rowData={data?.length ? undefined : []}
ref={gridRef}
datasource={{ getRows }}
onClickAsset={onClickAsset}
onClickDeposit={onClickDeposit}
onClickWithdraw={onClickWithdraw}
/>
)}
</AsyncRenderer>
);
};
export default AccountManager;

View File

@ -1,9 +1,10 @@
import AccountsTable from './accounts-table';
import { act, render, screen, waitFor } from '@testing-library/react';
import type { AccountFieldsFragment } from './__generated___/Accounts';
import { act, render, screen } from '@testing-library/react';
import { Schema as Types } from '@vegaprotocol/types';
import type { AccountFields } from './accounts-data-provider';
import { getAccountData } from './accounts-data-provider';
import { AccountTable } from './accounts-table';
const singleRow: AccountFieldsFragment = {
const singleRow: AccountFields = {
__typename: 'Account',
type: Types.AccountType.ACCOUNT_TYPE_MARGIN,
balance: '125600000',
@ -24,47 +25,101 @@ const singleRow: AccountFieldsFragment = {
symbol: 'tBTC',
decimals: 5,
},
available: '125600000',
used: '125600000',
deposited: '125600000',
};
const singleRowData = [singleRow];
describe('AccountsTable', () => {
it('should render successfully', async () => {
await act(async () => {
const { baseElement } = render(<AccountsTable data={[]} />);
expect(baseElement).toBeTruthy();
render(<AccountTable rowData={[]} onClickAsset={() => null} />);
});
const headers = await screen.getAllByRole('columnheader');
expect(headers).toHaveLength(6);
expect(
headers?.map((h) => h.querySelector('[ref="eText"]')?.textContent?.trim())
).toEqual(['Asset', 'Deposited', 'Used', '', '', '']);
});
it('should render correct columns', async () => {
act(async () => {
render(<AccountsTable data={singleRowData} />);
await waitFor(async () => {
const headers = await screen.getAllByRole('columnheader');
expect(headers).toHaveLength(4);
expect(
headers.map((h) =>
h.querySelector('[ref="eText"]')?.textContent?.trim()
)
).toEqual(['Asset', 'Type', 'Market', 'Balance']);
});
await act(async () => {
render(
<AccountTable rowData={singleRowData} onClickAsset={() => null} />
);
});
const headers = await screen.getAllByRole('columnheader');
expect(headers).toHaveLength(6);
expect(
headers?.map((h) => h.querySelector('[ref="eText"]')?.textContent?.trim())
).toEqual(['Asset', 'Deposited', 'Used', '', '', '']);
});
it('should apply correct formatting', async () => {
act(async () => {
render(<AccountsTable data={singleRowData} />);
await waitFor(async () => {
const cells = await screen.getAllByRole('gridcell');
const expectedValues = [
'tBTC',
singleRow.type,
'BTCUSD Monthly (30 Jun 2022)',
'1,256.00000',
];
cells.forEach((cell, i) => {
expect(cell).toHaveTextContent(expectedValues[i]);
});
});
await act(async () => {
render(
<AccountTable rowData={singleRowData} onClickAsset={() => null} />
);
});
const cells = await screen.getAllByRole('gridcell');
const expectedValues = [
'tBTC',
'1,256.00000',
'1,256.00001,256.0000',
'Collateral breakdown',
'Deposit',
'Withdraw',
];
cells.forEach((cell, i) => {
expect(cell).toHaveTextContent(expectedValues[i]);
});
});
});
it('should get correct account data', () => {
const result = getAccountData([singleRow]);
const expected = [
{
asset: {
__typename: 'Asset',
decimals: 5,
id: '5cfa87844724df6069b94e4c8a6f03af21907d7bc251593d08e4251043ee9f7c',
symbol: 'tBTC',
},
available: '0',
balance: '0',
breakdown: [
{
__typename: 'Account',
asset: {
__typename: 'Asset',
decimals: 5,
id: '5cfa87844724df6069b94e4c8a6f03af21907d7bc251593d08e4251043ee9f7c',
symbol: 'tBTC',
},
available: '0',
balance: '125600000',
deposited: '125600000',
market: {
__typename: 'Market',
id: '10cd0a793ad2887b340940337fa6d97a212e0e517fe8e9eab2b5ef3a38633f35',
tradableInstrument: {
__typename: 'TradableInstrument',
instrument: {
__typename: 'Instrument',
name: 'BTCUSD Monthly (30 Jun 2022)',
},
},
},
type: 'ACCOUNT_TYPE_MARGIN',
used: '125600000',
},
],
deposited: '125600000',
type: 'ACCOUNT_TYPE_GENERAL',
used: '125600000',
},
];
expect(result).toEqual(expected);
});

View File

@ -0,0 +1,123 @@
import type { Story, Meta } from '@storybook/react';
import { AccountType } from '@vegaprotocol/types';
import { getAccountData } from './accounts-data-provider';
import { AccountTable } from './accounts-table';
export default {
component: AccountTable,
title: 'AccountsTable',
} as Meta;
const Template: Story = (args) => (
<AccountTable data={args.data} onClickAsset={() => null} />
);
export const Primary = Template.bind({});
Primary.args = {
data: getAccountData([
{
__typename: 'Account',
type: AccountType.ACCOUNT_TYPE_MARGIN,
balance: '2781397',
market: {
__typename: 'Market',
id: 'd90fd7c746286625504d7a3f5f420a280875acd3cd611676d9e70acc675f4540',
tradableInstrument: {
__typename: 'TradableInstrument',
instrument: {
__typename: 'Instrument',
name: 'Tesla Quarterly (30 Jun 2022)',
},
},
},
asset: {
__typename: 'Asset',
id: '8b52d4a3a4b0ffe733cddbc2b67be273816cfeb6ca4c8b339bac03ffba08e4e4',
symbol: 'tEURO',
decimals: 5,
},
},
{
__typename: 'Account',
type: AccountType.ACCOUNT_TYPE_MARGIN,
balance: '406922',
market: {
__typename: 'Market',
id: '9c1ee71959e566c484fcea796513137f8a02219cca2e973b7ae72dc29d099581',
tradableInstrument: {
__typename: 'TradableInstrument',
instrument: {
__typename: 'Instrument',
name: 'AAVEDAI Monthly (30 Jun 2022)',
},
},
},
asset: {
__typename: 'Asset',
id: '6d9d35f657589e40ddfb448b7ad4a7463b66efb307527fedd2aa7df1bbd5ea61',
symbol: 'tDAI',
decimals: 5,
},
},
{
__typename: 'Account',
type: AccountType.ACCOUNT_TYPE_GENERAL,
balance: '10001000000',
market: null,
asset: {
__typename: 'Asset',
id: 'XYZalpha',
symbol: 'XYZalpha',
decimals: 5,
},
},
{
__typename: 'Account',
type: AccountType.ACCOUNT_TYPE_GENERAL,
balance: '1990351587',
market: null,
asset: {
__typename: 'Asset',
id: '993ed98f4f770d91a796faab1738551193ba45c62341d20597df70fea6704ede',
symbol: 'tUSDC',
decimals: 5,
},
},
{
__typename: 'Account',
type: AccountType.ACCOUNT_TYPE_GENERAL,
balance: '2996218603',
market: null,
asset: {
__typename: 'Asset',
id: '8b52d4a3a4b0ffe733cddbc2b67be273816cfeb6ca4c8b339bac03ffba08e4e4',
symbol: 'tEURO',
decimals: 5,
},
},
{
__typename: 'Account',
type: AccountType.ACCOUNT_TYPE_GENERAL,
balance: '5000593078',
market: null,
asset: {
__typename: 'Asset',
id: '6d9d35f657589e40ddfb448b7ad4a7463b66efb307527fedd2aa7df1bbd5ea61',
symbol: 'tDAI',
decimals: 5,
},
},
{
__typename: 'Account',
type: AccountType.ACCOUNT_TYPE_GENERAL,
balance: '4000000000000001006031',
market: null,
asset: {
__typename: 'Asset',
id: '5cfa87844724df6069b94e4c8a6f03af21907d7bc251593d08e4251043ee9f7c',
symbol: 'tBTC',
decimals: 5,
},
},
]),
};

View File

@ -1,172 +1,204 @@
import { forwardRef } from 'react';
import { forwardRef, useState } from 'react';
import type { ValueFormatterParams } from 'ag-grid-community';
import type { Asset } from '@vegaprotocol/react-helpers';
import { addDecimalsFormatNumber, t } from '@vegaprotocol/react-helpers';
import type {
ColumnApi,
GroupCellRendererParams,
ValueFormatterParams,
} from 'ag-grid-community';
ValueProps,
VegaICellRendererParams,
} from '@vegaprotocol/ui-toolkit';
import {
PriceCell,
addDecimalsFormatNumber,
t,
addSummaryRows,
} from '@vegaprotocol/react-helpers';
import type { SummaryRow } from '@vegaprotocol/react-helpers';
Button,
ButtonLink,
Dialog,
Intent,
progressBarCellRendererSelector,
} from '@vegaprotocol/ui-toolkit';
import { TooltipCellComponent } from '@vegaprotocol/ui-toolkit';
import { AgGridDynamic as AgGrid } from '@vegaprotocol/ui-toolkit';
import { AgGridColumn } from 'ag-grid-react';
import type { AgGridReact } from 'ag-grid-react';
import type { AccountFieldsFragment } from './__generated___/Accounts';
import { getId } from './accounts-data-provider';
import { useAssetDetailsDialogStore } from '@vegaprotocol/assets';
import type { AccountType } from '@vegaprotocol/types';
import { AccountTypeMapping } from '@vegaprotocol/types';
import type { IDatasource, IGetRowsParams } from 'ag-grid-community';
import type { AgGridReact, AgGridReactProps } from 'ag-grid-react';
import type { VegaValueFormatterParams } from '@vegaprotocol/ui-toolkit';
import BreakdownTable from './breakdown-table';
import type { AccountFields } from './accounts-data-provider';
interface AccountsTableProps {
data: AccountFieldsFragment[] | null;
export const progressBarValueFormatter = ({
data,
node,
}: ValueFormatterParams): ValueProps['valueFormatted'] | undefined => {
if (!data || node?.rowPinned) {
return undefined;
}
const min = BigInt(data.used);
const mid = BigInt(data.available);
const max = BigInt(data.deposited);
const range = max > min ? max : min;
return {
low: addDecimalsFormatNumber(min.toString(), data.asset.decimals, 4),
high: addDecimalsFormatNumber(mid.toString(), data.asset.decimals, 4),
value: range ? Number((min * BigInt(100)) / range) : 0,
intent: Intent.Warning,
};
};
export const progressBarHeaderComponentParams = {
template:
'<div class="ag-cell-label-container" role="presentation">' +
` <span>${t('Available')}</span>` +
' <span ref="eText" class="ag-header-cell-text"></span>' +
'</div>',
};
export interface GetRowsParams extends Omit<IGetRowsParams, 'successCallback'> {
successCallback(rowsThisBlock: AccountFields[], lastRow?: number): void;
}
interface AccountsTableValueFormatterParams extends ValueFormatterParams {
data: AccountFieldsFragment;
export interface Datasource extends IDatasource {
getRows(params: GetRowsParams): void;
}
export const getGroupId = (
data: AccountFieldsFragment & SummaryRow,
columnApi: ColumnApi
) => {
if (data.__summaryRow) {
return null;
}
const sortColumnId = columnApi.getColumnState().find((c) => c.sort)?.colId;
switch (sortColumnId) {
case 'asset.symbol':
return data.asset.id;
}
return undefined;
};
export interface AccountTableProps extends AgGridReactProps {
rowData?: AccountFields[] | null;
datasource?: Datasource;
onClickAsset: (asset: string | Asset) => void;
onClickWithdraw?: (assetId: string) => void;
onClickDeposit?: (assetId: string) => void;
}
export const getGroupSummaryRow = (
data: AccountFieldsFragment[],
columnApi: ColumnApi
): Partial<AccountFieldsFragment & SummaryRow> | null => {
if (!data.length) {
return null;
}
const sortColumnId = columnApi.getColumnState().find((c) => c.sort)?.colId;
switch (sortColumnId) {
case 'asset.symbol':
return {
__summaryRow: true,
balance: data
.reduce((a, i) => a + (parseFloat(i.balance) || 0), 0)
.toString(),
asset: data[0].asset,
};
}
return null;
};
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;
};
export const AccountsTable = forwardRef<AgGridReact, AccountsTableProps>(
({ data }, ref) => {
const { open: openAssetDetailsDialog } = useAssetDetailsDialogStore();
export const AccountTable = forwardRef<AgGridReact, AccountTableProps>(
({ onClickAsset, onClickWithdraw, onClickDeposit, ...props }, ref) => {
const [openBreakdown, setOpenBreakdown] = useState(false);
const [breakdown, setBreakdown] = useState<AccountFields[] | null>(null);
return (
<AgGrid
style={{ width: '100%', height: '100%' }}
overlayNoRowsTemplate={t('No accounts')}
rowData={data}
getRowId={({ data }) => getId(data)}
ref={ref}
defaultColDef={{
flex: 1,
resizable: true,
}}
components={{ PriceCell }}
onSortChanged={({ api, columnApi }) => {
addSummaryRows(api, columnApi, getGroupId, getGroupSummaryRow);
}}
onGridReady={(event) => {
event.columnApi.applyColumnState({
state: [
{
colId: 'asset.symbol',
sort: 'asc',
},
],
});
}}
>
<AgGridColumn
headerName={t('Asset')}
field="asset.symbol"
sortable
sortingOrder={['asc', 'desc']}
comparator={comparator}
cellRenderer={({ value }: GroupCellRendererParams) =>
value && value.length > 0 ? (
<button
className="hover:underline"
onClick={(e) => {
openAssetDetailsDialog(value, e.target as HTMLElement);
}}
>
{value}
</button>
) : (
''
)
}
/>
<AgGridColumn
headerName={t('Type')}
field="type"
valueFormatter={({ value }: ValueFormatterParams) =>
value ? AccountTypeMapping[value as AccountType] : '-'
}
/>
<AgGridColumn
headerName={t('Market')}
field="market.tradableInstrument.instrument.name"
valueFormatter="value || '—'"
/>
<AgGridColumn
headerName={t('Balance')}
field="balance"
cellRenderer="PriceCell"
type="rightAligned"
valueFormatter={({
value,
data,
}: AccountsTableValueFormatterParams) =>
addDecimalsFormatNumber(value, data.asset.decimals)
}
/>
</AgGrid>
<>
<AgGrid
style={{ width: '100%', height: '100%' }}
overlayNoRowsTemplate={t('No accounts')}
getRowId={({ data }: { data: AccountFields }) => data.asset.id}
ref={ref}
rowHeight={34}
tooltipShowDelay={500}
defaultColDef={{
flex: 1,
resizable: true,
tooltipComponent: TooltipCellComponent,
sortable: true,
}}
{...props}
>
<AgGridColumn
headerName={t('Asset')}
field="asset.symbol"
headerTooltip={t(
'Asset is the collateral that is deposited into the Vega protocol.'
)}
cellRenderer={({
value,
}: VegaICellRendererParams<AccountFields, 'asset.symbol'>) => {
return (
<ButtonLink
data-testid="deposit"
onClick={() => {
onClickAsset(value);
}}
>
{value}
</ButtonLink>
);
}}
maxWidth={300}
/>
<AgGridColumn
headerName={t('Deposited')}
field="deposited"
valueFormatter={({
value,
data,
}: VegaValueFormatterParams<AccountFields, 'deposited'>) =>
data &&
data.asset &&
addDecimalsFormatNumber(value, data.asset.decimals)
}
maxWidth={300}
/>
<AgGridColumn
headerName={t('Used')}
field="used"
flex={2}
maxWidth={500}
headerComponentParams={progressBarHeaderComponentParams}
cellRendererSelector={progressBarCellRendererSelector}
valueFormatter={progressBarValueFormatter}
/>
<AgGridColumn
headerName=""
field="breakdown"
maxWidth={150}
cellRenderer={({
value,
}: VegaICellRendererParams<AccountFields, 'breakdown'>) => {
return (
<ButtonLink
data-testid="breakdown"
onClick={() => {
setOpenBreakdown(!openBreakdown);
setBreakdown(value || null);
}}
>
{t('Collateral breakdown')}
</ButtonLink>
);
}}
/>
<AgGridColumn
headerName=""
field="deposit"
maxWidth={200}
cellRenderer={({
value,
data,
}: VegaICellRendererParams<AccountFields, 'asset'>) => {
return (
<Button
size="xs"
data-testid="deposit"
onClick={() => {
onClickDeposit && onClickDeposit(data.asset.id);
}}
>
{t('Deposit')}
</Button>
);
}}
/>
<AgGridColumn
headerName=""
field="withdraw"
maxWidth={200}
cellRenderer={({
data,
}: VegaICellRendererParams<AccountFields, 'asset'>) => {
return (
<Button
size="xs"
data-testid="withdraw"
onClick={() =>
onClickWithdraw && onClickWithdraw(data.asset.id)
}
>
{t('Withdraw')}
</Button>
);
}}
/>
</AgGrid>
<Dialog size="medium" open={openBreakdown} onChange={setOpenBreakdown}>
<div className="h-[35vh] w-full m-auto flex flex-col">
<h1 className="text-xl mb-4">{t('Collateral breakdown')}</h1>
<BreakdownTable data={breakdown} domLayout="autoHeight" />
</div>
</Dialog>
</>
);
}
);
export default AccountsTable;

View File

@ -0,0 +1,114 @@
import BreakdownTable from './breakdown-table';
import { act, render, screen } from '@testing-library/react';
import { Schema as Types } from '@vegaprotocol/types';
import type { AccountFields } from './accounts-data-provider';
import { getAccountData } from './accounts-data-provider';
const singleRow: AccountFields = {
__typename: 'Account',
type: Types.AccountType.ACCOUNT_TYPE_MARGIN,
balance: '125600000',
market: {
__typename: 'Market',
tradableInstrument: {
__typename: 'TradableInstrument',
instrument: {
__typename: 'Instrument',
name: 'BTCUSD Monthly (30 Jun 2022)',
},
},
id: '10cd0a793ad2887b340940337fa6d97a212e0e517fe8e9eab2b5ef3a38633f35',
},
asset: {
__typename: 'Asset',
id: '5cfa87844724df6069b94e4c8a6f03af21907d7bc251593d08e4251043ee9f7c',
symbol: 'tBTC',
decimals: 5,
},
available: '125600000',
used: '125600000',
deposited: '125600000',
};
const singleRowData = [singleRow];
describe('BreakdownTable', () => {
it('should render successfully', async () => {
const { baseElement } = render(<BreakdownTable data={[]} />);
expect(baseElement).toBeTruthy();
});
it('should render correct columns', async () => {
await act(async () => {
render(<BreakdownTable data={singleRowData} />);
});
const headers = await screen.getAllByRole('columnheader');
expect(headers).toHaveLength(5);
expect(
headers.map((h) => h.querySelector('[ref="eText"]')?.textContent?.trim())
).toEqual(['Account type', 'Market', 'Used', 'Deposited', 'Balance']);
});
it('should apply correct formatting', async () => {
await act(async () => {
render(<BreakdownTable data={singleRowData} />);
});
const cells = await screen.getAllByRole('gridcell');
const expectedValues = [
'Margin',
'BTCUSD Monthly (30 Jun 2022)',
'1,256.00001,256.0000',
'1,256.00000',
'1,256.00000',
];
cells.forEach((cell, i) => {
expect(cell).toHaveTextContent(expectedValues[i]);
});
});
it('should get correct account data', () => {
const result = getAccountData([singleRow]);
const expected = [
{
asset: {
__typename: 'Asset',
decimals: 5,
id: '5cfa87844724df6069b94e4c8a6f03af21907d7bc251593d08e4251043ee9f7c',
symbol: 'tBTC',
},
available: '0',
balance: '0',
breakdown: [
{
__typename: 'Account',
asset: {
__typename: 'Asset',
decimals: 5,
id: '5cfa87844724df6069b94e4c8a6f03af21907d7bc251593d08e4251043ee9f7c',
symbol: 'tBTC',
},
available: '0',
balance: '125600000',
deposited: '125600000',
market: {
__typename: 'Market',
id: '10cd0a793ad2887b340940337fa6d97a212e0e517fe8e9eab2b5ef3a38633f35',
tradableInstrument: {
__typename: 'TradableInstrument',
instrument: {
__typename: 'Instrument',
name: 'BTCUSD Monthly (30 Jun 2022)',
},
},
},
type: 'ACCOUNT_TYPE_MARGIN',
used: '125600000',
},
],
deposited: '125600000',
type: 'ACCOUNT_TYPE_GENERAL',
used: '125600000',
},
];
expect(result).toEqual(expected);
});
});

View File

@ -0,0 +1,111 @@
import { forwardRef } from 'react';
import {
addDecimalsFormatNumber,
PriceCell,
t,
} from '@vegaprotocol/react-helpers';
import type { VegaValueFormatterParams } from '@vegaprotocol/ui-toolkit';
import {
AgGridDynamic as AgGrid,
progressBarCellRendererSelector,
} from '@vegaprotocol/ui-toolkit';
import { AgGridColumn } from 'ag-grid-react';
import type { AgGridReact, AgGridReactProps } from 'ag-grid-react';
import type { AccountFields } from './accounts-data-provider';
import { AccountTypeMapping } from '@vegaprotocol/types';
import {
progressBarHeaderComponentParams,
progressBarValueFormatter,
} from './accounts-table';
interface BreakdownTableProps extends AgGridReactProps {
data: AccountFields[] | null;
}
const BreakdownTable = forwardRef<AgGridReact, BreakdownTableProps>(
({ data }, ref) => {
return (
<AgGrid
style={{ width: '100%', height: '100%' }}
overlayNoRowsTemplate={t('Collateral not used')}
rowData={data}
getRowId={({ data }: { data: AccountFields }) =>
`${data.asset.id}-${data.type}-${data.market?.id}`
}
ref={ref}
rowHeight={34}
components={{ PriceCell }}
tooltipShowDelay={500}
defaultColDef={{
flex: 1,
resizable: true,
}}
>
<AgGridColumn
headerName={t('Account type')}
field="type"
maxWidth={300}
valueFormatter={({
value,
}: VegaValueFormatterParams<AccountFields, 'type'>) =>
AccountTypeMapping[value]
}
/>
<AgGridColumn
headerName={t('Market')}
field="market.tradableInstrument.instrument.name"
valueFormatter={({
value,
}: VegaValueFormatterParams<
AccountFields,
'market.tradableInstrument.instrument.name'
>) => {
if (!value) return '-';
return value;
}}
minWidth={200}
/>
<AgGridColumn
headerName={t('Used')}
field="used"
flex={2}
maxWidth={500}
headerComponentParams={progressBarHeaderComponentParams}
cellRendererSelector={progressBarCellRendererSelector}
valueFormatter={progressBarValueFormatter}
/>
<AgGridColumn
headerName={t('Deposited')}
field="deposited"
valueFormatter={({
value,
data,
}: VegaValueFormatterParams<AccountFields, 'deposited'>) => {
if (data && data.asset) {
return addDecimalsFormatNumber(value, data.asset.decimals);
}
return '-';
}}
maxWidth={300}
/>
<AgGridColumn
headerName={t('Balance')}
field="balance"
valueFormatter={({
value,
data,
}: VegaValueFormatterParams<AccountFields, 'balance'>) => {
if (data && data.asset) {
return addDecimalsFormatNumber(value, data.asset.decimals);
}
return '-';
}}
maxWidth={300}
/>
</AgGrid>
);
}
);
export default BreakdownTable;

View File

@ -1,6 +1,6 @@
export * from './__generated___/Accounts';
export * from './accounts-container';
export * from './__generated__/Accounts';
export * from './accounts-data-provider';
export * from './accounts-manager';
export * from './accounts-table';
export * from './asset-balance';
export * from './accounts-manager';
export * from './breakdown-table';

View File

@ -20,6 +20,9 @@
},
{
"path": "./tsconfig.spec.json"
},
{
"path": "./.storybook/tsconfig.json"
}
]
}

View File

@ -1,3 +1 @@
export * from './lib/deposit-manager';
export * from './lib/use-deposits';
export * from './lib/deposits-table';
export * from './lib';

View File

@ -0,0 +1,72 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { AssetStatus } from "@vegaprotocol/types";
// ====================================================
// GraphQL query operation: DepositAsset
// ====================================================
export interface DepositAsset_assetsConnection_edges_node_source_BuiltinAsset {
__typename: "BuiltinAsset";
}
export interface DepositAsset_assetsConnection_edges_node_source_ERC20 {
__typename: "ERC20";
/**
* The address of the ERC20 contract
*/
contractAddress: string;
}
export type DepositAsset_assetsConnection_edges_node_source = DepositAsset_assetsConnection_edges_node_source_BuiltinAsset | DepositAsset_assetsConnection_edges_node_source_ERC20;
export interface DepositAsset_assetsConnection_edges_node {
__typename: "Asset";
/**
* The ID of the asset
*/
id: string;
/**
* The full name of the asset (e.g: Great British Pound)
*/
name: string;
/**
* The symbol of the asset (e.g: GBP)
*/
symbol: string;
/**
* The precision of the asset. Should match the decimal precision of the asset on its native chain, e.g: for ERC20 assets, it is often 18
*/
decimals: number;
/**
* The status of the asset in the Vega network
*/
status: AssetStatus;
/**
* The origin source of the asset (e.g: an ERC20 asset)
*/
source: DepositAsset_assetsConnection_edges_node_source;
}
export interface DepositAsset_assetsConnection_edges {
__typename: "AssetEdge";
node: DepositAsset_assetsConnection_edges_node;
}
export interface DepositAsset_assetsConnection {
__typename: "AssetsConnection";
/**
* The assets
*/
edges: (DepositAsset_assetsConnection_edges | null)[] | null;
}
export interface DepositAsset {
/**
* The list of all assets in use in the Vega network or the specified asset if ID is provided
*/
assetsConnection: DepositAsset_assetsConnection;
}

View File

@ -0,0 +1,2 @@
export * from './Deposit';
export * from './DepositAsset';

View File

@ -0,0 +1,62 @@
import { gql, useQuery } from '@apollo/client';
import { Networks, useEnvironment } from '@vegaprotocol/environment';
import { AsyncRenderer, Splash } from '@vegaprotocol/ui-toolkit';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { Web3Container } from '@vegaprotocol/web3';
import { DepositManager } from './deposit-manager';
import { getEnabledAssets, t } from '@vegaprotocol/react-helpers';
import type { DepositAsset } from './__generated__/DepositAsset';
const DEPOSITS_QUERY = gql`
query DepositAsset {
assetsConnection {
edges {
node {
id
name
symbol
decimals
status
source {
... on ERC20 {
contractAddress
}
}
}
}
}
}
`;
/**
* Fetches data required for the Deposit page
*/
export const DepositContainer = ({ assetId }: { assetId?: string }) => {
const { VEGA_ENV } = useEnvironment();
const { keypair } = useVegaWallet();
const { data, loading, error } = useQuery<DepositAsset>(DEPOSITS_QUERY, {
variables: { partyId: keypair?.pub },
skip: !keypair?.pub,
});
const assets = getEnabledAssets(data);
return (
<AsyncRenderer<DepositAsset> data={data} loading={loading} error={error}>
{assets.length ? (
<Web3Container>
<DepositManager
assetId={assetId}
assets={assets}
isFaucetable={VEGA_ENV !== Networks.MAINNET}
/>
</Web3Container>
) : (
<Splash>
<p>{t('No assets on this network')}</p>
</Splash>
)}
</AsyncRenderer>
);
};

View File

@ -77,9 +77,9 @@ export const DepositForm = ({
formState: { errors },
} = useForm<FormFields>({
defaultValues: {
asset: selectedAsset?.id,
from: account,
to: keypair?.pub,
asset: selectedAsset?.id || '',
},
});
@ -151,7 +151,11 @@ export const DepositForm = ({
<Controller
control={control}
name="asset"
rules={{ validate: { required } }}
rules={{
validate: {
required: (value) => !!selectedAsset || required(value),
},
}}
render={({ field }) => (
<Select
id="asset"
@ -160,6 +164,7 @@ export const DepositForm = ({
field.onChange(e);
onSelectAsset(e.target.value);
}}
value={selectedAsset?.id || ''}
>
<option value="">{t('Please select')}</option>
{assets.filter(isAssetTypeERC20).map((a) => (

View File

@ -1,4 +1,4 @@
import { t } from '@vegaprotocol/react-helpers';
import { formatNumber, t } from '@vegaprotocol/react-helpers';
import type BigNumber from 'bignumber.js';
interface DepositLimitsProps {
@ -36,7 +36,9 @@ export const DepositLimits = ({
<tbody>
<tr>
<th className="text-left font-normal">{t('Balance available')}</th>
<td className="text-right">{balance ? balance.toString() : 0}</td>
<td className="text-right">
{balance ? formatNumber(balance) : '-'}
</td>
</tr>
<tr>
<th className="text-left font-normal">
@ -46,7 +48,7 @@ export const DepositLimits = ({
</tr>
<tr>
<th className="text-left font-normal">{t('Deposited')}</th>
<td className="text-right">{deposited.toString()}</td>
<td className="text-right">{formatNumber(deposited)}</td>
</tr>
<tr>
<th className="text-left font-normal">{t('Remaining')}</th>

View File

@ -4,21 +4,43 @@ import sortBy from 'lodash/sortBy';
import { useSubmitApproval } from './use-submit-approval';
import { useSubmitFaucet } from './use-submit-faucet';
import { useDepositStore } from './deposit-store';
import { useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import { useDepositBalances } from './use-deposit-balances';
import type { Asset } from '@vegaprotocol/react-helpers';
interface DepositManagerProps {
assetId?: string;
assets: Asset[];
isFaucetable: boolean;
}
const useDepositAsset = (assets: Asset[], assetId?: string) => {
const { asset, balance, allowance, deposited, max, update } =
useDepositStore();
const handleSelectAsset = useCallback(
(id: string) => {
const asset = assets.find((a) => a.id === id);
update({ asset });
},
[assets, update]
);
useEffect(() => {
handleSelectAsset(assetId || '');
}, [assetId, handleSelectAsset]);
return { asset, balance, allowance, deposited, max, handleSelectAsset };
};
export const DepositManager = ({
assetId,
assets,
isFaucetable,
}: DepositManagerProps) => {
const { asset, balance, allowance, deposited, max, update } =
useDepositStore();
const { asset, balance, allowance, deposited, max, handleSelectAsset } =
useDepositAsset(assets, assetId);
useDepositBalances(isFaucetable);
// Set up approve transaction
@ -30,15 +52,6 @@ export const DepositManager = ({
// Set up faucet transaction
const faucet = useSubmitFaucet();
const handleSelectAsset = useCallback(
(id: string) => {
const asset = assets.find((a) => a.id === id);
if (!asset) return;
update({ asset });
},
[assets, update]
);
return (
<>
<DepositForm

View File

@ -1,6 +1,5 @@
import type { Asset } from '@vegaprotocol/react-helpers';
import BigNumber from 'bignumber.js';
import type { SetState } from 'zustand';
import create from 'zustand';
interface DepositStore {
@ -12,7 +11,7 @@ interface DepositStore {
update: (state: Partial<DepositStore>) => void;
}
export const useDepositStore = create((set: SetState<DepositStore>) => ({
export const useDepositStore = create<DepositStore>((set) => ({
balance: new BigNumber(0),
allowance: new BigNumber(0),
deposited: new BigNumber(0),

View File

@ -0,0 +1,16 @@
export * from './__generated__';
export * from './deposit-container';
export * from './deposit-form';
export * from './deposit-limits';
export * from './deposit-manager';
export * from './deposit-store';
export * from './deposits-table';
export * from './use-deposit-balances';
export * from './use-deposits';
export * from './use-get-allowance';
export * from './use-get-balance-of-erc20-token';
export * from './use-get-deposit-maximum';
export * from './use-get-deposited-amount';
export * from './use-submit-approval';
export * from './use-submit-deposit';
export * from './use-submit-faucet';

View File

@ -9,7 +9,7 @@ import { useGetDepositedAmount } from './use-get-deposited-amount';
import { isAssetTypeERC20 } from '@vegaprotocol/react-helpers';
/**
* Hook which fetches all the balances required for despoiting
* Hook which fetches all the balances required for depositing
* whenever the asset changes in the form
*/
export const useDepositBalances = (isFaucetable: boolean) => {

View File

@ -44,7 +44,7 @@ export const LiquidityTable = forwardRef<AgGridReact, LiquidityTableProps>(
return (
<AgGrid
style={{ width: '100%', height: '100%' }}
overlayNoRowsTemplate="No liquidity provisions"
overlayNoRowsTemplate={t('No liquidity provisions')}
getRowId={({ data }) => data.party}
rowHeight={34}
ref={ref}

View File

@ -30,8 +30,9 @@ export const PositionsManager = ({ partyId }: PositionsManagerProps) => {
/>
))}
</AsyncRenderer>
<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>
</Dialog>
</>
);

View File

@ -7,6 +7,8 @@ import type {
ICellRendererParams,
CellRendererSelectorResult,
} from 'ag-grid-community';
import type { ValueProps as PriceCellProps } from '@vegaprotocol/ui-toolkit';
import { EmptyCell, ProgressBarCell } from '@vegaprotocol/ui-toolkit';
import {
PriceFlashCell,
addDecimalsFormatNumber,
@ -17,7 +19,7 @@ import {
signedNumberCssClass,
signedNumberCssClassRules,
} from '@vegaprotocol/react-helpers';
import { AgGridDynamic as AgGrid, ProgressBar } from '@vegaprotocol/ui-toolkit';
import { AgGridDynamic as AgGrid } from '@vegaprotocol/ui-toolkit';
import { AgGridColumn } from 'ag-grid-react';
import type { AgGridReact, AgGridReactProps } from 'ag-grid-react';
import type { IDatasource, IGetRowsParams } from 'ag-grid-community';
@ -64,33 +66,6 @@ export const MarketNameCell = ({ valueFormatted }: MarketNameCellProps) => {
return (valueFormatted && valueFormatted[0]) || undefined;
};
export interface PriceCellProps {
valueFormatted?: {
low: string;
high: string;
value: number;
intent?: Intent;
};
}
export const ProgressBarCell = ({ valueFormatted }: PriceCellProps) => {
return valueFormatted ? (
<>
<div className="flex justify-between leading-tight font-mono">
<div>{valueFormatted.low}</div>
<div>{valueFormatted.high}</div>
</div>
<ProgressBar
value={valueFormatted.value}
intent={valueFormatted.intent}
className="mt-2 w-full"
/>
</>
) : null;
};
ProgressBarCell.displayName = 'PriceFlashCell';
export interface AmountCellProps {
valueFormatted?: Pick<
Position,
@ -140,14 +115,33 @@ const ButtonCell = ({
);
};
const EmptyCell = () => '';
const progressBarValueFormatter = ({
data,
node,
}: PositionsTableValueFormatterParams):
| PriceCellProps['valueFormatted']
| undefined => {
if (!data || node?.rowPinned) {
return undefined;
}
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,
};
};
export const PositionsTable = forwardRef<AgGridReact, Props>(
({ onClose, ...props }, ref) => {
return (
<AgGrid
style={{ width: '100%', height: '100%' }}
overlayNoRowsTemplate="No positions"
overlayNoRowsTemplate={t('No positions')}
getRowId={getRowId}
rowHeight={34}
ref={ref}
@ -254,6 +248,9 @@ export const PositionsTable = forwardRef<AgGridReact, Props>(
'</div>',
}}
flex={2}
headerTooltip={t(
'Liquidation prices are based on the amount of collateral you have available, the risk of your position and the liquidity on the order book. They can change rapidly based on the profit and loss of your positions and any changes to collateral from opening/closing other positions and making deposits/withdrawals.'
)}
cellRendererSelector={(
params: ICellRendererParams
): CellRendererSelectorResult => {
@ -261,32 +258,7 @@ export const PositionsTable = forwardRef<AgGridReact, Props>(
component: params.node.rowPinned ? EmptyCell : ProgressBarCell,
};
}}
valueFormatter={({
data,
node,
}: PositionsTableValueFormatterParams):
| PriceCellProps['valueFormatted']
| undefined => {
if (!data || node?.rowPinned) {
return undefined;
}
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,
};
}}
valueFormatter={progressBarValueFormatter}
/>
<AgGridColumn
headerName={t('Leverage')}
@ -354,7 +326,9 @@ export const PositionsTable = forwardRef<AgGridReact, Props>(
: addDecimalsFormatNumber(value.toString(), data.decimals)
}
cellRenderer="PriceFlashCell"
headerTooltip={t('P&L excludes any fees paid.')}
headerTooltip={t(
'Profit or loss is realised whenever your position is reduced to zero and the margin is released back to your collateral balance. P&L excludes any fees paid.'
)}
/>
<AgGridColumn
headerName={t('Unrealised PNL')}
@ -372,6 +346,9 @@ export const PositionsTable = forwardRef<AgGridReact, Props>(
: addDecimalsFormatNumber(value.toString(), data.decimals)
}
cellRenderer="PriceFlashCell"
headerTooltip={t(
'Unrealised profit is the current profit on your open position. Margin is still allocated to your position.'
)}
/>
<AgGridColumn
headerName={t('Updated')}

View File

@ -2,19 +2,22 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node", "@testing-library/jest-dom"]
"types": ["node"]
},
"include": [
"**/*.test.ts",
"files": [
"../../node_modules/@nrwl/react/typings/cssmodule.d.ts",
"../../node_modules/@nrwl/next/typings/image.d.ts"
],
"exclude": [
"**/*.spec.ts",
"**/*.test.tsx",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.js",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.test.jsx",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.d.ts",
"**/*.test.jsx",
"jest.config.ts"
]
],
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]
}

View File

@ -8,16 +8,18 @@ export interface AccordionItemProps {
content: React.ReactNode;
}
export interface AccordionProps {
panels: AccordionItemProps[];
export interface AccordionPanelProps extends AccordionItemProps {
itemId: string;
active: boolean;
}
export const Accordion = ({ panels }: AccordionProps) => {
export interface AccordionProps {
panels?: AccordionItemProps[];
children?: React.ReactNode;
}
export const Accordion = ({ panels, children }: AccordionProps) => {
const [values, setValues] = useState<string[]>([]);
const triggerClassNames = classNames(
'w-full py-2',
'flex items-center justify-between border-b border-neutral-500'
);
return (
<AccordionPrimitive.Root
@ -25,31 +27,50 @@ export const Accordion = ({ panels }: AccordionProps) => {
value={values}
onValueChange={setValues}
>
{panels.map(({ title, content }, i) => (
<AccordionPrimitive.Item value={`item-${i + 1}`} key={`item-${i + 1}`}>
<AccordionPrimitive.Header>
<AccordionPrimitive.Trigger
data-testid="accordion-toggle"
className={triggerClassNames}
>
<span data-testid="accordion-title">{title}</span>
<AccordionChevron
active={values.includes(`item-${i + 1}`)}
aria-hidden
/>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
<AccordionPrimitive.Content data-testid="accordion-content-ref">
<div className="py-4 text-sm" data-testid="accordion-content">
{content}
</div>
</AccordionPrimitive.Content>
</AccordionPrimitive.Item>
{panels?.map(({ title, content }, i) => (
<AccordionItem
key={`item-${i + 1}`}
itemId={`item-${i + 1}`}
title={title}
content={content}
active={values.includes(`item-${i + 1}`)}
/>
))}
{children}
</AccordionPrimitive.Root>
);
};
export const AccordionItem = ({
title,
content,
itemId,
active,
}: AccordionPanelProps) => {
const triggerClassNames = classNames(
'w-full py-2',
'flex items-center justify-between border-b border-neutral-500'
);
return (
<AccordionPrimitive.Item value={itemId}>
<AccordionPrimitive.Header>
<AccordionPrimitive.Trigger
data-testid="accordion-toggle"
className={triggerClassNames}
>
<span data-testid="accordion-title">{title}</span>
<AccordionChevron active={active} aria-hidden />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
<AccordionPrimitive.Content data-testid="accordion-content-ref">
<div className="py-4 text-sm" data-testid="accordion-content">
{content}
</div>
</AccordionPrimitive.Content>
</AccordionPrimitive.Item>
);
};
export const AccordionChevron = ({ active }: { active: boolean }) => {
return (
<Icon

View File

@ -12,8 +12,8 @@ export * from './dropdown-menu';
export * from './form-group';
export * from './icon';
export * from './indicator';
export * from './input';
export * from './input-error';
export * from './input';
export * from './key-value-table';
export * from './link';
export * from './loader';
@ -24,6 +24,7 @@ export * from './progress-bar';
export * from './radio-group';
export * from './resizable-grid';
export * from './select';
export * from './slider';
export * from './sparkline';
export * from './splash';
export * from './syntax-highlighter';
@ -33,4 +34,3 @@ export * from './theme-switcher';
export * from './toggle';
export * from './tooltip';
export * from './vega-logo';
export * from './slider';

View File

@ -0,0 +1,41 @@
import type {
CellRendererSelectorResult,
ICellRendererParams,
} from 'ag-grid-community';
import type { Intent } from '../../utils/intent';
import { ProgressBar } from './progress-bar';
export interface ValueProps {
valueFormatted?: {
low: string;
high: string;
value: number;
intent?: Intent;
};
}
export const EmptyCell = () => '';
export const ProgressBarCell = ({ valueFormatted }: ValueProps) => {
return valueFormatted ? (
<>
<div className="flex justify-between leading-tight font-mono">
<div>{valueFormatted.low}</div>
<div>{valueFormatted.high}</div>
</div>
<ProgressBar
value={valueFormatted.value}
intent={valueFormatted.intent}
className="mt-2 w-full"
/>
</>
) : null;
};
export const progressBarCellRendererSelector = (
params: ICellRendererParams
): CellRendererSelectorResult => {
return {
component: params.node.rowPinned ? EmptyCell : ProgressBarCell,
};
};

View File

@ -1 +1,2 @@
export * from './grid-progress-bar';
export * from './progress-bar';

View File

@ -0,0 +1,58 @@
import { captureException } from '@sentry/react';
import type { Asset } from '@vegaprotocol/react-helpers';
import { addDecimal } from '@vegaprotocol/react-helpers';
import { AccountType } from '@vegaprotocol/types';
import BigNumber from 'bignumber.js';
import { useCallback, useEffect } from 'react';
import type { Account } from './types';
import { useGetWithdrawDelay } from './use-get-withdraw-delay';
import { useGetWithdrawThreshold } from './use-get-withdraw-threshold';
import { useWithdrawStore } from './withdraw-store';
export const useWithdrawAsset = (
assets: Asset[],
accounts: Account[],
assetId?: string
) => {
const { asset, balance, min, threshold, delay, update } = useWithdrawStore();
const getThreshold = useGetWithdrawThreshold();
const getDelay = useGetWithdrawDelay();
// Every time an asset is selected we need to find the corresponding
// account, balance, min viable amount and delay threshold
const handleSelectAsset = useCallback(
async (id: string) => {
const asset = assets.find((a) => a.id === id);
const account = accounts.find(
(a) =>
a.type === AccountType.ACCOUNT_TYPE_GENERAL &&
a.asset.id === asset?.id
);
const balance =
account && asset
? new BigNumber(addDecimal(account.balance, asset.decimals))
: new BigNumber(0);
const min = asset
? new BigNumber(addDecimal('1', asset.decimals))
: new BigNumber(0);
// Query collateral bridge for threshold for selected asset
// and subsequent delay if withdrawal amount is larger than it
let threshold;
let delay;
try {
const result = await Promise.all([getThreshold(asset), getDelay()]);
threshold = result[0];
delay = result[1];
update({ asset, balance, min, threshold, delay });
} catch (err) {
captureException(err);
}
},
[accounts, assets, update, getThreshold, getDelay]
);
useEffect(() => {
handleSelectAsset(assetId || '');
}, [assetId, handleSelectAsset]);
return { asset, balance, min, threshold, delay, handleSelectAsset };
};

View File

@ -51,9 +51,11 @@ const WITHDRAW_FORM_QUERY = gql`
interface WithdrawFormContainerProps {
partyId?: string;
submit: (args: WithdrawalArgs) => void;
assetId?: string;
}
export const WithdrawFormContainer = ({
assetId,
partyId,
submit,
}: WithdrawFormContainerProps) => {
@ -82,6 +84,7 @@ export const WithdrawFormContainer = ({
return (
<WithdrawManager
assetId={assetId}
assets={assets}
accounts={data.party?.accounts || []}
submit={submit}

View File

@ -58,7 +58,7 @@ export const WithdrawForm = ({
formState: { errors },
} = useForm<FormFields>({
defaultValues: {
asset: selectedAsset?.id,
asset: selectedAsset?.id || '',
to: address,
},
});
@ -98,7 +98,11 @@ export const WithdrawForm = ({
<Controller
control={control}
name="asset"
rules={{ validate: { required } }}
rules={{
validate: {
required: (value) => !!selectedAsset || required(value),
},
}}
render={({ field }) => (
<Select
{...field}

View File

@ -3,14 +3,32 @@ import userEvent from '@testing-library/user-event';
import { generateAccount, generateAsset } from './test-helpers';
import type { WithdrawManagerProps } from './withdraw-manager';
import { WithdrawManager } from './withdraw-manager';
import type { Asset } from '@vegaprotocol/react-helpers';
import BigNumber from 'bignumber.js';
import type { Account } from './types';
const asset = generateAsset();
const ethereumAddress = '0x72c22822A19D20DE7e426fB84aa047399Ddd8853';
jest.mock('@web3-react/core', () => ({
useWeb3React: () => ({ account: ethereumAddress }),
}));
jest.mock('./use-withdraw-asset', () => ({
useWithdrawAsset: (
assets: Asset[],
accounts: Account[],
assetId?: string
) => ({
asset,
balance: new BigNumber(1),
min: new BigNumber(0.0000001),
threshold: new BigNumber(1000),
delay: 10,
handleSelectAsset: jest.fn(),
}),
}));
jest.mock('./use-get-withdraw-threshold', () => ({
useGetWithdrawThreshold: () => {
return () => Promise.resolve(new BigNumber(100));
@ -23,77 +41,82 @@ jest.mock('./use-get-withdraw-delay', () => ({
},
}));
let props: WithdrawManagerProps;
describe('WithdrawManager', () => {
let props: WithdrawManagerProps;
beforeEach(() => {
props = {
assets: [generateAsset()],
accounts: [generateAccount()],
submit: jest.fn(),
beforeEach(() => {
props = {
assets: [asset],
accounts: [generateAccount()],
submit: jest.fn(),
assetId: asset.id,
};
});
const generateJsx = (props: WithdrawManagerProps) => (
<WithdrawManager {...props} />
);
it('calls submit if valid form submission', async () => {
render(generateJsx(props));
await act(async () => {
await submitValid();
});
expect(await props.submit).toHaveBeenCalledWith({
amount: '1000',
asset: props.assets[0].id,
receiverAddress: ethereumAddress,
availableTimestamp: null,
});
});
it('validates correctly', async () => {
render(generateJsx(props));
// Set other fields to be valid
fireEvent.change(screen.getByLabelText('Asset'), {
target: { value: props.assets[0].id },
});
fireEvent.change(screen.getByLabelText('To (Ethereum address)'), {
target: { value: ethereumAddress },
});
// Min amount
fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: '0.00000001' },
});
fireEvent.submit(screen.getByTestId('withdraw-form'));
expect(
await screen.findByText('Value is below minimum')
).toBeInTheDocument();
expect(props.submit).not.toBeCalled();
fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: '0.00001' },
});
// Max amount (balance is 1)
fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: '2' },
});
fireEvent.submit(screen.getByTestId('withdraw-form'));
expect(
await screen.findByText('Insufficient amount in account')
).toBeInTheDocument();
expect(props.submit).not.toBeCalled();
});
const submitValid = async () => {
await userEvent.selectOptions(
screen.getByLabelText('Asset'),
props.assets[0].id
);
fireEvent.change(screen.getByLabelText('To (Ethereum address)'), {
target: { value: ethereumAddress },
});
fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: '0.01' },
});
fireEvent.submit(screen.getByTestId('withdraw-form'));
};
});
const generateJsx = (props: WithdrawManagerProps) => (
<WithdrawManager {...props} />
);
it('calls submit if valid form submission', async () => {
render(generateJsx(props));
await act(async () => {
await submitValid();
});
expect(props.submit).toHaveBeenCalledWith({
amount: '1000',
asset: props.assets[0].id,
receiverAddress: ethereumAddress,
availableTimestamp: null,
});
});
it('validates correctly', async () => {
render(generateJsx(props));
// Set other fields to be valid
fireEvent.change(screen.getByLabelText('Asset'), {
target: { value: props.assets[0].id },
});
fireEvent.change(screen.getByLabelText('To (Ethereum address)'), {
target: { value: ethereumAddress },
});
// Min amount
fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: '0.00000001' },
});
fireEvent.submit(screen.getByTestId('withdraw-form'));
expect(await screen.findByText('Value is below minimum')).toBeInTheDocument();
expect(props.submit).not.toBeCalled();
fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: '0.00001' },
});
// Max amount (balance is 1)
fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: '2' },
});
fireEvent.submit(screen.getByTestId('withdraw-form'));
expect(
await screen.findByText('Insufficient amount in account')
).toBeInTheDocument();
expect(props.submit).not.toBeCalled();
});
const submitValid = async () => {
await userEvent.selectOptions(
screen.getByLabelText('Asset'),
props.assets[0].id
);
fireEvent.change(screen.getByLabelText('To (Ethereum address)'), {
target: { value: ethereumAddress },
});
fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: '0.01' },
});
fireEvent.submit(screen.getByTestId('withdraw-form'));
};

View File

@ -1,68 +1,25 @@
import { useCallback } from 'react';
import sortBy from 'lodash/sortBy';
import { WithdrawForm } from './withdraw-form';
import type { WithdrawalArgs } from './use-create-withdraw';
import type { Asset } from '@vegaprotocol/react-helpers';
import { addDecimal } from '@vegaprotocol/react-helpers';
import { AccountType } from '@vegaprotocol/types';
import BigNumber from 'bignumber.js';
import type { Account } from './types';
import { useGetWithdrawThreshold } from './use-get-withdraw-threshold';
import { captureException } from '@sentry/react';
import { useGetWithdrawDelay } from './use-get-withdraw-delay';
import { useWithdrawStore } from './withdraw-store';
import { useWithdrawAsset } from './use-withdraw-asset';
export interface WithdrawManagerProps {
assets: Asset[];
accounts: Account[];
submit: (args: WithdrawalArgs) => void;
assetId?: string;
}
export const WithdrawManager = ({
assets,
accounts,
submit,
assetId,
}: WithdrawManagerProps) => {
const { asset, balance, min, threshold, delay, update } = useWithdrawStore();
const getThreshold = useGetWithdrawThreshold();
const getDelay = useGetWithdrawDelay();
// Everytime an asset is selected we need to find the corresponding
// account, balance, min viable amount and delay threshold
const handleSelectAsset = useCallback(
async (id: string) => {
const asset = assets.find((a) => a.id === id);
const account = accounts.find(
(a) =>
a.type === AccountType.ACCOUNT_TYPE_GENERAL &&
a.asset.id === asset?.id
);
const balance =
account && asset
? new BigNumber(addDecimal(account.balance, asset.decimals))
: new BigNumber(0);
const min = asset
? new BigNumber(addDecimal('1', asset.decimals))
: new BigNumber(0);
// Query collateral bridge for threshold for selected asset
// and subsequent delay if withdrawal amount is larger than it
let threshold;
let delay;
try {
const result = await Promise.all([getThreshold(asset), getDelay()]);
threshold = result[0];
delay = result[1];
} catch (err) {
captureException(err);
}
update({ asset, balance, min, threshold, delay });
},
[accounts, assets, update, getThreshold, getDelay]
);
const { asset, balance, min, threshold, delay, handleSelectAsset } =
useWithdrawAsset(assets, accounts, assetId);
return (
<WithdrawForm
selectedAsset={asset}

View File

@ -1,9 +1,8 @@
import type { Asset } from '@vegaprotocol/react-helpers';
import BigNumber from 'bignumber.js';
import type { SetState } from 'zustand';
import create from 'zustand';
interface WithdrawStore {
export interface WithdrawStore {
asset: Asset | undefined;
balance: BigNumber;
min: BigNumber;
@ -12,7 +11,7 @@ interface WithdrawStore {
update: (state: Partial<WithdrawStore>) => void;
}
export const useWithdrawStore = create((set: SetState<WithdrawStore>) => ({
export const useWithdrawStore = create<WithdrawStore>((set) => ({
asset: undefined,
balance: new BigNumber(0),
min: new BigNumber(0),

View File

@ -9,9 +9,11 @@ import { WithdrawalFeedback } from './withdrawal-feedback';
export const WithdrawalDialogs = ({
withdrawDialog,
setWithdrawDialog,
assetId,
}: {
withdrawDialog: boolean;
setWithdrawDialog: (open: boolean) => void;
assetId?: string;
}) => {
const { keypair } = useVegaWallet();
const createWithdraw = useCreateWithdraw();
@ -25,6 +27,7 @@ export const WithdrawalDialogs = ({
size="small"
>
<WithdrawFormContainer
assetId={assetId}
partyId={keypair?.pub}
submit={(args) => {
setWithdrawDialog(false);