Compare commits

..
Author SHA1 Message Date
maciek bfc8446237 chore: floating buttons - back the cancel button on order list 2023-03-08 15:42:12 +01:00
maciek c58a599663 chore: floating buttons - fix failing test 2023-03-08 15:19:03 +01:00
maciek 246da7529e chore: floating buttons - hide buttons on trade, add deposit button for pinned 2023-03-08 15:08:12 +01:00
maciek a85bf127fb chore: floating buttons - hide buttons on trade, add deposit button for pinned 2023-03-08 14:55:47 +01:00
maciek a49e607642 Merge remote-tracking branch 'origin/develop' into chore/2825-buttons-floating-over-table-rows 2023-03-08 14:51:51 +01:00
maciek 201f452fab chore: floating buttons - hide buttons on trade, add deposit button for pinned 2023-03-08 14:51:36 +01:00
Matthew RussellandMadalina Raicu 8f5a2276de fix(trading): order store connection to deal ticket (#3100)
Co-authored-by: Madalina Raicu <madalina@raygroup.uk>
2023-03-08 12:14:56 +00:00
dexturr 46513685c8 chore: update tranches
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-03-08 12:10:32 +00:00
m.ray e66e96f12d fix(trading): add loss socialisation docs links (#3109) 2023-03-08 09:42:25 +00:00
dexturr 31c8365812 chore: update tranches
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-03-08 06:07:09 +00:00
dexturr 460f534c7a chore: update tranches
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-03-08 00:17:38 +00:00
dexturr 24cd080dd4 chore: update tranches
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-03-07 18:08:33 +00:00
maciek fa5b7c8915 chore: make footer lower buttons floating over table rows - resolve conflicts 2023-03-07 17:39:09 +01:00
maciek 27d4888294 chore: make footer lower buttons floating over table rows - resolve conflicts 2023-03-07 17:30:04 +01:00
Bartłomiej Głownia f10c33748f fix(orders): orders table flickering (#3105) 2023-03-07 17:13:34 +01:00
dexturr ac163d0194 chore: update tranches
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-03-07 12:08:15 +00:00
maciek 72c02db088 chore: make footer lower buttons floating over table rows - manage sorting and filtering 2023-03-07 12:07:44 +01:00
maciek 08ae6de505 Merge remote-tracking branch 'origin/develop' into chore/2825-buttons-floating-over-table-rows 2023-03-07 12:03:32 +01:00
maciek 861648c1b3 chore: make footer lower buttons floating over table rows - manage sorting and filtering 2023-03-07 12:03:10 +01:00
Joe Tsang e7616e64f8 test(governance): tranche service (#3099) 2023-03-07 10:42:20 +00:00
maciek b505e5305d Merge remote-tracking branch 'origin/develop' into chore/2825-buttons-floating-over-table-rows 2023-03-07 09:18:15 +01:00
maciek 88bcaf9b72 chore: make footer lower buttons floating over table rows - move hook to react helpers 2023-03-07 09:17:39 +01:00
Matthew Russell adca4600c2 feat(deposits): deposit flow (#3062) 2023-03-07 08:17:02 +00:00
maciek 7bff186e44 chore: make footer lower buttons floating over table rows - move hook to react helpers 2023-03-07 08:58:43 +01:00
maciek 73e951e2a9 Merge remote-tracking branch 'origin/develop' into chore/2825-buttons-floating-over-table-rows 2023-03-07 08:47:21 +01:00
maciek 4916c9cffe chore: make footer lower buttons floating over table rows - move hook to react helpers 2023-03-07 08:39:53 +01:00
dexturr a8eef1cb53 chore: update tranches
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-03-07 06:07:42 +00:00
dexturr 58d4bd1459 chore: update tranches
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-03-07 00:16:09 +00:00
maciek b34c82f830 chore: make footer lower buttons floating over table rows - fix lint error 2023-03-06 23:21:39 +01:00
maciek 3a2f7ae0e8 Merge remote-tracking branch 'origin/develop' into chore/2825-buttons-floating-over-table-rows 2023-03-06 23:03:40 +01:00
maciek e7a648df29 chore: make footer lower buttons floating over table rows - refactor solution 2023-03-06 23:03:22 +01:00
maciek abed44309f chore: make footer lower buttons floating over table rows - refactor solution 2023-03-06 22:49:46 +01:00
dexturr 26020b5f71 chore: update tranches
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-03-06 18:07:54 +00:00
daro-maj 16d345eb4a test(trading): revert nonparallel tests option (#3098) 2023-03-06 17:48:33 +01:00
m.ray 3b3fcab4f4 fix(trading): fills table fees display (#3071) 2023-03-06 17:24:18 +01:00
Matthew Russell f54a629179 feat(positions): positions losses and maintenance warnings (#2985) 2023-03-06 16:01:31 +00:00
maciek 556e8b0dbf chore: make footer lower buttons floating over table rows - fix failling test 2023-03-06 15:56:55 +01:00
maciek 48d246f2da Merge remote-tracking branch 'origin/develop' into chore/2825-buttons-floating-over-table-rows 2023-03-06 14:49:24 +01:00
maciek ff209e0e66 chore: make footer lower buttons floating over table rows - tweaks and fixes 2023-03-06 14:49:08 +01:00
dexturr 56f48671cc chore: update tranches
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-03-06 12:09:18 +00:00
Bartłomiej Głownia a65ee358ff fix(market-depth): orderbook row height calculation (#3068) 2023-03-06 10:21:45 +00:00
maciek 9480d183da chore: make footer lower buttons floating over table rows - rebase and resolve conflicts 2023-03-06 09:48:08 +01:00
maciek 8b206accd6 chore: make footer lower buttons floating over table rows - fix lint errors 2023-03-06 09:33:56 +01:00
maciek d3c6b18729 chore: make footer lower buttons floating over table rows - add logic for placeholders 2023-03-03 17:17:46 +01:00
maciek 9d4841bfda Merge remote-tracking branch 'origin/develop' into chore/2825-buttons-floating-over-table-rows 2023-03-03 17:16:00 +01:00
maciek 61a6c42aad chore: make footer lower buttons floating over table rows - add logic for placeholders 2023-03-03 17:15:34 +01:00
maciek 1b5560ae4e Merge remote-tracking branch 'origin/develop' into chore/2825-buttons-floating-over-table-rows 2023-03-03 09:31:33 +01:00
maciek 33252f6c23 chore: make footer lower buttons floating over table rows 2023-03-02 17:32:30 +01:00
91 changed files with 3702 additions and 1505 deletions
+1 -3
View File
@@ -16,8 +16,6 @@ jobs:
strategy:
fail-fast: false
matrix:
# run copies of the current job in parallel
containers: [1, 2]
project: ${{ fromJSON(inputs.projects) }}
runs-on: self-hosted-runner
timeout-minutes: 30
@@ -66,7 +64,7 @@ jobs:
######
- name: Run Cypress tests
run: yarn nx run ${{ matrix.project }}:e2e ${{ env.SKIP_CACHE }} --record --group 'UI - Chrome' --key ${{ secrets.CYPRESS_RECORD_KEY }} --parallel --browser chrome --env.grepTags="${{ inputs.tags }}"
run: yarn nx run ${{ matrix.project }}:e2e ${{ env.SKIP_CACHE }} --record --key ${{ secrets.CYPRESS_RECORD_KEY }} --browser chrome --env.grepTags="${{ inputs.tags }}"
working-directory: frontend-monorepo
env:
CYPRESS_SLACK_WEBHOOK: ${{ secrets.CYPRESS_SLACK_WEBHOOK }}
@@ -0,0 +1,83 @@
export const trancheData = {
'0': {
tranche_id: 0,
users: [],
initial_balance: 0,
current_balance: 0,
cliff_start: 0,
duration: 0,
},
'1': {
tranche_id: 1,
users: [],
initial_balance: 0,
current_balance: 0,
cliff_start: 1670422330,
duration: 1670453883,
},
'107': {
tranche_id: 107,
users: [],
initial_balance: 0,
current_balance: 0,
cliff_start: 0,
duration: 0,
},
'110': {
tranche_id: 110,
users: [],
initial_balance: 0,
current_balance: 0,
cliff_start: 0,
duration: 0,
},
'154': {
tranche_id: 154,
users: [],
initial_balance: 0,
current_balance: 0,
cliff_start: 0,
duration: 0,
},
'2': {
tranche_id: 2,
users: [
'0x8716c75bb3abe54b959adc529e6355f0422454ed',
'0xc704da0e96a6b910ffa8d3f9f667d3d35db8e5a1',
'0xbb82d4d6e4381ae98f8c33629285867b54d91a03',
'0x82ffe4ed818a6c7d4f0f044998611f24ec916c43',
'0xb0594132540ebc14f17863f8296f4546a7afe4e7',
'0x9015a3db9a922521007a9b495cafda14d8d4128e',
'0x5426f7f717fdbe331bb5a99b908ad2fb75dff711',
'0xf73e7ad8aa300a7f86bcc4d55e6c4ea25546e057',
],
initial_balance: 111300000000000000000,
current_balance: 111297025049610000000,
cliff_start: 1677578461,
duration: 15209600,
},
'3': {
tranche_id: 3,
users: [
'0xbb82d4d6e4381ae98f8c33629285867b54d91a03',
'0x82ffe4ed818a6c7d4f0f044998611f24ec916c43',
'0xb0594132540ebc14f17863f8296f4546a7afe4e7',
'0x9015a3db9a922521007a9b495cafda14d8d4128e',
'0x77a0b7e247b7b8ec99e068a24c401783d6c4dfff',
'0x5426f7f717fdbe331bb5a99b908ad2fb75dff711',
'0xc704da0e96a6b910ffa8d3f9f667d3d35db8e5a1',
],
initial_balance: 21000000000000000000,
current_balance: 21000000000000000000,
cliff_start: 1677578461,
duration: 18628800,
},
'66': {
tranche_id: 66,
users: [],
initial_balance: 0,
current_balance: 0,
cliff_start: 0,
duration: 0,
},
};
@@ -0,0 +1,97 @@
import { trancheData } from '../../fixtures/mocks/tranches';
const tranches = trancheData;
context(
'Tranches page - verify elements on the page',
{ tags: '@smoke' },
function () {
before('visit homepage', function () {
cy.intercept('GET', '**/tranches/stats', { tranches });
cy.visit('/');
});
it('Able to navigate to tranches page', function () {
cy.navigate_to('supply');
cy.url().should('include', '/token/tranches');
cy.get('h1').should('contain.text', 'Vesting tranches');
});
// 1005-VEST-001
// 1005-VEST-002
it('Able to view tranches', function () {
cy.getByTestId('tranche-item')
.should('have.length', 2)
.first()
.within(() => {
cy.get('a')
.should('have.text', 'Tranche 2') // 1005-VEST-003
.and('have.attr', 'href', '/token/tranches/2');
cy.get('span').eq(1).should('have.text', '111.30'); // 1005-VEST-005
cy.contains('Unlocking starts') // 1005-VEST-008
.parent()
.should('contain.text', '28 Feb 2023');
cy.contains('Fully unlocked')
.parent()
.should('contain.text', '23 Aug 2023');
cy.getByTestId('progress-bar').should('exist');
cy.getByTestId('currency-locked') // 1005-VEST-006
.invoke('text')
.should('not.be.empty');
cy.getByTestId('currency-unlocked') // 1005-VEST-007
.invoke('text')
.should('not.be.empty');
});
});
it('Able to see individual tranche data', function () {
cy.get('[href="/token/tranches/2"]').click();
cy.getByTestId('redeemed-tranche-tokens').within(() => {
cy.get('span').eq(1).should('have.text', 0);
});
cy.getByTestId('key-value-table').within(() => {
cy.getByTestId('link')
.should('have.length', 8)
.each((ethLink) => {
cy.wrap(ethLink)
.should('have.attr', 'href')
.and('contain', 'https://sepolia.etherscan.io/address/');
});
cy.getByTestId('redeem-link')
.should('have.length', 8)
.each((redeemLink) => {
cy.wrap(redeemLink)
.should('have.attr', 'href')
.and('contain', '/token/redeem/');
});
});
});
it('Able to view tranches with less than 10 vega', function () {
cy.navigate_to('supply');
cy.getByTestId('show-all-tranches').click();
cy.getByTestId('tranche-item')
.should('have.length', 8)
.first()
.within(() => {
cy.get('a')
.should('have.text', 'Tranche 0')
.and('have.attr', 'href', '/token/tranches/0');
cy.get('span').eq(1).should('have.text', '0.00');
cy.contains('Unlocking starts')
.parent()
.should('contain.text', '01 Jan 1970');
cy.contains('Fully unlocked')
.parent()
.should('contain.text', '01 Jan 1970');
cy.getByTestId('progress-bar').should('exist');
cy.getByTestId('currency-locked')
.invoke('text')
.should('not.be.empty');
cy.getByTestId('currency-unlocked')
.invoke('text')
.should('not.be.empty');
});
});
}
);
@@ -1,13 +1,15 @@
const connectButton = '[data-testid="connect-to-eth-btn"]';
const lockedTokensInVestingContract = '6,499,972.30';
context(
'Vesting Page - verify elements on page',
{ tags: '@smoke' },
function () {
before('navigate to vesting page', function () {
cy.visit('/').navigate_to('vesting');
});
describe('with wallets disconnected', function () {
before('navigate to vesting page', function () {
cy.visit('/').navigate_to('vesting');
});
it('should have vesting tab highlighted', function () {
cy.verify_tab_highlighted('token');
});
@@ -16,10 +18,7 @@ context(
cy.verify_page_header('Vesting');
});
it('should have connect Eth wallet info', function () {
cy.get(connectButton).should('be.visible');
});
// 1005-VEST-018
it('should have connect Eth wallet button', function () {
cy.get(connectButton)
.should('be.visible')
@@ -27,18 +26,101 @@ context(
});
});
describe('with eth wallet connected', function () {
describe('With Eth wallet connected', function () {
before('connect eth wallet', function () {
cy.ethereum_wallet_connect();
cy.visit('/');
cy.getByTestId('view-connected-eth-btn').click();
});
// 1005-VEST-001
// 1005-VEST-002
it('Able to view tranches', function () {
cy.navigate_to('supply');
cy.url().should('include', '/token/tranches');
cy.get('h1').should('contain.text', 'Vesting tranches');
// 1005-VEST-020 1005-VEST-021
it('Tokens in vesting contract for eth wallet is displayed on wallet window', function () {
cy.getByTestId('vega-in-vesting-contract').within(() => {
cy.getByTestId('currency-title')
.should('contain.text', 'VEGA')
.and('contain.text', 'In vesting contract');
cy.getByTestId('currency-value').should(
'have.text',
lockedTokensInVestingContract
);
cy.getByTestId('currency-locked').should(
'have.text',
lockedTokensInVestingContract
);
cy.getByTestId('currency-unlocked').should('have.text', '0.00');
});
});
// 1005-VEST-022 1005-VEST-023
it('Tokens amount displayed in vesting page', function () {
cy.getByTestId(
'redemption-description',
Cypress.env('txTimeout')
).should('exist');
const redemptionText =
'The connected Ethereum wallet (0xEe7D…d94F) has 6,499,972.30 $VEGA tokens in 1 tranche(s) of the vesting contract.';
cy.getByTestId('redemption-description').should(
'have.text',
redemptionText
);
cy.getByTestId('vesting-table').within(() => {
cy.getByTestId('key-value-table-row')
.eq(0)
.within(() => {
cy.get('dt').should('have.text', 'Vesting VEGA');
cy.get('dd').should('have.text', lockedTokensInVestingContract);
});
cy.getByTestId('key-value-table-row')
.eq(1)
.within(() => {
cy.get('dt').should('have.text', 'Locked');
cy.get('dd').should('have.text', lockedTokensInVestingContract);
});
cy.getByTestId('key-value-table-row')
.eq(2)
.within(() => {
cy.get('dt').should('have.text', 'Unlocked');
cy.get('dd').should('have.text', '0.00');
});
cy.getByTestId('key-value-table-row')
.eq(3)
.within(() => {
cy.get('dt').should('have.text', 'Associated');
cy.get('dd').should('have.text', '0.00');
});
});
});
// 1005-VEST-024 1005-VEST-025 1005-VEST-026 1005-VEST-036 1005-VEST-037
it('Tokens locked in individual tranches are displayed', function () {
cy.getByTestId('tranche-table-footer').should(
'have.text',
'All the tokens in this tranche are locked and must be assigned to a tranche before they can be redeemed.'
);
cy.getByTestId('tranche-item').within(() => {
cy.get('a')
.should('have.text', 'Tranche 0')
.and('have.attr', 'href', '/token/tranches/0');
cy.get('span')
.eq(1)
.should('have.text', lockedTokensInVestingContract);
cy.contains('Unlocking starts') // 1005-VEST-008
.parent()
.should('contain.text', '01 Jan 1970');
cy.contains('Fully unlocked')
.parent()
.should('contain.text', '01 Jan 1970');
cy.getByTestId('progress-bar').should('exist');
cy.getByTestId('currency-locked') // 1005-VEST-006
.should('have.text', lockedTokensInVestingContract);
cy.getByTestId('currency-unlocked') // 1005-VEST-007
.invoke('text')
.should('not.be.empty');
cy.getByTestId('tranche-item-footer').should(
'have.text',
'All the tokens in this tranche are locked and can not be redeemed yet.'
);
});
cy.connectVegaWallet();
});
});
}
@@ -84,6 +84,7 @@ const RedemptionRouter = () => {
fill={true}
variant="primary"
onClick={() => navigate(`${RoutesConfig.REDEEM}/${account}`)}
data-testid="view-connected-eth-btn"
>
{t('View connected Eth Wallet')}
</Button>
@@ -73,6 +73,7 @@ export const Tranche = () => {
className="underline"
title={t('View vesting information')}
to={`${Routes.REDEEM}/${user}`}
data-testid="redeem-link"
>
{t('View vesting information')}
</RouterLink>
@@ -50,7 +50,10 @@ export const Tranches = () => {
)}
<section className="text-center mt-4">
<ButtonLink onClick={() => setShowAll(!showAll)}>
<ButtonLink
data-testid="show-all-tranches"
onClick={() => setShowAll(!showAll)}
>
{showAll
? t(
'Showing tranches with <{{trancheMinimum}} VEGA, click to hide these tranches',
File diff suppressed because it is too large Load Diff
@@ -41,6 +41,7 @@ const completeWithdrawalBtn = 'complete-withdrawal';
const submitTransferBtn = '[type="submit"]';
const transferForm = 'transfer-form';
const depositSubmit = 'deposit-submit';
const approveSubmit = 'approve-submit';
// Because the tests are run on a live network to optimize time, the tests are interdependent and must be run in the given order.
describe('capsule - without MultiSign', { tags: '@slow' }, () => {
@@ -73,13 +74,13 @@ describe('capsule - without MultiSign', { tags: '@slow' }, () => {
cy.getByTestId('deposit-button').click();
connectEthereumWallet('Unknown');
cy.get(assetSelectField, txTimeout).select(btcName, { force: true });
cy.getByTestId('approve-warning').should(
cy.getByTestId('approve-default').should(
'contain.text',
`Deposits of ${btcSymbol} not approved`
`Before you can make a deposit of your chosen asset, ${btcSymbol}, you need to approve its use in your Ethereum wallet`
);
cy.getByTestId(depositSubmit).click();
cy.getByTestId('dialog-title').should('contain.text', 'Approve complete');
cy.get('[data-testid="Return to deposit"]').click();
cy.getByTestId(approveSubmit).click();
cy.getByTestId('approve-pending').should('exist');
cy.getByTestId('approve-confirmed').should('exist');
cy.get(amountField).clear().type('10');
cy.getByTestId(depositSubmit).click();
cy.getByTestId(toastContent, txTimeout).should(
+14 -7
View File
@@ -31,7 +31,13 @@ describe('deposit form validation', { tags: '@smoke' }, () => {
it('handles empty fields', () => {
cy.getByTestId('deposit-submit').click();
cy.getByTestId(formFieldError).should('contain.text', 'Required');
cy.getByTestId(formFieldError).should('have.length', 2);
// once Ethereum wallet is connected and key selected the only field that will
// error is the asset select
cy.getByTestId(formFieldError).should('have.length', 1);
cy.get('[data-testid="input-error-text"][aria-describedby="asset"]').should(
'have.length',
1
);
});
it('unable to select assets not enabled', () => {
@@ -41,12 +47,13 @@ describe('deposit form validation', { tags: '@smoke' }, () => {
cy.get(assetSelectField + ' option:contains(Asset 4)').should('not.exist');
});
it('invalid public key', () => {
cy.get(toAddressField)
.clear()
.type('INVALID_DEPOSIT_TO_ADDRESS')
.next(`[data-testid="${formFieldError}"]`)
.should('have.text', 'Invalid Vega key');
it('invalid public key when entering address manually', () => {
cy.getByTestId('enter-pubkey-manually').click();
cy.get(toAddressField).clear().type('INVALID_DEPOSIT_TO_ADDRESS');
cy.get(`[data-testid="${formFieldError}"][aria-describedby="to"]`).should(
'have.text',
'Invalid Vega key'
);
});
it('invalid amount', () => {
@@ -669,7 +669,7 @@ describe('account validation', { tags: '@regression' }, () => {
'have.text',
'You need ' +
'tDAI' +
' in your wallet to trade in this market. See all your collateral.Make a deposit'
' in your wallet to trade in this market.See all your collateral.Make a deposit'
);
cy.getByTestId('deal-ticket-deposit-dialog-button').should('exist');
});
@@ -151,7 +151,7 @@ describe('ethereum wallet', { tags: '@smoke' }, () => {
cy.getByTestId('Deposits').click();
cy.getByTestId('deposit-button').click();
connectEthereumWallet('MetaMask');
cy.get('#ethereum-address').should('have.value', ethWalletAddress);
cy.getByTestId('ethereum-address').should('have.text', ethWalletAddress);
cy.getByTestId('disconnect-ethereum-wallet')
.should('have.text', 'Disconnect')
.click();
@@ -2,10 +2,16 @@ import { AsyncRenderer, Button } from '@vegaprotocol/ui-toolkit';
import { useDepositDialog, DepositsTable } from '@vegaprotocol/deposits';
import { depositsProvider } from '@vegaprotocol/deposits';
import { t } from '@vegaprotocol/i18n';
import { useDataProvider } from '@vegaprotocol/react-helpers';
import {
useDataProvider,
useBottomPlaceholder,
} from '@vegaprotocol/react-helpers';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { useRef } from 'react';
import type { AgGridReact } from 'ag-grid-react';
export const DepositsContainer = () => {
const gridRef = useRef<AgGridReact | null>(null);
const { pubKey, isReadOnly } = useVegaWallet();
const { data, loading, error, reload } = useDataProvider({
dataProvider: depositsProvider,
@@ -13,13 +19,15 @@ export const DepositsContainer = () => {
skip: !pubKey,
});
const openDepositDialog = useDepositDialog((state) => state.open);
const bottomPlaceholderProps = useBottomPlaceholder({ gridRef });
return (
<div className="h-full grid grid-rows-[1fr,min-content]">
<div className="h-full">
<div className="h-full relative">
<DepositsTable
rowData={data || []}
noRowsOverlayComponent={() => null}
ref={gridRef}
{...bottomPlaceholderProps}
/>
<div className="pointer-events-none absolute inset-0">
<AsyncRenderer
@@ -33,8 +41,9 @@ export const DepositsContainer = () => {
</div>
</div>
{!isReadOnly && (
<div className="w-full dark:bg-black bg-white absolute bottom-0 h-auto flex justify-end px-[11px] py-2">
<div className="h-auto flex justify-end px-[11px] py-2 bottom-0 right-3 absolute dark:bg-black/75 bg-white/75 rounded">
<Button
variant="primary"
size="sm"
onClick={() => openDepositDialog()}
data-testid="deposit-button"
@@ -20,36 +20,35 @@ export const WithdrawalsContainer = () => {
return (
<VegaWalletContainer>
<div className="h-full relative grid grid-rows-[1fr,min-content]">
<div className="h-full relative">
<WithdrawalsTable
data-testid="withdrawals-history"
rowData={data}
noRowsOverlayComponent={() => null}
<div className="h-full relative">
<WithdrawalsTable
data-testid="withdrawals-history"
rowData={data}
noRowsOverlayComponent={() => null}
/>
<div className="pointer-events-none absolute inset-0">
<AsyncRenderer
data={data}
loading={loading}
error={error}
noDataCondition={(data) => !(data && data.length)}
noDataMessage={t('No withdrawals')}
reload={reload}
/>
<div className="pointer-events-none absolute inset-0">
<AsyncRenderer
data={data}
loading={loading}
error={error}
noDataCondition={(data) => !(data && data.length)}
noDataMessage={t('No withdrawals')}
reload={reload}
/>
</div>
</div>
{!isReadOnly && (
<div className="w-full dark:bg-black bg-white absolute bottom-0 h-auto flex justify-end px-[11px] py-2">
<Button
size="sm"
onClick={() => openWithdrawDialog()}
data-testid="withdraw-dialog-button"
>
{t('Make withdrawal')}
</Button>
</div>
)}
</div>
{!isReadOnly && (
<div className="h-auto flex justify-end px-[11px] py-2 bottom-0 right-3 absolute dark:bg-black/75 bg-white/75 rounded">
<Button
variant="primary"
size="sm"
onClick={() => openWithdrawDialog()}
data-testid="withdraw-dialog-button"
>
{t('Make withdrawal')}
</Button>
</div>
)}
</VegaWalletContainer>
);
};
@@ -8,12 +8,15 @@ import { useVegaWallet } from '@vegaprotocol/wallet';
import type { PinnedAsset } from '@vegaprotocol/accounts';
import { AccountManager, useTransferDialog } from '@vegaprotocol/accounts';
import { useDepositDialog } from '@vegaprotocol/deposits';
import { useParams } from 'react-router-dom';
export const AccountsContainer = ({
pinnedAsset,
}: {
pinnedAsset?: PinnedAsset;
}) => {
const params = useParams();
const hideButtons = 'marketId' in params;
const { pubKey, isReadOnly } = useVegaWallet();
const { open: openAssetDetailsDialog } = useAssetDetailsDialogStore();
const openWithdrawalDialog = useWithdrawalDialog((store) => store.open);
@@ -36,27 +39,30 @@ export const AccountsContainer = ({
}
return (
<div className="h-full relative grid grid-rows-[1fr,min-content]">
<div>
<AccountManager
partyId={pubKey}
onClickAsset={onClickAsset}
onClickWithdraw={openWithdrawalDialog}
onClickDeposit={openDepositDialog}
isReadOnly={isReadOnly}
pinnedAsset={pinnedAsset}
/>
</div>
{!isReadOnly && (
<div className="flex gap-2 justify-end p-2 px-[11px]">
<div className="h-full relative">
<AccountManager
partyId={pubKey}
onClickAsset={onClickAsset}
onClickWithdraw={openWithdrawalDialog}
onClickDeposit={openDepositDialog}
isReadOnly={isReadOnly}
pinnedAsset={pinnedAsset}
/>
{!isReadOnly && !hideButtons && (
<div className="flex gap-2 justify-end p-2 px-[11px] fixed bottom-0 right-2 dark:bg-black/75 bg-white/75 rounded">
<Button
variant="primary"
size="sm"
data-testid="open-transfer-dialog"
onClick={() => openTransferDialog()}
>
{t('Transfer')}
</Button>
<Button size="sm" onClick={() => openDepositDialog()}>
<Button
variant="primary"
size="sm"
onClick={() => openDepositDialog()}
>
{t('Deposit')}
</Button>
</div>
+1 -1
View File
@@ -14,7 +14,7 @@ export const Footer = () => {
const { blockDiff, datanodeBlockHeight } = useNodeHealth();
return (
<footer className="px-4 py-1 text-xs border-t border-default text-vega-light-300 dark:text-vega-dark-300">
<footer className="px-4 py-1 text-xs border-t border-default text-vega-light-300 dark:text-vega-dark-300 fixed bottom-0 left-0 border-r bg-white dark:bg-black">
{/* Pull left to align with top nav, due to button padding */}
<div className="-ml-2">
{VEGA_URL && (
@@ -177,22 +177,14 @@ export const useEthereumTransactionToasts = () => {
store.remove,
]);
const [dismissTx, deleteTx] = useEthTransactionStore((state) => [
state.dismiss,
state.delete,
]);
const dismissTx = useEthTransactionStore((state) => state.dismiss);
const onClose = useCallback(
(tx: EthStoredTxState) => () => {
const safeToDelete = isFinal(tx);
if (safeToDelete) {
deleteTx(tx.id);
} else {
dismissTx(tx.id);
}
dismissTx(tx.id);
removeToast(`eth-${tx.id}`);
},
[deleteTx, dismissTx, removeToast]
[dismissTx, removeToast]
);
const fromEthTransaction = useCallback(
+1 -1
View File
@@ -72,7 +72,7 @@ function AppBody({ Component }: AppProps) {
const gridClasses = classNames(
'h-full relative z-0 grid',
'grid-rows-[repeat(3,min-content),1fr,min-content]'
'grid-rows-[repeat(3,min-content),1fr]'
);
return (
+24 -3
View File
@@ -1,12 +1,16 @@
import { useRef, useMemo, memo, useCallback } from 'react';
import { t } from '@vegaprotocol/i18n';
import { useDataProvider } from '@vegaprotocol/react-helpers';
import {
useDataProvider,
useBottomPlaceholder,
} from '@vegaprotocol/react-helpers';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import type { AgGridReact } from 'ag-grid-react';
import { useRef, useMemo, memo } from 'react';
import type { AccountFields } from './accounts-data-provider';
import { aggregatedAccountsDataProvider } from './accounts-data-provider';
import type { PinnedAsset } from './accounts-table';
import { AccountTable } from './accounts-table';
import type { RowHeightParams } from 'ag-grid-community';
interface AccountManagerProps {
partyId: string;
@@ -27,7 +31,6 @@ export const AccountManager = ({
}: AccountManagerProps) => {
const gridRef = useRef<AgGridReact | null>(null);
const variables = useMemo(() => ({ partyId }), [partyId]);
const { data, loading, error, reload } = useDataProvider<
AccountFields[],
never
@@ -35,6 +38,22 @@ export const AccountManager = ({
dataProvider: aggregatedAccountsDataProvider,
variables,
});
const setId = useCallback(
(data: AccountFields) => ({
...data,
asset: { ...data.asset, id: `${data.asset.id}-1` },
}),
[]
);
const bottomPlaceholderProps = useBottomPlaceholder<AccountFields>({
gridRef,
setId,
});
const getRowHeight = useCallback(
(params: RowHeightParams) => (params.node.rowPinned ? 32 : 22),
[]
);
return (
<div className="relative h-full">
<AccountTable
@@ -46,6 +65,8 @@ export const AccountManager = ({
isReadOnly={isReadOnly}
noRowsOverlayComponent={() => null}
pinnedAsset={pinnedAsset}
getRowHeight={getRowHeight}
{...bottomPlaceholderProps}
/>
<div className="pointer-events-none absolute inset-0">
<AsyncRenderer
+136 -87
View File
@@ -5,9 +5,12 @@ import type {
VegaICellRendererParams,
VegaValueFormatterParams,
} from '@vegaprotocol/datagrid';
import { ButtonLink, Dialog } from '@vegaprotocol/ui-toolkit';
import { Button, ButtonLink, Dialog } from '@vegaprotocol/ui-toolkit';
import { TooltipCellComponent } from '@vegaprotocol/ui-toolkit';
import { AgGridDynamic as AgGrid } from '@vegaprotocol/datagrid';
import {
AgGridDynamic as AgGrid,
CenteredGridCellWrapper,
} from '@vegaprotocol/datagrid';
import { AgGridColumn } from 'ag-grid-react';
import type { IDatasource, IGetRowsParams } from 'ag-grid-community';
import type { AgGridReact, AgGridReactProps } from 'ag-grid-react';
@@ -86,59 +89,85 @@ export const AccountTable = forwardRef<AgGridReact, AccountTableProps>(
cellRenderer={({
value,
data,
node,
}: VegaICellRendererParams<AccountFields, 'asset.symbol'>) => {
return value ? (
<ButtonLink
data-testid="asset"
onClick={() => {
if (data) {
onClickAsset(data.asset.id);
}
}}
<CenteredGridCellWrapper
className={node.rowPinned ? 'h-[30px]' : undefined}
>
{value}
</ButtonLink>
<ButtonLink
data-testid="asset"
onClick={() => {
if (data) {
onClickAsset(data.asset.id);
}
}}
>
{value}
</ButtonLink>
</CenteredGridCellWrapper>
) : null;
}}
maxWidth={300}
/>
<AgGridColumn
headerName={t('Total')}
type="rightAligned"
field="deposited"
headerTooltip={t(
'This is the total amount of collateral used plus the amount available in your general account.'
)}
valueFormatter={({
value,
data,
}: VegaValueFormatterParams<AccountFields, 'deposited'>) =>
data &&
data.asset &&
isNumeric(value) &&
addDecimalsFormatNumber(value, data.asset.decimals)
}
maxWidth={300}
cellRenderer={({
data,
value,
node,
}: VegaICellRendererParams<AccountFields, 'deposited'>) => {
const valueFormatted =
data &&
data.asset &&
isNumeric(value) &&
addDecimalsFormatNumber(value, data.asset.decimals);
return node.rowPinned ? (
<CenteredGridCellWrapper className="h-[30px] justify-end">
{valueFormatted}
</CenteredGridCellWrapper>
) : (
valueFormatted
);
}}
/>
<AgGridColumn
headerName={t('Used')}
type="rightAligned"
field="used"
headerTooltip={t(
'This is the amount of collateral used from your general account.'
)}
valueFormatter={({
value,
data,
}: VegaValueFormatterParams<AccountFields, 'used'>) =>
data &&
data.asset &&
isNumeric(value) &&
addDecimalsFormatNumber(value, data.asset.decimals)
}
maxWidth={300}
cellRenderer={({
data,
value,
node,
}: VegaICellRendererParams<AccountFields, 'used'>) => {
const valueFormatted =
data &&
data.asset &&
isNumeric(value) &&
addDecimalsFormatNumber(value, data.asset.decimals);
return node.rowPinned ? (
<CenteredGridCellWrapper className="h-[30px] justify-end">
{valueFormatted}
</CenteredGridCellWrapper>
) : (
valueFormatted
);
}}
/>
<AgGridColumn
headerName={t('Available')}
field="available"
type="rightAligned"
headerTooltip={t(
'This is the amount of collateral available in your general account.'
)}
@@ -152,73 +181,93 @@ export const AccountTable = forwardRef<AgGridReact, AccountTableProps>(
addDecimalsFormatNumber(value, data.asset.decimals)
}
maxWidth={300}
cellRenderer={({
data,
value,
node,
}: VegaICellRendererParams<AccountFields, 'available'>) => {
const valueFormatted =
data &&
data.asset &&
isNumeric(value) &&
addDecimalsFormatNumber(value, data.asset.decimals);
return node.rowPinned ? (
<CenteredGridCellWrapper className="h-[30px] justify-end">
{valueFormatted}
</CenteredGridCellWrapper>
) : (
valueFormatted
);
}}
/>
{
<AgGridColumn
colId="breakdown"
headerName=""
sortable={false}
minWidth={200}
type="rightAligned"
cellRenderer={({
data,
}: VegaICellRendererParams<AccountFields>) => {
if (!data) return null;
else {
if (
data.asset.id === pinnedAssetId &&
new BigNumber(data.deposited).isLessThanOrEqualTo(0)
) {
return (
<ButtonLink
<AgGridColumn
colId="breakdown"
headerName=""
sortable={false}
minWidth={200}
type="rightAligned"
cellRenderer={({
data,
}: VegaICellRendererParams<AccountFields>) => {
if (!data) return null;
else {
if (
data.asset.id === pinnedAssetId &&
new BigNumber(data.deposited).isLessThanOrEqualTo(0)
) {
return (
<CenteredGridCellWrapper className="h-[30px] justify-end py-1">
<Button
size="xs"
variant="primary"
data-testid="deposit"
onClick={() => {
onClickDeposit && onClickDeposit(data.asset.id);
}}
>
{t('Deposit to trade')}
</ButtonLink>
);
}
return (
<>
<ButtonLink
data-testid="breakdown"
onClick={() => {
setOpenBreakdown(!openBreakdown);
setBreakdown(data.breakdown || null);
}}
>
{t('Breakdown')}
</ButtonLink>
<span className="mx-1" />
{!props.isReadOnly && (
<ButtonLink
data-testid="deposit"
onClick={() => {
onClickDeposit && onClickDeposit(data.asset.id);
}}
>
{t('Deposit')}
</ButtonLink>
)}
<span className="mx-1" />
{!props.isReadOnly && (
<ButtonLink
data-testid="withdraw"
onClick={() =>
onClickWithdraw && onClickWithdraw(data.asset.id)
}
>
{t('Withdraw')}
</ButtonLink>
)}
</>
</Button>
</CenteredGridCellWrapper>
);
}
}}
/>
}
return (
<>
<ButtonLink
data-testid="breakdown"
onClick={() => {
setOpenBreakdown(!openBreakdown);
setBreakdown(data.breakdown || null);
}}
>
{t('Breakdown')}
</ButtonLink>
<span className="mx-1" />
{!props.isReadOnly && (
<ButtonLink
data-testid="deposit"
onClick={() => {
onClickDeposit && onClickDeposit(data.asset.id);
}}
>
{t('Deposit')}
</ButtonLink>
)}
<span className="mx-1" />
{!props.isReadOnly && (
<ButtonLink
data-testid="withdraw"
onClick={() =>
onClickWithdraw && onClickWithdraw(data.asset.id)
}
>
{t('Withdraw')}
</ButtonLink>
)}
</>
);
}
}}
/>
</AgGrid>
<Dialog size="medium" open={openBreakdown} onChange={setOpenBreakdown}>
<div className="h-[35vh] w-full m-auto flex flex-col">
+1
View File
@@ -8,6 +8,7 @@ export * from './lib/cells/price-cell';
export * from './lib/cells/price-change-cell';
export * from './lib/cells/price-flash-cell';
export * from './lib/cells/vol-cell';
export * from './lib/cells/centered-grid-cell';
export * from './lib/filters/date-range-filter';
export * from './lib/filters/set-filter';
@@ -22,13 +22,15 @@ const agGridDarkVariables = `
border-width: 1px 0;
border-bottom: 1px solid transparent;
}
.ag-theme-balham-dark .ag-row.no-hover, .ag-theme-balham-dark .ag-row.no-hover:hover {
background: black;
}
.ag-theme-balham-dark .ag-react-container {
overflow: hidden;
text-overflow: ellipsis;
}
.ag-theme-balham-dark .ag-cell, .ag-theme-balham-dark .ag-full-width-row .ag-cell-wrapper.ag-row-group {
.ag-theme-balham-dark .ag-cell, .ag-theme-balham-dark .ag-full-width-row .ag-cell-wrapper.ag-row-group {
line-height: calc(min(var(--ag-line-height, 26px), 26px) - 4px);
}
`;
@@ -22,13 +22,15 @@ const agGridLightVariables = `
border-width: 1px 0;
border-bottom: 1px solid transparent;
}
.ag-theme-balham .ag-row.no-hover, .ag-theme-balham .ag-row.no-hover:hover {
background: white;
}
.ag-theme-balham .ag-react-container {
overflow: hidden;
text-overflow: ellipsis;
}
.ag-theme-balham .ag-cell, .ag-theme-balham .ag-full-width-row .ag-cell-wrapper.ag-row-group {
.ag-theme-balham .ag-cell, .ag-theme-balham .ag-full-width-row .ag-cell-wrapper.ag-row-group {
line-height: calc(min(var(--ag-line-height, 26px), 26px) - 4px);
}
`;
@@ -0,0 +1,16 @@
import type { ReactNode } from 'react';
import classNames from 'classnames';
export const CenteredGridCellWrapper = ({
children,
className,
}: {
children: ReactNode;
className?: string;
}) => (
<div
className={classNames('flex h-[20px] p-0 justify-items-center', className)}
>
<div className="self-center">{children}</div>
</div>
);
+21
View File
@@ -0,0 +1,21 @@
import type { StateCreator } from 'zustand';
import { act } from 'react-dom/test-utils';
const { create: actualCreate } = jest.requireActual('zustand'); // if using jest
// a variable to hold reset functions for all stores declared in the app
const storeResetFns = new Set<() => void>();
// when creating a store, we get its initial state, create a reset function and add it in the set
export const create =
() =>
<S>(createState: StateCreator<S>) => {
const store = actualCreate(createState);
const initialState = store.getState();
storeResetFns.add(() => store.setState(initialState, true));
return store;
};
// Reset all stores after each test run
beforeEach(() => {
act(() => storeResetFns.forEach((resetFn) => resetFn()));
});
@@ -1,7 +1,7 @@
import { formatNumber } from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n';
import { Notification, Intent } from '@vegaprotocol/ui-toolkit';
import { DepositDialog, useDepositDialog } from '@vegaprotocol/deposits';
import { useDepositDialog } from '@vegaprotocol/deposits';
interface Props {
margin: string;
@@ -16,25 +16,22 @@ interface Props {
export const MarginWarning = ({ margin, balance, asset }: Props) => {
const openDepositDialog = useDepositDialog((state) => state.open);
return (
<>
<Notification
intent={Intent.Warning}
testId="dealticket-warning-margin"
message={`You may not have enough margin available to open this position. ${formatNumber(
margin,
asset.decimals
)} ${asset.symbol} ${t(
'is currently required. You have only'
)} ${formatNumber(balance, asset.decimals)} ${asset.symbol} ${t(
'available.'
)}`}
buttonProps={{
text: t(`Deposit ${asset.symbol}`),
action: () => openDepositDialog(asset.id),
dataTestId: 'deal-ticket-deposit-dialog-button',
}}
/>
<DepositDialog />
</>
<Notification
intent={Intent.Warning}
testId="dealticket-warning-margin"
message={`You may not have enough margin available to open this position. ${formatNumber(
margin,
asset.decimals
)} ${asset.symbol} ${t(
'is currently required. You have only'
)} ${formatNumber(balance, asset.decimals)} ${asset.symbol} ${t(
'available.'
)}`}
buttonProps={{
text: t(`Deposit ${asset.symbol}`),
action: () => openDepositDialog(asset.id),
dataTestId: 'deal-ticket-deposit-dialog-button',
}}
/>
);
};
@@ -7,7 +7,7 @@ interface ZeroBalanceErrorProps {
id: string;
symbol: string;
};
onClickCollateral: () => void;
onClickCollateral?: () => void;
}
export const ZeroBalanceError = ({
@@ -21,8 +21,12 @@ export const ZeroBalanceError = ({
testId="dealticket-error-message-zero-balance"
message={
<>
You need {asset.symbol} in your wallet to trade in this market. See
all your <Link onClick={onClickCollateral}>collateral</Link>.
You need {asset.symbol} in your wallet to trade in this market.
{onClickCollateral && (
<>
See all your <Link onClick={onClickCollateral}>collateral</Link>.
</>
)}
</>
}
buttonProps={{
@@ -1,17 +1,21 @@
import type { UseFormRegister } from 'react-hook-form';
import type { Control } from 'react-hook-form';
import type { Market, MarketData } from '@vegaprotocol/market-list';
import { DealTicketMarketAmount } from './deal-ticket-market-amount';
import { DealTicketLimitAmount } from './deal-ticket-limit-amount';
import * as Schema from '@vegaprotocol/types';
import type { DealTicketFormFields } from './deal-ticket';
import type { OrderObj } from '@vegaprotocol/orders';
import type { OrderFormFields } from '../../hooks/use-order-form';
export interface DealTicketAmountProps {
control: Control<OrderFormFields>;
orderType: Schema.OrderType;
marketData: MarketData;
market: Market;
register: UseFormRegister<DealTicketFormFields>;
sizeError?: string;
priceError?: string;
update: (obj: Partial<OrderObj>) => void;
size: string;
price?: string;
}
export const DealTicketAmount = ({
@@ -47,7 +47,7 @@ export const DealTicketContainer = ({
market={market}
marketData={marketData}
submit={(orderSubmission) => create({ orderSubmission })}
onClickCollateral={onClickCollateral || (() => null)}
onClickCollateral={onClickCollateral}
/>
) : (
<Splash>
@@ -2,6 +2,7 @@ import { FormGroup, Input, InputError } from '@vegaprotocol/ui-toolkit';
import { toDecimal, validateAmount } from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n';
import type { DealTicketAmountProps } from './deal-ticket-amount';
import { Controller } from 'react-hook-form';
export type DealTicketLimitAmountProps = Omit<
Omit<DealTicketAmountProps, 'marketData'>,
@@ -9,10 +10,13 @@ export type DealTicketLimitAmountProps = Omit<
>;
export const DealTicketLimitAmount = ({
register,
control,
market,
sizeError,
priceError,
update,
price,
size,
}: DealTicketLimitAmountProps) => {
const priceStep = toDecimal(market?.decimalPlaces);
const sizeStep = toDecimal(market?.positionDecimalPlaces);
@@ -47,22 +51,30 @@ export const DealTicketLimitAmount = ({
labelFor="input-order-size-limit"
className="!mb-1"
>
<Input
id="input-order-size-limit"
className="w-full"
type="number"
step={sizeStep}
min={sizeStep}
data-testid="order-size"
onWheel={(e) => e.currentTarget.blur()}
{...register('size', {
<Controller
name="size"
control={control}
rules={{
required: t('You need to provide a size'),
min: {
value: sizeStep,
message: t('Size cannot be lower than ' + sizeStep),
},
validate: validateAmount(sizeStep, 'Size'),
})}
}}
render={() => (
<Input
id="input-order-size-limit"
className="w-full"
type="number"
value={size}
onChange={(e) => update({ size: e.target.value })}
step={sizeStep}
min={sizeStep}
data-testid="order-size"
onWheel={(e) => e.currentTarget.blur()}
/>
)}
/>
</FormGroup>
</div>
@@ -77,14 +89,10 @@ export const DealTicketLimitAmount = ({
labelAlign="right"
className="!mb-1"
>
<Input
id="input-price-quote"
className="w-full"
type="number"
step={priceStep}
data-testid="order-price"
onWheel={(e) => e.currentTarget.blur()}
{...register('price', {
<Controller
name="price"
control={control}
rules={{
required: t('You need provide a price'),
min: {
value: priceStep,
@@ -92,7 +100,19 @@ export const DealTicketLimitAmount = ({
},
// @ts-ignore this fulfills the interface but still errors
validate: validateAmount(priceStep, 'Price'),
})}
}}
render={() => (
<Input
id="input-price-quote"
className="w-full"
type="number"
value={price}
onChange={(e) => update({ price: e.target.value })}
step={priceStep}
data-testid="order-price"
onWheel={(e) => e.currentTarget.blur()}
/>
)}
/>
</FormGroup>
</div>
@@ -8,6 +8,7 @@ import { Input, InputError, Tooltip } from '@vegaprotocol/ui-toolkit';
import { isMarketInAuction } from '../../utils';
import type { DealTicketAmountProps } from './deal-ticket-amount';
import { getMarketPrice } from '../../utils/get-price';
import { Controller } from 'react-hook-form';
export type DealTicketMarketAmountProps = Omit<
DealTicketAmountProps,
@@ -15,10 +16,12 @@ export type DealTicketMarketAmountProps = Omit<
>;
export const DealTicketMarketAmount = ({
register,
control,
market,
marketData,
sizeError,
update,
size,
}: DealTicketMarketAmountProps) => {
const quoteName = market.tradableInstrument.instrument.product.quoteName;
const sizeStep = toDecimal(market?.positionDecimalPlaces);
@@ -47,22 +50,30 @@ export const DealTicketMarketAmount = ({
</div>
<div className="flex items-center gap-4">
<div className="flex-1">
<Input
id="input-order-size-market"
className="w-full"
type="number"
step={sizeStep}
min={sizeStep}
onWheel={(e) => e.currentTarget.blur()}
data-testid="order-size"
{...register('size', {
<Controller
name="size"
control={control}
rules={{
required: t('You need to provide a size'),
min: {
value: sizeStep,
message: t('Size cannot be lower than ' + sizeStep),
},
validate: validateAmount(sizeStep, 'Size'),
})}
}}
render={() => (
<Input
id="input-order-size-market"
className="w-full"
type="number"
value={size}
onChange={(e) => update({ size: e.target.value })}
step={sizeStep}
min={sizeStep}
onWheel={(e) => e.currentTarget.blur()}
data-testid="order-size"
/>
)}
/>
</div>
<div>@</div>
@@ -1,38 +1,28 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { VegaWalletContext } from '@vegaprotocol/wallet';
import { fireEvent, render, screen, act } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { generateMarket, generateMarketData } from '../../test-helpers';
import { DealTicket } from './deal-ticket';
import * as Schema from '@vegaprotocol/types';
import type { OrderSubmissionBody } from '@vegaprotocol/wallet';
import type { MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing';
import type { ChainIdQuery } from '@vegaprotocol/react-helpers';
import { ChainIdDocument } from '@vegaprotocol/react-helpers';
import { addDecimal } from '@vegaprotocol/utils';
import { useOrderStore } from '@vegaprotocol/orders';
jest.mock('zustand');
jest.mock('./deal-ticket-fee-details', () => ({
DealTicketFeeDetails: () => <div data-testid="deal-ticket-fee-details" />,
}));
const pubKey = 'pubKey';
const market = generateMarket();
const marketData = generateMarketData();
const submit = jest.fn();
const mockChainId = 'chain-id';
function generateJsx(order?: OrderSubmissionBody['orderSubmission']) {
const chainIdMock: MockedResponse<ChainIdQuery> = {
request: {
query: ChainIdDocument,
},
result: {
data: {
statistics: {
chainId: mockChainId,
},
},
},
};
function generateJsx() {
return (
<MockedProvider mocks={[chainIdMock]}>
<VegaWalletContext.Provider value={{ pubKey: mockChainId } as any}>
<MockedProvider>
<VegaWalletContext.Provider value={{ pubKey, isReadOnly: false } as any}>
<DealTicket market={market} marketData={marketData} submit={submit} />
</VegaWalletContext.Provider>
</MockedProvider>
@@ -41,10 +31,11 @@ function generateJsx(order?: OrderSubmissionBody['orderSubmission']) {
describe('DealTicket', () => {
beforeEach(() => {
window.localStorage.clear();
localStorage.clear();
});
afterEach(() => {
window.localStorage.clear();
localStorage.clear();
jest.clearAllMocks();
});
@@ -61,9 +52,7 @@ describe('DealTicket', () => {
expect(
screen.queryByTestId('order-side-SIDE_SELL')?.querySelector('input')
).not.toBeChecked();
expect(screen.getByTestId('order-size')).toHaveDisplayValue(
String(1 / Math.pow(10, market.positionDecimalPlaces))
);
expect(screen.getByTestId('order-size')).toHaveDisplayValue('0');
expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
);
@@ -76,7 +65,49 @@ describe('DealTicket', () => {
);
});
it('handles TIF select box dependent on order type', () => {
it('should use local storage state for initial values', () => {
const expectedOrder = {
marketId: market.id,
type: Schema.OrderType.TYPE_LIMIT,
side: Schema.Side.SIDE_SELL,
size: '0.1',
price: '300.22',
timeInForce: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC,
persist: true,
};
useOrderStore.setState({
orders: {
[expectedOrder.marketId]: expectedOrder,
},
});
render(generateJsx());
// Assert correct defaults are used from store
expect(
screen
.getByTestId(`order-type-${Schema.OrderType.TYPE_LIMIT}`)
.querySelector('input')
).toBeChecked();
expect(
screen.queryByTestId('order-side-SIDE_SELL')?.querySelector('input')
).toBeChecked();
expect(
screen.queryByTestId('order-side-SIDE_BUY')?.querySelector('input')
).not.toBeChecked();
expect(screen.getByTestId('order-size')).toHaveDisplayValue(
expectedOrder.size
);
expect(screen.getByTestId('order-tif')).toHaveValue(
expectedOrder.timeInForce
);
expect(screen.getByTestId('order-price')).toHaveDisplayValue(
expectedOrder.price
);
});
it('handles TIF select box dependent on order type', async () => {
render(generateJsx());
// Only FOK and IOC should be present by default (type market order)
@@ -86,50 +117,72 @@ describe('DealTicket', () => {
)
).toEqual(['Fill or Kill (FOK)', 'Immediate or Cancel (IOC)']);
// IOC should be default
expect(screen.getByTestId('order-tif')).toHaveDisplayValue(
'Immediate or Cancel (IOC)'
);
// Select FOK - FOK should be selected
await userEvent.selectOptions(
screen.getByTestId('order-tif'),
Schema.OrderTimeInForce.TIME_IN_FORCE_FOK
);
expect(screen.getByTestId('order-tif')).toHaveDisplayValue(
'Fill or Kill (FOK)'
);
// Switch to type limit order -> all TIF options should be shown
fireEvent.click(screen.getByTestId('order-type-TYPE_LIMIT'));
await userEvent.click(screen.getByTestId('order-type-TYPE_LIMIT'));
expect(screen.getByTestId('order-tif').children).toHaveLength(
Object.keys(Schema.OrderTimeInForce).length
);
// Select GTC -> GTC should be selected
fireEvent.change(screen.getByTestId('order-tif'), {
target: { value: Schema.OrderTimeInForce.TIME_IN_FORCE_GTC },
});
expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_GTC
);
// Switch to type market order -> IOC should be selected (default)
fireEvent.click(screen.getByTestId('order-type-TYPE_MARKET'));
expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
);
// Select IOC -> IOC should be selected
fireEvent.change(screen.getByTestId('order-tif'), {
target: { value: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC },
});
expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
);
// Switch to type limit order -> GTC should be selected
fireEvent.click(screen.getByTestId('order-type-TYPE_LIMIT'));
// expect GTC as LIMIT default
expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_GTC
);
// Select GTT -> GTT should be selected
fireEvent.change(screen.getByTestId('order-tif'), {
target: { value: Schema.OrderTimeInForce.TIME_IN_FORCE_GTT },
});
await userEvent.selectOptions(
screen.getByTestId('order-tif'),
Schema.OrderTimeInForce.TIME_IN_FORCE_GTT
);
expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_GTT
);
// Switch to type market order -> IOC should be selected
fireEvent.click(screen.getByTestId('order-type-TYPE_MARKET'));
// Switch back to type market order -> FOK should be preserved from previous selection
await userEvent.click(screen.getByTestId('order-type-TYPE_MARKET'));
expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_FOK
);
// Select IOC -> IOC should be selected
await userEvent.selectOptions(
screen.getByTestId('order-tif'),
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
);
expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
);
// Switch back type limit order -> GTT should be preserved
await userEvent.click(screen.getByTestId('order-type-TYPE_LIMIT'));
expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_GTT
);
// Select GFN -> GFN should be selected
await userEvent.selectOptions(
screen.getByTestId('order-tif'),
Schema.OrderTimeInForce.TIME_IN_FORCE_GFN
);
expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_GFN
);
// Switch to type market order -> IOC should be preserved
await userEvent.click(screen.getByTestId('order-type-TYPE_MARKET'));
expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
);
@@ -143,23 +196,20 @@ describe('DealTicket', () => {
screen.getByTestId('order-side-SIDE_BUY')?.querySelector('input')
).toBeChecked();
await act(async () => {
fireEvent.change(screen.getByTestId('order-size'), {
target: { value: '200' },
});
});
await userEvent.type(screen.getByTestId('order-size'), '200');
expect(screen.getByTestId('order-size')).toHaveDisplayValue('200');
fireEvent.change(screen.getByTestId('order-tif'), {
target: { value: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC },
});
await userEvent.selectOptions(
screen.getByTestId('order-tif'),
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
);
expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
);
// Switch to limit order
fireEvent.click(screen.getByTestId('order-type-TYPE_LIMIT'));
await userEvent.click(screen.getByTestId('order-type-TYPE_LIMIT'));
// Check all TIF options shown
expect(screen.getByTestId('order-tif').children).toHaveLength(
@@ -1,7 +1,7 @@
import { t } from '@vegaprotocol/i18n';
import * as Schema from '@vegaprotocol/types';
import { memo, useCallback, useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { memo, useCallback, useEffect, useState } from 'react';
import { Controller } from 'react-hook-form';
import { DealTicketAmount } from './deal-ticket-amount';
import { DealTicketButton } from './deal-ticket-button';
import { DealTicketFeeDetails } from './deal-ticket-fee-details';
@@ -9,10 +9,12 @@ import { ExpirySelector } from './expiry-selector';
import { SideSelector } from './side-selector';
import { TimeInForceSelector } from './time-in-force-selector';
import { TypeSelector } from './type-selector';
import type { OrderSubmissionBody } from '@vegaprotocol/wallet';
import { useVegaWalletDialogStore } from '@vegaprotocol/wallet';
import { normalizeOrderSubmission } from '@vegaprotocol/wallet';
import { useVegaWallet } from '@vegaprotocol/wallet';
import type { OrderSubmission } from '@vegaprotocol/wallet';
import {
normalizeOrderSubmission,
useVegaWallet,
useVegaWalletDialogStore,
} from '@vegaprotocol/wallet';
import {
ExternalLink,
InputError,
@@ -22,7 +24,7 @@ import {
import { useOrderMarginValidation } from '../../hooks/use-order-margin-validation';
import { MarginWarning } from '../deal-ticket-validation/margin-warning';
import {
getDefaultOrder,
validateExpiration,
validateMarketState,
validateMarketTradingMode,
validateTimeInForce,
@@ -32,27 +34,17 @@ import { ZeroBalanceError } from '../deal-ticket-validation/zero-balance-error';
import { SummaryValidationType } from '../../constants';
import { useHasNoBalance } from '../../hooks/use-has-no-balance';
import type { Market, MarketData } from '@vegaprotocol/market-list';
import {
usePersistedOrderStore,
usePersistedOrderStoreSubscription,
} from '@vegaprotocol/orders';
import { OrderType } from '@vegaprotocol/types';
export type TransactionStatus = 'default' | 'pending';
import { OrderTimeInForce, OrderType } from '@vegaprotocol/types';
import { useOrderForm } from '../../hooks/use-order-form';
import type { OrderObj } from '@vegaprotocol/orders';
export interface DealTicketProps {
market: Market;
marketData: MarketData;
submit: (order: OrderSubmissionBody['orderSubmission']) => void;
submit: (order: OrderSubmission) => void;
onClickCollateral?: () => void;
}
export type DealTicketFormFields = OrderSubmissionBody['orderSubmission'] & {
// This is not a field used in the form but allows us to set a
// summary error message
summary: string;
};
export const DealTicket = ({
market,
marketData,
@@ -60,44 +52,21 @@ export const DealTicket = ({
onClickCollateral,
}: DealTicketProps) => {
const { pubKey, isReadOnly } = useVegaWallet();
const { getPersistedOrder, setPersistedOrder } = usePersistedOrderStore(
(store) => ({
getPersistedOrder: store.getOrder,
setPersistedOrder: store.setOrder,
})
);
// store last used tif for market so that when changing OrderType the previous TIF
// selection for that type is used when switching back
const [lastTIF, setLastTIF] = useState({
[OrderType.TYPE_MARKET]: OrderTimeInForce.TIME_IN_FORCE_IOC,
[OrderType.TYPE_LIMIT]: OrderTimeInForce.TIME_IN_FORCE_GTC,
});
const {
register,
control,
handleSubmit,
watch,
errors,
order,
setError,
clearErrors,
formState: { errors },
setValue,
} = useForm<DealTicketFormFields>({
defaultValues: getPersistedOrder(market.id) || getDefaultOrder(market),
});
const order = watch();
watch((orderData) => {
const persistable = !(
orderData.type === OrderType.TYPE_LIMIT && orderData.price === ''
);
if (persistable) {
setPersistedOrder(orderData as DealTicketFormFields);
}
});
usePersistedOrderStoreSubscription(market.id, (storedOrder) => {
if (order.price !== storedOrder.price) {
clearErrors('price');
setValue('price', storedOrder.price);
}
});
update,
handleSubmit,
} = useOrderForm(market.id);
const marketStateError = validateMarketState(marketData.marketState);
const hasNoBalance = useHasNoBalance(
market.tradableInstrument.instrument.product.settlementAsset.id
@@ -166,7 +135,7 @@ export const DealTicket = ({
]);
const onSubmit = useCallback(
(order: OrderSubmissionBody['orderSubmission']) => {
(order: OrderSubmission) => {
checkForErrors();
submit(
normalizeOrderSubmission(
@@ -179,9 +148,12 @@ export const DealTicket = ({
[checkForErrors, submit, market.decimalPlaces, market.positionDecimalPlaces]
);
// if an order doesn't exist one will be created by the store immediately
if (!order) return null;
return (
<form
onSubmit={isReadOnly ? () => null : handleSubmit(onSubmit)}
onSubmit={isReadOnly ? undefined : handleSubmit(onSubmit)}
className="p-4"
noValidate
>
@@ -194,10 +166,17 @@ export const DealTicket = ({
marketData.trigger
),
}}
render={({ field }) => (
render={() => (
<TypeSelector
value={field.value}
onSelect={field.onChange}
value={order.type}
onSelect={(type) => {
if (type === OrderType.TYPE_NETWORK) return;
update({
type,
// when changing type also update the tif to what was last used of new type
timeInForce: lastTIF[type] || order.timeInForce,
});
}}
market={market}
marketData={marketData}
errorMessage={errors.type?.message}
@@ -207,17 +186,25 @@ export const DealTicket = ({
<Controller
name="side"
control={control}
render={({ field }) => (
<SideSelector value={field.value} onSelect={field.onChange} />
render={() => (
<SideSelector
value={order.side}
onSelect={(side) => {
update({ side });
}}
/>
)}
/>
<DealTicketAmount
control={control}
orderType={order.type}
market={market}
marketData={marketData}
register={register}
sizeError={errors.size?.message}
priceError={errors.price?.message}
update={update}
size={order.size}
price={order.price}
/>
<Controller
name="timeInForce"
@@ -228,11 +215,16 @@ export const DealTicket = ({
marketData.trigger
),
}}
render={({ field }) => (
render={() => (
<TimeInForceSelector
value={field.value}
value={order.timeInForce}
orderType={order.type}
onSelect={field.onChange}
onSelect={(timeInForce) => {
update({ timeInForce });
// Set tif value for the given order type, so that when switching
// types we know the last used TIF for the given order type
setLastTIF((curr) => ({ ...curr, [order.type]: timeInForce }));
}}
market={market}
marketData={marketData}
errorMessage={errors.timeInForce?.message}
@@ -244,12 +236,18 @@ export const DealTicket = ({
<Controller
name="expiresAt"
control={control}
render={({ field }) => (
rules={{
validate: validateExpiration,
}}
render={() => (
<ExpirySelector
value={field.value}
onSelect={field.onChange}
value={order.expiresAt}
onSelect={(expiresAt) =>
update({
expiresAt: expiresAt || undefined,
})
}
errorMessage={errors.expiresAt?.message}
register={register}
/>
)}
/>
@@ -261,7 +259,7 @@ export const DealTicket = ({
order={order}
isReadOnly={isReadOnly}
pubKey={pubKey}
onClickCollateral={onClickCollateral || (() => null)}
onClickCollateral={onClickCollateral}
/>
<DealTicketButton
disabled={Object.keys(errors).length >= 1 || isReadOnly}
@@ -284,10 +282,10 @@ interface SummaryMessageProps {
errorMessage?: string;
market: Market;
marketData: MarketData;
order: OrderSubmissionBody['orderSubmission'];
order: OrderObj;
isReadOnly: boolean;
pubKey: string | null;
onClickCollateral: () => void;
onClickCollateral?: () => void;
}
const SummaryMessage = memo(
({
@@ -1,28 +1,27 @@
import { FormGroup, Input, InputError } from '@vegaprotocol/ui-toolkit';
import { formatForInput } from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n';
import type { UseFormRegister } from 'react-hook-form';
import { validateExpiration } from '../../utils/validate-expiration';
import type { DealTicketFormFields } from '.';
interface ExpirySelectorProps {
value?: string;
onSelect: (expiration: string | null) => void;
errorMessage?: string;
register?: UseFormRegister<DealTicketFormFields>;
}
export const ExpirySelector = ({
value,
onSelect,
errorMessage,
register,
}: ExpirySelectorProps) => {
const date = value ? new Date(value) : new Date();
const dateFormatted = formatForInput(date);
const minDate = formatForInput(date);
return (
<FormGroup label={t('Expiry time/date')} labelFor="expiration">
<FormGroup
label={t('Expiry time/date')}
labelFor="expiration"
compact={true}
>
<Input
data-testid="date-picker-field"
id="expiration"
@@ -30,9 +29,6 @@ export const ExpirySelector = ({
value={dateFormatted}
onChange={(e) => onSelect(e.target.value)}
min={minDate}
{...register?.('expiresAt', {
validate: validateExpiration,
})}
/>
{errorMessage && (
<InputError testId="dealticket-error-message-expiry">
@@ -26,7 +26,11 @@ export const SideSelector = ({ value, onSelect }: SideSelectorProps) => {
};
return (
<FormGroup label={t('Direction')} labelFor="order-side-toggle">
<FormGroup
label={t('Direction')}
labelFor="order-side-toggle"
compact={true}
>
<Toggle
id="order-side-toggle"
name="order-side"
@@ -1,4 +1,3 @@
import { useEffect, useState } from 'react';
import {
FormGroup,
InputError,
@@ -22,15 +21,6 @@ interface TimeInForceSelectorProps {
errorMessage?: string;
}
type OrderType = Schema.OrderType.TYPE_MARKET | Schema.OrderType.TYPE_LIMIT;
type PreviousTimeInForce = {
[key in OrderType]: Schema.OrderTimeInForce;
};
const DEFAULT_TIME_IN_FORCE: PreviousTimeInForce = {
[Schema.OrderType.TYPE_MARKET]: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC,
[Schema.OrderType.TYPE_LIMIT]: Schema.OrderTimeInForce.TIME_IN_FORCE_GTC,
};
export const TimeInForceSelector = ({
value,
orderType,
@@ -47,28 +37,6 @@ export const TimeInForceSelector = ({
timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_FOK ||
timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
);
const [previousOrderType, setPreviousOrderType] = useState(
Schema.OrderType.TYPE_MARKET
);
const [previousTimeInForce, setPreviousTimeInForce] =
useState<PreviousTimeInForce>({
...DEFAULT_TIME_IN_FORCE,
[orderType]: value,
});
useEffect(() => {
if (previousOrderType !== orderType) {
setPreviousOrderType(orderType);
const prev = previousTimeInForce[orderType as OrderType];
onSelect(prev);
}
}, [
onSelect,
orderType,
previousTimeInForce,
previousOrderType,
setPreviousOrderType,
]);
const renderError = (errorType: string) => {
if (errorType === MarketModeValidationType.Auction) {
@@ -119,15 +87,25 @@ export const TimeInForceSelector = ({
};
return (
<FormGroup label={t('Time in force')} labelFor="select-time-in-force">
<FormGroup
label={t('Time in force')}
labelFor="select-time-in-force"
compact={true}
>
<Select
id="select-time-in-force"
value={value}
onChange={(e) => {
setPreviousTimeInForce({
...previousTimeInForce,
[orderType]: e.target.value,
});
// setPreviousTimeInForce({
// ...previousTimeInForce,
// [orderType]: e.target.value,
// });
// if (previousOrderType !== orderType) {
// setPreviousOrderType(orderType);
// const prev = previousTimeInForce[orderType as OrderType];
// onSelect(prev);
// }
onSelect(e.target.value as Schema.OrderTimeInForce);
}}
className="w-full"
@@ -74,7 +74,7 @@ export const TypeSelector = ({
};
return (
<FormGroup label={t('Order type')} labelFor="order-type">
<FormGroup label={t('Order type')} labelFor="order-type" compact={true}>
<Toggle
id="order-type"
name="order-type"
@@ -0,0 +1,66 @@
import omit from 'lodash/omit';
import { act, renderHook } from '@testing-library/react';
import { getDefaultOrder, useOrderStore } from '@vegaprotocol/orders';
import { useOrderForm } from './use-order-form';
jest.mock('zustand');
describe('useOrderForm', () => {
const marketId = 'market-id';
const setup = (marketId: string) => {
return renderHook(() => useOrderForm(marketId));
};
it('updates form fields when the order changes', async () => {
const order = getDefaultOrder(marketId);
const { result } = setup(marketId);
// expect default values
expect(result.current.order).toEqual(order);
expect(result.current.getValues()).toEqual(order);
const priceUpdate = {
...order,
price: '100',
size: '22',
};
await act(async () => {
useOrderStore.setState({
orders: {
[marketId]: priceUpdate,
},
});
});
// check order store has updated fields
expect(result.current.order).toEqual(priceUpdate);
// check react-hook-form has updated fields
expect(result.current.getValues()).toEqual(priceUpdate);
});
it('removes persist key on submit', async () => {
const order = {
...getDefaultOrder(marketId),
price: '99',
size: '22',
};
const onSubmit = jest.fn();
const { result } = setup(marketId);
await act(async () => {
useOrderStore.setState({
orders: {
[marketId]: order,
},
});
});
await act(async () => {
result.current.handleSubmit(onSubmit)();
});
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0]).toEqual(omit(order, 'persist'));
expect(onSubmit.mock.calls[0][0].persist).toBeUndefined();
});
});
@@ -0,0 +1,74 @@
import omit from 'lodash/omit';
import type { OrderObj } from '@vegaprotocol/orders';
import { getDefaultOrder, useOrder } from '@vegaprotocol/orders';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import type { OrderSubmission } from '@vegaprotocol/wallet';
import type { Exact } from 'type-fest';
export type OrderFormFields = OrderObj & {
summary: string;
};
/**
* Connects the order store to a react-hook-form instance. Any time a field
* changes in the store the form will be updated so that validation rules
* for those fields are applied
*/
export const useOrderForm = (marketId: string) => {
const [order, update] = useOrder(marketId);
const {
control,
formState: { errors, isSubmitted },
handleSubmit,
setError,
setValue,
clearErrors,
getValues,
} = useForm<OrderFormFields>({
// order can be undefined if there is nothing in the store, it
// will be created but the form still needs some default values
defaultValues: order || getDefaultOrder(marketId),
});
// Keep form fields in sync with the store values,
// inputs are updating the store, fields need updating
// to ensure validation rules are applied
useEffect(() => {
if (!order) return;
const currOrder = getValues();
for (const k in order) {
const key = k as keyof typeof order;
const curr = currOrder[key];
const value = order[key];
if (value !== curr) {
setValue(key, value, {
shouldValidate: isSubmitted, // only apply validation after the form has been submitted and failed
shouldDirty: true,
shouldTouch: true,
});
}
}
}, [order, isSubmitted, getValues, setValue]);
const handleSubmitWrapper = (
cb: <T>(o: Exact<OrderSubmission, T>) => void
) => {
return handleSubmit(() => {
// remove the persist key from the order in the store, the wallet will reject
// an order that contains unrecognized additional keys
cb(omit(order, 'persist'));
});
};
return {
order,
update,
control,
errors,
setError,
clearErrors,
getValues, // returned for test purposes only
handleSubmit: handleSubmitWrapper,
};
};
+2 -1
View File
@@ -17,7 +17,8 @@
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx",
"jest.config.ts"
"jest.config.ts",
"__mocks__"
],
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]
}
@@ -0,0 +1,215 @@
import type { Asset } from '@vegaprotocol/assets';
import { useEnvironment } from '@vegaprotocol/environment';
import { t } from '@vegaprotocol/i18n';
import { ExternalLink, Intent, Notification } from '@vegaprotocol/ui-toolkit';
import { formatNumber } from '@vegaprotocol/utils';
import type { EthStoredTxState } from '@vegaprotocol/web3';
import { EthTxStatus, useEthTransactionStore } from '@vegaprotocol/web3';
import BigNumber from 'bignumber.js';
import type { DepositBalances } from './use-deposit-balances';
interface ApproveNotificationProps {
isActive: boolean;
selectedAsset?: Asset;
onApprove: () => void;
approved: boolean;
balances: DepositBalances | null;
amount: string;
approveTxId: number | null;
}
export const ApproveNotification = ({
isActive,
selectedAsset,
onApprove,
amount,
balances,
approved,
approveTxId,
}: ApproveNotificationProps) => {
const tx = useEthTransactionStore((state) => {
return state.transactions.find((t) => t?.id === approveTxId);
});
if (!isActive) {
return null;
}
if (!selectedAsset) {
return null;
}
if (!balances) {
return null;
}
const approvePrompt = (
<div className="mb-4">
<Notification
intent={Intent.Warning}
testId="approve-default"
message={t(
`Before you can make a deposit of your chosen asset, ${selectedAsset?.symbol}, you need to approve its use in your Ethereum wallet`
)}
buttonProps={{
size: 'sm',
text: `Approve ${selectedAsset?.symbol}`,
action: onApprove,
dataTestId: 'approve-submit',
}}
/>
</div>
);
const reApprovePrompt = (
<div className="mb-4">
<Notification
intent={Intent.Warning}
testId="reapprove-default"
message={t(
`Approve again to deposit more than ${formatNumber(
balances.allowance.toString()
)}`
)}
buttonProps={{
size: 'sm',
text: `Approve ${selectedAsset?.symbol}`,
action: onApprove,
dataTestId: 'reapprove-submit',
}}
/>
</div>
);
const approvalFeedback = (
<ApprovalTxFeedback
tx={tx}
selectedAsset={selectedAsset}
allowance={balances.allowance}
/>
);
// always show requested and pending states
if (
tx &&
[EthTxStatus.Requested, EthTxStatus.Pending, EthTxStatus.Complete].includes(
tx.status
)
) {
return approvalFeedback;
}
if (!approved) {
return approvePrompt;
}
if (new BigNumber(amount).isGreaterThan(balances.allowance)) {
return reApprovePrompt;
}
if (
tx &&
tx.status === EthTxStatus.Error &&
// @ts-ignore tx.error not typed correctly
tx.error.code === 'ACTION_REJECTED'
) {
return approvePrompt;
}
return approvalFeedback;
};
const ApprovalTxFeedback = ({
tx,
selectedAsset,
allowance,
}: {
tx: EthStoredTxState | undefined;
selectedAsset: Asset;
allowance?: BigNumber;
}) => {
const { ETHERSCAN_URL } = useEnvironment();
if (!tx) return null;
const txLink = tx.txHash && (
<ExternalLink href={`${ETHERSCAN_URL}/tx/${tx.txHash}`}>
{t('View on Etherscan')}
</ExternalLink>
);
if (tx.status === EthTxStatus.Error) {
return (
<div className="mb-4">
<Notification
intent={Intent.Danger}
testId="approve-error"
message={
<p>
{t('Approval failed')} {txLink}
</p>
}
/>
</div>
);
}
if (tx.status === EthTxStatus.Requested) {
return (
<div className="mb-4">
<Notification
intent={Intent.Warning}
testId="approve-requested"
message={t(
`Go to your Ethereum wallet and approve the transaction to enable the use of ${selectedAsset?.symbol}`
)}
/>
</div>
);
}
if (tx.status === EthTxStatus.Pending) {
return (
<div className="mb-4">
<Notification
intent={Intent.Primary}
testId="approve-pending"
message={
<>
<p>
{t(
`Your ${selectedAsset?.symbol} is being confirmed by the Ethereum network. When this is complete, you can continue your deposit`
)}{' '}
</p>
{txLink && <p>{txLink}</p>}
</>
}
/>
</div>
);
}
if (tx.status === EthTxStatus.Confirmed) {
return (
<div className="mb-4">
<Notification
intent={Intent.Success}
testId="approve-confirmed"
message={
<>
<p>
{t(
`You can now make deposits in ${
selectedAsset?.symbol
}, up to a maximum of ${formatNumber(
allowance?.toString() || 0
)}`
)}
</p>
{txLink && <p>{txLink}</p>}
</>
}
/>
</div>
);
}
return null;
};
+1 -9
View File
@@ -4,18 +4,11 @@ import { DepositManager } from './deposit-manager';
import { t } from '@vegaprotocol/i18n';
import { useDataProvider } from '@vegaprotocol/react-helpers';
import { enabledAssetsProvider } from '@vegaprotocol/assets';
import type { DepositDialogStylePropsSetter } from './deposit-dialog';
/**
* Fetches data required for the Deposit page
*/
export const DepositContainer = ({
assetId,
setDialogStyleProps,
}: {
assetId?: string;
setDialogStyleProps?: DepositDialogStylePropsSetter;
}) => {
export const DepositContainer = ({ assetId }: { assetId?: string }) => {
const { VEGA_ENV } = useEnvironment();
const { data, loading, error } = useDataProvider({
dataProvider: enabledAssetsProvider,
@@ -28,7 +21,6 @@ export const DepositContainer = ({
assetId={assetId}
assets={data}
isFaucetable={VEGA_ENV !== Networks.MAINNET}
setDialogStyleProps={setDialogStyleProps}
/>
) : (
<Splash>
+2 -32
View File
@@ -1,8 +1,6 @@
import { create } from 'zustand';
import { t } from '@vegaprotocol/i18n';
import type { Intent } from '@vegaprotocol/ui-toolkit';
import { Dialog } from '@vegaprotocol/ui-toolkit';
import { useCallback, useState } from 'react';
import { DepositContainer } from './deposit-container';
import { useWeb3ConnectStore } from '@vegaprotocol/web3';
import { useAssetDetailsDialogStore } from '@vegaprotocol/assets';
@@ -24,22 +22,6 @@ export const useDepositDialog = create<State & Actions>((set) => ({
close: () => set(() => ({ assetId: undefined, isOpen: false })),
}));
export type DepositDialogStyleProps = {
title: string;
icon?: JSX.Element;
intent?: Intent;
};
export type DepositDialogStylePropsSetter = (
props?: DepositDialogStyleProps
) => void;
const DEFAULT_STYLE: DepositDialogStyleProps = {
title: t('Deposit'),
intent: undefined,
icon: undefined,
};
export const DepositDialog = () => {
const { assetId, isOpen, open, close } = useDepositDialog();
const assetDetailsDialogOpen = useAssetDetailsDialogStore(
@@ -48,25 +30,13 @@ export const DepositDialog = () => {
const connectWalletDialogIsOpen = useWeb3ConnectStore(
(state) => state.isOpen
);
const [dialogStyleProps, _setDialogStyleProps] = useState(DEFAULT_STYLE);
const setDialogStyleProps: DepositDialogStylePropsSetter =
useCallback<DepositDialogStylePropsSetter>(
(props) =>
props
? _setDialogStyleProps(props)
: _setDialogStyleProps(DEFAULT_STYLE),
[_setDialogStyleProps]
);
return (
<Dialog
open={isOpen && !(connectWalletDialogIsOpen || assetDetailsDialogOpen)}
onChange={(isOpen) => (isOpen ? open() : close())}
{...dialogStyleProps}
title={t('Deposit')}
>
<DepositContainer
assetId={assetId}
setDialogStyleProps={setDialogStyleProps}
/>
<DepositContainer assetId={assetId} />
</Dialog>
);
};
+40 -23
View File
@@ -7,6 +7,7 @@ import { useVegaWallet } from '@vegaprotocol/wallet';
import { useWeb3ConnectStore } from '@vegaprotocol/web3';
import { useWeb3React } from '@web3-react/core';
import type { AssetFieldsFragment } from '@vegaprotocol/assets';
import type { DepositBalances } from './use-deposit-balances';
jest.mock('@vegaprotocol/wallet');
jest.mock('@vegaprotocol/web3');
@@ -38,27 +39,34 @@ function generateAsset(): AssetFieldsFragment {
let asset: AssetFieldsFragment;
let props: DepositFormProps;
let balances: DepositBalances;
const MOCK_ETH_ADDRESS = '0x72c22822A19D20DE7e426fB84aa047399Ddd8853';
const MOCK_VEGA_KEY =
'70d14a321e02e71992fd115563df765000ccc4775cbe71a0e2f9ff5a3b9dc680';
beforeEach(() => {
asset = generateAsset();
balances = {
balance: new BigNumber(5),
max: new BigNumber(20),
allowance: new BigNumber(30),
deposited: new BigNumber(10),
};
props = {
assets: [asset],
selectedAsset: undefined,
onSelectAsset: jest.fn(),
balance: new BigNumber(5),
balances,
submitApprove: jest.fn(),
submitDeposit: jest.fn(),
requestFaucet: jest.fn(),
max: new BigNumber(20),
deposited: new BigNumber(10),
allowance: new BigNumber(30),
submitFaucet: jest.fn(),
onDisconnect: jest.fn(),
approveTxId: null,
faucetTxId: null,
isFaucetable: true,
};
(useVegaWallet as jest.Mock).mockReturnValue({ pubKey: null });
(useVegaWallet as jest.Mock).mockReturnValue({ pubKey: null, pubKeys: [] });
(useWeb3React as jest.Mock).mockReturnValue({
isActive: true,
account: MOCK_ETH_ADDRESS,
@@ -71,7 +79,8 @@ describe('Deposit form', () => {
render(<DepositForm {...props} />);
// Assert default values (including) from/to provided by useVegaWallet and useWeb3React
expect(screen.getByLabelText('From (Ethereum address)')).toHaveValue(
expect(screen.getByText('From (Ethereum address)')).toBeInTheDocument();
expect(screen.getByTestId('ethereum-address')).toHaveTextContent(
MOCK_ETH_ADDRESS
);
expect(screen.getByLabelText('Asset')).toHaveValue('');
@@ -145,7 +154,7 @@ describe('Deposit form', () => {
fireEvent.submit(screen.getByTestId('deposit-form'));
expect(
await screen.findByText('Amount is above deposit limit')
await screen.findByText('Amount is above lifetime deposit limit')
).toBeInTheDocument();
});
@@ -153,9 +162,12 @@ describe('Deposit form', () => {
render(
<DepositForm
{...props}
balance={new BigNumber(100)}
max={new BigNumber(100)}
deposited={new BigNumber(10)}
balances={{
...balances,
balance: BigNumber(100),
max: new BigNumber(100),
deposited: new BigNumber(10),
}}
/>
);
@@ -166,7 +178,7 @@ describe('Deposit form', () => {
fireEvent.submit(screen.getByTestId('deposit-form'));
expect(
await screen.findByText('Amount is above approved amount.')
await screen.findByText('Amount is above approved amount')
).toBeInTheDocument();
});
@@ -214,14 +226,17 @@ describe('Deposit form', () => {
render(
<DepositForm
{...props}
allowance={new BigNumber(0)}
balances={{
...balances,
allowance: new BigNumber(0),
}}
selectedAsset={asset}
/>
);
expect(screen.queryByLabelText('Amount')).not.toBeInTheDocument();
expect(screen.getByTestId('approve-warning')).toHaveTextContent(
`Deposits of ${asset.symbol} not approved`
expect(screen.getByTestId('approve-default')).toHaveTextContent(
`Before you can make a deposit of your chosen asset, ${asset.symbol}, you need to approve its use in your Ethereum wallet`
);
fireEvent.click(
@@ -253,10 +268,12 @@ describe('Deposit form', () => {
render(
<DepositForm
{...props}
allowance={new BigNumber(100)}
balance={balance}
max={max}
deposited={deposited}
balances={{
allowance: new BigNumber(100),
balance,
max,
deposited,
}}
selectedAsset={asset}
/>
);
@@ -320,10 +337,10 @@ describe('Deposit form', () => {
expect(
screen.queryByRole('button', { name: 'Connect' })
).not.toBeInTheDocument();
const fromInput = screen.getByLabelText('From (Ethereum address)');
expect(fromInput).toHaveValue(MOCK_ETH_ADDRESS);
expect(fromInput).toBeDisabled();
expect(fromInput).toHaveAttribute('readonly');
expect(screen.getByText('From (Ethereum address)')).toBeInTheDocument();
expect(screen.getByTestId('ethereum-address')).toHaveTextContent(
MOCK_ETH_ADDRESS
);
});
it('prevents submission if you are on the wrong chain', () => {
+159 -146
View File
@@ -1,4 +1,4 @@
import type { Asset } from '@vegaprotocol/assets';
import type { Asset, AssetFieldsFragment } from '@vegaprotocol/assets';
import { AssetOption } from '@vegaprotocol/assets';
import {
ethereumAddress,
@@ -19,13 +19,16 @@ import {
RichSelect,
Notification,
Intent,
ButtonLink,
Select,
} from '@vegaprotocol/ui-toolkit';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { useWeb3React } from '@web3-react/core';
import BigNumber from 'bignumber.js';
import type { ButtonHTMLAttributes } from 'react';
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { useState } from 'react';
import { useMemo } from 'react';
import type { FieldError } from 'react-hook-form';
import { useWatch } from 'react-hook-form';
import { Controller, useForm } from 'react-hook-form';
import { DepositLimits } from './deposit-limits';
import { useAssetDetailsDialogStore } from '@vegaprotocol/assets';
@@ -34,6 +37,9 @@ import {
useWeb3ConnectStore,
getChainName,
} from '@vegaprotocol/web3';
import type { DepositBalances } from './use-deposit-balances';
import { FaucetNotification } from './faucet-notification';
import { ApproveNotification } from './approve-notification';
interface FormFields {
asset: string;
@@ -45,38 +51,38 @@ interface FormFields {
export interface DepositFormProps {
assets: Asset[];
selectedAsset?: Asset;
balances: DepositBalances | null;
onSelectAsset: (assetId: string) => void;
balance: BigNumber | undefined;
onDisconnect: () => void;
submitApprove: () => void;
approveTxId: number | null;
submitFaucet: () => void;
faucetTxId: number | null;
submitDeposit: (args: {
assetSource: string;
amount: string;
vegaPublicKey: string;
}) => void;
requestFaucet: () => void;
max: BigNumber | undefined;
deposited: BigNumber | undefined;
allowance: BigNumber | undefined;
isFaucetable?: boolean;
}
export const DepositForm = ({
assets,
selectedAsset,
balances,
onSelectAsset,
balance,
max,
deposited,
onDisconnect,
submitApprove,
submitDeposit,
requestFaucet,
allowance,
submitFaucet,
faucetTxId,
approveTxId,
isFaucetable,
}: DepositFormProps) => {
const { open: openAssetDetailsDialog } = useAssetDetailsDialogStore();
const openDialog = useWeb3ConnectStore((store) => store.open);
const { isActive, account } = useWeb3React();
const { pubKey } = useVegaWallet();
const { pubKey, pubKeys: _pubKeys } = useVegaWallet();
const {
register,
handleSubmit,
@@ -91,43 +97,21 @@ export const DepositForm = ({
},
});
const amount = useWatch({ name: 'amount', control });
const onSubmit = async (fields: FormFields) => {
if (!selectedAsset || selectedAsset.source.__typename !== 'ERC20') {
throw new Error('Invalid asset');
}
if (!approved) throw new Error('Deposits not approved');
if (approved) {
submitDeposit({
assetSource: selectedAsset.source.contractAddress,
amount: fields.amount,
vegaPublicKey: fields.to,
});
} else {
submitApprove();
}
submitDeposit({
assetSource: selectedAsset.source.contractAddress,
amount: fields.amount,
vegaPublicKey: fields.to,
});
};
const maxAmount = useMemo(() => {
const maxApproved = allowance ? allowance : new BigNumber(0);
const maxAvailable = balance ? balance : new BigNumber(0);
// limits.max is a lifetime deposit limit, so the actual max value for form
// input is the max minus whats already been deposited
let maxLimit = new BigNumber(Infinity);
// A max limit of zero indicates that there is no limit
if (max && deposited && max.isGreaterThan(0)) {
maxLimit = max.minus(deposited);
}
return {
approved: maxApproved,
available: maxAvailable,
limit: maxLimit,
amount: BigNumber.minimum(maxLimit, maxApproved, maxAvailable),
};
}, [max, deposited, allowance, balance]);
const min = useMemo(() => {
// Min viable amount given asset decimals EG for WEI 0.000000000000000001
const minViableAmount = selectedAsset
@@ -137,8 +121,15 @@ export const DepositForm = ({
return minViableAmount;
}, [selectedAsset]);
const approved = allowance && allowance.isGreaterThan(0) ? true : false;
const formState = getFormState(selectedAsset, isActive, approved);
const pubKeys = useMemo(() => {
return _pubKeys ? _pubKeys.map((pk) => pk.publicKey) : [];
}, [_pubKeys]);
const approved = balances
? balances.allowance.isGreaterThan(0)
? true
: false
: false;
return (
<form
@@ -166,32 +157,23 @@ export const DepositForm = ({
render={() => {
if (isActive && account) {
return (
<>
<Input
id="ethereum-address"
value={account}
readOnly={true}
disabled={true}
{...register('from', {
validate: {
required,
ethereumAddress,
},
})}
/>
<div className="text-sm" aria-describedby="ethereum-address">
<p className="mb-1" data-testid="ethereum-address">
{account}
</p>
<DisconnectEthereumButton
onDisconnect={() => {
setValue('from', ''); // clear from value so required ethereum connection validation works
onDisconnect();
}}
/>
</>
</div>
);
}
return (
<Button
onClick={openDialog}
variant="primary"
fill={true}
type="button"
data-testid="connect-eth-wallet-btn"
>
@@ -238,7 +220,7 @@ export const DepositForm = ({
</InputError>
)}
{isFaucetable && selectedAsset && (
<UseButton onClick={requestFaucet}>
<UseButton onClick={submitFaucet}>
{t(`Get ${selectedAsset.symbol}`)}
</UseButton>
)}
@@ -255,39 +237,55 @@ export const DepositForm = ({
</button>
)}
</FormGroup>
<FaucetNotification
isActive={isActive}
selectedAsset={selectedAsset}
faucetTxId={faucetTxId}
/>
<FormGroup label={t('To (Vega key)')} labelFor="to">
<Input
{...register('to', { validate: { required, vegaPublicKey } })}
id="to"
<AddressField
pubKeys={pubKeys}
onChange={() => setValue('to', '')}
select={
<Select {...register('to')} id="to" defaultValue="">
<option value="" disabled={true}>
{t('Please select')}
</option>
{pubKeys?.length &&
pubKeys.map((pk) => (
<option key={pk} value={pk}>
{pk}
</option>
))}
</Select>
}
input={
<Input
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={true} // focus input immediately after is shown
id="to"
type="text"
{...register('to', {
validate: {
required,
vegaPublicKey,
},
})}
/>
}
/>
{errors.to?.message && (
<InputError intent="danger" forInput="to">
{errors.to.message}
</InputError>
)}
{pubKey && (
<UseButton
onClick={() => {
setValue('to', pubKey);
clearErrors('to');
}}
>
{t('Use connected')}
</UseButton>
)}
</FormGroup>
{selectedAsset && max && deposited && (
{selectedAsset && balances && (
<div className="mb-6">
<DepositLimits
max={max}
deposited={deposited}
balance={balance}
asset={selectedAsset}
allowance={allowance}
/>
<DepositLimits {...balances} asset={selectedAsset} />
</div>
)}
{formState === 'deposit' && (
{approved && (
<FormGroup label={t('Amount')} labelFor="amount">
<Input
type="number"
@@ -299,38 +297,52 @@ export const DepositForm = ({
minSafe: (value) => minSafe(new BigNumber(min))(value),
approved: (v) => {
const value = new BigNumber(v);
if (value.isGreaterThan(maxAmount.approved)) {
if (value.isGreaterThan(balances?.allowance || 0)) {
return t('Amount is above approved amount');
}
return true;
},
limit: (v) => {
const value = new BigNumber(v);
if (value.isGreaterThan(maxAmount.limit)) {
return t('Amount is above deposit limit');
if (!balances) {
return t('Could not verify balances of account'); // this should never happen
}
let lifetimeLimit = new BigNumber(Infinity);
if (balances.max.isGreaterThan(0)) {
lifetimeLimit = balances.max.minus(balances.deposited);
}
if (value.isGreaterThan(lifetimeLimit)) {
return t('Amount is above lifetime deposit limit');
}
return true;
},
balance: (v) => {
const value = new BigNumber(v);
if (value.isGreaterThan(maxAmount.available)) {
if (value.isGreaterThan(balances?.balance || 0)) {
return t('Insufficient amount in Ethereum wallet');
}
return true;
},
maxSafe: (v) => {
return maxSafe(maxAmount.amount)(v);
return maxSafe(balances?.balance || new BigNumber(0))(v);
},
},
})}
/>
{errors.amount?.message && (
<AmountError error={errors.amount} submitApprove={submitApprove} />
<InputError intent="danger" forInput="amount">
{errors.amount.message}
</InputError>
)}
{selectedAsset && balance && (
{selectedAsset && balances && (
<UseButton
onClick={() => {
setValue('amount', balance.toFixed(selectedAsset.decimals));
setValue(
'amount',
balances.balance.toFixed(selectedAsset.decimals)
);
clearErrors('amount');
}}
>
@@ -339,59 +351,31 @@ export const DepositForm = ({
)}
</FormGroup>
)}
<FormButton selectedAsset={selectedAsset} formState={formState} />
<ApproveNotification
isActive={isActive}
approveTxId={approveTxId}
selectedAsset={selectedAsset}
onApprove={submitApprove}
balances={balances}
approved={approved}
amount={amount}
/>
<FormButton approved={approved} selectedAsset={selectedAsset} />
</form>
);
};
const AmountError = ({
error,
submitApprove,
}: {
error: FieldError;
submitApprove: () => void;
}) => {
if (error.type === 'approved') {
return (
<InputError intent="danger" forInput="amount">
{error.message}.
<button onClick={submitApprove} className="underline ml-2">
{t('Update approve amount')}
</button>
</InputError>
);
}
return (
<InputError intent="danger" forInput="amount">
{error.message}
</InputError>
);
};
interface FormButtonProps {
selectedAsset?: Asset;
formState: ReturnType<typeof getFormState>;
approved: boolean;
selectedAsset: AssetFieldsFragment | undefined;
}
const FormButton = ({ selectedAsset, formState }: FormButtonProps) => {
const FormButton = ({ approved, selectedAsset }: FormButtonProps) => {
const { isActive, chainId } = useWeb3React();
const desiredChainId = useWeb3ConnectStore((store) => store.desiredChainId);
const submitText =
formState === 'approve'
? t(`Approve ${selectedAsset ? selectedAsset.symbol : ''}`)
: t('Deposit');
const invalidChain = isActive && chainId !== desiredChainId;
return (
<>
{formState === 'approve' && (
<div className="mb-2">
<Notification
intent={Intent.Warning}
testId="approve-warning"
message={t(`Deposits of ${selectedAsset?.symbol} not approved`)}
/>
</div>
)}
{invalidChain && (
<div className="mb-2">
<Notification
@@ -408,9 +392,9 @@ const FormButton = ({ selectedAsset, formState }: FormButtonProps) => {
data-testid="deposit-submit"
variant={isActive ? 'primary' : 'default'}
fill={true}
disabled={invalidChain}
disabled={invalidChain || (selectedAsset && !approved)}
>
{submitText}
{t('Deposit')}
</Button>
</>
);
@@ -437,7 +421,7 @@ const DisconnectEthereumButton = ({
const [, , removeEagerConnector] = useLocalStorage(ETHEREUM_EAGER_CONNECT);
return (
<UseButton
<ButtonLink
onClick={() => {
connector.deactivate();
removeEagerConnector();
@@ -446,17 +430,46 @@ const DisconnectEthereumButton = ({
data-testid="disconnect-ethereum-wallet"
>
{t('Disconnect')}
</UseButton>
</ButtonLink>
);
};
const getFormState = (
selectedAsset: Asset | undefined,
isActive: boolean,
approved: boolean
) => {
if (!selectedAsset) return 'deposit';
if (!isActive) return 'deposit';
if (approved) return 'deposit';
return 'approve';
interface AddressInputProps {
pubKeys: string[] | null;
select: ReactNode;
input: ReactNode;
onChange: () => void;
}
export const AddressField = ({
pubKeys,
select,
input,
onChange,
}: AddressInputProps) => {
const [isInput, setIsInput] = useState(() => {
if (pubKeys && pubKeys.length <= 1) {
return true;
}
return false;
});
return (
<>
{isInput ? input : select}
{pubKeys && pubKeys.length > 1 && (
<button
type="button"
onClick={() => {
setIsInput((curr) => !curr);
onChange();
}}
className="ml-auto text-sm absolute top-0 right-0 underline"
data-testid="enter-pubkey-manually"
>
{isInput ? t('Select from wallet') : t('Enter manually')}
</button>
)}
</>
);
};
+1 -1
View File
@@ -35,7 +35,7 @@ export const DepositLimits = ({
},
{
key: 'MAX_LIMIT',
label: t('Maximum total deposit amount'),
label: t('Lifetime deposit allowance'),
rawValue: max,
value: <CompactNumber number={max} decimals={asset.decimals} />,
},
+23 -50
View File
@@ -5,36 +5,26 @@ import { prepend0x } from '@vegaprotocol/smart-contracts';
import sortBy from 'lodash/sortBy';
import { useSubmitApproval } from './use-submit-approval';
import { useSubmitFaucet } from './use-submit-faucet';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { useDepositBalances } from './use-deposit-balances';
import { useDepositDialog } from './deposit-dialog';
import type { Asset } from '@vegaprotocol/assets';
import type { DepositDialogStylePropsSetter } from './deposit-dialog';
import pick from 'lodash/pick';
import type { EthTransaction } from '@vegaprotocol/web3';
import {
EthTxStatus,
useEthTransactionStore,
useBridgeContract,
useEthereumConfig,
} from '@vegaprotocol/web3';
import { t } from '@vegaprotocol/i18n';
interface DepositManagerProps {
assetId?: string;
assets: Asset[];
isFaucetable: boolean;
setDialogStyleProps?: DepositDialogStylePropsSetter;
}
const getProps = (txContent?: EthTransaction['TxContent']) =>
txContent ? pick(txContent, ['title', 'icon', 'intent']) : undefined;
export const DepositManager = ({
assetId: initialAssetId,
assets,
isFaucetable,
setDialogStyleProps,
}: DepositManagerProps) => {
const createEthTransaction = useEthTransactionStore((state) => state.create);
const { config } = useEthereumConfig();
@@ -43,26 +33,16 @@ export const DepositManager = ({
const bridgeContract = useBridgeContract();
const closeDepositDialog = useDepositDialog((state) => state.close);
const { balance, allowance, deposited, max, refresh } = useDepositBalances(
const { getBalances, reset, balances } = useDepositBalances(
asset,
isFaucetable
);
// Set up approve transaction
const approve = useSubmitApproval(asset);
const approve = useSubmitApproval(asset, getBalances);
// Set up faucet transaction
const faucet = useSubmitFaucet(asset);
const transactionInProgress = [approve.TxContent, faucet.TxContent].filter(
(t) => t.status !== EthTxStatus.Default
)[0];
useEffect(() => {
setDialogStyleProps?.(getProps(transactionInProgress));
}, [setDialogStyleProps, transactionInProgress]);
const returnLabel = t('Return to deposit');
const faucet = useSubmitFaucet(asset, getBalances);
const submitDeposit = (
args: Parameters<DepositFormProps['submitDeposit']>['0']
@@ -86,31 +66,24 @@ export const DepositManager = ({
};
return (
<>
{!transactionInProgress && (
<DepositForm
balance={balance}
selectedAsset={asset}
onSelectAsset={setAssetId}
assets={sortBy(assets, 'name')}
submitApprove={async () => {
await approve.perform();
refresh();
}}
submitDeposit={submitDeposit}
requestFaucet={async () => {
await faucet.perform();
refresh();
}}
deposited={deposited}
max={max}
allowance={allowance}
isFaucetable={isFaucetable}
/>
)}
<approve.TxContent.Content returnLabel={returnLabel} />
<faucet.TxContent.Content returnLabel={returnLabel} />
</>
<DepositForm
selectedAsset={asset}
onDisconnect={reset}
onSelectAsset={(id) => {
setAssetId(id);
// When we change asset, also clear the tracked faucet/approve transactions so
// we dont render stale UI
approve.reset();
faucet.reset();
}}
assets={sortBy(assets, 'name')}
submitApprove={approve.perform}
submitDeposit={submitDeposit}
submitFaucet={faucet.perform}
faucetTxId={faucet.id}
approveTxId={approve.id}
balances={balances}
isFaucetable={isFaucetable}
/>
);
};
+2
View File
@@ -72,7 +72,9 @@ export const DepositsTable = forwardRef<
field="txHash"
cellRenderer={({
value,
data,
}: VegaICellRendererParams<DepositFieldsFragment, 'txHash'>) => {
if (!data) return null;
if (!value) return '-';
return (
<Link
@@ -0,0 +1,108 @@
import type { Asset } from '@vegaprotocol/assets';
import { useEnvironment } from '@vegaprotocol/environment';
import { t } from '@vegaprotocol/i18n';
import { ExternalLink, Intent, Notification } from '@vegaprotocol/ui-toolkit';
import { EthTxStatus, useEthTransactionStore } from '@vegaprotocol/web3';
interface FaucetNotificationProps {
isActive: boolean;
selectedAsset?: Asset;
faucetTxId: number | null;
}
/**
* Render a notification for the faucet transaction
*/
export const FaucetNotification = ({
isActive,
selectedAsset,
faucetTxId,
}: FaucetNotificationProps) => {
const { ETHERSCAN_URL } = useEnvironment();
const tx = useEthTransactionStore((state) => {
return state.transactions.find((t) => t?.id === faucetTxId);
});
if (!isActive) {
return null;
}
if (!selectedAsset) {
return null;
}
if (!tx) {
return null;
}
if (tx.status === EthTxStatus.Error) {
return (
<div className="mb-4">
<Notification
intent={Intent.Danger}
testId="faucet-error"
// @ts-ignore tx.error not typed correctly
message={t(`Faucet failed: ${tx.error?.reason}`)}
/>
</div>
);
}
if (tx.status === EthTxStatus.Requested) {
return (
<div className="mb-4">
<Notification
intent={Intent.Warning}
testId="faucet-requested"
message={t(
`Go to your Ethereum wallet and approve the faucet transaction for ${selectedAsset?.symbol}`
)}
/>
</div>
);
}
if (tx.status === EthTxStatus.Pending) {
return (
<div className="mb-4">
<Notification
intent={Intent.Primary}
testId="faucet-pending"
message={
<p>
{t('Faucet pending...')}{' '}
{tx.txHash && (
<ExternalLink href={`${ETHERSCAN_URL}/tx/${tx.txHash}`}>
{t('View on Etherscan')}
</ExternalLink>
)}
</p>
}
/>
</div>
);
}
if (tx.status === EthTxStatus.Confirmed) {
return (
<div className="mb-4">
<Notification
intent={Intent.Success}
testId="faucet-confirmed"
message={
<p>
{t('Faucet successful')}{' '}
{tx.txHash && (
<ExternalLink href={`${ETHERSCAN_URL}/tx/${tx.txHash}`}>
{t('View on Etherscan')}
</ExternalLink>
)}
</p>
}
/>
</div>
);
}
return null;
};
+16 -22
View File
@@ -7,21 +7,17 @@ import { useGetBalanceOfERC20Token } from './use-get-balance-of-erc20-token';
import { useGetDepositMaximum } from './use-get-deposit-maximum';
import { useGetDepositedAmount } from './use-get-deposited-amount';
import { isAssetTypeERC20 } from '@vegaprotocol/utils';
import { usePrevious } from '@vegaprotocol/react-helpers';
import { useAccountBalance } from '@vegaprotocol/accounts';
import type { Asset } from '@vegaprotocol/assets';
type DepositBalances = {
balance: BigNumber;
allowance: BigNumber;
deposited: BigNumber;
max: BigNumber;
refresh: () => void;
};
export interface DepositBalances {
balance: BigNumber; // amount in Ethereum wallet
allowance: BigNumber; // amount approved
deposited: BigNumber; // total amounted deposited over lifetime
max: BigNumber; // life time deposit cap
}
type DepositBalancesState = Omit<DepositBalances, 'refresh'>;
const initialState: DepositBalancesState = {
const initialState: DepositBalances = {
balance: new BigNumber(0),
allowance: new BigNumber(0),
deposited: new BigNumber(0),
@@ -35,7 +31,7 @@ const initialState: DepositBalancesState = {
export const useDepositBalances = (
asset: Asset | undefined,
isFaucetable: boolean
): DepositBalances => {
) => {
const tokenContract = useTokenContract(
isAssetTypeERC20(asset) ? asset.source.contractAddress : undefined,
isFaucetable
@@ -45,21 +41,14 @@ export const useDepositBalances = (
const getBalance = useGetBalanceOfERC20Token(tokenContract, asset);
const getDepositMaximum = useGetDepositMaximum(bridgeContract, asset);
const getDepositedAmount = useGetDepositedAmount(asset);
const prevAsset = usePrevious(asset);
const [state, setState] = useState<DepositBalancesState>(initialState);
useEffect(() => {
if (asset?.id !== prevAsset?.id) {
// reset values to initial state when asset changes
setState(initialState);
}
}, [asset?.id, prevAsset?.id]);
const [state, setState] = useState<DepositBalances | null>(null);
const { accountBalance } = useAccountBalance(asset?.id);
const getBalances = useCallback(async () => {
if (!asset) return;
try {
setState(null);
const [max, deposited, balance, allowance] = await Promise.all([
getDepositMaximum(),
getDepositedAmount(),
@@ -75,12 +64,17 @@ export const useDepositBalances = (
});
} catch (err) {
Sentry.captureException(err);
setState(null);
}
}, [asset, getAllowance, getBalance, getDepositMaximum, getDepositedAmount]);
const reset = useCallback(() => {
setState(null);
}, []);
useEffect(() => {
getBalances();
}, [asset, getBalances, accountBalance]);
return { ...state, refresh: getBalances };
return { balances: state, getBalances, reset };
};
+31 -19
View File
@@ -1,36 +1,48 @@
import { isAssetTypeERC20, removeDecimal } from '@vegaprotocol/utils';
import * as Sentry from '@sentry/react';
import type { Token } from '@vegaprotocol/smart-contracts';
import {
EthTxStatus,
useEthereumConfig,
useEthereumTransaction,
useEthTransactionStore,
useTokenContract,
} from '@vegaprotocol/web3';
import type { Asset } from '@vegaprotocol/assets';
import { useEffect, useState } from 'react';
export const useSubmitApproval = (asset?: Asset) => {
export const useSubmitApproval = (
asset: Asset | undefined,
getBalances: () => void
) => {
const [id, setId] = useState<number | null>(null);
const createEthTransaction = useEthTransactionStore((state) => state.create);
const tx = useEthTransactionStore((state) => {
return state.transactions.find((t) => t?.id === id);
});
const { config } = useEthereumConfig();
const contract = useTokenContract(
isAssetTypeERC20(asset) ? asset.source.contractAddress : undefined,
true
);
const transaction = useEthereumTransaction<Token, 'approve'>(
contract,
'approve'
);
// When tx is confirmed refresh balances
useEffect(() => {
if (tx?.status === EthTxStatus.Confirmed) {
getBalances();
}
}, [tx?.status, getBalances]);
return {
...transaction,
perform: async () => {
id,
reset: () => {
setId(null);
},
perform: () => {
if (!asset || !config) return;
try {
const amount = removeDecimal('1000000', asset.decimals);
await transaction.perform(
config.collateral_bridge_contract.address,
amount
);
} catch (err) {
Sentry.captureException(err);
}
const amount = removeDecimal('1000000', asset.decimals);
const id = createEthTransaction(contract, 'approve', [
config?.collateral_bridge_contract.address,
amount,
]);
setId(id);
},
};
};
+30 -15
View File
@@ -1,26 +1,41 @@
import type { TokenFaucetable } from '@vegaprotocol/smart-contracts';
import * as Sentry from '@sentry/react';
import { useEthereumTransaction, useTokenContract } from '@vegaprotocol/web3';
import {
EthTxStatus,
useEthTransactionStore,
useTokenContract,
} from '@vegaprotocol/web3';
import { isAssetTypeERC20 } from '@vegaprotocol/utils';
import type { Asset } from '@vegaprotocol/assets';
import { useEffect, useState } from 'react';
export const useSubmitFaucet = (asset?: Asset) => {
export const useSubmitFaucet = (
asset: Asset | undefined,
getBalances: () => void
) => {
const [id, setId] = useState<number | null>(null);
const createEthTransaction = useEthTransactionStore((state) => state.create);
const tx = useEthTransactionStore((state) => {
return state.transactions.find((t) => t?.id === id);
});
const contract = useTokenContract(
isAssetTypeERC20(asset) ? asset.source.contractAddress : undefined,
true
);
const transaction = useEthereumTransaction<TokenFaucetable, 'faucet'>(
contract,
'faucet'
);
// When tx is confirmed refresh balances
useEffect(() => {
if (tx?.status === EthTxStatus.Confirmed) {
getBalances();
}
}, [tx?.status, getBalances]);
return {
...transaction,
perform: async () => {
try {
await transaction.perform();
} catch (err) {
Sentry.captureException(err);
}
id,
reset: () => {
setId(null);
},
perform: () => {
const id = createEthTransaction(contract, 'faucet', []);
setId(id);
},
};
};
-1
View File
@@ -15,7 +15,6 @@
"**/*.test.jsx",
"**/*.spec.jsx",
"**/*.d.ts",
"**/__mocks__/*.tsx",
"jest.config.ts"
]
}
+34 -2
View File
@@ -5,7 +5,7 @@ import * as Schema from '@vegaprotocol/types';
import type { PartialDeep } from 'type-fest';
import type { Trade } from './fills-data-provider';
import { FillsTable } from './fills-table';
import { FillsTable, getFeesBreakdown } from './fills-table';
import { generateFill } from './test-helpers';
describe('FillsTable', () => {
@@ -75,7 +75,7 @@ describe('FillsTable', () => {
'1.00 BTC',
'3.00 BTC',
'Maker',
'0.06 BTC',
'2.00 BTC',
getDateTimeFormat().format(new Date(buyerFill.createdAt)),
];
cells.forEach((cell, i) => {
@@ -176,4 +176,36 @@ describe('FillsTable', () => {
await screen.findByTestId('fee-breakdown-tooltip')
).toBeInTheDocument();
});
describe('getFeesBreakdown', () => {
it('should return correct fees breakdown for a taker', () => {
const fees = {
makerFee: '1000',
infrastructureFee: '2000',
liquidityFee: '3000',
};
const expectedBreakdown = {
infrastructureFee: '2000',
liquidityFee: '3000',
makerFee: '1000',
totalFee: '6000',
};
expect(getFeesBreakdown('TAKER', fees)).toEqual(expectedBreakdown);
});
it('should return correct fees breakdown for a maker', () => {
const fees = {
makerFee: '1000',
infrastructureFee: '2000',
liquidityFee: '3000',
};
const expectedBreakdown = {
infrastructureFee: '2000',
liquidityFee: '3000',
makerFee: '-1000',
totalFee: '4000',
};
expect(getFeesBreakdown('MAKER', fees)).toEqual(expectedBreakdown);
});
});
});
+101 -31
View File
@@ -27,6 +27,10 @@ import type {
import { forwardRef } from 'react';
import BigNumber from 'bignumber.js';
import type { Trade } from './fills-data-provider';
import type { FillFieldsFragment } from './__generated__/Fills';
const TAKER = 'TAKER';
const MAKER = 'MAKER';
export type Props = (AgGridReactProps | AgReactUiProps) & {
partyId: string;
@@ -228,66 +232,132 @@ const formatFee = (partyId: string) => {
return '-';
}
const asset = value.settlementAsset;
let feesObj;
if (data?.buyer.id === partyId) {
feesObj = data?.buyerFee;
} else if (data?.seller.id === partyId) {
feesObj = data?.sellerFee;
} else {
return '-';
}
const { fees: feesObj, role } = getRoleAndFees({ data, partyId });
if (!feesObj) return '-';
const fee = new BigNumber(feesObj.makerFee)
.plus(feesObj.infrastructureFee)
.plus(feesObj.liquidityFee);
const totalFees = addDecimalsFormatNumber(fee.toString(), asset.decimals);
const { totalFee } = getFeesBreakdown(role, feesObj);
const totalFees = addDecimalsFormatNumber(totalFee, asset.decimals);
return `${totalFees} ${asset.symbol}`;
};
};
export const getRoleAndFees = ({
data,
partyId,
}: {
data: Pick<
FillFieldsFragment,
'buyerFee' | 'sellerFee' | 'buyer' | 'seller' | 'aggressor'
>;
partyId?: string;
}) => {
let role;
let feesObj;
if (data?.buyer.id === partyId) {
role = data.aggressor === Schema.Side.SIDE_BUY ? TAKER : MAKER;
feesObj = role === TAKER ? data?.buyerFee : data.sellerFee;
} else if (data?.seller.id === partyId) {
role = data.aggressor === Schema.Side.SIDE_SELL ? TAKER : MAKER;
feesObj = role === TAKER ? data?.sellerFee : data.buyerFee;
} else {
return { role: '-', feesObj: '-' };
}
return { role, fees: feesObj };
};
const FeesBreakdownTooltip = ({
data,
value,
valueFormatted,
partyId,
}: ITooltipParams & { partyId?: string }) => {
if (!value?.settlementAsset || !data) {
return null;
}
const asset = value.settlementAsset;
let feesObj;
if (data?.buyer.id === partyId) {
feesObj = data?.buyerFee;
} else if (data?.seller.id === partyId) {
feesObj = data?.sellerFee;
} else {
return null;
}
const { role, fees: feesObj } = getRoleAndFees({ data, partyId }) ?? {};
if (!feesObj) return null;
const { infrastructureFee, liquidityFee, makerFee, totalFee } =
getFeesBreakdown(role, feesObj);
return (
<div
data-testid="fee-breakdown-tooltip"
className="max-w-sm border border-neutral-600 bg-neutral-100 dark:bg-neutral-800 px-4 py-2 z-20 rounded text-sm break-word text-black dark:text-white"
>
<dl className="grid grid-cols-3 gap-x-1">
{role === MAKER && (
<>
<p className="mb-1">{t('The maker will receive the maker fee.')}</p>
<p className="mb-1">
{t(
'If the market is in monitoring auction the maker will pay half of the infrastructure and liquidity fees.'
)}
</p>
<p className="mb-1">
{t(
'If the market is active the maker will pay zero infrastructure and liquidity fees.'
)}
</p>
</>
)}
{role === TAKER && (
<>
<p className="mb-1">{t('Fees to be paid by the taker.')}</p>
<p className="mb-1">
{t(
'If the market is in monitoring auction the taker will pay half of the infrastructure and liquidity fees.'
)}
</p>
</>
)}
<dl className="grid grid-cols-2 gap-x-1">
<dt className="col-span-1">{t('Infrastructure fee')}</dt>
<dd className="text-right col-span-2">
{addDecimalsFormatNumber(feesObj.infrastructureFee, asset.decimals)}{' '}
<dd className="text-right col-span-1">
{addDecimalsFormatNumber(infrastructureFee, asset.decimals)}{' '}
{asset.symbol}
</dd>
<dt className="col-span-1">{t('Liquidity fee')}</dt>
<dd className="text-right col-span-2">
{addDecimalsFormatNumber(feesObj.liquidityFee, asset.decimals)}{' '}
{asset.symbol}
<dd className="text-right col-span-1">
{addDecimalsFormatNumber(liquidityFee, asset.decimals)} {asset.symbol}
</dd>
<dt className="col-span-1">{t('Maker fee')}</dt>
<dd className="text-right col-span-2">
{addDecimalsFormatNumber(feesObj.makerFee, asset.decimals)}{' '}
{asset.symbol}
<dd className="text-right col-span-1">
{addDecimalsFormatNumber(makerFee, asset.decimals)} {asset.symbol}
</dd>
<dt className="col-span-1">{t('Total fees')}</dt>
<dd className="text-right col-span-2">{valueFormatted}</dd>
<dd className="text-right col-span-1">
{addDecimalsFormatNumber(totalFee, asset.decimals)} {asset.symbol}
</dd>
</dl>
</div>
);
};
export const getFeesBreakdown = (
role: string,
feesObj: {
__typename?: 'TradeFee' | undefined;
makerFee: string;
infrastructureFee: string;
liquidityFee: string;
}
) => {
const makerFee =
role === MAKER
? new BigNumber(feesObj.makerFee).times(-1).toString()
: feesObj.makerFee;
const infrastructureFee = feesObj.infrastructureFee;
const liquidityFee = feesObj.liquidityFee;
const totalFee = new BigNumber(infrastructureFee)
.plus(makerFee)
.plus(liquidityFee)
.toString();
return {
infrastructureFee,
liquidityFee,
makerFee,
totalFee,
};
};
@@ -20,7 +20,7 @@ import {
getPriceLevel,
} from './orderbook-data';
import type { OrderbookData } from './orderbook-data';
import { usePersistedOrderStore } from '@vegaprotocol/orders';
import { useOrderStore } from '@vegaprotocol/orders';
interface OrderbookManagerProps {
marketId: string;
@@ -172,7 +172,7 @@ export const OrderbookManager = ({ marketId }: OrderbookManagerProps) => {
flush();
}, [resolution, flush]);
const updatePrice = usePersistedOrderStore((store) => store.updatePrice);
const updateOrder = useOrderStore((store) => store.update);
return (
<AsyncRenderer
@@ -190,7 +190,7 @@ export const OrderbookManager = ({ marketId }: OrderbookManagerProps) => {
onClick={(price?: string | number) => {
if (price) {
const priceValue = addDecimal(price, market?.decimalPlaces ?? 0);
updatePrice(marketId, priceValue);
updateOrder(marketId, { price: priceValue });
}
}}
/>
+5 -4
View File
@@ -88,9 +88,9 @@ const getRowsToRender = (
return selectedRows;
};
// 17px of row height plus 5px gap
export const gridGap = 5;
export const rowHeight = 22;
// 17px of row height plus 4px gap
export const gridGap = 4;
export const rowHeight = 21;
// top padding to make space for header
const headerPadding = 30;
// bottom padding to make space for footer
@@ -393,7 +393,8 @@ export const Orderbook = ({
numberOfRows * rowHeight +
headerPadding +
footerPadding +
-viewportHeight
-viewportHeight -
gridGap
)
);
if (scrollTopRef.current !== priceCenterScrollOffset) {
+21
View File
@@ -0,0 +1,21 @@
import type { StateCreator } from 'zustand';
import { act } from 'react-dom/test-utils';
const { create: actualCreate } = jest.requireActual('zustand'); // if using jest
// a variable to hold reset functions for all stores declared in the app
const storeResetFns = new Set<() => void>();
// when creating a store, we get its initial state, create a reset function and add it in the set
export const create =
() =>
<S>(createState: StateCreator<S>) => {
const store = actualCreate(createState);
const initialState = store.getState();
storeResetFns.add(() => store.setState(initialState, true));
return store;
};
// Reset all stores after each test run
beforeEach(() => {
act(() => storeResetFns.forEach((resetFn) => resetFn()));
});
@@ -11,6 +11,7 @@ import {
import type { Market } from '@vegaprotocol/market-list';
import { marketsProvider } from '@vegaprotocol/market-list';
import type { PageInfo, Edge } from '@vegaprotocol/utils';
import { OrderStatus } from '@vegaprotocol/types';
import type {
OrderFieldsFragment,
OrderUpdateFieldsFragment,
@@ -22,6 +23,7 @@ import { OrdersDocument, OrdersUpdateDocument } from './__generated__/Orders';
export type Order = Omit<OrderFieldsFragment, 'market'> & {
market?: Market;
isLastPlaceholder?: boolean;
};
export type OrderEdge = Edge<Order>;
@@ -50,6 +52,9 @@ const orderMatchFilters = (
) {
return false;
}
if (variables?.filter?.excludeLiquidity && order.liquidityProvisionId) {
return false;
}
if (
variables?.dateRange?.start &&
!(
@@ -177,3 +182,57 @@ export const ordersWithMarketProvider = makeDerivedDataProvider<
combineDelta<Order, ReturnType<typeof getDelta>['0']>,
combineInsertionData<Order>
);
const hasActiveOrderProviderInternal = makeDataProvider({
query: OrdersDocument,
subscriptionQuery: OrdersUpdateDocument,
update: (
data: boolean | null,
delta: ReturnType<typeof getDelta>,
reload: () => void
) => {
const orders = delta?.filter(
(order) => !(order.peggedOrder || order.liquidityProvisionId)
);
if (!orders?.length) {
return data;
}
const hasActiveOrders = orders.some(
(order) => order.status === OrderStatus.STATUS_ACTIVE
);
if (hasActiveOrders) {
return true;
} else if (data && !hasActiveOrders) {
reload();
}
return data;
},
getData: (responseData: OrdersQuery | null) => {
const hasActiveOrder = !!responseData?.party?.ordersConnection?.edges?.some(
(order) => !(order.node.peggedOrder || order.node.liquidityProvision)
);
return hasActiveOrder;
},
getDelta,
});
export const hasActiveOrderProvider = makeDerivedDataProvider<
boolean,
never,
{ partyId: string; marketId?: string }
>(
[
(callback, client, variables) =>
hasActiveOrderProviderInternal(callback, client, {
filter: {
status: [OrderStatus.STATUS_ACTIVE],
excludeLiquidity: true,
},
pagination: {
first: 1,
},
...variables,
} as OrdersQueryVariables),
],
(parts) => parts[0]
);
@@ -8,6 +8,10 @@ import type { VegaWalletContextShape } from '@vegaprotocol/wallet';
import { VegaWalletContext } from '@vegaprotocol/wallet';
import { MockedProvider } from '@apollo/client/testing';
// @ts-ignore OrderList is read only but we need to override with the forwardRef to
// avoid warnings about padding refs
orderListMock.OrderListTable = forwardRef(() => <div>OrderList</div>);
const generateJsx = () => {
const pubKey = '0x123';
return (
@@ -56,9 +60,6 @@ describe('OrderListManager', () => {
});
it('should render the order list if orders provided', async () => {
// @ts-ignore OrderList is read only but we need to override with the forwardRef to
// avoid warnings about padding refs
orderListMock.OrderListTable = forwardRef(() => <div>OrderList</div>);
jest.spyOn(useDataProviderHook, 'useDataProvider').mockReturnValue({
data: [{ id: '1' } as OrderFieldsFragment],
loading: false,
@@ -1,5 +1,4 @@
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { truncateByChars } from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n';
import { useCallback, useRef, useState } from 'react';
import type {
@@ -8,28 +7,19 @@ import type {
FilterChangedEvent,
SortChangedEvent,
} from 'ag-grid-community';
import { Button, Intent } from '@vegaprotocol/ui-toolkit';
import { Button } from '@vegaprotocol/ui-toolkit';
import type { AgGridReact } from 'ag-grid-react';
import { OrderListTable } from '../order-list/order-list';
import { useOrderListData } from './use-order-list-data';
import { useHasActiveOrder } from '../../order-hooks/use-has-active-order';
import type { Filter, Sort } from './use-order-list-data';
import { useEnvironment } from '@vegaprotocol/environment';
import { Link } from '@vegaprotocol/ui-toolkit';
import { useBottomPlaceholder } from '@vegaprotocol/react-helpers';
import {
normalizeOrderAmendment,
useVegaTransactionStore,
} from '@vegaprotocol/wallet';
import type {
VegaTxState,
TransactionResult,
OrderTxUpdateFieldsFragment,
} from '@vegaprotocol/wallet';
import type { OrderTxUpdateFieldsFragment } from '@vegaprotocol/wallet';
import { OrderEditDialog } from '../order-list/order-edit-dialog';
import type { OrderSubFieldsFragment } from '../../order-hooks';
import * as Schema from '@vegaprotocol/types';
import type { Order } from '../order-data-provider';
export interface OrderListManagerProps {
@@ -39,39 +29,26 @@ export interface OrderListManagerProps {
isReadOnly: boolean;
}
export const TransactionComplete = ({
transaction,
transactionResult,
const CancelAllOrdersButton = ({
onClick,
marketId,
}: {
transaction: VegaTxState;
transactionResult?: TransactionResult;
onClick: (marketId?: string) => void;
marketId?: string;
}) => {
const { VEGA_EXPLORER_URL } = useEnvironment();
if (!transactionResult) return null;
return (
<>
{transactionResult.status ? (
<p>{t('Transaction successful')}</p>
) : (
<p className="text-vega-pink">
{t('Transaction failed')}: {transactionResult.error}
</p>
)}
{transaction.txHash && (
<>
<p className="font-semibold mt-4">{t('Transaction')}</p>
<p>
<Link
href={`${VEGA_EXPLORER_URL}/txs/${transaction.txHash}`}
target="_blank"
>
{truncateByChars(transaction.txHash)}
</Link>
</p>
</>
)}
</>
);
const hasActiveOrder = useHasActiveOrder(marketId);
return hasActiveOrder ? (
<div className="dark:bg-black/75 bg-white/75 h-auto flex justify-end px-[11px] py-2 absolute bottom-0 right-3 rounded">
<Button
variant="primary"
size="sm"
onClick={() => onClick(marketId)}
data-testid="cancelAll"
>
{t('Cancel all')}
</Button>
</div>
) : null;
};
export const OrderListManager = ({
@@ -88,23 +65,46 @@ export const OrderListManager = ({
const create = useVegaTransactionStore((state) => state.create);
const hasActiveOrder = useHasActiveOrder(marketId);
const { data, error, loading, addNewRows, getRows, reload } =
useOrderListData({
partyId,
marketId,
sort,
filter,
gridRef,
scrolledToTop,
});
const {
data,
error,
loading,
addNewRows,
getRows,
reload,
makeBottomPlaceholders,
} = useOrderListData({
partyId,
marketId,
sort,
filter,
gridRef,
scrolledToTop,
});
const checkBottomPlaceholder = useCallback(() => {
if (!isReadOnly && hasActiveOrder) {
const rowCont = gridRef.current?.api?.getModel().getRowCount() ?? 0;
const lastRowIndex = gridRef.current?.api?.getLastDisplayedRow();
if (lastRowIndex && rowCont - 1 === lastRowIndex) {
const lastrow =
gridRef.current?.api.getDisplayedRowAtIndex(lastRowIndex);
lastrow?.setRowHeight(50);
makeBottomPlaceholders(lastrow?.data);
gridRef.current?.api.onRowHeightChanged();
gridRef.current?.api.refreshInfiniteCache();
}
}
}, [isReadOnly, hasActiveOrder, makeBottomPlaceholders]);
const onBodyScrollEnd = useCallback(
(event: BodyScrollEndEvent) => {
if (event.top === 0) {
addNewRows();
}
checkBottomPlaceholder();
},
[addNewRows]
[addNewRows, checkBottomPlaceholder]
);
const onBodyScroll = useCallback((event: BodyScrollEvent) => {
@@ -113,18 +113,21 @@ export const OrderListManager = ({
const onFilterChanged = useCallback(
(event: FilterChangedEvent) => {
makeBottomPlaceholders();
const updatedFilter = event.api.getFilterModel();
if (Object.keys(updatedFilter).length) {
setFilter(updatedFilter);
} else {
setFilter(undefined);
}
checkBottomPlaceholder();
},
[setFilter]
[setFilter, makeBottomPlaceholders, checkBottomPlaceholder]
);
const onSortChange = useCallback(
(event: SortChangedEvent) => {
makeBottomPlaceholders();
const sort = event.columnApi
.getColumnState()
.sort((a, b) => (a.sortIndex || 0) - (b.sortIndex || 0))
@@ -136,11 +139,12 @@ export const OrderListManager = ({
return acc;
}, [] as { colId: string; sort: string }[]);
setSort(sort.length > 0 ? sort : undefined);
checkBottomPlaceholder();
},
[setSort]
[setSort, makeBottomPlaceholders, checkBottomPlaceholder]
);
const onCancel = useCallback(
const cancel = useCallback(
(order: Order) => {
if (!order.market) return;
create({
@@ -152,58 +156,57 @@ export const OrderListManager = ({
},
[create]
);
const cancelAll = useCallback(
(marketId?: string) => {
create({
orderCancellation: {
marketId,
},
});
},
[create]
);
const { isFullWidthRow, fullWidthCellRenderer, rowClassRules } =
useBottomPlaceholder<Order>({
gridRef,
});
return (
<>
<div className="h-full relative grid grid-rows-[1fr,min-content]">
<div className="relative">
<OrderListTable
ref={gridRef}
rowModelType="infinite"
datasource={{ getRows }}
onBodyScrollEnd={onBodyScrollEnd}
onBodyScroll={onBodyScroll}
onFilterChanged={onFilterChanged}
onSortChanged={onSortChange}
cancel={onCancel}
setEditOrder={setEditOrder}
onMarketClick={onMarketClick}
isReadOnly={isReadOnly}
hasActiveOrder={hasActiveOrder}
blockLoadDebounceMillis={100}
suppressLoadingOverlay
suppressNoRowsOverlay
<div className="h-full relative">
<OrderListTable
ref={gridRef}
rowModelType="infinite"
datasource={{ getRows }}
onBodyScrollEnd={onBodyScrollEnd}
onBodyScroll={onBodyScroll}
onFilterChanged={onFilterChanged}
onSortChanged={onSortChange}
cancel={cancel}
setEditOrder={setEditOrder}
onMarketClick={onMarketClick}
isReadOnly={isReadOnly}
blockLoadDebounceMillis={100}
suppressLoadingOverlay
suppressNoRowsOverlay
isFullWidthRow={isFullWidthRow}
fullWidthCellRenderer={fullWidthCellRenderer}
rowClassRules={rowClassRules}
/>
<div className="pointer-events-none absolute inset-0">
<AsyncRenderer
loading={loading}
error={error}
data={data}
noDataMessage={t('No orders')}
noDataCondition={(data) => !(data && data.length)}
reload={reload}
/>
<div className="pointer-events-none absolute inset-0">
<AsyncRenderer
loading={loading}
error={error}
data={data}
noDataMessage={t('No orders')}
noDataCondition={(data) => !(data && data.length)}
reload={reload}
/>
</div>
</div>
{!isReadOnly && hasActiveOrder && (
<div className="w-full dark:bg-black bg-white absolute bottom-0 h-auto flex justify-end px-[11px] py-2">
<Button
size="sm"
onClick={() => {
create({
orderCancellation: {
marketId,
},
});
}}
data-testid="cancelAll"
>
{t('Cancel all')}
</Button>
</div>
)}
</div>
{!isReadOnly && (
<CancelAllOrdersButton onClick={cancelAll} marketId={marketId} />
)}
{editOrder && (
<OrderEditDialog
isOpen={Boolean(editOrder)}
@@ -241,76 +244,3 @@ export const OrderListManager = ({
</>
);
};
export const getEditDialogTitle = (
status?: Schema.OrderStatus
): string | undefined => {
if (!status) {
return;
}
switch (status) {
case Schema.OrderStatus.STATUS_ACTIVE:
return t('Order updated');
case Schema.OrderStatus.STATUS_FILLED:
return t('Order filled');
case Schema.OrderStatus.STATUS_PARTIALLY_FILLED:
return t('Order partially filled');
case Schema.OrderStatus.STATUS_PARKED:
return t('Order parked');
case Schema.OrderStatus.STATUS_STOPPED:
return t('Order stopped');
case Schema.OrderStatus.STATUS_EXPIRED:
return t('Order expired');
case Schema.OrderStatus.STATUS_CANCELLED:
return t('Order cancelled');
case Schema.OrderStatus.STATUS_REJECTED:
return t('Order rejected');
default:
return t('Order amendment failed');
}
};
export const getCancelDialogIntent = ({
cancelledOrder,
transactionResult,
}: {
cancelledOrder: OrderSubFieldsFragment | null;
transactionResult?: TransactionResult;
}): Intent | undefined => {
if (cancelledOrder) {
if (cancelledOrder.status === Schema.OrderStatus.STATUS_CANCELLED) {
return Intent.Success;
}
return Intent.Danger;
}
if (transactionResult) {
if ('error' in transactionResult && transactionResult.error) {
return Intent.Danger;
}
return Intent.Success;
}
return;
};
export const getCancelDialogTitle = ({
cancelledOrder,
transactionResult,
}: {
cancelledOrder: OrderSubFieldsFragment | null;
transactionResult?: TransactionResult;
}): string | undefined => {
if (cancelledOrder) {
if (cancelledOrder.status === Schema.OrderStatus.STATUS_CANCELLED) {
return t('Order cancelled');
}
return t('Order cancellation failed');
}
if (transactionResult) {
if (transactionResult.status) {
return t('Orders cancelled');
}
return t('Orders not cancelled');
}
return;
};
@@ -51,6 +51,21 @@ export const useOrderListData = ({
const dataRef = useRef<(OrderEdge | null)[] | null>(null);
const totalCountRef = useRef<number | undefined>(undefined);
const newRows = useRef(0);
const placeholderAdded = useRef(-1);
const makeBottomPlaceholders = useCallback((order?: Order) => {
if (!order) {
if (placeholderAdded.current >= 0) {
dataRef.current?.splice(placeholderAdded.current, 1);
}
placeholderAdded.current = -1;
} else if (placeholderAdded.current === -1) {
dataRef.current?.push({
node: { ...order, id: `${order?.id}-1`, isLastPlaceholder: true },
});
placeholderAdded.current = (dataRef.current?.length || 0) - 1;
}
}, []);
const variables = useMemo<
OrdersQueryVariables & OrdersUpdateSubscriptionVariables
@@ -130,5 +145,13 @@ export const useOrderListData = ({
load,
newRows
);
return { loading, error, data, addNewRows, getRows, reload };
return {
loading,
error,
data,
addNewRows,
getRows,
reload,
makeBottomPlaceholders,
};
};
@@ -6,7 +6,6 @@ import type { PartialDeep } from 'type-fest';
import type { VegaWalletContextShape } from '@vegaprotocol/wallet';
import { VegaWalletContext } from '@vegaprotocol/wallet';
import { MockedProvider } from '@apollo/client/testing';
import type { OrderListTableProps } from '../';
import { OrderListTable } from '../';
import {
@@ -15,6 +14,15 @@ import {
marketOrder,
} from '../mocks/generate-orders';
// Mock theme switcher to get around inconsistent mocking of zustand
// stores
jest.mock('@vegaprotocol/react-helpers', () => ({
...jest.requireActual('@vegaprotocol/react-helpers'),
useThemeSwitcher: () => ({
theme: 'light',
}),
}));
const defaultProps: OrderListTableProps = {
rowData: [],
setEditOrder: jest.fn(),
@@ -8,7 +8,7 @@ import * as Schema from '@vegaprotocol/types';
import { ButtonLink, Link } from '@vegaprotocol/ui-toolkit';
import { AgGridColumn } from 'ag-grid-react';
import BigNumber from 'bignumber.js';
import { forwardRef } from 'react';
import { memo, forwardRef } from 'react';
import {
AgGridDynamic as AgGrid,
SetFilter,
@@ -31,260 +31,264 @@ export type OrderListTableProps = OrderListProps & {
setEditOrder: (order: Order) => void;
onMarketClick?: (marketId: string) => void;
isReadOnly: boolean;
hasActiveOrder?: boolean;
};
export const OrderListTable = forwardRef<AgGridReact, OrderListTableProps>(
({ cancel, setEditOrder, onMarketClick, hasActiveOrder, ...props }, ref) => {
return (
<AgGrid
ref={ref}
overlayNoRowsTemplate={t('No orders')}
defaultColDef={{
flex: 1,
resizable: true,
filterParams: { buttons: ['reset'] },
}}
style={{
width: '100%',
height: hasActiveOrder ? 'calc(100% - 46px)' : '100%',
}}
getRowId={({ data }) => data.id}
{...props}
>
<AgGridColumn
headerName={t('Market')}
field="market.tradableInstrument.instrument.code"
cellRenderer={({
value,
data,
}: VegaICellRendererParams<
Order,
'market.tradableInstrument.instrument.code'
>) =>
onMarketClick ? (
<Link
onClick={() =>
data?.market?.id && onMarketClick(data?.market?.id)
}
>
{value}
</Link>
) : (
value
)
}
/>
<AgGridColumn
headerName={t('Size')}
field="size"
cellClass="font-mono text-right"
type="rightAligned"
cellClassRules={{
[positiveClassNames]: ({ data }: { data: Order }) =>
data?.side === Schema.Side.SIDE_BUY,
[negativeClassNames]: ({ data }: { data: Order }) =>
data?.side === Schema.Side.SIDE_SELL,
export const OrderListTable = memo(
forwardRef<AgGridReact, OrderListTableProps>(
({ cancel, setEditOrder, onMarketClick, ...props }, ref) => {
return (
<AgGrid
ref={ref}
overlayNoRowsTemplate={t('No orders')}
defaultColDef={{
flex: 1,
resizable: true,
filterParams: { buttons: ['reset'] },
}}
valueFormatter={({
value,
data,
node,
}: VegaValueFormatterParams<Order, 'size'>) => {
if (!data) {
return undefined;
}
if (!data?.market || !isNumeric(value)) {
return '-';
}
const prefix = data
? data.side === Schema.Side.SIDE_BUY
? '+'
: '-'
: '';
return (
prefix +
addDecimalsFormatNumber(value, data.market.positionDecimalPlaces)
);
style={{
width: '100%',
height: '100%',
}}
/>
<AgGridColumn
field="type"
filter={SetFilter}
filterParams={{
set: Schema.OrderTypeMapping,
}}
valueFormatter={({
data: order,
value,
node,
}: VegaValueFormatterParams<Order, 'type'>) => {
if (!order) {
return undefined;
}
if (!value) return '-';
if (order?.peggedOrder) return t('Pegged');
if (order?.liquidityProvision) return t('Liquidity provision');
return Schema.OrderTypeMapping[value];
}}
/>
<AgGridColumn
field="status"
filter={SetFilter}
filterParams={{
set: Schema.OrderStatusMapping,
}}
valueFormatter={({
value,
data,
}: VegaValueFormatterParams<Order, 'status'>) => {
if (data?.rejectionReason && value) {
return `${Schema.OrderStatusMapping[value]}: ${
data?.rejectionReason &&
Schema.OrderRejectionReasonMapping[data.rejectionReason]
}`;
}
return value ? Schema.OrderStatusMapping[value] : '';
}}
cellRenderer={({
valueFormatted,
data,
}: {
valueFormatted: string;
data: Order;
}) => (
<span data-testid={`order-status-${data?.id}`}>
{valueFormatted}
</span>
)}
/>
<AgGridColumn
headerName={t('Filled')}
field="remaining"
cellClass="font-mono text-right"
type="rightAligned"
valueFormatter={({
data,
value,
node,
}: VegaValueFormatterParams<Order, 'remaining'>) => {
if (!data) {
return undefined;
}
if (!data?.market || !isNumeric(value) || !isNumeric(data.size)) {
return '-';
}
const dps = data.market.positionDecimalPlaces;
const size = new BigNumber(data.size);
const remaining = new BigNumber(value);
const fills = size.minus(remaining);
return `${addDecimalsFormatNumber(
fills.toString(),
dps
)}/${addDecimalsFormatNumber(size.toString(), dps)}`;
}}
/>
<AgGridColumn
field="price"
type="rightAligned"
cellClass="font-mono text-right"
valueFormatter={({
value,
data,
node,
}: VegaValueFormatterParams<Order, 'price'>) => {
if (!data) {
return undefined;
}
if (
!data?.market ||
data.type === Schema.OrderType.TYPE_MARKET ||
!isNumeric(value)
) {
return '-';
}
return addDecimalsFormatNumber(value, data.market.decimalPlaces);
}}
/>
<AgGridColumn
field="timeInForce"
filter={SetFilter}
filterParams={{
set: Schema.OrderTimeInForceMapping,
}}
valueFormatter={({
value,
data,
}: VegaValueFormatterParams<Order, 'timeInForce'>) => {
if (
value === Schema.OrderTimeInForce.TIME_IN_FORCE_GTT &&
data?.expiresAt
) {
const expiry = getDateTimeFormat().format(
new Date(data.expiresAt)
);
return `${Schema.OrderTimeInForceMapping[value]}: ${expiry}`;
}
return value ? Schema.OrderTimeInForceMapping[value] : '';
}}
/>
<AgGridColumn
field="createdAt"
cellRenderer={({
data,
value,
}: VegaICellRendererParams<Order, 'createdAt'>) => {
return (
<span data-value={value}>
{value ? getDateTimeFormat().format(new Date(value)) : value}
</span>
);
}}
/>
<AgGridColumn
field="updatedAt"
filter={DateRangeFilter}
cellRenderer={({
data,
value,
}: VegaICellRendererParams<Order, 'updatedAt'>) => {
if (!data) {
return undefined;
}
return (
<span data-value={value}>
{value ? getDateTimeFormat().format(new Date(value)) : '-'}
</span>
);
}}
/>
<AgGridColumn
colId="amend"
headerName=""
field="status"
minWidth={100}
type="rightAligned"
cellRenderer={({ data, node }: VegaICellRendererParams<Order>) => {
return data && isOrderAmendable(data) && !props.isReadOnly ? (
<>
<ButtonLink
data-testid="edit"
onClick={() => setEditOrder(data)}
getRowId={({ data }) => data.id}
{...props}
>
<AgGridColumn
headerName={t('Market')}
field="market.tradableInstrument.instrument.code"
cellRenderer={({
value,
data,
}: VegaICellRendererParams<
Order,
'market.tradableInstrument.instrument.code'
>) =>
onMarketClick ? (
<Link
onClick={() =>
data?.market?.id && onMarketClick(data?.market?.id)
}
>
{t('Edit')}
</ButtonLink>
<span className="mx-1" />
<ButtonLink data-testid="cancel" onClick={() => cancel(data)}>
{t('Cancel')}
</ButtonLink>
</>
) : null;
}}
/>
</AgGrid>
);
}
{value}
</Link>
) : (
value
)
}
/>
<AgGridColumn
headerName={t('Size')}
field="size"
cellClass="font-mono text-right"
type="rightAligned"
cellClassRules={{
[positiveClassNames]: ({ data }: { data: Order }) =>
data?.side === Schema.Side.SIDE_BUY,
[negativeClassNames]: ({ data }: { data: Order }) =>
data?.side === Schema.Side.SIDE_SELL,
}}
valueFormatter={({
value,
data,
node,
}: VegaValueFormatterParams<Order, 'size'>) => {
if (!data) {
return undefined;
}
if (!data?.market || !isNumeric(value)) {
return '-';
}
const prefix = data
? data.side === Schema.Side.SIDE_BUY
? '+'
: '-'
: '';
return (
prefix +
addDecimalsFormatNumber(
value,
data.market.positionDecimalPlaces
)
);
}}
/>
<AgGridColumn
field="type"
filter={SetFilter}
filterParams={{
set: Schema.OrderTypeMapping,
}}
valueFormatter={({
data: order,
value,
node,
}: VegaValueFormatterParams<Order, 'type'>) => {
if (!order) {
return undefined;
}
if (!value) return '-';
if (order?.peggedOrder) return t('Pegged');
if (order?.liquidityProvision) return t('Liquidity provision');
return Schema.OrderTypeMapping[value];
}}
/>
<AgGridColumn
field="status"
filter={SetFilter}
filterParams={{
set: Schema.OrderStatusMapping,
}}
valueFormatter={({
value,
data,
}: VegaValueFormatterParams<Order, 'status'>) => {
if (data?.rejectionReason && value) {
return `${Schema.OrderStatusMapping[value]}: ${
data?.rejectionReason &&
Schema.OrderRejectionReasonMapping[data.rejectionReason]
}`;
}
return value ? Schema.OrderStatusMapping[value] : '';
}}
cellRenderer={({
valueFormatted,
data,
}: {
valueFormatted: string;
data: Order;
}) => (
<span data-testid={`order-status-${data?.id}`}>
{valueFormatted}
</span>
)}
/>
<AgGridColumn
headerName={t('Filled')}
field="remaining"
cellClass="font-mono text-right"
type="rightAligned"
valueFormatter={({
data,
value,
node,
}: VegaValueFormatterParams<Order, 'remaining'>) => {
if (!data) {
return undefined;
}
if (!data?.market || !isNumeric(value) || !isNumeric(data.size)) {
return '-';
}
const dps = data.market.positionDecimalPlaces;
const size = new BigNumber(data.size);
const remaining = new BigNumber(value);
const fills = size.minus(remaining);
return `${addDecimalsFormatNumber(
fills.toString(),
dps
)}/${addDecimalsFormatNumber(size.toString(), dps)}`;
}}
/>
<AgGridColumn
field="price"
type="rightAligned"
cellClass="font-mono text-right"
valueFormatter={({
value,
data,
node,
}: VegaValueFormatterParams<Order, 'price'>) => {
if (!data) {
return undefined;
}
if (
!data?.market ||
data.type === Schema.OrderType.TYPE_MARKET ||
!isNumeric(value)
) {
return '-';
}
return addDecimalsFormatNumber(value, data.market.decimalPlaces);
}}
/>
<AgGridColumn
field="timeInForce"
filter={SetFilter}
filterParams={{
set: Schema.OrderTimeInForceMapping,
}}
valueFormatter={({
value,
data,
}: VegaValueFormatterParams<Order, 'timeInForce'>) => {
if (
value === Schema.OrderTimeInForce.TIME_IN_FORCE_GTT &&
data?.expiresAt
) {
const expiry = getDateTimeFormat().format(
new Date(data.expiresAt)
);
return `${Schema.OrderTimeInForceMapping[value]}: ${expiry}`;
}
return value ? Schema.OrderTimeInForceMapping[value] : '';
}}
/>
<AgGridColumn
field="createdAt"
cellRenderer={({
data,
value,
}: VegaICellRendererParams<Order, 'createdAt'>) => {
return (
<span data-value={value}>
{value ? getDateTimeFormat().format(new Date(value)) : value}
</span>
);
}}
/>
<AgGridColumn
field="updatedAt"
filter={DateRangeFilter}
cellRenderer={({
data,
value,
}: VegaICellRendererParams<Order, 'updatedAt'>) => {
if (!data) {
return undefined;
}
return (
<span data-value={value}>
{value ? getDateTimeFormat().format(new Date(value)) : '-'}
</span>
);
}}
/>
<AgGridColumn
colId="amend"
headerName=""
field="status"
minWidth={100}
type="rightAligned"
cellRenderer={({ data, node }: VegaICellRendererParams<Order>) => {
return data && isOrderAmendable(data) && !props.isReadOnly ? (
<>
<ButtonLink
data-testid="edit"
onClick={() => setEditOrder(data)}
>
{t('Edit')}
</ButtonLink>
<span className="mx-1" />
<ButtonLink data-testid="cancel" onClick={() => cancel(data)}>
{t('Cancel')}
</ButtonLink>
</>
) : null;
}}
/>
</AgGrid>
);
}
)
);
/**
+1 -1
View File
@@ -4,4 +4,4 @@ export * from './use-order-cancel';
export * from './use-order-submit';
export * from './use-order-edit';
export * from './use-order-update';
export * from './use-persisted-order';
export * from './use-order-store';
@@ -1,46 +1,23 @@
import { useState, useEffect, useMemo } from 'react';
import {
useOrdersUpdateSubscription,
useOrdersQuery,
} from '../components/order-data-provider/__generated__/Orders';
import { useCallback, useState } from 'react';
import { hasActiveOrderProvider } from '../components/order-data-provider/';
import { useVegaWallet } from '@vegaprotocol/wallet';
import * as Schema from '@vegaprotocol/types';
import { useDataProvider } from '@vegaprotocol/react-helpers';
export const useHasActiveOrder = (marketId?: string) => {
const { pubKey } = useVegaWallet();
const skip = !pubKey;
const [hasActiveOrder, setHasActiveOrder] = useState(false);
const subscriptionVariables = useMemo(
() => ({
const update = useCallback(({ data }: { data: boolean | null }) => {
setHasActiveOrder(Boolean(data));
return true;
}, []);
useDataProvider({
dataProvider: hasActiveOrderProvider,
update,
variables: {
partyId: pubKey || '',
marketId,
}),
[pubKey, marketId]
);
const queryVariables = useMemo(
() => ({
...subscriptionVariables,
pagination: { first: 1 },
filter: { status: [Schema.OrderStatus.STATUS_ACTIVE] },
}),
[subscriptionVariables]
);
const { refetch, data, loading } = useOrdersQuery({
variables: queryVariables,
fetchPolicy: 'no-cache',
skip,
});
useEffect(() => {
if (!loading && data) {
setHasActiveOrder(!!data.party?.ordersConnection?.edges?.length);
}
}, [loading, data]);
useOrdersUpdateSubscription({
variables: subscriptionVariables,
onData: () => refetch(),
skip,
},
skip: !pubKey,
});
return hasActiveOrder;
@@ -0,0 +1,121 @@
import {
getDefaultOrder,
STORAGE_KEY,
useOrder,
useOrderStore,
} from './use-order-store';
import { act, renderHook } from '@testing-library/react';
import { OrderType } from '@vegaprotocol/types';
jest.mock('zustand');
describe('useOrderStore', () => {
const setup = () => {
return renderHook(() => useOrderStore());
};
afterEach(() => {
localStorage.clear();
});
it('has a empty default state', async () => {
const { result } = setup();
expect(result.current).toEqual({
orders: {},
update: expect.any(Function),
});
});
it('can update', () => {
const marketId = 'persisted-market-id';
const expectedOrder = {
...getDefaultOrder(marketId),
type: OrderType.TYPE_LIMIT,
persist: true,
};
const { result } = setup();
act(() => {
result.current.update(marketId, { type: OrderType.TYPE_LIMIT });
});
// order should be stored in memory
expect(result.current.orders).toEqual({
[marketId]: expectedOrder,
});
// order SHOULD also be in localStorage
expect(JSON.parse(localStorage.getItem(STORAGE_KEY) || '')).toEqual({
state: {
orders: {
[marketId]: expectedOrder,
},
},
version: 0,
});
});
it('can update without persisting', () => {
const marketId = 'non-persisted-market-id';
const expectedOrder = {
...getDefaultOrder(marketId),
type: OrderType.TYPE_LIMIT,
persist: false,
};
const { result } = setup();
act(() => {
result.current.update(marketId, { type: OrderType.TYPE_LIMIT }, false);
});
// order should be stored in memory
expect(result.current.orders).toEqual({
[marketId]: expectedOrder,
});
// order should NOT be in localStorage
expect(JSON.parse(localStorage.getItem(STORAGE_KEY) || '')).toEqual({
state: {
orders: {},
},
version: 0,
});
});
});
describe('useOrder', () => {
const setup = (marketId: string) => {
return renderHook(() => useOrder(marketId));
};
afterEach(() => {
localStorage.clear();
});
it('creates a new order if it doesnt exist which is only persisted after editing', () => {
const marketId = 'market-id';
const expectedOrder = {
...getDefaultOrder(marketId),
persist: false,
};
const { result } = setup(marketId);
expect(result.current).toEqual([expectedOrder, expect.any(Function)]);
});
it('only persists an order if edited', () => {
const marketId = 'market-id';
const expectedOrder = {
...getDefaultOrder(marketId),
persist: false,
};
const { result } = setup(marketId);
expect(result.current[0]).toMatchObject({
price: expectedOrder.price,
persist: false,
});
const update = { price: '500' };
act(() => {
result.current[1](update);
});
expect(result.current[0]).toMatchObject({
...update,
persist: true,
});
});
});
@@ -0,0 +1,117 @@
import { OrderTimeInForce, Side } from '@vegaprotocol/types';
import { OrderType } from '@vegaprotocol/types';
import { useCallback, useEffect } from 'react';
import { create } from 'zustand';
import { persist, subscribeWithSelector } from 'zustand/middleware';
export type OrderObj = {
marketId: string;
type: OrderType;
side: Side;
size: string;
timeInForce: OrderTimeInForce;
price?: string;
expiresAt?: string | undefined;
persist: boolean; // key used to determine if order should be kept in localStorage
};
type OrderMap = { [marketId: string]: OrderObj | undefined };
type UpdateOrder = (
marketId: string,
order: Partial<OrderObj>,
persist?: boolean
) => void;
interface Store {
orders: OrderMap;
update: UpdateOrder;
}
export const STORAGE_KEY = 'vega_order_store';
export const useOrderStore = create<Store>()(
persist(
subscribeWithSelector((set) => ({
orders: {},
update: (marketId, order, persist = true) => {
set((state) => {
const curr = state.orders[marketId];
const defaultOrder = getDefaultOrder(marketId);
return {
orders: {
...state.orders,
[marketId]: {
...defaultOrder,
...curr,
...order,
persist,
},
},
};
});
},
})),
{
name: STORAGE_KEY,
partialize: (state) => {
// only store the order in localStorage if user has edited, this avoids
// bloating localStorage if a user just visits the page but does not
// edit the ticket
const partializedOrders: OrderMap = {};
for (const o in state.orders) {
const order = state.orders[o];
if (order && order.persist) {
partializedOrders[order.marketId] = order;
}
}
return {
...state,
orders: partializedOrders,
};
},
}
)
);
/**
* Retrieves an order from the store for a market and
* creates one if it doesn't already exist
*/
export const useOrder = (marketId: string) => {
const [order, _update] = useOrderStore((store) => {
return [store.orders[marketId], store.update];
});
const update = useCallback(
(o: Partial<OrderObj>, persist = true) => {
_update(marketId, o, persist);
},
[marketId, _update]
);
// add new order to store if it doesnt exist, but don't
// persist until user has edited
useEffect(() => {
if (!order) {
update(
getDefaultOrder(marketId),
false // dont persist the order
);
}
}, [order, marketId, update]);
return [order, update] as const; // make result a tuple
};
export const getDefaultOrder = (marketId: string): OrderObj => ({
marketId,
type: OrderType.TYPE_MARKET,
side: Side.SIDE_BUY,
timeInForce: OrderTimeInForce.TIME_IN_FORCE_IOC,
size: '0',
price: '0',
expiresAt: undefined,
persist: false,
});
@@ -1,78 +0,0 @@
import type { OrderSubmissionBody } from '@vegaprotocol/wallet';
import produce from 'immer';
import { create } from 'zustand';
import { persist, subscribeWithSelector } from 'zustand/middleware';
import isEqual from 'lodash/isEqual';
import { useEffect } from 'react';
type OrderData = OrderSubmissionBody['orderSubmission'] | null;
type PersistedOrderStore = {
orders: OrderData[];
getOrder: (marketId: string) => OrderData | undefined;
setOrder: (order: OrderData) => void;
clear: () => void;
updatePrice: (marketId: string, price: string) => void;
};
export const usePersistedOrderStore = create<PersistedOrderStore>()(
persist(
subscribeWithSelector((set, get) => ({
orders: [],
getOrder: (marketId: string) => {
const current = get() as PersistedOrderStore;
const persisted = current.orders.find((o) => o?.marketId === marketId);
return persisted;
},
setOrder: (order: OrderData) => {
set(
produce((store: PersistedOrderStore) => {
const persisted = store.orders.find(
(o) => o?.marketId === order?.marketId
);
if (persisted) {
if (!isEqual(persisted, order)) {
Object.assign(persisted, order);
} else {
// NOOP
}
} else {
store.orders.push(order);
}
})
);
},
clear: () => set({ orders: [] }),
updatePrice: (marketId: string, price: string) =>
set(
produce((store: PersistedOrderStore) => {
const persisted = store.orders.find(
(o) => o?.marketId === marketId
);
if (persisted) {
persisted.price = price;
}
})
),
})),
{
name: 'VEGA_DEAL_TICKET_ORDER_STORE',
}
)
);
export const usePersistedOrderStoreSubscription = (
marketId: string,
onOrderChange: (order: NonNullable<OrderData>) => void
) => {
const selector = (state: PersistedOrderStore) =>
state.orders.find((o) => o?.marketId === marketId);
const action = (storedOrder: OrderData | undefined) => {
if (storedOrder) {
onOrderChange(storedOrder);
}
};
const unsubscribe = usePersistedOrderStore.subscribe(selector, action);
useEffect(() => () => unsubscribe(), [unsubscribe]);
};
+2 -1
View File
@@ -21,7 +21,8 @@
"**/*.stories.js",
"**/*.stories.jsx",
"**/*.stories.tsx",
"jest.config.ts"
"jest.config.ts",
"__mocks__"
],
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]
}
+4
View File
@@ -4,6 +4,8 @@ fragment PositionFields on Position {
unrealisedPNL
averageEntryPrice
updatedAt
positionStatus
lossSocializationAmount
market {
id
}
@@ -30,6 +32,8 @@ subscription PositionsSubscription($partyId: ID!) {
averageEntryPrice
updatedAt
marketId
lossSocializationAmount
positionStatus
}
}
+7 -3
View File
@@ -3,21 +3,21 @@ import * as Types from '@vegaprotocol/types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type PositionFieldsFragment = { __typename?: 'Position', realisedPNL: string, openVolume: string, unrealisedPNL: string, averageEntryPrice: string, updatedAt?: any | null, market: { __typename?: 'Market', id: string } };
export type PositionFieldsFragment = { __typename?: 'Position', realisedPNL: string, openVolume: string, unrealisedPNL: string, averageEntryPrice: string, updatedAt?: any | null, positionStatus: Types.PositionStatus, lossSocializationAmount: string, market: { __typename?: 'Market', id: string } };
export type PositionsQueryVariables = Types.Exact<{
partyId: Types.Scalars['ID'];
}>;
export type PositionsQuery = { __typename?: 'Query', party?: { __typename?: 'Party', id: string, positionsConnection?: { __typename?: 'PositionConnection', edges?: Array<{ __typename?: 'PositionEdge', node: { __typename?: 'Position', realisedPNL: string, openVolume: string, unrealisedPNL: string, averageEntryPrice: string, updatedAt?: any | null, market: { __typename?: 'Market', id: string } } }> | null } | null } | null };
export type PositionsQuery = { __typename?: 'Query', party?: { __typename?: 'Party', id: string, positionsConnection?: { __typename?: 'PositionConnection', edges?: Array<{ __typename?: 'PositionEdge', node: { __typename?: 'Position', realisedPNL: string, openVolume: string, unrealisedPNL: string, averageEntryPrice: string, updatedAt?: any | null, positionStatus: Types.PositionStatus, lossSocializationAmount: string, market: { __typename?: 'Market', id: string } } }> | null } | null } | null };
export type PositionsSubscriptionSubscriptionVariables = Types.Exact<{
partyId: Types.Scalars['ID'];
}>;
export type PositionsSubscriptionSubscription = { __typename?: 'Subscription', positions: Array<{ __typename?: 'PositionUpdate', realisedPNL: string, openVolume: string, unrealisedPNL: string, averageEntryPrice: string, updatedAt?: any | null, marketId: string }> };
export type PositionsSubscriptionSubscription = { __typename?: 'Subscription', positions: Array<{ __typename?: 'PositionUpdate', realisedPNL: string, openVolume: string, unrealisedPNL: string, averageEntryPrice: string, updatedAt?: any | null, marketId: string, lossSocializationAmount: string, positionStatus: Types.PositionStatus }> };
export type MarginFieldsFragment = { __typename?: 'MarginLevels', maintenanceLevel: string, searchLevel: string, initialLevel: string, collateralReleaseLevel: string, asset: { __typename?: 'Asset', id: string }, market: { __typename?: 'Market', id: string } };
@@ -42,6 +42,8 @@ export const PositionFieldsFragmentDoc = gql`
unrealisedPNL
averageEntryPrice
updatedAt
positionStatus
lossSocializationAmount
market {
id
}
@@ -112,6 +114,8 @@ export const PositionsSubscriptionDocument = gql`
averageEntryPrice
updatedAt
marketId
lossSocializationAmount
positionStatus
}
}
`;
@@ -6,6 +6,7 @@ import type {
MarginFieldsFragment,
} from './__generated__/Positions';
import { getMetrics, rejoinPositionData } from './positions-data-providers';
import { PositionStatus } from '@vegaprotocol/types';
const accounts = [
{
@@ -78,6 +79,8 @@ const positions: PositionFieldsFragment[] = [
__typename: 'Market',
id: '5e6035fe6a6df78c9ec44b333c231e63d357acef0a0620d2c243f5865d1dc0d8',
},
lossSocializationAmount: '0',
positionStatus: PositionStatus.POSITION_STATUS_UNSPECIFIED,
},
{
__typename: 'Position',
@@ -90,6 +93,8 @@ const positions: PositionFieldsFragment[] = [
__typename: 'Market',
id: '10c4b1114d2f6fda239b73d018bca55888b6018f0ac70029972a17fea0a6a56e',
},
lossSocializationAmount: '100',
positionStatus: PositionStatus.POSITION_STATUS_ORDERS_CLOSED,
},
];
@@ -225,6 +230,10 @@ describe('getMetrics && rejoinPositionData', () => {
expect(metrics[0].totalBalance).toEqual('926178496');
expect(metrics[0].unrealisedPNL).toEqual('43804770');
expect(metrics[0].updatedAt).toEqual('2022-07-28T14:53:54.725477Z');
expect(metrics[0].lossSocializationAmount).toEqual(
positions[0].lossSocializationAmount
);
expect(metrics[0].status).toEqual(positions[0].positionStatus);
expect(metrics[1].assetSymbol).toEqual('tDAI');
expect(metrics[1].averageEntryPrice).toEqual('840158');
@@ -248,5 +257,9 @@ describe('getMetrics && rejoinPositionData', () => {
expect(metrics[1].totalBalance).toEqual('896098819');
expect(metrics[1].unrealisedPNL).toEqual('-9112700');
expect(metrics[1].updatedAt).toEqual('2022-07-28T15:09:34.441143Z');
expect(metrics[1].lossSocializationAmount).toEqual(
positions[1].lossSocializationAmount
);
expect(metrics[1].status).toEqual(positions[1].positionStatus);
});
});
@@ -24,6 +24,7 @@ import {
PositionsSubscriptionDocument,
} from './__generated__/Positions';
import { marginsDataProvider } from './margin-data-provider';
import type { PositionStatus } from '@vegaprotocol/types';
type PositionMarginLevel = Pick<
MarginFieldsFragment,
@@ -38,6 +39,8 @@ interface PositionRejoined {
updatedAt?: string | null;
market: MarketMaybeWithData | null;
margins: PositionMarginLevel | null;
lossSocializationAmount: string | null;
status: PositionStatus;
}
export interface Position {
@@ -62,6 +65,8 @@ export interface Position {
unrealisedPNL: string;
searchPrice: string | undefined;
updatedAt: string | null;
lossSocializationAmount: string;
status: PositionStatus;
}
export interface Data {
@@ -178,6 +183,8 @@ export const getMetrics = (
? searchPrice.multipliedBy(10 ** marketDecimalPlaces).toFixed(0)
: undefined,
updatedAt: position.updatedAt || null,
lossSocializationAmount: position.lossSocializationAmount || '0',
status: position.status,
});
});
return metrics;
@@ -201,6 +208,8 @@ export const update = (
openVolume: delta.openVolume,
averageEntryPrice: delta.averageEntryPrice,
updatedAt: delta.updatedAt,
lossSocializationAmount: delta.lossSocializationAmount,
positionStatus: delta.positionStatus,
};
} else {
draft.unshift({
@@ -267,6 +276,8 @@ export const rejoinPositionData = (
market:
marketsData?.find((market) => market.id === node.market.id) || null,
margins: upgradeMarginsConnection(node.market.id, margins),
lossSocializationAmount: node.lossSocializationAmount,
status: node.positionStatus,
};
});
}
+101 -2
View File
@@ -1,8 +1,11 @@
import type { RenderResult } from '@testing-library/react';
import { act, render, screen } from '@testing-library/react';
import PositionsTable from './positions-table';
import { act, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import PositionsTable, { OpenVolumeCell, PNLCell } from './positions-table';
import type { Position } from './positions-data-providers';
import * as Schema from '@vegaprotocol/types';
import { PositionStatus, PositionStatusMapping } from '@vegaprotocol/types';
import type { ICellRendererParams } from 'ag-grid-community';
const singleRow: Position = {
marketName: 'ETH/BTC (31 july 2022)',
@@ -26,6 +29,8 @@ const singleRow: Position = {
searchPrice: '0',
updatedAt: '2022-07-27T15:02:58.400Z',
marginAccountBalance: '12345600',
status: PositionStatus.POSITION_STATUS_UNSPECIFIED,
lossSocializationAmount: '0',
};
const singleRowData = [singleRow];
@@ -219,3 +224,97 @@ it('do not display close button if openVolume is zero', async () => {
const cells = screen.getAllByRole('gridcell');
expect(cells[12].textContent).toEqual('');
});
describe('PNLCell', () => {
const props = {
data: undefined,
valueFormatted: '100',
};
it('renders a dash if no data', () => {
render(<PNLCell {...(props as ICellRendererParams)} />);
expect(screen.getByText('-')).toBeInTheDocument();
});
it('renders value if no loss socialisation has occurred', () => {
const props = {
data: {
...singleRow,
lossSocialisationAmount: '0',
},
valueFormatted: '100',
};
render(<PNLCell {...(props as ICellRendererParams)} />);
expect(screen.getByText(props.valueFormatted)).toBeInTheDocument();
expect(screen.queryByRole('img')).not.toBeInTheDocument();
});
it('renders value with warning tooltip if loss socialisation occurred', async () => {
const props = {
data: {
...singleRow,
lossSocializationAmount: '500',
decimals: 2,
},
valueFormatted: '100',
};
render(<PNLCell {...(props as ICellRendererParams)} />);
const content = screen.getByText(props.valueFormatted);
expect(content).toBeInTheDocument();
expect(screen.getByRole('img')).toBeInTheDocument();
await userEvent.hover(content);
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toBeInTheDocument();
expect(
// using within as radix renders tooltip content twice
within(tooltip).getByText('Lifetime loss socialisation deductions: 5.00')
).toBeInTheDocument();
});
});
describe('OpenVolumeCell', () => {
const props = {
data: undefined,
valueFormatted: '100',
};
it('renders a dash if no data', () => {
render(<OpenVolumeCell {...(props as ICellRendererParams)} />);
expect(screen.getByText('-')).toBeInTheDocument();
});
it('renders value if no status is normal', () => {
const props = {
data: {
...singleRow,
status: PositionStatus.POSITION_STATUS_UNSPECIFIED,
},
valueFormatted: '100',
};
render(<OpenVolumeCell {...(props as ICellRendererParams)} />);
expect(screen.getByText(props.valueFormatted)).toBeInTheDocument();
expect(screen.queryByRole('img')).not.toBeInTheDocument();
});
it('renders status with warning tooltip if not normal', async () => {
const props = {
data: {
...singleRow,
status: PositionStatus.POSITION_STATUS_ORDERS_CLOSED,
},
valueFormatted: '100',
};
render(<OpenVolumeCell {...(props as ICellRendererParams)} />);
const content = screen.getByText(props.valueFormatted);
expect(content).toBeInTheDocument();
expect(screen.getByRole('img')).toBeInTheDocument();
await userEvent.hover(content);
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toBeInTheDocument();
expect(
// using within as radix renders tooltip content twice
within(tooltip).getByText(
`Status: ${PositionStatusMapping[props.data.status]}`
)
).toBeInTheDocument();
});
});
+126 -10
View File
@@ -1,6 +1,6 @@
import classNames from 'classnames';
import { forwardRef } from 'react';
import type { CSSProperties } from 'react';
import type { CSSProperties, ReactNode } from 'react';
import type { CellRendererSelectorResult } from 'ag-grid-community';
import type {
VegaValueFormatterParams,
@@ -15,25 +15,31 @@ import {
signedNumberCssClass,
signedNumberCssClassRules,
} from '@vegaprotocol/datagrid';
import {
ButtonLink,
Tooltip,
TooltipCellComponent,
Link,
ExternalLink,
Icon,
ProgressBarCell,
} from '@vegaprotocol/ui-toolkit';
import {
volumePrefix,
toBigNum,
formatNumber,
getDateTimeFormat,
addDecimalsFormatNumber,
createDocsLinks,
} from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n';
import { AgGridColumn } from 'ag-grid-react';
import type { AgGridReact } from 'ag-grid-react';
import type { Position } from './positions-data-providers';
import * as Schema from '@vegaprotocol/types';
import {
ButtonLink,
Link,
ProgressBarCell,
TooltipCellComponent,
} from '@vegaprotocol/ui-toolkit';
import { getRowId } from './use-positions-data';
import { PositionStatus, PositionStatusMapping } from '@vegaprotocol/types';
import { useEnvironment } from '@vegaprotocol/environment';
interface Props extends TypedDataAgGrid<Position> {
onClose?: (data: Position) => void;
@@ -167,6 +173,7 @@ export const PositionsTable = forwardRef<AgGridReact, Props>(
)
);
}}
cellRenderer={OpenVolumeCell}
/>
<AgGridColumn
headerName={t('Mark price')}
@@ -311,7 +318,6 @@ export const PositionsTable = forwardRef<AgGridReact, Props>(
}}
valueFormatter={({
data,
node,
}: VegaValueFormatterParams<Position, 'marginAccountBalance'>):
| string
| undefined => {
@@ -329,7 +335,7 @@ export const PositionsTable = forwardRef<AgGridReact, Props>(
field="realisedPNL"
type="rightAligned"
cellClassRules={signedNumberCssClassRules}
cellClass="text-right font-mono"
cellClass="font-mono text-right"
filter="agNumberColumnFilter"
valueGetter={({
data,
@@ -348,13 +354,14 @@ export const PositionsTable = forwardRef<AgGridReact, Props>(
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.'
)}
cellRenderer={PNLCell}
/>
<AgGridColumn
headerName={t('Unrealised PNL')}
field="unrealisedPNL"
type="rightAligned"
cellClassRules={signedNumberCssClassRules}
cellClass="text-right font-mono"
cellClass="font-mono text-right"
filter="agNumberColumnFilter"
valueGetter={({
data,
@@ -373,6 +380,7 @@ export const PositionsTable = forwardRef<AgGridReact, Props>(
headerTooltip={t(
'Unrealised profit is the current profit on your open position. Margin is still allocated to your position.'
)}
cellRenderer={PNLCell}
/>
<AgGridColumn
headerName={t('Updated')}
@@ -409,3 +417,111 @@ export const PositionsTable = forwardRef<AgGridReact, Props>(
);
export default PositionsTable;
export const PNLCell = ({
valueFormatted,
data,
}: VegaICellRendererParams<Position, 'realisedPNL'>) => {
const { VEGA_DOCS_URL } = useEnvironment();
const LOSS_SOCIALIZATION_LINK =
VEGA_DOCS_URL && createDocsLinks(VEGA_DOCS_URL).LOSS_SOCIALIZATION;
if (!data) {
return <>-</>;
}
const losses = parseInt(data?.lossSocializationAmount ?? '0');
if (losses <= 0) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{valueFormatted}</>;
}
const lossesFormatted = addDecimalsFormatNumber(
data.lossSocializationAmount,
data.decimals
);
return (
<WarningCell
tooltipContent={
<>
<p className="mb-2">
{t('Lifetime loss socialisation deductions: %s', lossesFormatted)}
</p>
{VEGA_DOCS_URL && (
<ExternalLink href={LOSS_SOCIALIZATION_LINK}>
{t('Read more about loss socialisation')}
</ExternalLink>
)}
</>
}
>
{valueFormatted}
</WarningCell>
);
};
export const OpenVolumeCell = ({
valueFormatted,
data,
}: VegaICellRendererParams<Position, 'openVolume'>) => {
const { VEGA_DOCS_URL } = useEnvironment();
if (!data) {
return <>-</>;
}
if (data.status === PositionStatus.POSITION_STATUS_UNSPECIFIED) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{valueFormatted}</>;
}
const POSITION_RESOLUTION_LINK =
VEGA_DOCS_URL && createDocsLinks(VEGA_DOCS_URL).POSITION_RESOLUTION;
return (
<WarningCell
tooltipContent={
<>
<p className="mb-2">
{t('Your position was affected by market conditions')}
</p>
<p className="mb-2">
{t(
'Status: %s',
PositionStatusMapping[
PositionStatus.POSITION_STATUS_ORDERS_CLOSED
]
)}
</p>
{VEGA_DOCS_URL && (
<ExternalLink href={POSITION_RESOLUTION_LINK}>
{t('Read more about position resolution')}
</ExternalLink>
)}
</>
}
>
{valueFormatted}
</WarningCell>
);
};
const WarningCell = ({
children,
tooltipContent,
}: {
children: ReactNode;
tooltipContent: ReactNode;
}) => {
return (
<Tooltip description={tooltipContent}>
<div className="w-full flex items-center justify-between underline decoration-dashed underline-offest-2">
<span className="text-black dark:text-white mr-1">
<Icon name="warning-sign" size={3} />
</span>
<span className="text-ellipsis overflow-hidden">{children}</span>
</div>
</Tooltip>
);
};
+8 -1
View File
@@ -1,3 +1,4 @@
import { PositionStatus } from '@vegaprotocol/types';
import merge from 'lodash/merge';
import type { PartialDeep } from 'type-fest';
import type {
@@ -58,6 +59,8 @@ const positionFields: PositionFieldsFragment[] = [
id: 'market-0',
__typename: 'Market',
},
lossSocializationAmount: '0',
positionStatus: PositionStatus.POSITION_STATUS_UNSPECIFIED,
},
{
__typename: 'Position',
@@ -70,8 +73,11 @@ const positionFields: PositionFieldsFragment[] = [
id: 'market-1',
__typename: 'Market',
},
lossSocializationAmount: '0',
positionStatus: PositionStatus.POSITION_STATUS_UNSPECIFIED,
},
{
__typename: 'Position',
realisedPNL: '230000',
openVolume: '1',
unrealisedPNL: '-22519',
@@ -81,7 +87,8 @@ const positionFields: PositionFieldsFragment[] = [
id: 'market-2',
__typename: 'Market',
},
__typename: 'Position',
lossSocializationAmount: '0',
positionStatus: PositionStatus.POSITION_STATUS_UNSPECIFIED,
},
];
+3
View File
@@ -1 +1,4 @@
import '@testing-library/jest-dom';
import ResizeObserver from 'resize-observer-polyfill';
global.ResizeObserver = ResizeObserver;
+1
View File
@@ -16,3 +16,4 @@ export * from './use-storybook-theme-observer';
export * from './use-yesterday';
export * from './use-previous';
export * from './use-logger';
export * from './use-bottom-placeholder';
@@ -0,0 +1,71 @@
import type { RefObject } from 'react';
import { useCallback, useMemo } from 'react';
import type { AgGridReact } from 'ag-grid-react';
import type { IsFullWidthRowParams } from 'ag-grid-community';
const NO_HOVER_CSS_RULE = { 'no-hover': 'data?.isLastPlaceholder' };
const fullWidthCellRenderer = () => null;
const isFullWidthRow = (params: IsFullWidthRowParams) =>
params.rowNode.data?.isLastPlaceholder;
interface Props<T> {
gridRef: RefObject<AgGridReact>;
setId?: (data: T) => T;
}
// eslint-disable-next-line @typescript-eslint/ban-types
export const useBottomPlaceholder = <T extends {}>({
gridRef,
setId,
}: Props<T>) => {
const onBodyScrollEnd = useCallback(() => {
const rowCont = gridRef.current?.api.getModel().getRowCount() ?? 0;
const lastRowIndex = gridRef.current?.api.getLastDisplayedRow() ?? 0;
if (lastRowIndex && rowCont - 1 === lastRowIndex) {
const lastRow = gridRef.current?.api.getDisplayedRowAtIndex(lastRowIndex);
if (lastRow?.data && !lastRow?.data.isLastPlaceholder) {
const newData = setId
? setId({ ...lastRow.data, isLastPlaceholder: true })
: {
...lastRow.data,
isLastPlaceholder: true,
id: `${lastRow.data?.id || '-'}-1`,
};
const add = [newData];
const newIndex = lastRowIndex + 1;
gridRef.current?.api.applyTransaction({
add,
addIndex: newIndex,
});
const newLastRow =
gridRef.current?.api.getDisplayedRowAtIndex(newIndex);
newLastRow?.setRowHeight(50);
gridRef.current?.api.onRowHeightChanged();
}
}
}, [gridRef, setId]);
const onRowsChanged = useCallback(() => {
const remove: T[] = [];
gridRef.current?.api.forEachNodeAfterFilterAndSort((rowNode) => {
if (rowNode.data.isLastPlaceholder) {
remove.push(rowNode.data);
}
});
gridRef.current?.api.applyTransaction({
remove,
});
onBodyScrollEnd();
}, [gridRef, onBodyScrollEnd]);
return useMemo(
() => ({
onBodyScrollEnd,
rowClassRules: NO_HOVER_CSS_RULE,
isFullWidthRow,
fullWidthCellRenderer,
onSortChanged: onRowsChanged,
onFilterChange: onRowsChanged,
}),
[onBodyScrollEnd, onRowsChanged]
);
};
@@ -62,8 +62,8 @@ describe('useDataProvider hook', () => {
});
expect(result.current.data).toEqual(updateCallbackPayload.data);
expect(result.current.loading).toEqual(false);
expect(update).toBeCalledTimes(1);
expect(update.mock.calls[0][0].data).toEqual(updateCallbackPayload.data);
expect(update).toBeCalledTimes(2);
expect(update.mock.calls[1][0].data).toEqual(updateCallbackPayload.data);
});
it('calls update on error', async () => {
@@ -77,8 +77,8 @@ describe('useDataProvider hook', () => {
});
expect(result.current.data).toEqual(updateCallbackPayload.data);
expect(result.current.loading).toEqual(false);
expect(update).toBeCalledTimes(1);
expect(update.mock.calls[0][0].data).toEqual(updateCallbackPayload.data);
expect(update).toBeCalledTimes(2);
expect(update.mock.calls[1][0].data).toEqual(updateCallbackPayload.data);
});
it('calls update if isUpdate and skip setting state if update returns true', async () => {
@@ -89,7 +89,8 @@ describe('useDataProvider hook', () => {
await act(async () => {
callback({ ...updateCallbackPayload, data: ++data });
});
expect(update).toBeCalledTimes(1);
expect(update).toBeCalledTimes(2);
expect(result.current.data).toEqual(data);
await act(async () => {
callback({
...updateCallbackPayload,
@@ -99,9 +100,9 @@ describe('useDataProvider hook', () => {
});
});
expect(result.current.data).toEqual(data);
expect(update).toBeCalledTimes(2);
expect(update.mock.calls[1][0].data).toEqual(data);
expect(update.mock.calls[1][0].delta).toEqual(delta);
expect(update).toBeCalledTimes(3);
expect(update.mock.calls[2][0].data).toEqual(data);
expect(update.mock.calls[2][0].delta).toEqual(delta);
update.mockReturnValueOnce(true);
await act(async () => {
callback({
@@ -111,10 +112,10 @@ describe('useDataProvider hook', () => {
isUpdate: true,
});
});
expect(result.current.data).toEqual(update.mock.calls[1][0].data);
expect(update).toBeCalledTimes(3);
expect(update.mock.calls[2][0].data).toEqual(data);
expect(update.mock.calls[2][0].delta).toEqual(delta);
expect(result.current.data).toEqual(update.mock.calls[2][0].data);
expect(update).toBeCalledTimes(4);
expect(update.mock.calls[3][0].data).toEqual(data);
expect(update.mock.calls[3][0].delta).toEqual(delta);
});
it('calls insert if isInsert and skip setting state if update returns true', async () => {
@@ -159,7 +160,7 @@ describe('useDataProvider hook', () => {
await act(async () => {
callback({ ...updateCallbackPayload });
});
expect(update).toBeCalledTimes(1);
expect(update).toBeCalledTimes(2);
// setting same variables, with different object reference
await act(async () => {
@@ -180,7 +181,7 @@ describe('useDataProvider hook', () => {
await act(async () => {
callback({ ...updateCallbackPayload });
});
expect(update).toBeCalledTimes(3);
expect(update).toBeCalledTimes(4);
// changing variables, apollo query will return error
await act(async () => {
@@ -200,7 +201,7 @@ describe('useDataProvider hook', () => {
pageInfo: null,
});
});
expect(update).toBeCalledTimes(5);
expect(update).toBeCalledTimes(6);
});
it('do not create data provider instance when skip is true', async () => {
@@ -62,7 +62,6 @@ export const useDataProvider = <
const flushRef = useRef<(() => void) | undefined>(undefined);
const reloadRef = useRef<((force?: boolean) => void) | undefined>(undefined);
const loadRef = useRef<Load<Data> | undefined>(undefined);
const initialized = useRef<boolean>(false);
const prevVariables = usePrevious(props.variables);
const [variables, setVariables] = useState(props.variables);
useEffect(() => {
@@ -76,7 +75,6 @@ export const useDataProvider = <
}
}, []);
const reload = useCallback((force = false) => {
initialized.current = false;
if (reloadRef.current) {
reloadRef.current(force);
}
@@ -118,11 +116,8 @@ export const useDataProvider = <
}
setTotalCount(totalCount);
setData(data);
if (!loading && !initialized.current) {
initialized.current = true;
if (update) {
update({ data });
}
if (!loading && !isUpdate && update) {
update({ data });
}
},
[update, insert, skipUpdates]
@@ -131,10 +126,9 @@ export const useDataProvider = <
setData(null);
setError(undefined);
setTotalCount(undefined);
if (initialized.current && update) {
if (update) {
update({ data: null });
}
initialized.current = false;
if (skip) {
setLoading(false);
if (update) {
@@ -157,7 +151,7 @@ export const useDataProvider = <
loadRef.current = undefined;
return unsubscribe();
};
}, [client, initialized, dataProvider, callback, variables, skip, update]);
}, [client, dataProvider, callback, variables, skip, update]);
return {
data,
loading,
+3 -3
View File
@@ -8,7 +8,7 @@ import { MAX_TRADES, tradesWithMarketProvider } from './trades-data-provider';
import { TradesTable } from './trades-table';
import type { Trade, TradeEdge } from './trades-data-provider';
import type { TradesQueryVariables } from './__generated__/Trades';
import { usePersistedOrderStore } from '@vegaprotocol/orders';
import { useOrderStore } from '@vegaprotocol/orders';
interface TradesContainerProps {
marketId: string;
@@ -20,7 +20,7 @@ export const TradesContainer = ({ marketId }: TradesContainerProps) => {
const totalCountRef = useRef<number | undefined>(undefined);
const newRows = useRef(0);
const scrolledToTop = useRef(true);
const updatePrice = usePersistedOrderStore((store) => store.updatePrice);
const updateOrder = useOrderStore((store) => store.update);
const variables = useMemo<TradesQueryVariables>(
() => ({ marketId, maxTrades: MAX_TRADES }),
@@ -115,7 +115,7 @@ export const TradesContainer = ({ marketId }: TradesContainerProps) => {
onBodyScroll={onBodyScroll}
onClick={(price?: string) => {
if (price) {
updatePrice(marketId, price);
updateOrder(marketId, { price });
}
}}
/>
+9
View File
@@ -12,6 +12,7 @@ import type {
OrderStatus,
OrderTimeInForce,
OrderType,
PositionStatus,
ProposalRejectionReason,
ProposalState,
Side,
@@ -445,3 +446,11 @@ export const DispatchMetricLabels: DispatchMetricLabel = {
DISPATCH_METRIC_MAKER_FEES_RECEIVED: 'Price maker fees earned',
DISPATCH_METRIC_MARKET_VALUE: 'Total market Value',
};
export const PositionStatusMapping: {
[T in PositionStatus]: string;
} = {
POSITION_STATUS_CLOSED_OUT: 'Closed by network',
POSITION_STATUS_ORDERS_CLOSED: 'Maintained by network',
POSITION_STATUS_UNSPECIFIED: 'Normal',
};
+1 -1
View File
@@ -1,4 +1,4 @@
import { useThemeSwitcher } from '@vegaprotocol/utils';
import { useThemeSwitcher } from '@vegaprotocol/react-helpers';
import classNames from 'classnames';
import { useEffect } from 'react';
import '../src/styles.css';
@@ -9,6 +9,7 @@ export interface FormGroupProps {
hideLabel?: boolean;
labelDescription?: string;
labelAlign?: 'left' | 'right';
compact?: boolean;
}
export const FormGroup = ({
@@ -19,8 +20,16 @@ export const FormGroup = ({
labelDescription,
labelAlign = 'left',
hideLabel = false,
compact = false,
}: FormGroupProps) => {
const wrapperClasses = classNames('relative mb-2', className);
const wrapperClasses = classNames(
'relative',
{
'mb-2': compact,
'mb-4': !compact,
},
className
);
const labelClasses = classNames('block mb-2 text-sm', {
'text-right': labelAlign === 'right',
'sr-only': hideLabel,
@@ -89,6 +89,7 @@ export const Notification = ({
onClick={buttonProps.action}
className={classNames(buttonProps.className)}
data-testid={buttonProps.dataTestId}
type="button"
>
{buttonProps.text}
</Button>
+2
View File
@@ -12,6 +12,8 @@ export const createDocsLinks = (docsUrl: string) => ({
VEGA_WALLET_CONCEPTS_URL: `${docsUrl}/concepts/vega-wallet`,
PROPOSALS_GUIDE: `${docsUrl}/tutorials/proposals`,
NODE_OPERATORS: `${docsUrl}/node-operators`,
LOSS_SOCIALIZATION: `${docsUrl}/concepts/trading-on-vega/market-protections#loss-socialisation`,
POSITION_RESOLUTION: `${docsUrl}/concepts/trading-on-vega/market-protections#position-resolution`,
});
export const ExternalLinks = {
+9 -8
View File
@@ -5,11 +5,12 @@ import BigNumber from 'bignumber.js';
import { ethers } from 'ethers';
import { sha3_256 } from 'js-sha3';
import type {
OrderAmendmentBody,
OrderSubmissionBody,
OrderAmendment,
OrderSubmission,
Transaction,
Transfer,
} from './connectors';
import type { Exact } from 'type-fest';
/**
* Creates an ID in the same way that core does on the backend. This way we
@@ -28,11 +29,11 @@ export const encodeTransaction = (tx: Transaction): string => {
);
};
export const normalizeOrderSubmission = (
order: OrderSubmissionBody['orderSubmission'],
export const normalizeOrderSubmission = <T extends Exact<OrderSubmission, T>>(
order: T,
decimalPlaces: number,
positionDecimalPlaces: number
): OrderSubmissionBody['orderSubmission'] => ({
): OrderSubmission => ({
...order,
price:
order.type === OrderType.TYPE_LIMIT && order.price
@@ -45,12 +46,12 @@ export const normalizeOrderSubmission = (
: undefined,
});
export const normalizeOrderAmendment = (
export const normalizeOrderAmendment = <T extends Exact<OrderAmendment, T>>(
order: Pick<Order, 'id' | 'timeInForce' | 'size' | 'expiresAt'>,
market: Pick<Market, 'id' | 'decimalPlaces' | 'positionDecimalPlaces'>,
price: string,
size: string
): OrderAmendmentBody['orderAmendment'] => ({
): OrderAmendment => ({
orderId: order.id,
marketId: market.id,
price: removeDecimal(price, market.decimalPlaces),
@@ -65,7 +66,7 @@ export const normalizeOrderAmendment = (
: undefined,
});
export const normalizeTransfer = (
export const normalizeTransfer = <T extends Exact<Transfer, T>>(
address: string,
amount: string,
asset: {
@@ -26,7 +26,7 @@ export interface EthStoredTxState extends EthTxState {
methodName: ContractMethod;
args: string[];
requiredConfirmations: number;
requiresConfirmation: boolean;
requiresConfirmation: boolean; // whether or not the tx needs external confirmation (IE from a subscription even)
assetId?: string;
deposit?: DepositBusEventFieldsFragment;
}
+36 -32
View File
@@ -1,3 +1,5 @@
import { useRef } from 'react';
import type { AgGridReact } from 'ag-grid-react';
import { AgGridColumn } from 'ag-grid-react';
import {
getDateTimeFormat,
@@ -5,14 +7,9 @@ import {
addDecimalsFormatNumber,
isNumeric,
} from '@vegaprotocol/utils';
import { useBottomPlaceholder } from '@vegaprotocol/react-helpers';
import { t } from '@vegaprotocol/i18n';
import {
Link,
ButtonLink,
Intent,
Icon,
Loader,
} from '@vegaprotocol/ui-toolkit';
import { Link, ButtonLink } from '@vegaprotocol/ui-toolkit';
import { AgGridDynamic as AgGrid } from '@vegaprotocol/datagrid';
import type {
TypedDataAgGrid,
@@ -29,11 +26,13 @@ import { ApprovalStatus } from './use-verify-withdrawal';
export const WithdrawalsTable = (
props: TypedDataAgGrid<WithdrawalFieldsFragment>
) => {
const gridRef = useRef<AgGridReact | null>(null);
const { ETHERSCAN_URL } = useEnvironment();
const createWithdrawApproval = useEthWithdrawApprovalsStore(
(store) => store.create
);
const bottomPlaceholderProps = useBottomPlaceholder({ gridRef });
return (
<AgGrid
overlayNoRowsTemplate={t('No withdrawals')}
@@ -45,8 +44,9 @@ export const WithdrawalsTable = (
EtherscanLinkCell,
CompleteCell,
}}
suppressCellFocus={true}
domLayout="autoHeight"
suppressCellFocus
ref={gridRef}
{...bottomPlaceholderProps}
{...props}
>
<AgGridColumn headerName="Asset" field="asset.symbol" />
@@ -69,10 +69,12 @@ export const WithdrawalsTable = (
cellRendererParams={{ ethUrl: ETHERSCAN_URL }}
valueFormatter={({
value,
data,
}: VegaValueFormatterParams<
WithdrawalFieldsFragment,
'details.receiverAddress'
>) => {
if (!data) return null;
if (!value) return '-';
return truncateByChars(value);
}}
@@ -82,20 +84,34 @@ export const WithdrawalsTable = (
field="createdTimestamp"
valueFormatter={({
value,
data,
}: VegaValueFormatterParams<
WithdrawalFieldsFragment,
'createdTimestamp'
>) => (value ? getDateTimeFormat().format(new Date(value)) : '-')}
>) =>
data
? value
? getDateTimeFormat().format(new Date(value))
: '-'
: null
}
/>
<AgGridColumn
headerName={t('Completed')}
field="withdrawnTimestamp"
valueFormatter={({
value,
data,
}: VegaValueFormatterParams<
WithdrawalFieldsFragment,
'withdrawnTimestamp'
>) => (value ? getDateTimeFormat().format(new Date(value)) : '-')}
>) =>
data
? value
? getDateTimeFormat().format(new Date(value))
: '-'
: null
}
/>
<AgGridColumn
headerName={t('Status')}
@@ -126,8 +142,11 @@ export type CompleteCellProps = {
data: WithdrawalFieldsFragment;
complete: (withdrawal: WithdrawalFieldsFragment) => void;
};
export const CompleteCell = ({ data, complete }: CompleteCellProps) =>
data.pendingOnForeignChain ? (
export const CompleteCell = ({ data, complete }: CompleteCellProps) => {
if (!data) {
return null;
}
return data.pendingOnForeignChain ? (
'-'
) : (
<ButtonLink
@@ -137,6 +156,7 @@ export const CompleteCell = ({ data, complete }: CompleteCellProps) =>
{t('Complete withdrawal')}
</ButtonLink>
);
};
export const EtherscanLinkCell = ({
value,
@@ -158,6 +178,9 @@ export const EtherscanLinkCell = ({
};
export const StatusCell = ({ data }: { data: WithdrawalFieldsFragment }) => {
if (!data) {
return null;
}
if (data.pendingOnForeignChain || !data.txHash) {
return <span>{t('Pending')}</span>;
}
@@ -195,25 +218,6 @@ const RecipientCell = ({
);
};
export const getVerifyDialogProps = (status: ApprovalStatus) => {
if (status === ApprovalStatus.Error) {
return {
intent: Intent.Danger,
icon: <Icon name="warning-sign" />,
};
}
if (status === ApprovalStatus.Pending) {
return { intent: Intent.None, icon: <Loader size="small" /> };
}
if (status === ApprovalStatus.Delayed) {
return { intent: Intent.Warning, icon: <Icon name="time" /> };
}
return { intent: Intent.None };
};
export const VerificationStatus = ({ state }: { state: VerifyState }) => {
if (state.status === ApprovalStatus.Error) {
return <p>{state.message || t('Something went wrong')}</p>;