Compare commits

...
Author SHA1 Message Date
asiaznik f61ec4983c fix: fixed failing e2e tests, refactored assets page a bit 2023-02-09 15:52:33 +01:00
ArtandEdd 1a1a151969 Update apps/explorer/src/app/components/links/asset-link/asset-link.tsx
Co-authored-by: Edd <edd@vega.xyz>
2023-02-08 19:14:42 +01:00
asiaznik 02773bdae3 feat(explorer): asset details 2023-02-08 16:48:00 +01:00
Maciek 1952cb0e78 fix(trading): inform the user his connection to wallet is lost (#2863) 2023-02-07 11:31:15 +01:00
mattrussell36 69340f4ddf chore: update tranches
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-02-07 06:06:15 +00:00
mattrussell36 c52bf2200e chore: update tranches
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-02-07 00:10:59 +00:00
Matthew Russell 8dccee69f2 chore(trading): remove unnecessary sentry capture from asyncrenderer component (#2869) 2023-02-06 15:21:39 -08:00
Art c7a6fdd879 chore(trading): new toasts designs (#2779) 2023-02-06 20:09:56 +00:00
Matthew Russell 8bcdaf4cda feat(trading): key to key transfers (#2784) 2023-02-06 11:35:40 -08:00
91 changed files with 2306 additions and 1002 deletions
+45 -138
View File
@@ -1,150 +1,57 @@
context('Asset page', { tags: '@regression' }, function () {
before('gather system asset information', function () {
cy.get_asset_information().as('assetsInfo');
});
describe('Verify elements on page', function () {
const assetsNavigation = 'a[href="/assets"]';
const assetHeader = '[data-testid="asset-header"]';
const jsonSection = '.language-json';
before('Navigate to assets page', function () {
cy.visit('/');
cy.get(assetsNavigation).click();
context('Asset page', { tags: '@regression' }, () => {
const columns = ['symbol', 'name', 'id', 'type', 'status', 'actions'];
const hiddenOnMobile = ['id', 'type', 'status'];
describe('Verify elements on page', () => {
before('Navigate to assets page', () => {
cy.visit('/assets');
// Check we have enough enough assets
const assetNames = Object.keys(this.assetsInfo);
assert.isAtLeast(
assetNames.length,
5,
'Ensuring we have at least 5 assets to test'
);
});
it('should be able to see assets page sections', function () {
const assetNames = Object.keys(this.assetsInfo);
assetNames.forEach((assetName) => {
cy.get(assetHeader)
.contains(assetName)
.should('be.visible')
.next()
.within(() => {
cy.get(jsonSection).should('not.be.empty');
});
cy.getAssets().then((assets) => {
assert.isAtLeast(
Object.keys(assets).length,
5,
'Ensuring we have at least 5 assets to test'
);
});
});
it('should be able to see all asset details displayed in JSON', function () {
const assetNames = Object.keys(this.assetsInfo);
assetNames.forEach((assetName) => {
cy.get(assetHeader)
.contains(assetName)
.next()
.within(() => {
cy.get(jsonSection)
.invoke('text')
.convert_string_json_to_js_object()
.then((assetsListedInJson) => {
const assetInfo = this.assetsInfo[assetName];
assert.equal(assetsListedInJson.name, assetInfo.node.name);
assert.equal(assetsListedInJson.id, assetInfo.node.id);
assert.equal(
assetsListedInJson.decimals,
assetInfo.node.decimals
);
assert.equal(assetsListedInJson.symbol, assetInfo.node.symbol);
assert.equal(
assetsListedInJson.source.__typename,
assetInfo.node.source.__typename
);
if (assetInfo.node.source.__typename == 'ERC20') {
assert.equal(
assetsListedInJson.source.contractAddress,
assetInfo.node.source.contractAddress
);
}
if (assetInfo.node.source.__typename == 'BuiltinAsset') {
assert.equal(
assetsListedInJson.source.maxFaucetAmountMint,
assetInfo.node.source.maxFaucetAmountMint
);
}
let knownAssetTypes = ['BuiltinAsset', 'ERC20'];
assert.include(
knownAssetTypes,
assetInfo.node.source.__typename,
`Checking that current asset type of ${assetInfo.node.source.__typename} /
is one of: ${knownAssetTypes}: /
If fail then we need to add extra tests for un-encountered asset types`
);
});
});
});
});
it('should be able to switch assets between light and dark mode', function () {
const whiteThemeSelectedMenuOptionColor = 'rgb(255, 7, 127)';
const whiteThemeJsonFieldBackColor = 'rgb(255, 255, 255)';
const whiteThemeSideMenuBackgroundColor = 'rgb(255, 255, 255)';
const darkThemeSelectedMenuOptionColor = 'rgb(215, 251, 80)';
const darkThemeJsonFieldBackColor = 'rgb(38, 38, 38)';
const darkThemeSideMenuBackgroundColor = 'rgb(0, 0, 0)';
const themeSwitcher = '[data-testid="theme-switcher"]';
const jsonFields = '.hljs';
const sideMenuBackground = '.absolute';
// Engage dark mode if not allready set
cy.get(sideMenuBackground)
.should('have.css', 'background-color')
.then((background_color) => {
if (background_color.includes(whiteThemeSideMenuBackgroundColor))
cy.get(themeSwitcher).click();
it('should be able to see full assets list', () => {
cy.getAssets().then((assets) => {
Object.values(assets).forEach((asset) => {
cy.get(`[row-id="${asset.id}"]`).should('be.visible');
});
// Engage white mode
cy.get(themeSwitcher).click();
// White Mode
cy.get(assetsNavigation)
.should('have.css', 'background-color')
.and('include', whiteThemeSelectedMenuOptionColor);
cy.get(jsonFields)
.should('have.css', 'background-color')
.and('include', whiteThemeJsonFieldBackColor);
cy.get(sideMenuBackground)
.should('have.css', 'background-color')
.and('include', whiteThemeSideMenuBackgroundColor);
// Dark Mode
cy.get(themeSwitcher).click();
cy.get(assetsNavigation)
.should('have.css', 'background-color')
.and('include', darkThemeSelectedMenuOptionColor);
cy.get(jsonFields)
.should('have.css', 'background-color')
.and('include', darkThemeJsonFieldBackColor);
cy.get(sideMenuBackground)
.should('have.css', 'background-color')
.and('include', darkThemeSideMenuBackgroundColor);
});
columns.forEach((col) => {
cy.get(`[col-id="${col}"]`).should('be.visible');
});
});
it('should be able to see assets page displayed in mobile', function () {
cy.common_switch_to_mobile_and_click_toggle();
cy.get(assetsNavigation).click();
it('should be able to see assets page displayed in mobile', () => {
cy.switchToMobile();
const assetNames = Object.keys(this.assetsInfo);
assetNames.forEach((assetName) => {
cy.get(assetHeader)
.contains(assetName)
.should('be.visible')
.next()
.within(() => {
cy.get(jsonSection).should('not.be.empty');
});
hiddenOnMobile.forEach((col) => {
cy.get(`[col-id="${col}"]`).should('have.length', 0);
});
cy.getAssets().then((assets) => {
Object.values(assets).forEach((asset) => {
cy.get(`[row-id="${asset.id}"]`).should('be.visible');
});
});
});
it('should open details dialog when clicked on "View details"', () => {
cy.getAssets().then((assets) => {
Object.values(assets).forEach((asset) => {
cy.get(`[row-id="${asset.id}"] [col-id="actions"] button`)
.eq(0)
.should('contain.text', 'View details');
cy.get(`[row-id="${asset.id}"] [col-id="actions"] button`)
.eq(0)
.click();
cy.getByTestId('dialog-content').should('be.visible');
cy.getByTestId('dialog-close').click();
});
});
});
});
@@ -123,7 +123,7 @@ context.skip('Parties page', { tags: '@regression' }, function () {
.convert_string_json_to_js_object()
.get_party_accounts_data_from_js_object()
.then((accountsListedInJson) => {
cy.get_asset_information().then((assetsInfo) => {
cy.getAssets().then((assetsInfo) => {
const assetInfo =
assetsInfo[accountsListedInJson[assetInTest].asset.name];
@@ -205,7 +205,7 @@ context.skip('Parties page', { tags: '@regression' }, function () {
});
Cypress.Commands.add('get_asset_decimals', (assetID) => {
cy.get_asset_information().then((assetsInfo) => {
cy.getAssets().then((assetsInfo) => {
const assetDecimals = assetsInfo[assetData[assetID].name].decimals;
let decimals = '';
for (let i = 0; i < assetDecimals; i++) decimals += '0';
@@ -21,6 +21,10 @@ Cypress.Commands.add(
}
);
Cypress.Commands.add('switchToMobile', () => {
cy.viewport('iphone-x');
});
Cypress.Commands.add('common_switch_to_mobile_and_click_toggle', function () {
cy.viewport('iphone-x');
cy.visit('/');
+19
View File
@@ -9,6 +9,23 @@ import { TendermintWebsocketProvider } from './contexts/websocket/tendermint-web
import type { InMemoryCacheConfig } from '@apollo/client';
import { Footer } from './components/footer/footer';
import { AnnouncementBanner, ExternalLink } from '@vegaprotocol/ui-toolkit';
import {
AssetDetailsDialog,
useAssetDetailsDialogStore,
} from '@vegaprotocol/assets';
const DialogsContainer = () => {
const { isOpen, id, trigger, asJson, setOpen } = useAssetDetailsDialogStore();
return (
<AssetDetailsDialog
assetId={id}
trigger={trigger || null}
asJson={asJson}
open={isOpen}
onChange={setOpen}
/>
);
};
function App() {
const [menuOpen, setMenuOpen] = useState(false);
@@ -56,6 +73,8 @@ function App() {
<Main />
<Footer />
</div>
<DialogsContainer />
</NetworkLoader>
</TendermintWebsocketProvider>
);
@@ -1,6 +1,6 @@
import { useAssetDataProvider } from '@vegaprotocol/assets';
import { addDecimalsFormatNumber } from '@vegaprotocol/react-helpers';
import { AssetLink } from '../links';
import { useExplorerAssetQuery } from '../links/asset-link/__generated__/Asset';
export type AssetBalanceProps = {
assetId: string;
@@ -17,21 +17,17 @@ const AssetBalance = ({
price,
showAssetLink = true,
}: AssetBalanceProps) => {
const { data } = useExplorerAssetQuery({
variables: { id: assetId },
});
const { data: asset } = useAssetDataProvider(assetId);
const label =
data && data.asset?.decimals
? addDecimalsFormatNumber(price, data.asset.decimals)
asset && asset.decimals
? addDecimalsFormatNumber(price, asset.decimals)
: price;
return (
<div className="inline-block">
<span>{label}</span>{' '}
{showAssetLink && data?.asset?.id ? (
<AssetLink id={data.asset.id} />
) : null}
{showAssetLink && asset?.id ? <AssetLink assetId={assetId} /> : null}
</div>
);
};
@@ -0,0 +1,28 @@
import { render, waitFor } from '@testing-library/react';
import { assetsList } from '../../mocks/assets';
import { AssetsTable } from './assets-table';
describe('AssetsTable', () => {
it('shows loading message on first render', async () => {
const res = render(<AssetsTable data={null} />);
expect(await res.findByText('Loading...')).toBeInTheDocument();
});
it('shows no data message if no assets found', async () => {
const res = render(<AssetsTable data={[]} />);
expect(
await res.findByText('This chain has no assets')
).toBeInTheDocument();
});
it('shows a table/list with all the assets', async () => {
const res = render(<AssetsTable data={assetsList} />);
await waitFor(() => {
const rowA1 = res.container.querySelector('[row-id="123"]');
expect(rowA1).toBeInTheDocument();
const rowA2 = res.container.querySelector('[row-id="456"]');
expect(rowA2).toBeInTheDocument();
});
});
});
@@ -0,0 +1,115 @@
import type { AssetFieldsFragment } from '@vegaprotocol/assets';
import { useAssetDetailsDialogStore } from '@vegaprotocol/assets';
import { AssetTypeMapping, AssetStatusMapping } from '@vegaprotocol/assets';
import { t } from '@vegaprotocol/react-helpers';
import type { VegaICellRendererParams } from '@vegaprotocol/ui-toolkit';
import { ButtonLink } from '@vegaprotocol/ui-toolkit';
import type { AgGridReact } from 'ag-grid-react';
import { AgGridColumn } from 'ag-grid-react';
import { AgGridDynamic as AgGrid } from '@vegaprotocol/ui-toolkit';
import { useRef, useLayoutEffect } from 'react';
import { BREAKPOINT_MD } from '../../config/breakpoints';
type AssetsTableProps = {
data: AssetFieldsFragment[] | null;
};
export const AssetsTable = ({ data }: AssetsTableProps) => {
const openAssetDetailsDialog = useAssetDetailsDialogStore(
(state) => state.open
);
const ref = useRef<AgGridReact>(null);
const showColumnsOnDesktop = () => {
ref.current?.columnApi.setColumnsVisible(
['id', 'type', 'status'],
window.innerWidth > BREAKPOINT_MD
);
};
useLayoutEffect(() => {
window.addEventListener('resize', showColumnsOnDesktop);
return () => {
window.removeEventListener('resize', showColumnsOnDesktop);
};
}, []);
return (
<AgGrid
ref={ref}
rowData={data}
getRowId={({ data }: { data: AssetFieldsFragment }) => data.id}
overlayNoRowsTemplate={t('This chain has no assets')}
domLayout="autoHeight"
defaultColDef={{
flex: 1,
resizable: true,
sortable: true,
filter: true,
filterParams: { buttons: ['reset'] },
autoHeight: true,
}}
suppressCellFocus={true}
onGridReady={() => {
showColumnsOnDesktop();
}}
>
<AgGridColumn headerName={t('Symbol')} field="symbol" />
<AgGridColumn headerName={t('Name')} field="name" />
<AgGridColumn flex="2" headerName={t('ID')} field="id" />
<AgGridColumn
colId="type"
headerName={t('Type')}
field="source.__typename"
valueFormatter={({ value }: { value?: string }) =>
value && AssetTypeMapping[value].value
}
/>
<AgGridColumn
headerName={t('Status')}
field="status"
valueFormatter={({ value }: { value?: string }) =>
value && AssetStatusMapping[value].value
}
/>
<AgGridColumn
colId="actions"
headerName=""
sortable={false}
filter={false}
resizable={false}
wrapText={true}
field="id"
cellRenderer={({
value,
}: VegaICellRendererParams<AssetFieldsFragment, 'id'>) =>
value ? (
<div className="pb-1">
<ButtonLink
onClick={(e) => {
openAssetDetailsDialog(value, e.target as HTMLElement);
}}
>
{t('View details')}
</ButtonLink>{' '}
<span className="max-md:hidden">
<ButtonLink
onClick={(e) => {
openAssetDetailsDialog(
value,
e.target as HTMLElement,
true
);
}}
>
{t('View JSON')}
</ButtonLink>
</span>
</div>
) : (
''
)
}
/>
</AgGrid>
);
};
@@ -1,8 +0,0 @@
query ExplorerAsset($id: ID!) {
asset(id: $id) {
id
name
status
decimals
}
}
@@ -1,51 +0,0 @@
import * as Types from '@vegaprotocol/types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type ExplorerAssetQueryVariables = Types.Exact<{
id: Types.Scalars['ID'];
}>;
export type ExplorerAssetQuery = { __typename?: 'Query', asset?: { __typename?: 'Asset', id: string, name: string, status: Types.AssetStatus, decimals: number } | null };
export const ExplorerAssetDocument = gql`
query ExplorerAsset($id: ID!) {
asset(id: $id) {
id
name
status
decimals
}
}
`;
/**
* __useExplorerAssetQuery__
*
* To run a query within a React component, call `useExplorerAssetQuery` and pass it any options that fit your needs.
* When your component renders, `useExplorerAssetQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useExplorerAssetQuery({
* variables: {
* id: // value for 'id'
* },
* });
*/
export function useExplorerAssetQuery(baseOptions: Apollo.QueryHookOptions<ExplorerAssetQuery, ExplorerAssetQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<ExplorerAssetQuery, ExplorerAssetQueryVariables>(ExplorerAssetDocument, options);
}
export function useExplorerAssetLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ExplorerAssetQuery, ExplorerAssetQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<ExplorerAssetQuery, ExplorerAssetQueryVariables>(ExplorerAssetDocument, options);
}
export type ExplorerAssetQueryHookResult = ReturnType<typeof useExplorerAssetQuery>;
export type ExplorerAssetLazyQueryHookResult = ReturnType<typeof useExplorerAssetLazyQuery>;
export type ExplorerAssetQueryResult = Apollo.QueryResult<ExplorerAssetQuery, ExplorerAssetQueryVariables>;
@@ -1,63 +1,37 @@
import { MemoryRouter } from 'react-router-dom';
import { MockedProvider } from '@apollo/client/testing';
import type { MockedResponse } from '@apollo/client/testing';
import { render } from '@testing-library/react';
import AssetLink from './asset-link';
import { ExplorerAssetDocument } from './__generated__/Asset';
import { render, waitFor } from '@testing-library/react';
import { AssetLink } from './asset-link';
import { mockAssetA1 } from '../../../mocks/assets';
function renderComponent(id: string, mock: MockedResponse[]) {
return (
<MockedProvider mocks={mock}>
<MockedProvider mocks={mock} addTypename={false}>
<MemoryRouter>
<AssetLink id={id} />
<AssetLink assetId={id} />
</MemoryRouter>
</MockedProvider>
);
}
describe('Asset link component', () => {
it('Renders the ID at first', () => {
describe('AssetLink', () => {
it('renders the asset id when not found and makes the button disabled', async () => {
const res = render(renderComponent('123', []));
expect(res.getByText('123')).toBeInTheDocument();
expect(await res.findByTestId('asset-link')).toBeDisabled();
await waitFor(async () => {
expect(await res.queryByText('A ONE')).toBeFalsy();
});
});
it('Renders the asset name when the query returns a result', async () => {
const mock = {
request: {
query: ExplorerAssetDocument,
variables: {
id: '123',
},
},
result: {
data: {
asset: {
id: '123',
name: 'test-label',
status: 'irrelevant-test-data',
decimals: 18,
},
},
},
};
const res = render(renderComponent('123', [mock]));
it('renders the asset name when found and make the button enabled', async () => {
const res = render(renderComponent('123', [mockAssetA1]));
expect(res.getByText('123')).toBeInTheDocument();
expect(await res.findByText('test-label')).toBeInTheDocument();
});
it('Leaves the asset id when the asset is not found', async () => {
const mock = {
request: {
query: ExplorerAssetDocument,
variables: {
id: '123',
},
},
error: new Error('No such asset'),
};
const res = render(renderComponent('123', [mock]));
expect(await res.findByText('123')).toBeInTheDocument();
await waitFor(async () => {
expect(await res.findByText('A ONE')).toBeInTheDocument();
expect(await res.findByTestId('asset-link')).not.toBeDisabled();
});
});
});
@@ -1,36 +1,35 @@
import React from 'react';
import { Routes } from '../../../routes/route-names';
import { useExplorerAssetQuery } from './__generated__/Asset';
import { Link } from 'react-router-dom';
import type { ComponentProps } from 'react';
import Hash from '../hash';
import { ButtonLink } from '@vegaprotocol/ui-toolkit';
import {
useAssetDataProvider,
useAssetDetailsDialogStore,
} from '@vegaprotocol/assets';
export type AssetLinkProps = Partial<ComponentProps<typeof Link>> & {
id: string;
export type AssetLinkProps = Partial<ComponentProps<typeof ButtonLink>> & {
assetId: string;
};
/**
* Given an asset ID, it will fetch the asset name and show that,
* with a link to the assets list. If the name does not come back
* with a link to the assets modal. If the name does not come back
* it will use the ID instead.
*/
const AssetLink = ({ id, ...props }: AssetLinkProps) => {
const { data } = useExplorerAssetQuery({
variables: { id },
});
let label: string = id;
if (data?.asset?.name) {
label = data.asset.name;
}
export const AssetLink = ({ assetId, ...props }: AssetLinkProps) => {
const { data: asset } = useAssetDataProvider(assetId);
const open = useAssetDetailsDialogStore((state) => state.open);
const label = asset?.name ? asset.name : assetId;
return (
<Link className="underline" {...props} to={`/${Routes.ASSETS}#${id}`}>
<ButtonLink
data-testid="asset-link"
disabled={!asset}
onClick={(e) => {
open(assetId, e.target as HTMLElement);
}}
{...props}
>
<Hash text={label} />
</Link>
</ButtonLink>
);
};
export default AssetLink;
@@ -2,4 +2,4 @@ export { default as BlockLink } from './block-link/block-link';
export { default as PartyLink } from './party-link/party-link';
export { default as NodeLink } from './node-link/node-link';
export { default as MarketLink } from './market-link/market-link';
export { default as AssetLink } from './asset-link/asset-link';
export * from './asset-link/asset-link';
@@ -76,9 +76,7 @@ describe('Chain Event: Builtin asset deposit', () => {
if (!assetLink.parentElement) {
throw new Error('Asset link does not exist');
}
expect(assetLink.parentElement.tagName).toEqual('A');
expect(assetLink.parentElement.getAttribute('href')).toEqual(
`/assets#${fullMock.vegaAssetId}`
);
expect(assetLink.parentElement.tagName).toEqual('BUTTON');
expect(assetLink.parentElement.textContent).toEqual(fullMock.vegaAssetId);
});
});
@@ -34,7 +34,7 @@ export const TxDetailsChainEventBuiltinDeposit = ({
<TableRow modifier="bordered">
<TableCell>{t('Asset')}</TableCell>
<TableCell>
<AssetLink id={deposit.vegaAssetId} /> ({t('built in asset')})
<AssetLink assetId={deposit.vegaAssetId} /> ({t('built in asset')})
</TableCell>
</TableRow>
<TableRow modifier="bordered">
@@ -82,9 +82,7 @@ describe('Chain Event: Builtin asset withdrawal', () => {
if (!assetLink.parentElement) {
throw new Error('Asset link does not exist');
}
expect(assetLink.parentElement.tagName).toEqual('A');
expect(assetLink.parentElement.getAttribute('href')).toEqual(
`/assets#${fullMock.vegaAssetId}`
);
expect(assetLink.parentElement.tagName).toEqual('BUTTON');
expect(assetLink.parentElement.textContent).toEqual(fullMock.vegaAssetId);
});
});
@@ -39,8 +39,8 @@ export const TxDetailsChainEventBuiltinWithdrawal = ({
<TableRow modifier="bordered">
<TableCell>{t('Asset')}</TableCell>
<TableCell>
<AssetLink id={withdrawal.vegaAssetId || ''} /> ({t('built in asset')}
)
<AssetLink assetId={withdrawal.vegaAssetId || ''} /> (
{t('built in asset')})
</TableCell>
</TableRow>
<TableRow modifier="bordered">
@@ -63,9 +63,7 @@ describe('Chain Event: ERC20 Asset Delist', () => {
if (!assetLink.parentElement) {
throw new Error('Asset link does not exist');
}
expect(assetLink.parentElement.tagName).toEqual('A');
expect(assetLink.parentElement.getAttribute('href')).toEqual(
`/assets#${fullMock.vegaAssetId}`
);
expect(assetLink.parentElement.tagName).toEqual('BUTTON');
expect(assetLink.parentElement.textContent).toEqual(fullMock.vegaAssetId);
});
});
@@ -29,7 +29,7 @@ export const TxDetailsChainEventErc20AssetDelist = ({
<TableRow modifier="bordered">
<TableCell>{t('Removed Vega asset')}</TableCell>
<TableCell>
<AssetLink id={assetDelist.vegaAssetId || ''} />
<AssetLink assetId={assetDelist.vegaAssetId || ''} />
</TableCell>
</TableRow>
</>
@@ -79,10 +79,8 @@ describe('Chain Event: ERC20 Asset limits updated', () => {
if (!assetLink.parentElement) {
throw new Error('Asset link does not exist');
}
expect(assetLink.parentElement.tagName).toEqual('A');
expect(assetLink.parentElement.getAttribute('href')).toEqual(
`/assets#${fullMock.vegaAssetId}`
);
expect(assetLink.parentElement.tagName).toEqual('BUTTON');
expect(assetLink.parentElement.textContent).toEqual(fullMock.vegaAssetId);
expect(screen.getByText(t('ERC20 asset'))).toBeInTheDocument();
const ethLink = screen.getByText(`${fullMock.sourceEthereumAddress}`);
@@ -51,7 +51,7 @@ export const TxDetailsChainEventErc20AssetLimitsUpdated = ({
<TableRow modifier="bordered">
<TableCell>{t('Vega asset')}</TableCell>
<TableCell>
<AssetLink id={assetLimitsUpdated.vegaAssetId} />
<AssetLink assetId={assetLimitsUpdated.vegaAssetId} />
</TableCell>
</TableRow>
<TableRow modifier="bordered">
@@ -65,10 +65,8 @@ describe('Chain Event: ERC20 Asset List', () => {
if (!assetLink.parentElement) {
throw new Error('Asset link does not exist');
}
expect(assetLink.parentElement.tagName).toEqual('A');
expect(assetLink.parentElement.getAttribute('href')).toEqual(
`/assets#${fullMock.vegaAssetId}`
);
expect(assetLink.parentElement.tagName).toEqual('BUTTON');
expect(assetLink.parentElement.textContent).toEqual(fullMock.vegaAssetId);
expect(screen.getByText(t('Source'))).toBeInTheDocument();
const ethLink = screen.getByText(`${fullMock.assetSource}`);
@@ -41,7 +41,7 @@ export const TxDetailsChainEventErc20AssetList = ({
<TableRow modifier="bordered">
<TableCell>{t('Added Vega asset')}</TableCell>
<TableCell>
<AssetLink id={assetList.vegaAssetId} />
<AssetLink assetId={assetList.vegaAssetId} />
</TableCell>
</TableRow>
</>
@@ -75,10 +75,8 @@ describe('Chain Event: ERC20 asset deposit', () => {
if (!assetLink.parentElement) {
throw new Error('Asset link does not exist');
}
expect(assetLink.parentElement.tagName).toEqual('A');
expect(assetLink.parentElement.getAttribute('href')).toEqual(
`/assets#${fullMock.vegaAssetId}`
);
expect(assetLink.parentElement.tagName).toEqual('BUTTON');
expect(assetLink.parentElement.textContent).toEqual(fullMock.vegaAssetId);
expect(screen.getByText(t('Source'))).toBeInTheDocument();
const ethLink = screen.getByText(`${fullMock.sourceEthereumAddress}`);
@@ -51,7 +51,7 @@ export const TxDetailsChainEventDeposit = ({
<TableRow modifier="bordered">
<TableCell>{t('Asset')}</TableCell>
<TableCell>
<AssetLink id={deposit.vegaAssetId} />
<AssetLink assetId={deposit.vegaAssetId} />
</TableCell>
</TableRow>
<TableRow modifier="bordered">
@@ -60,10 +60,8 @@ describe('Chain Event: ERC20 asset deposit', () => {
if (!assetLink.parentElement) {
throw new Error('Asset link does not exist');
}
expect(assetLink.parentElement.tagName).toEqual('A');
expect(assetLink.parentElement.getAttribute('href')).toEqual(
`/assets#${fullMock.vegaAssetId}`
);
expect(assetLink.parentElement.tagName).toEqual('BUTTON');
expect(assetLink.parentElement.textContent).toEqual(fullMock.vegaAssetId);
expect(screen.getByText(t('Recipient'))).toBeInTheDocument();
const ethLink = screen.getByText(`${fullMock.targetEthereumAddress}`);
@@ -45,7 +45,7 @@ export const TxDetailsChainEventWithdrawal = ({
<TableRow modifier="bordered">
<TableCell>{t('Asset')}</TableCell>
<TableCell>
<AssetLink id={withdrawal.vegaAssetId} />
<AssetLink assetId={withdrawal.vegaAssetId} />
</TableCell>
</TableRow>
</>
@@ -0,0 +1,4 @@
/**
* Equivalent of tailwind's `md` modifier
*/
export const BREAKPOINT_MD = 768;
+134
View File
@@ -0,0 +1,134 @@
import type { AssetFieldsFragment } from '@vegaprotocol/assets';
import { AssetDocument } from '@vegaprotocol/assets';
import { AssetsDocument } from '@vegaprotocol/assets';
import { AssetStatus } from '@vegaprotocol/types';
const A1: AssetFieldsFragment = {
__typename: 'Asset',
id: '123',
name: 'A ONE',
symbol: 'A1',
decimals: 0,
quantum: '',
status: AssetStatus.STATUS_ENABLED,
source: {
__typename: 'BuiltinAsset',
maxFaucetAmountMint: '',
},
infrastructureFeeAccount: {
__typename: 'AccountBalance',
balance: '',
},
globalRewardPoolAccount: {
__typename: 'AccountBalance',
balance: '',
},
lpFeeRewardAccount: {
__typename: 'AccountBalance',
balance: '',
},
makerFeeRewardAccount: {
__typename: 'AccountBalance',
balance: '',
},
marketProposerRewardAccount: {
__typename: 'AccountBalance',
balance: '',
},
takerFeeRewardAccount: {
__typename: 'AccountBalance',
balance: '',
},
};
const A2: AssetFieldsFragment = {
__typename: 'Asset',
id: '456',
name: 'A TWO',
symbol: 'A2',
decimals: 0,
quantum: '',
status: AssetStatus.STATUS_ENABLED,
source: {
__typename: 'BuiltinAsset',
maxFaucetAmountMint: '',
},
infrastructureFeeAccount: {
__typename: 'AccountBalance',
balance: '',
},
globalRewardPoolAccount: {
__typename: 'AccountBalance',
balance: '',
},
lpFeeRewardAccount: {
__typename: 'AccountBalance',
balance: '',
},
makerFeeRewardAccount: {
__typename: 'AccountBalance',
balance: '',
},
marketProposerRewardAccount: {
__typename: 'AccountBalance',
balance: '',
},
takerFeeRewardAccount: {
__typename: 'AccountBalance',
balance: '',
},
};
export const assetsList = [A1, A2];
export const mockAssetsList = {
request: {
query: AssetsDocument,
},
result: {
data: {
assetsConnection: {
__typename: 'AssetsConnection',
edges: [
{
__typename: 'AssetEdge',
node: A1,
},
{
__typename: 'AssetEdge',
node: A2,
},
],
},
},
},
};
export const mockEmptyAssetsList = {
request: {
query: AssetsDocument,
},
result: { data: null },
};
export const mockAssetA1 = {
request: {
query: AssetDocument,
variables: {
assetId: '123',
},
},
result: {
data: {
assetsConnection: {
__typename: 'AssetsConnection',
edges: [
{
__typename: 'AssetEdge',
node: A1,
},
],
},
},
},
};
@@ -1,32 +0,0 @@
fragment AssetsFields on Asset {
id
name
symbol
decimals
source {
... on ERC20 {
contractAddress
}
... on BuiltinAsset {
maxFaucetAmountMint
}
}
infrastructureFeeAccount {
type
balance
market {
id
}
}
}
query ExplorerAssets {
assetsConnection {
edges {
node {
...AssetsFields
}
}
}
}
@@ -1,73 +0,0 @@
import * as Types from '@vegaprotocol/types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type AssetsFieldsFragment = { __typename?: 'Asset', id: string, name: string, symbol: string, decimals: number, source: { __typename?: 'BuiltinAsset', maxFaucetAmountMint: string } | { __typename?: 'ERC20', contractAddress: string }, infrastructureFeeAccount?: { __typename?: 'AccountBalance', type: Types.AccountType, balance: string, market?: { __typename?: 'Market', id: string } | null } | null };
export type ExplorerAssetsQueryVariables = Types.Exact<{ [key: string]: never; }>;
export type ExplorerAssetsQuery = { __typename?: 'Query', assetsConnection?: { __typename?: 'AssetsConnection', edges?: Array<{ __typename?: 'AssetEdge', node: { __typename?: 'Asset', id: string, name: string, symbol: string, decimals: number, source: { __typename?: 'BuiltinAsset', maxFaucetAmountMint: string } | { __typename?: 'ERC20', contractAddress: string }, infrastructureFeeAccount?: { __typename?: 'AccountBalance', type: Types.AccountType, balance: string, market?: { __typename?: 'Market', id: string } | null } | null } } | null> | null } | null };
export const AssetsFieldsFragmentDoc = gql`
fragment AssetsFields on Asset {
id
name
symbol
decimals
source {
... on ERC20 {
contractAddress
}
... on BuiltinAsset {
maxFaucetAmountMint
}
}
infrastructureFeeAccount {
type
balance
market {
id
}
}
}
`;
export const ExplorerAssetsDocument = gql`
query ExplorerAssets {
assetsConnection {
edges {
node {
...AssetsFields
}
}
}
}
${AssetsFieldsFragmentDoc}`;
/**
* __useExplorerAssetsQuery__
*
* To run a query within a React component, call `useExplorerAssetsQuery` and pass it any options that fit your needs.
* When your component renders, `useExplorerAssetsQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useExplorerAssetsQuery({
* variables: {
* },
* });
*/
export function useExplorerAssetsQuery(baseOptions?: Apollo.QueryHookOptions<ExplorerAssetsQuery, ExplorerAssetsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<ExplorerAssetsQuery, ExplorerAssetsQueryVariables>(ExplorerAssetsDocument, options);
}
export function useExplorerAssetsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ExplorerAssetsQuery, ExplorerAssetsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<ExplorerAssetsQuery, ExplorerAssetsQueryVariables>(ExplorerAssetsDocument, options);
}
export type ExplorerAssetsQueryHookResult = ReturnType<typeof useExplorerAssetsQuery>;
export type ExplorerAssetsLazyQueryHookResult = ReturnType<typeof useExplorerAssetsLazyQuery>;
export type ExplorerAssetsQueryResult = Apollo.QueryResult<ExplorerAssetsQuery, ExplorerAssetsQueryVariables>;
@@ -0,0 +1,30 @@
import { t } from '@vegaprotocol/react-helpers';
import { RouteTitle } from '../../components/route-title';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { useScrollToLocation } from '../../hooks/scroll-to-location';
import { useDocumentTitle } from '../../hooks/use-document-title';
import { useAssetsDataProvider } from '@vegaprotocol/assets';
import { AssetsTable } from '../../components/assets/assets-table';
export const Assets = () => {
useDocumentTitle(['Assets']);
useScrollToLocation();
const { data, loading, error } = useAssetsDataProvider();
return (
<section>
<RouteTitle data-testid="assets-header">{t('Assets')}</RouteTitle>
<AsyncRenderer
noDataMessage={t('This chain has no assets')}
data={data}
loading={loading}
error={error}
>
<div className="h-full relative">
<AssetsTable data={data} />
</div>
</AsyncRenderer>
</section>
);
};
@@ -1,44 +0,0 @@
import { MockedProvider } from '@apollo/client/testing';
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import Assets from './index';
import type { MockedResponse } from '@apollo/client/testing';
import { ExplorerAssetDocument } from '../../components/links/asset-link/__generated__/Asset';
function renderComponent(mock: MockedResponse[]) {
return (
<MemoryRouter>
<MockedProvider mocks={mock}>
<Assets />
</MockedProvider>
</MemoryRouter>
);
}
describe('Assets index', () => {
it('Renders loader when loading', async () => {
const mock = {
request: {
query: ExplorerAssetDocument,
},
result: {
data: {},
},
};
const res = render(renderComponent([mock]));
expect(await res.findByTestId('loader')).toBeInTheDocument();
});
it('Renders EmptyList when loading completes and there are no results', async () => {
const mock = {
request: {
query: ExplorerAssetDocument,
},
result: {
data: {},
},
};
const res = render(renderComponent([mock]));
expect(await res.findByTestId('emptylist')).toBeInTheDocument();
});
});
+1 -53
View File
@@ -1,53 +1 @@
import { getNodes, t } from '@vegaprotocol/react-helpers';
import React from 'react';
import { RouteTitle } from '../../components/route-title';
import { SubHeading } from '../../components/sub-heading';
import { Loader, SyntaxHighlighter } from '@vegaprotocol/ui-toolkit';
import { useExplorerAssetsQuery } from './__generated__/Assets';
import type { AssetsFieldsFragment } from './__generated__/Assets';
import { useScrollToLocation } from '../../hooks/scroll-to-location';
import { useDocumentTitle } from '../../hooks/use-document-title';
import EmptyList from '../../components/empty-list/empty-list';
const Assets = () => {
const { data, loading } = useExplorerAssetsQuery();
useDocumentTitle(['Assets']);
useScrollToLocation();
const assets = getNodes<AssetsFieldsFragment>(data?.assetsConnection);
if (!assets || assets.length === 0) {
if (!loading) {
return (
<section>
<RouteTitle data-testid="assets-header">{t('Assets')}</RouteTitle>
<EmptyList
heading={t('This chain has no assets')}
label={t('0 assets')}
/>
</section>
);
} else {
return <Loader />;
}
}
return (
<section>
<RouteTitle data-testid="assets-header">{t('Assets')}</RouteTitle>
{assets.map((a) => {
return (
<React.Fragment key={a.id}>
<SubHeading data-testid="asset-header" id={a.id}>
{a.name} ({a.symbol})
</SubHeading>
<SyntaxHighlighter data={a} />
</React.Fragment>
);
})}
</section>
);
};
export default Assets;
export * from './assets';
@@ -71,7 +71,7 @@ export const PartyAccounts = ({ accounts }: PartyAccountsProps) => {
/>
</td>
<td className="text-md">
<AssetLink id={account.asset.id} />
<AssetLink assetId={account.asset.id} />
</td>
</TableRow>
);
@@ -1,4 +1,4 @@
import Assets from './assets';
import { Assets } from './assets';
import BlockPage from './blocks';
import Governance from './governance';
import Home from './home';
+1 -9
View File
@@ -1,9 +1,6 @@
const { join } = require('path');
const { createGlobPatternsForDependencies } = require('@nrwl/next/tailwind');
const theme = require('../../libs/tailwindcss-config/src/theme');
const {
VegaColours,
} = require('../../libs/tailwindcss-config/src/vega-colours');
const vegaCustomClasses = require('../../libs/tailwindcss-config/src/vega-custom-classes');
module.exports = {
@@ -14,12 +11,7 @@ module.exports = {
],
darkMode: 'class',
theme: {
extend: {
...theme,
colors: {
vega: VegaColours,
},
},
extend: theme,
},
plugins: [vegaCustomClasses],
};
+59 -26
View File
@@ -104,7 +104,7 @@
"tranche_end": "2023-12-05T00:00:00.000Z",
"total_added": "86666.297",
"total_removed": "0",
"locked_amount": "71527.9874377224479678513",
"locked_amount": "71409.6264932306570752577",
"deposits": [
{
"amount": "86666.297",
@@ -170,7 +170,7 @@
"tranche_end": "2023-06-01T00:00:00.000Z",
"total_added": "2500",
"total_removed": "0",
"locked_amount": "1569.28784467846975",
"locked_amount": "1562.44053978429",
"deposits": [
{
"amount": "2500",
@@ -558,7 +558,7 @@
"tranche_end": "2023-08-01T00:00:00.000Z",
"total_added": "37500",
"total_removed": "183.137181525",
"locked_amount": "36307.49069597912625",
"locked_amount": "36204.21366635973",
"deposits": [
{
"amount": "7500",
@@ -624,7 +624,7 @@
"tranche_end": "2023-12-05T00:00:00.000Z",
"total_added": "129999.45",
"total_removed": "0",
"locked_amount": "71462.72714918200044174",
"locked_amount": "71344.47419415930866616",
"deposits": [
{
"amount": "129999.45",
@@ -690,7 +690,7 @@
"tranche_end": "2023-09-03T00:00:00.000Z",
"total_added": "62600",
"total_removed": "0",
"locked_amount": "35715.29892820902776",
"locked_amount": "35629.80554287163288",
"deposits": [
{
"amount": "10000",
@@ -883,7 +883,7 @@
"tranche_end": "2023-09-17T00:00:00.000Z",
"total_added": "5000",
"total_removed": "0",
"locked_amount": "3044.440480720446",
"locked_amount": "3037.6119355657025",
"deposits": [
{
"amount": "5000",
@@ -1094,7 +1094,7 @@
"tranche_end": "2023-04-05T00:00:00.000Z",
"total_added": "97499.58",
"total_removed": "0",
"locked_amount": "13320.4799021065353983616",
"locked_amount": "13204.4847689903243424804",
"deposits": [
{
"amount": "97499.58",
@@ -1127,7 +1127,7 @@
"tranche_end": "2023-04-05T00:00:00.000Z",
"total_added": "135173.4239508",
"total_removed": "98230.390980249184455396",
"locked_amount": "18206.796341262034809441102696",
"locked_amount": "18048.251020016467239758944512",
"deposits": [
{
"amount": "135173.4239508",
@@ -1173,7 +1173,7 @@
"tranche_end": "2023-04-05T00:00:00.000Z",
"total_added": "32499.86",
"total_removed": "0",
"locked_amount": "5603.6958624323669808404",
"locked_amount": "5554.898713059182667218",
"deposits": [
{
"amount": "32499.86",
@@ -1206,7 +1206,7 @@
"tranche_end": "2023-04-05T00:00:00.000Z",
"total_added": "10833.29",
"total_removed": "0",
"locked_amount": "1823.94862624421256173",
"locked_amount": "1808.0656276395702426268",
"deposits": [
{
"amount": "10833.29",
@@ -1239,7 +1239,7 @@
"tranche_end": "2023-04-05T00:00:00.000Z",
"total_added": "22749.93",
"total_removed": "0",
"locked_amount": "6818.3273364692396522472",
"locked_amount": "6758.953140281414065623",
"deposits": [
{
"amount": "6500",
@@ -1378,7 +1378,7 @@
"tranche_end": "2023-05-01T00:00:00.000Z",
"total_added": "22500",
"total_removed": "3995.28612255",
"locked_amount": "10348.030329189685875",
"locked_amount": "10286.06411141804925",
"deposits": [
{
"amount": "7500",
@@ -1576,7 +1576,7 @@
"tranche_end": "2023-06-02T00:00:00.000Z",
"total_added": "1939928.38",
"total_removed": "928642.9598472029154",
"locked_amount": "612507.96463901900484135",
"locked_amount": "609858.586931059129394406",
"deposits": [
{
"amount": "1852091.69",
@@ -33537,7 +33537,7 @@
"tranche_end": "2023-06-05T00:00:00.000Z",
"total_added": "3732368.4671",
"total_removed": "589730.8667090699299",
"locked_amount": "965712.81375596155222172624",
"locked_amount": "961641.54810745914738555073",
"deposits": [
{
"amount": "1998.95815",
@@ -34851,8 +34851,8 @@
"tranche_start": "2022-06-05T00:00:00.000Z",
"tranche_end": "2023-12-05T00:00:00.000Z",
"total_added": "15870102.715470999700000001",
"total_removed": "557133.30560592463281452",
"locked_amount": "8724043.2185305122313058695420656514828332",
"total_removed": "559800.32709992463281452",
"locked_amount": "8709606.7719411603187794011240412383982157",
"deposits": [
{
"amount": "16249.93",
@@ -35416,6 +35416,11 @@
"user": "0x20CD77B9FC2f1fEDfb6F184E25f7127BFE991C8b",
"tx": "0xa344428c2b1bf9b4685959441983da46bcd434ac0ee5ada325876c8733eba603"
},
{
"amount": "2667.021494",
"user": "0xF4c75FdbAe821C6B5DBB9f001bB23cBA58D1bA37",
"tx": "0x94fe358ee7c973dd1d28ca4c56250dfaccbcb718673872c8e9cf8651a22a1108"
},
{
"amount": "858.360074993579125",
"user": "0x20CD77B9FC2f1fEDfb6F184E25f7127BFE991C8b",
@@ -38778,6 +38783,12 @@
"tranche_id": 2,
"tx": "0x606ddaa3882cccb0062bc2827cfcfceb64cef8a6c4df41d06747ae651e5dd52e"
},
{
"amount": "2667.021494",
"user": "0xF4c75FdbAe821C6B5DBB9f001bB23cBA58D1bA37",
"tranche_id": 2,
"tx": "0x94fe358ee7c973dd1d28ca4c56250dfaccbcb718673872c8e9cf8651a22a1108"
},
{
"amount": "1099.300488",
"user": "0xF4c75FdbAe821C6B5DBB9f001bB23cBA58D1bA37",
@@ -38960,8 +38971,8 @@
}
],
"total_tokens": "200000",
"withdrawn_tokens": "87428.726512",
"remaining_tokens": "112571.273488"
"withdrawn_tokens": "90095.748006",
"remaining_tokens": "109904.251994"
},
{
"address": "0x1b956E6c00E238194B331eddEFF72Cb5f28A8d01",
@@ -40308,7 +40319,7 @@
"tranche_end": "2023-05-05T00:00:00.000Z",
"total_added": "14597706.0446472999",
"total_removed": "3920990.096830703963164282",
"locked_amount": "2332535.769515174096512550505057687",
"locked_amount": "2319208.134201467221266024728336535",
"deposits": [
{
"amount": "129284.449",
@@ -47133,7 +47144,7 @@
"tranche_end": "2023-04-05T00:00:00.000Z",
"total_added": "5778205.3912159303",
"total_removed": "2730068.739915456784546642",
"locked_amount": "604695.586054148570133080743399363",
"locked_amount": "599429.756736835401363472875184419",
"deposits": [
{
"amount": "552496.6455",
@@ -49115,8 +49126,8 @@
"tranche_start": "2022-06-05T00:00:00.000Z",
"tranche_end": "2023-06-05T00:00:00.000Z",
"total_added": "472355.6199999996",
"total_removed": "32361.4666889012685",
"locked_amount": "153022.71558941830041836828209032",
"total_removed": "32476.7515062512685",
"locked_amount": "152377.6002750664683569305783866",
"deposits": [
{
"amount": "3000",
@@ -55775,6 +55786,16 @@
"user": "0x0e199b123f71f964d6567869B6B849C1f255A855",
"tx": "0x6df8666e49f7d491c740d6a36df031aac232a6cf37fa197aa1f6b2bb481be99c"
},
{
"amount": "99.47735921",
"user": "0x2E32F49389CF3039ab6365ca59329002922Cc01D",
"tx": "0x7de3c1703d23970f04ce1bebe2cece8a2fe3fbe6b876502da1812c312151b954"
},
{
"amount": "15.80745814",
"user": "0x4eD2b3c68BB4fda084ce1591a210F4aC8b71234A",
"tx": "0xc0f953d7ecc45bfc1d7b69ecd491a63e8b00b0d732ff006bee008e917a04179f"
},
{
"amount": "78.261187214",
"user": "0x479F059c46c6873C6B970A92911084b3eA036d56",
@@ -68030,6 +68051,12 @@
}
],
"withdrawals": [
{
"amount": "99.47735921",
"user": "0x2E32F49389CF3039ab6365ca59329002922Cc01D",
"tranche_id": 5,
"tx": "0x7de3c1703d23970f04ce1bebe2cece8a2fe3fbe6b876502da1812c312151b954"
},
{
"amount": "35.842421358",
"user": "0x2E32F49389CF3039ab6365ca59329002922Cc01D",
@@ -68038,8 +68065,8 @@
}
],
"total_tokens": "200",
"withdrawn_tokens": "35.842421358",
"remaining_tokens": "164.157578642"
"withdrawn_tokens": "135.319780568",
"remaining_tokens": "64.680219432"
},
{
"address": "0x2F2588aCd44253312b4A94bF6753bE67514A5Cc6",
@@ -69114,6 +69141,12 @@
}
],
"withdrawals": [
{
"amount": "15.80745814",
"user": "0x4eD2b3c68BB4fda084ce1591a210F4aC8b71234A",
"tranche_id": 5,
"tx": "0xc0f953d7ecc45bfc1d7b69ecd491a63e8b00b0d732ff006bee008e917a04179f"
},
{
"amount": "32.761796044",
"user": "0x4eD2b3c68BB4fda084ce1591a210F4aC8b71234A",
@@ -69188,8 +69221,8 @@
}
],
"total_tokens": "400",
"withdrawn_tokens": "254.83529934",
"remaining_tokens": "145.16470066"
"withdrawn_tokens": "270.64275748",
"remaining_tokens": "129.35724252"
},
{
"address": "0x5c90765F50629570738fEe7b7FA82ae118f81Ed1",
@@ -5,7 +5,7 @@ import { useWithdrawalDialog } from '@vegaprotocol/withdraws';
import { useAssetDetailsDialogStore } from '@vegaprotocol/assets';
import { Splash } from '@vegaprotocol/ui-toolkit';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { AccountManager } from '@vegaprotocol/accounts';
import { AccountManager, useTransferDialog } from '@vegaprotocol/accounts';
import { useDepositDialog } from '@vegaprotocol/deposits';
export const AccountsContainer = () => {
@@ -13,6 +13,7 @@ export const AccountsContainer = () => {
const { open: openAssetDetailsDialog } = useAssetDetailsDialogStore();
const openWithdrawalDialog = useWithdrawalDialog((store) => store.open);
const openDepositDialog = useDepositDialog((store) => store.open);
const openTransferDialog = useTransferDialog((store) => store.open);
const onClickAsset = useCallback(
(assetId?: string) => {
@@ -41,7 +42,10 @@ export const AccountsContainer = () => {
/>
</div>
{!isReadOnly && (
<div className="flex justify-end p-2 px-[11px]">
<div className="flex gap-2 justify-end p-2 px-[11px]">
<Button size="sm" onClick={() => openTransferDialog()}>
{t('Transfer')}
</Button>
<Button size="sm" onClick={() => openDepositDialog()}>
{t('Deposit')}
</Button>
@@ -13,11 +13,13 @@ import {
DropdownMenuTrigger,
Icon,
Drawer,
DropdownMenuSeparator,
} from '@vegaprotocol/ui-toolkit';
import type { PubKey } from '@vegaprotocol/wallet';
import { useVegaWallet, useVegaWalletDialogStore } from '@vegaprotocol/wallet';
import { Networks, useEnvironment } from '@vegaprotocol/environment';
import { WalletIcon } from '../icons/wallet';
import { useTransferDialog } from '@vegaprotocol/accounts';
const MobileWalletButton = ({
isConnected,
@@ -30,6 +32,7 @@ const MobileWalletButton = ({
const openVegaWalletDialog = useVegaWalletDialogStore(
(store) => store.openVegaWalletDialog
);
const openTransferDialog = useTransferDialog((store) => store.open);
const { VEGA_ENV } = useEnvironment();
const isYellow = VEGA_ENV === Networks.TESTNET;
const [drawerOpen, setDrawerOpen] = useState(false);
@@ -115,7 +118,16 @@ const MobileWalletButton = ({
/>
))}
</div>
<div className="m-4">
<div className="flex flex-col gap-2 m-4">
<Button
onClick={() => {
setDrawerOpen(false);
openTransferDialog(true);
}}
fill
>
{t('Transfer')}
</Button>
<Button onClick={mobileDisconnect} fill>
{t('Disconnect')}
</Button>
@@ -131,6 +143,7 @@ export const VegaWalletConnectButton = () => {
const openVegaWalletDialog = useVegaWalletDialogStore(
(store) => store.openVegaWalletDialog
);
const openTransferDialog = useTransferDialog((store) => store.open);
const { pubKey, pubKeys, selectPubKey, disconnect } = useVegaWallet();
const isConnected = pubKey !== null;
@@ -171,6 +184,10 @@ export const VegaWalletConnectButton = () => {
<KeypairItem key={pk.publicKey} pk={pk} />
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => openTransferDialog(true)}>
{t('Transfer')}
</DropdownMenuItem>
<DropdownMenuItem data-testid="disconnect" onClick={disconnect}>
{t('Disconnect')}
</DropdownMenuItem>
@@ -3,8 +3,12 @@ import { useAssetsDataProvider } from '@vegaprotocol/assets';
import { ETHERSCAN_TX, useEtherscanLink } from '@vegaprotocol/environment';
import { formatNumber, t, toBigNum } from '@vegaprotocol/react-helpers';
import type { Toast, ToastContent } from '@vegaprotocol/ui-toolkit';
import { ToastHeading } from '@vegaprotocol/ui-toolkit';
import { Panel } from '@vegaprotocol/ui-toolkit';
import { CLOSE_AFTER } from '@vegaprotocol/ui-toolkit';
import { useToasts } from '@vegaprotocol/ui-toolkit';
import { ExternalLink, Intent, ProgressBar } from '@vegaprotocol/ui-toolkit';
import { useCallback, useMemo } from 'react';
import { useCallback } from 'react';
import compact from 'lodash/compact';
import type { EthStoredTxState } from '@vegaprotocol/web3';
import {
@@ -44,34 +48,35 @@ const EthTransactionDetails = ({ tx }: { tx: EthStoredTxState }) => {
if (isWithdraw) label = t('Withdraw');
if (isDeposit) label = t('Deposit');
assetInfo = (
<div className="mt-[5px]">
<span className="font-mono text-xs p-1 bg-gray-100 rounded">
{label}{' '}
{formatNumber(toBigNum(tx.args[1], asset.decimals), asset.decimals)}{' '}
{asset.symbol}
</span>
</div>
<strong>
{label}{' '}
{formatNumber(toBigNum(tx.args[1], asset.decimals), asset.decimals)}{' '}
{asset.symbol}
</strong>
);
}
}
return (
<>
{assetInfo}
{tx.status === EthTxStatus.Pending && (
<div className="mt-[10px]">
<span className="font-mono text-xs">
{t('Awaiting confirmations')}{' '}
{`(${tx.confirmations}/${tx.requiredConfirmations})`}
</span>
<ProgressBar
value={(tx.confirmations / tx.requiredConfirmations) * 100}
intent={Intent.Warning}
/>
</div>
)}
</>
);
if (assetInfo || tx.requiresConfirmation) {
return (
<Panel>
{assetInfo}
{tx.status === EthTxStatus.Pending && (
<>
<p className="mt-[2px]">
{t('Awaiting confirmations')}{' '}
{`(${tx.confirmations}/${tx.requiredConfirmations})`}
</p>
<ProgressBar
value={(tx.confirmations / tx.requiredConfirmations) * 100}
/>
</>
)}
</Panel>
);
}
return null;
};
type EthTxToastContentProps = {
@@ -80,26 +85,26 @@ type EthTxToastContentProps = {
const EthTxRequestedToastContent = ({ tx }: EthTxToastContentProps) => {
return (
<div>
<h3 className="font-bold">{t('Action required')}</h3>
<>
<ToastHeading>{t('Action required')}</ToastHeading>
<p>
{t(
'Please go to your wallet application and approve or reject the transaction.'
)}
</p>
<EthTransactionDetails tx={tx} />
</div>
</>
);
};
const EthTxPendingToastContent = ({ tx }: EthTxToastContentProps) => {
return (
<div>
<h3 className="font-bold">{t('Awaiting confirmation')}</h3>
<>
<ToastHeading>{t('Awaiting confirmation')}</ToastHeading>
<p>{t('Please wait for your transaction to be confirmed.')}</p>
<EtherscanLink tx={tx} />
<EthTransactionDetails tx={tx} />
</div>
</>
);
};
@@ -112,11 +117,11 @@ const EthTxErrorToastContent = ({ tx }: EthTxToastContentProps) => {
errorMessage = tx.error.message;
}
return (
<div>
<h3 className="font-bold">{t('Error occurred')}</h3>
<p>{errorMessage}</p>
<>
<ToastHeading>{t('Error occurred')}</ToastHeading>
<p className="first-letter:uppercase">{errorMessage}</p>
<EthTransactionDetails tx={tx} />
</div>
</>
);
};
@@ -136,42 +141,63 @@ const EtherscanLink = ({ tx }: EthTxToastContentProps) => {
const EthTxConfirmedToastContent = ({ tx }: EthTxToastContentProps) => {
return (
<div>
<h3 className="font-bold">{t('Transaction confirmed')}</h3>
<>
<ToastHeading>{t('Transaction confirmed')}</ToastHeading>
<p>{t('Your transaction has been confirmed.')}</p>
<EtherscanLink tx={tx} />
<EthTransactionDetails tx={tx} />
</div>
</>
);
};
const EthTxCompletedToastContent = ({ tx }: EthTxToastContentProps) => {
const isDeposit = isDepositTransaction(tx);
return (
<div>
<h3 className="font-bold">
<>
<ToastHeading>
{t('Processing')} {isDeposit && t('deposit')}
</h3>
</ToastHeading>
<p>
{t('Your transaction has been completed.')}{' '}
{isDeposit && t('Waiting for deposit confirmation.')}
</p>
<EtherscanLink tx={tx} />
<EthTransactionDetails tx={tx} />
</div>
</>
);
};
const isFinal = (tx: EthStoredTxState) =>
[EthTxStatus.Confirmed, EthTxStatus.Error].includes(tx.status);
export const useEthereumTransactionToasts = () => {
const ethTransactions = useEthTransactionStore((state) =>
state.transactions.filter((transaction) => transaction?.dialogOpen)
);
const dismissEthTransaction = useEthTransactionStore(
(state) => state.dismiss
const [setToast, removeToast] = useToasts((store) => [
store.setToast,
store.remove,
]);
const [dismissTx, deleteTx] = useEthTransactionStore((state) => [
state.dismiss,
state.delete,
]);
const onClose = useCallback(
(tx: EthStoredTxState) => () => {
const safeToDelete = isFinal(tx);
if (safeToDelete) {
deleteTx(tx.id);
} else {
dismissTx(tx.id);
}
removeToast(`eth-${tx.id}`);
},
[deleteTx, dismissTx, removeToast]
);
const fromEthTransaction = useCallback(
(tx: EthStoredTxState): Toast => {
let content: ToastContent = <TransactionContent {...tx} />;
const closeAfter = isFinal(tx) ? CLOSE_AFTER : undefined;
if (tx.status === EthTxStatus.Requested) {
content = <EthTxRequestedToastContent tx={tx} />;
}
@@ -191,15 +217,21 @@ export const useEthereumTransactionToasts = () => {
return {
id: `eth-${tx.id}`,
intent: intentMap[tx.status],
onClose: () => dismissEthTransaction(tx.id),
onClose: onClose(tx),
loader: [EthTxStatus.Pending, EthTxStatus.Complete].includes(tx.status),
content,
closeAfter,
};
},
[dismissEthTransaction]
[onClose]
);
return useMemo(() => {
return [...compact(ethTransactions).map(fromEthTransaction)];
}, [ethTransactions, fromEthTransaction]);
useEthTransactionStore.subscribe(
(state) => compact(state.transactions.filter((tx) => tx?.dialogOpen)),
(txs) => {
txs.forEach((tx) => {
setToast(fromEthTransaction(tx));
});
}
);
};
@@ -1,8 +1,12 @@
import { formatNumber, t, toBigNum } from '@vegaprotocol/react-helpers';
import type { Toast } from '@vegaprotocol/ui-toolkit';
import { ToastHeading } from '@vegaprotocol/ui-toolkit';
import { Panel } from '@vegaprotocol/ui-toolkit';
import { CLOSE_AFTER } from '@vegaprotocol/ui-toolkit';
import { useToasts } from '@vegaprotocol/ui-toolkit';
import { Intent } from '@vegaprotocol/ui-toolkit';
import { ApprovalStatus, VerificationStatus } from '@vegaprotocol/withdraws';
import { useCallback, useMemo } from 'react';
import { useCallback } from 'react';
import compact from 'lodash/compact';
import type { EthWithdrawalApprovalState } from '@vegaprotocol/web3';
import { useEthWithdrawApprovalsStore } from '@vegaprotocol/web3';
@@ -30,49 +34,72 @@ const EthWithdrawalApprovalToastContent = ({
if (tx.status === ApprovalStatus.Delayed) {
title = t('Delayed');
}
if (tx.status === ApprovalStatus.Ready) {
title = t('Approved');
}
const num = formatNumber(
toBigNum(tx.withdrawal.amount, tx.withdrawal.asset.decimals),
tx.withdrawal.asset.decimals
);
const details = (
<div className="mt-[5px]">
<span className="font-mono text-xs p-1 bg-gray-100 rounded">
<Panel>
<strong>
{t('Withdraw')} {num} {tx.withdrawal.asset.symbol}
</span>
</div>
</strong>
</Panel>
);
return (
<div>
{title.length > 0 && <h3 className="font-bold">{title}</h3>}
<>
{title.length > 0 && (
<ToastHeading className="font-bold">{title}</ToastHeading>
)}
<VerificationStatus state={tx} />
{details}
</div>
</>
);
};
const isFinal = (tx: EthWithdrawalApprovalState) =>
[ApprovalStatus.Ready, ApprovalStatus.Error].includes(tx.status);
export const useEthereumWithdrawApprovalsToasts = () => {
const { withdrawApprovals, dismissWithdrawApproval } =
useEthWithdrawApprovalsStore((state) => ({
withdrawApprovals: state.transactions.filter(
(transaction) => transaction?.dialogOpen
),
dismissWithdrawApproval: state.dismiss,
}));
const [setToast, remove] = useToasts((state) => [
state.setToast,
state.remove,
]);
const [dismissTx, deleteTx] = useEthWithdrawApprovalsStore((state) => [
state.dismiss,
state.delete,
]);
const fromWithdrawalApproval = useCallback(
(tx: EthWithdrawalApprovalState): Toast => ({
id: `withdrawal-${tx.id}`,
intent: intentMap[tx.status],
onClose: () => dismissWithdrawApproval(tx.id),
onClose: () => {
if ([ApprovalStatus.Error, ApprovalStatus.Ready].includes(tx.status)) {
deleteTx(tx.id);
} else {
dismissTx(tx.id);
}
remove(`withdrawal-${tx.id}`);
},
loader: tx.status === ApprovalStatus.Pending,
content: <EthWithdrawalApprovalToastContent tx={tx} />,
closeAfter: isFinal(tx) ? CLOSE_AFTER : undefined,
}),
[dismissWithdrawApproval]
[deleteTx, dismissTx, remove]
);
const toasts = useMemo(() => {
return [...compact(withdrawApprovals).map(fromWithdrawalApproval)];
}, [fromWithdrawalApproval, withdrawApprovals]);
return toasts;
useEthWithdrawApprovalsStore.subscribe(
(state) =>
compact(
state.transactions.filter((transaction) => transaction?.dialogOpen)
),
(txs) => {
txs.forEach((tx) => {
setToast(fromWithdrawalApproval(tx));
});
}
);
};
@@ -260,7 +260,7 @@ describe('VegaTransactionDetails', () => {
const { queryByTestId } = render(
<VegaTransactionDetails tx={unsupportedTransaction} />
);
expect(queryByTestId('vega-tx-details')).toBeNull();
expect(queryByTestId('toast-panel')).toBeNull();
});
it.each([
{ tx: withdraw, details: 'Withdraw 12.34 $A' },
@@ -275,6 +275,6 @@ describe('VegaTransactionDetails', () => {
{ tx: batch, details: 'Batch market instruction' },
])('display details for transaction', ({ tx, details }) => {
const { queryByTestId } = render(<VegaTransactionDetails tx={tx} />);
expect(queryByTestId('vega-tx-details')?.textContent).toEqual(details);
expect(queryByTestId('toast-panel')?.textContent).toEqual(details);
});
});
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react';
import { useCallback, useMemo } from 'react';
import { useCallback } from 'react';
import first from 'lodash/first';
import compact from 'lodash/compact';
import type {
BatchMarketInstructionSubmissionBody,
@@ -10,13 +10,12 @@ import type {
VegaStoredTxState,
WithdrawalBusEventFieldsFragment,
} from '@vegaprotocol/wallet';
import { isBatchMarketInstructionsTransaction } from '@vegaprotocol/wallet';
import {
isTransferTransaction,
isBatchMarketInstructionsTransaction,
ClientErrors,
useReconnectVegaWallet,
WalletError,
} from '@vegaprotocol/wallet';
import {
isOrderAmendmentTransaction,
isOrderCancellationTransaction,
isOrderSubmissionTransaction,
@@ -25,6 +24,10 @@ import {
VegaTxStatus,
} from '@vegaprotocol/wallet';
import type { Toast, ToastContent } from '@vegaprotocol/ui-toolkit';
import { ToastHeading } from '@vegaprotocol/ui-toolkit';
import { Panel } from '@vegaprotocol/ui-toolkit';
import { CLOSE_AFTER } from '@vegaprotocol/ui-toolkit';
import { useToasts } from '@vegaprotocol/ui-toolkit';
import { Button, ExternalLink, Intent } from '@vegaprotocol/ui-toolkit';
import {
addDecimalsFormatNumber,
@@ -32,14 +35,15 @@ import {
Size,
t,
toBigNum,
truncateByChars,
} from '@vegaprotocol/react-helpers';
import { useAssetsDataProvider } from '@vegaprotocol/assets';
import { useEthWithdrawApprovalsStore } from '@vegaprotocol/web3';
import { DApp, EXPLORER_TX, useLinks } from '@vegaprotocol/environment';
import { getRejectionReason, useOrderByIdQuery } from '@vegaprotocol/orders';
import { useMarketList } from '@vegaprotocol/market-list';
import first from 'lodash/first';
import type { Side } from '@vegaprotocol/types';
import { OrderStatus } from '@vegaprotocol/types';
import { OrderStatusMapping } from '@vegaprotocol/types';
const intentMap: { [s in VegaTxStatus]: Intent } = {
@@ -50,15 +54,6 @@ const intentMap: { [s in VegaTxStatus]: Intent } = {
Complete: Intent.Success,
};
const getIntent = (tx: VegaStoredTxState) => {
// Transaction can be successful
// But the order can be rejected by the network
if (tx.order?.rejectionReason) {
return Intent.Danger;
}
return intentMap[tx.status];
};
const isClosePositionTransaction = (tx: VegaStoredTxState) => {
if (isBatchMarketInstructionsTransaction(tx.body)) {
const amendments =
@@ -87,29 +82,17 @@ const isTransactionTypeSupported = (tx: VegaStoredTxState) => {
const cancelOrder = isOrderCancellationTransaction(tx.body);
const editOrder = isOrderAmendmentTransaction(tx.body);
const batchMarketInstructions = isBatchMarketInstructionsTransaction(tx.body);
const transfer = isTransferTransaction(tx.body);
return (
withdraw ||
submitOrder ||
cancelOrder ||
editOrder ||
batchMarketInstructions
batchMarketInstructions ||
transfer
);
};
const Details = ({
children,
title = '',
}: {
children: ReactNode;
title?: string;
}) => (
<div className="pt-[5px]" data-testid="vega-tx-details" title={title}>
<div className="font-mono text-xs p-2 bg-neutral-100 rounded dark:bg-neutral-700 dark:text-white">
{children}
</div>
</div>
);
type SizeAtPriceProps = {
side: Side;
size: string;
@@ -150,8 +133,8 @@ const SubmitOrderDetails = ({
const side = order ? order.side : data.side;
return (
<Details>
<h4 className="font-bold">
<Panel>
<h4>
{order
? t(
`Submit order - ${OrderStatusMapping[order.status].toLowerCase()}`
@@ -173,10 +156,7 @@ const SubmitOrderDetails = ({
price={price}
/>
</p>
{order && order.rejectionReason && (
<p className="italic">{getRejectionReason(order)}</p>
)}
</Details>
</Panel>
);
};
@@ -192,9 +172,10 @@ const EditOrderDetails = ({
});
const { data: markets } = useMarketList();
const originalOrder = orderById?.orderByID;
const originalOrder = order || orderById?.orderByID;
const marketId = order?.marketId || orderById?.orderByID.market.id;
if (!originalOrder) return null;
const market = markets?.find((m) => m.id === originalOrder.market.id);
const market = markets?.find((m) => m.id === marketId);
if (!market) return null;
const original = (
@@ -226,8 +207,8 @@ const EditOrderDetails = ({
);
return (
<Details title={data.orderId}>
<h4 className="font-bold">
<Panel title={data.orderId}>
<h4>
{order
? t(`Edit order - ${OrderStatusMapping[order.status].toLowerCase()}`)
: t('Edit order')}
@@ -237,10 +218,7 @@ const EditOrderDetails = ({
<s>{original}</s>
</p>
<p>{edited}</p>
{order && order.rejectionReason && (
<p className="italic">{getRejectionReason(order)}</p>
)}
</Details>
</Panel>
);
};
@@ -275,8 +253,8 @@ const CancelOrderDetails = ({
/>
);
return (
<Details title={orderId}>
<h4 className="font-bold">
<Panel title={orderId}>
<h4>
{order
? t(
`Cancel order - ${OrderStatusMapping[order.status].toLowerCase()}`
@@ -287,10 +265,7 @@ const CancelOrderDetails = ({
<p>
<s>{original}</s>
</p>
{order && order.rejectionReason && (
<p className="italic">{getRejectionReason(order)}</p>
)}
</Details>
</Panel>
);
};
@@ -309,9 +284,11 @@ export const VegaTransactionDetails = ({ tx }: { tx: VegaStoredTxState }) => {
asset.decimals
);
return (
<Details>
{t('Withdraw')} {num} {asset.symbol}
</Details>
<Panel>
<strong>
{t('Withdraw')} {num} {asset.symbol}
</strong>
</Panel>
);
}
}
@@ -328,7 +305,7 @@ export const VegaTransactionDetails = ({ tx }: { tx: VegaStoredTxState }) => {
tx.body.orderCancellation.marketId === undefined &&
tx.body.orderCancellation.orderId === undefined
) {
return <Details>{t('Cancel all orders')}</Details>;
return <Panel>{t('Cancel all orders')}</Panel>;
}
// CANCEL
@@ -351,11 +328,15 @@ export const VegaTransactionDetails = ({ tx }: { tx: VegaStoredTxState }) => {
m.id === (tx.body as OrderCancellationBody).orderCancellation.marketId
)?.tradableInstrument.instrument.code;
return (
<Details>
{marketName
? `${t('Cancel all orders for')} ${marketName}`
: t('Cancel all orders')}
</Details>
<Panel>
{marketName ? (
<>
{t('Cancel all orders for')} <strong>{marketName}</strong>
</>
) : (
t('Cancel all orders')
)}
</Panel>
);
}
}
@@ -377,15 +358,36 @@ export const VegaTransactionDetails = ({ tx }: { tx: VegaStoredTxState }) => {
const market = marketId && markets?.find((m) => m.id === marketId);
if (market) {
return (
<Details>
{t('Close position for')} {market.tradableInstrument.instrument.code}
</Details>
<Panel>
{t('Close position for')}{' '}
<strong>{market.tradableInstrument.instrument.code}</strong>
</Panel>
);
}
}
if (isBatchMarketInstructionsTransaction(tx.body)) {
return <Details>{t('Batch market instruction')}</Details>;
return <Panel>{t('Batch market instruction')}</Panel>;
}
if (isTransferTransaction(tx.body)) {
const { amount, to, asset } = tx.body.transfer;
const transferAsset = assets?.find((a) => a.id === asset);
// only render if we have an asset to avoid unformatted amounts showing
if (transferAsset) {
const value = addDecimalsFormatNumber(amount, transferAsset.decimals);
return (
<Panel>
<h4>{t('Transfer')}</h4>
<p>
{t('To')} {truncateByChars(to)}
</p>
<p>
{value} {transferAsset.symbol}
</p>
</Panel>
);
}
}
return null;
@@ -394,22 +396,22 @@ export const VegaTransactionDetails = ({ tx }: { tx: VegaStoredTxState }) => {
type VegaTxToastContentProps = { tx: VegaStoredTxState };
const VegaTxRequestedToastContent = ({ tx }: VegaTxToastContentProps) => (
<div>
<h3 className="font-bold">{t('Action required')}</h3>
<>
<ToastHeading>{t('Action required')}</ToastHeading>
<p>
{t(
'Please go to your Vega wallet application and approve or reject the transaction.'
)}
</p>
<VegaTransactionDetails tx={tx} />
</div>
</>
);
const VegaTxPendingToastContentProps = ({ tx }: VegaTxToastContentProps) => {
const explorerLink = useLinks(DApp.Explorer);
return (
<div>
<h3 className="font-bold">{t('Awaiting confirmation')}</h3>
<>
<ToastHeading>{t('Awaiting confirmation')}</ToastHeading>
<p>{t('Please wait for your transaction to be confirmed')}</p>
{tx.txHash && (
<p className="break-all">
@@ -422,7 +424,7 @@ const VegaTxPendingToastContentProps = ({ tx }: VegaTxToastContentProps) => {
</p>
)}
<VegaTransactionDetails tx={tx} />
</div>
</>
);
};
@@ -433,9 +435,10 @@ const VegaTxCompleteToastsContent = ({ tx }: VegaTxToastContentProps) => {
})
);
const explorerLink = useLinks(DApp.Explorer);
if (isWithdrawTransaction(tx.body)) {
const completeWithdrawalButton = tx.withdrawal && (
<div className="mt-[10px]">
<p className="mt-1">
<Button
data-testid="toast-complete-withdrawal"
size="xs"
@@ -448,11 +451,11 @@ const VegaTxCompleteToastsContent = ({ tx }: VegaTxToastContentProps) => {
>
{t('Complete withdrawal')}
</Button>
</div>
</p>
);
return (
<div>
<h3 className="font-bold">{t('Funds unlocked')}</h3>
<>
<ToastHeading>{t('Funds unlocked')}</ToastHeading>
<p>{t('Your funds have been unlocked for withdrawal')}</p>
{tx.txHash && (
<p className="break-all">
@@ -466,7 +469,32 @@ const VegaTxCompleteToastsContent = ({ tx }: VegaTxToastContentProps) => {
)}
<VegaTransactionDetails tx={tx} />
{completeWithdrawalButton}
</div>
</>
);
}
if (tx.order && tx.order.rejectionReason) {
return (
<>
<ToastHeading>{t('Order rejected')}</ToastHeading>
<p>
{t(
'Your order has been rejected because: %s',
getRejectionReason(tx.order) || ''
)}
</p>
{tx.txHash && (
<p className="break-all">
<ExternalLink
href={explorerLink(EXPLORER_TX.replace(':hash', tx.txHash))}
rel="noreferrer"
>
{t('View in block explorer')}
</ExternalLink>
</p>
)}
<VegaTransactionDetails tx={tx} />
</>
);
}
@@ -490,9 +518,19 @@ const VegaTxCompleteToastsContent = ({ tx }: VegaTxToastContentProps) => {
);
}
if (isTransferTransaction(tx.body)) {
return (
<div>
<h3 className="font-bold">{t('Transfer complete')}</h3>
<p>{t('Your transaction has been confirmed ')}</p>
<VegaTransactionDetails tx={tx} />
</div>
);
}
return (
<div>
<h3 className="font-bold">{t('Confirmed')}</h3>
<>
<ToastHeading>{t('Confirmed')}</ToastHeading>
<p>{t('Your transaction has been confirmed ')}</p>
{tx.txHash && (
<p className="break-all">
@@ -505,7 +543,7 @@ const VegaTxCompleteToastsContent = ({ tx }: VegaTxToastContentProps) => {
</p>
)}
<VegaTransactionDetails tx={tx} />
</div>
</>
);
};
@@ -528,7 +566,10 @@ const VegaTxErrorToastContent = ({ tx }: VegaTxToastContentProps) => {
walletNoConnectionCodes.includes(tx.error.code);
if (orderRejection) {
label = t('Order rejected');
errorMessage = orderRejection;
errorMessage = t(
'Your order has been rejected because: %s',
orderRejection
);
}
if (walletError) {
label = t('Wallet disconnected');
@@ -536,60 +577,87 @@ const VegaTxErrorToastContent = ({ tx }: VegaTxToastContentProps) => {
}
return (
<div>
<h3 className="font-bold">{label}</h3>
<p>{errorMessage}</p>
<>
<ToastHeading>{label}</ToastHeading>
<p className="first-letter:uppercase">{errorMessage}</p>
{walletError && (
<Button size="xs" onClick={reconnectVegaWallet}>
{t('Connect vega wallet')}
</Button>
)}
<VegaTransactionDetails tx={tx} />
</div>
</>
);
};
const isFinal = (tx: VegaStoredTxState) =>
[VegaTxStatus.Error, VegaTxStatus.Complete].includes(tx.status);
export const useVegaTransactionToasts = () => {
const vegaTransactions = useVegaTransactionStore((state) =>
state.transactions.filter((transaction) => transaction?.dialogOpen)
);
const dismissVegaTransaction = useVegaTransactionStore(
(state) => state.dismiss
);
const [setToast, removeToast] = useToasts((store) => [
store.setToast,
store.remove,
]);
const fromVegaTransaction = useCallback(
(tx: VegaStoredTxState): Toast => {
let content: ToastContent;
if (tx.status === VegaTxStatus.Requested) {
content = <VegaTxRequestedToastContent tx={tx} />;
const [dismissTx, deleteTx] = useVegaTransactionStore((state) => [
state.dismiss,
state.delete,
]);
const onClose = useCallback(
(tx: VegaStoredTxState) => () => {
const safeToDelete = isFinal(tx);
if (safeToDelete) {
deleteTx(tx.id);
} else {
dismissTx(tx.id);
}
if (tx.status === VegaTxStatus.Pending) {
content = <VegaTxPendingToastContentProps tx={tx} />;
}
if (tx.status === VegaTxStatus.Complete) {
content = <VegaTxCompleteToastsContent tx={tx} />;
}
if (tx.status === VegaTxStatus.Error) {
content = <VegaTxErrorToastContent tx={tx} />;
}
return {
id: `vega-${tx.id}`,
intent: getIntent(tx),
onClose: () => dismissVegaTransaction(tx.id),
loader: tx.status === VegaTxStatus.Pending,
content,
};
removeToast(`vega-${tx.id}`);
},
[dismissVegaTransaction]
[deleteTx, dismissTx, removeToast]
);
const toasts = useMemo(() => {
return [
...compact(vegaTransactions)
.filter((tx) => isTransactionTypeSupported(tx))
.map(fromVegaTransaction),
];
}, [fromVegaTransaction, vegaTransactions]);
const fromVegaTransaction = (tx: VegaStoredTxState): Toast => {
let content: ToastContent;
const closeAfter = isFinal(tx) ? CLOSE_AFTER : undefined;
if (tx.status === VegaTxStatus.Requested) {
content = <VegaTxRequestedToastContent tx={tx} />;
}
if (tx.status === VegaTxStatus.Pending) {
content = <VegaTxPendingToastContentProps tx={tx} />;
}
if (tx.status === VegaTxStatus.Complete) {
content = <VegaTxCompleteToastsContent tx={tx} />;
}
if (tx.status === VegaTxStatus.Error) {
content = <VegaTxErrorToastContent tx={tx} />;
}
return toasts;
// Transaction can be successful but the order can be rejected by the network
const intent =
tx.order && [OrderStatus.STATUS_REJECTED].includes(tx.order.status)
? Intent.Danger
: intentMap[tx.status];
return {
id: `vega-${tx.id}`,
intent,
onClose: onClose(tx),
loader: tx.status === VegaTxStatus.Pending,
content,
closeAfter,
};
};
useVegaTransactionStore.subscribe(
(state) =>
compact(
state.transactions.filter(
(tx) => tx?.dialogOpen && isTransactionTypeSupported(tx)
)
),
(txs) => {
txs.forEach((tx) => setToast(fromVegaTransaction(tx)));
}
);
};
+2
View File
@@ -8,6 +8,7 @@ import { CreateWithdrawalDialog } from '@vegaprotocol/withdraws';
import { DepositDialog } from '@vegaprotocol/deposits';
import { Web3ConnectUncontrolledDialog } from '@vegaprotocol/web3';
import { WelcomeDialog } from '../components/welcome-dialog';
import { TransferDialog } from '@vegaprotocol/accounts';
const DialogsContainer = () => {
const { isOpen, id, trigger, setOpen } = useAssetDetailsDialogStore();
@@ -25,6 +26,7 @@ const DialogsContainer = () => {
<DepositDialog />
<Web3ConnectUncontrolledDialog />
<CreateWithdrawalDialog />
<TransferDialog />
</>
);
};
+6 -25
View File
@@ -1,35 +1,16 @@
import { ToastsContainer } from '@vegaprotocol/ui-toolkit';
import { useMemo } from 'react';
import sortBy from 'lodash/sortBy';
import { ToastsContainer, useToasts } from '@vegaprotocol/ui-toolkit';
import { useUpdateNetworkParametersToasts } from '@vegaprotocol/governance';
import { useVegaTransactionToasts } from '../lib/hooks/use-vega-transaction-toasts';
import { useEthereumTransactionToasts } from '../lib/hooks/use-ethereum-transaction-toasts';
import { useEthereumWithdrawApprovalsToasts } from '../lib/hooks/use-ethereum-withdraw-approval-toasts';
export const ToastsManager = () => {
const updateNetworkParametersToasts = useUpdateNetworkParametersToasts();
const vegaTransactionToasts = useVegaTransactionToasts();
const ethTransactionToasts = useEthereumTransactionToasts();
const withdrawApprovalToasts = useEthereumWithdrawApprovalsToasts();
const toasts = useMemo(() => {
return sortBy(
[
...vegaTransactionToasts,
...ethTransactionToasts,
...withdrawApprovalToasts,
...updateNetworkParametersToasts,
],
['createdBy']
);
}, [
vegaTransactionToasts,
ethTransactionToasts,
withdrawApprovalToasts,
updateNetworkParametersToasts,
]);
useUpdateNetworkParametersToasts();
useVegaTransactionToasts();
useEthereumTransactionToasts();
useEthereumWithdrawApprovalsToasts();
const toasts = useToasts((store) => store.toasts);
return <ToastsContainer order="desc" toasts={toasts} />;
};
+1
View File
@@ -7,3 +7,4 @@ export * from './breakdown-table';
export * from './use-account-balance';
export * from './get-settlement-account';
export * from './use-market-account-balance';
export * from './transfer-dialog';
@@ -0,0 +1,68 @@
import * as Schema from '@vegaprotocol/types';
import {
addDecimal,
NetworkParams,
t,
truncateByChars,
useDataProvider,
useNetworkParam,
} from '@vegaprotocol/react-helpers';
import type { Transfer } from '@vegaprotocol/wallet';
import { useVegaTransactionStore, useVegaWallet } from '@vegaprotocol/wallet';
import { useCallback, useMemo } from 'react';
import { accountsDataProvider } from './accounts-data-provider';
import { TransferForm } from './transfer-form';
import { useTransferDialog } from './transfer-dialog';
import { Lozenge } from '@vegaprotocol/ui-toolkit';
export const TransferContainer = () => {
const { pubKey, pubKeys } = useVegaWallet();
const open = useTransferDialog((store) => store.open);
const { param } = useNetworkParam(NetworkParams.transfer_fee_factor);
const { data } = useDataProvider({
dataProvider: accountsDataProvider,
variables: { partyId: pubKey },
skip: !pubKey,
});
const create = useVegaTransactionStore((store) => store.create);
const transfer = useCallback(
(transfer: Transfer) => {
create({ transfer });
open(false);
},
[create, open]
);
const assets = useMemo(() => {
if (!data) return [];
return data
.filter(
(account) => account.type === Schema.AccountType.ACCOUNT_TYPE_GENERAL
)
.map((account) => ({
id: account.asset.id,
symbol: account.asset.symbol,
name: account.asset.name,
decimals: account.asset.decimals,
balance: addDecimal(account.balance, account.asset.decimals),
}));
}, [data]);
return (
<>
<p className="text-sm mb-4">
{t('Transfer funds to another Vega key from')}{' '}
<Lozenge className="font-mono">{truncateByChars(pubKey || '')}</Lozenge>{' '}
{t('If you are at all unsure, stop and seek advice.')}
</p>
<TransferForm
pubKey={pubKey}
pubKeys={pubKeys ? pubKeys?.map((pk) => pk.publicKey) : null}
assets={assets}
feeFactor={param}
submitTransfer={transfer}
/>
</>
);
};
+28
View File
@@ -0,0 +1,28 @@
import { t } from '@vegaprotocol/react-helpers';
import { Dialog } from '@vegaprotocol/ui-toolkit';
import { create } from 'zustand';
import { TransferContainer } from './transfer-container';
interface State {
isOpen: boolean;
}
interface Actions {
open: (open?: boolean) => void;
}
export const useTransferDialog = create<State & Actions>((set) => ({
isOpen: false,
open: (open = true) => {
set(() => ({ isOpen: open }));
},
}));
export const TransferDialog = () => {
const { isOpen, open } = useTransferDialog();
return (
<Dialog title={t('Transfer')} open={isOpen} onChange={open} size="small">
<TransferContainer />
</Dialog>
);
};
@@ -0,0 +1,177 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import BigNumber from 'bignumber.js';
import { AddressField, TransferFee, TransferForm } from './transfer-form';
import { AccountType } from '@vegaprotocol/types';
import { formatNumber, removeDecimal } from '@vegaprotocol/react-helpers';
describe('TransferForm', () => {
const submit = () => fireEvent.submit(screen.getByTestId('transfer-form'));
const amount = '100';
const pubKey =
'70d14a321e02e71992fd115563df765000ccc4775cbe71a0e2f9ff5a3b9dc680';
const asset = {
id: 'asset-0',
symbol: 'ASSET 0',
name: 'Asset 0',
decimals: 2,
balance: '1000',
};
const props = {
pubKey,
pubKeys: [
pubKey,
'a4b6e3de5d7ef4e31ae1b090be49d1a2ef7bcefff60cccf7658a0d4922651cce',
],
assets: [asset],
feeFactor: '0.001',
submitTransfer: jest.fn(),
};
it('validates fields and submits', async () => {
render(<TransferForm {...props} />);
// check current pubkey not shown
const keySelect: HTMLSelectElement = screen.getByLabelText('Vega key');
expect(keySelect.children).toHaveLength(2);
expect(Array.from(keySelect.options).map((o) => o.value)).toEqual([
'',
props.pubKeys[1],
]);
submit();
expect(await screen.findAllByText('Required')).toHaveLength(3);
// Select a pubkey
fireEvent.change(screen.getByLabelText('Vega key'), {
target: { value: props.pubKeys[1] },
});
// Select asset
fireEvent.change(
// Bypass RichSelect and target hidden native select
// eslint-disable-next-line
document.querySelector('select[name="asset"]')!,
{ target: { value: asset.id } }
);
// assert rich select as updated
expect(await screen.findByTestId('select-asset')).toHaveTextContent(
asset.name
);
expect(screen.getByTestId('asset-balance')).toHaveTextContent(
formatNumber(asset.balance, asset.decimals)
);
// Test amount validation
fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: '0.00000001' },
});
expect(
await screen.findByText('Value is below minimum')
).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: '9999999' },
});
expect(
await screen.findByText(/cannot transfer more/i)
).toBeInTheDocument();
// set valid amount
fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: amount },
});
expect(screen.getByTestId('transfer-fee')).toHaveTextContent(
new BigNumber(props.feeFactor).times(amount).toFixed()
);
submit();
await waitFor(() => {
expect(props.submitTransfer).toHaveBeenCalledTimes(1);
expect(props.submitTransfer).toHaveBeenCalledWith({
fromAccountType: AccountType.ACCOUNT_TYPE_GENERAL,
toAccountType: AccountType.ACCOUNT_TYPE_GENERAL,
to: props.pubKeys[1],
asset: asset.id,
amount: removeDecimal(amount, asset.decimals),
oneOff: {},
});
});
});
it('validates a manually entered address', async () => {
render(<TransferForm {...props} />);
submit();
expect(await screen.findAllByText('Required')).toHaveLength(3);
const toggle = screen.getByText('Enter manually');
fireEvent.click(toggle);
// has switched to input
expect(toggle).toHaveTextContent('Select from wallet');
expect(screen.getByLabelText('Vega key')).toHaveAttribute('type', 'text');
fireEvent.change(screen.getByLabelText('Vega key'), {
target: { value: 'invalid-address' },
});
await waitFor(() => {
const errors = screen.getAllByTestId('input-error-text');
expect(errors[0]).toHaveTextContent('Invalid Vega key');
});
// same pubkey
fireEvent.change(screen.getByLabelText('Vega key'), {
target: { value: pubKey },
});
await waitFor(() => {
const errors = screen.getAllByTestId('input-error-text');
expect(errors[0]).toHaveTextContent('Vega key is the same');
});
});
});
describe('AddressField', () => {
const props = {
pubKeys: ['pubkey-1', 'pubkey-2'],
select: <div>select</div>,
input: <div>input</div>,
onChange: jest.fn(),
};
it('toggles content and calls onChange', async () => {
const mockOnChange = jest.fn();
render(<AddressField {...props} onChange={mockOnChange} />);
// select should be shown as multiple pubkeys provided
expect(screen.getByText('select')).toBeInTheDocument();
expect(screen.queryByText('input')).not.toBeInTheDocument();
fireEvent.click(screen.getByText('Enter manually'));
expect(screen.queryByText('select')).not.toBeInTheDocument();
expect(screen.getByText('input')).toBeInTheDocument();
expect(mockOnChange).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByText('Select from wallet'));
expect(screen.getByText('select')).toBeInTheDocument();
expect(screen.queryByText('input')).not.toBeInTheDocument();
expect(mockOnChange).toHaveBeenCalledTimes(2);
});
it('Does not provide select option if there is only a single key', () => {
render(<AddressField {...props} pubKeys={['single-pubKey']} />);
expect(screen.getByText('input')).toBeInTheDocument();
expect(screen.queryByText('Select from wallet')).not.toBeInTheDocument();
});
});
describe('TransferFee', () => {
const props = {
amount: '200',
feeFactor: '0.001',
};
it('calculates and renders the transfer fee', () => {
render(<TransferFee {...props} />);
const expected = new BigNumber(props.amount)
.times(props.feeFactor)
.toFixed();
expect(screen.getByTestId('transfer-fee')).toHaveTextContent(expected);
});
});
+296
View File
@@ -0,0 +1,296 @@
import {
t,
minSafe,
maxSafe,
required,
vegaPublicKey,
addDecimal,
formatNumber,
} from '@vegaprotocol/react-helpers';
import {
Button,
FormGroup,
Input,
InputError,
Option,
RichSelect,
Select,
Tooltip,
} from '@vegaprotocol/ui-toolkit';
import type { Transfer } from '@vegaprotocol/wallet';
import { normalizeTransfer } from '@vegaprotocol/wallet';
import BigNumber from 'bignumber.js';
import type { ReactNode } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
interface FormFields {
toAddress: string;
asset: string;
amount: string;
}
interface TransferFormProps {
pubKey: string | null;
pubKeys: string[] | null;
assets: Array<{
id: string;
symbol: string;
name: string;
decimals: number;
balance: string;
}>;
feeFactor: string | null;
submitTransfer: (transfer: Transfer) => void;
}
export const TransferForm = ({
pubKey,
pubKeys,
assets,
feeFactor,
submitTransfer,
}: TransferFormProps) => {
const {
control,
register,
watch,
handleSubmit,
setValue,
formState: { errors },
} = useForm<FormFields>();
const amount = watch('amount');
const assetId = watch('asset');
const asset = useMemo(() => {
return assets.find((a) => a.id === assetId);
}, [assets, assetId]);
const onSubmit = useCallback(
(fields: FormFields) => {
if (!asset) {
throw new Error('Submitted transfer with no asset selected');
}
const transfer = normalizeTransfer(fields.toAddress, fields.amount, {
id: asset.id,
decimals: asset.decimals,
});
submitTransfer(transfer);
},
[asset, submitTransfer]
);
const min = useMemo(() => {
// Min viable amount given asset decimals EG for WEI 0.000000000000000001
const minViableAmount = asset
? new BigNumber(addDecimal('1', asset.decimals))
: new BigNumber(0);
return minViableAmount;
}, [asset]);
const max = useMemo(() => {
const maxAmount = asset ? new BigNumber(asset.balance) : new BigNumber(0);
return maxAmount;
}, [asset]);
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="text-sm"
data-testid="transfer-form"
>
<FormGroup label="Vega key" labelFor="to-address">
<AddressField
pubKeys={pubKeys}
onChange={() => setValue('toAddress', '')}
select={
<Select {...register('toAddress')} id="to-address" defaultValue="">
<option value="" disabled={true}>
{t('Please select')}
</option>
{pubKeys?.length &&
pubKeys
.filter((pk) => pk !== pubKey) // remove currently selected pubkey
.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-address"
type="text"
{...register('toAddress', {
validate: {
required,
vegaPublicKey,
sameKey: (value) => {
if (value === pubKey) {
return t('Vega key is the same as current key');
}
return true;
},
},
})}
/>
}
/>
{errors.toAddress?.message && (
<InputError forInput="to-address">
{errors.toAddress.message}
</InputError>
)}
</FormGroup>
<FormGroup label="Asset" labelFor="asset">
<Controller
control={control}
name="asset"
rules={{
validate: {
required,
},
}}
render={({ field }) => (
<RichSelect
data-testid="select-asset"
id={field.name}
name={field.name}
onValueChange={(value) => {
field.onChange(value);
}}
placeholder={t('Please select')}
value={field.value}
>
{assets.map((a) => (
<Option key={a.id} value={a.id}>
<div className="text-left" data-testid={`asset-${a.id}`}>
<div>{a.name}</div>
<div className="text-xs">
<span className="font-mono" data-testid="asset-balance">
{formatNumber(a.balance, a.decimals)}
</span>{' '}
<span>{a.symbol}</span>
</div>
</div>
</Option>
))}
</RichSelect>
)}
/>
{errors.asset?.message && (
<InputError forInput="asset">{errors.asset.message}</InputError>
)}
</FormGroup>
<FormGroup label="Amount" labelFor="amount">
<Input
id="amount"
autoComplete="off"
appendElement={
asset && <span className="text-xs">{asset.symbol}</span>
}
{...register('amount', {
validate: {
required,
minSafe: (value) => minSafe(new BigNumber(min))(value),
maxSafe: (v) => {
const value = new BigNumber(v);
if (value.isGreaterThan(max)) {
return t(
'You cannot transfer more than your available collateral'
);
}
return maxSafe(max)(v);
},
},
})}
/>
{errors.amount?.message && (
<InputError forInput="amount">{errors.amount.message}</InputError>
)}
</FormGroup>
<TransferFee amount={amount} feeFactor={feeFactor} />
<Button type="submit" variant="primary" fill={true}>
{t('Confirm transfer')}
</Button>
</form>
);
};
export const TransferFee = ({
amount,
feeFactor,
}: {
amount: string;
feeFactor: string | null;
}) => {
if (!feeFactor || !amount) return null;
// using toFixed without an argument will always return a
// number in normal notation without rounding, formatting functions
// arent working in a way which won't round the decimal places
const value = new BigNumber(amount).times(feeFactor).toFixed();
return (
<div className="mb-4 flex justify-between items-center gap-4 flex-wrap">
<div>
<Tooltip
description={t(
`The transfer fee is set by the network parameter transfer.fee.factor, currently set to ${feeFactor}`
)}
>
<div>{t('Transfer fee')}</div>
</Tooltip>
</div>
<div
data-testid="transfer-fee"
className="text-neutral-500 dark:text-neutral-300"
>
{value}
</div>
</div>
);
};
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"
>
{isInput ? t('Select from wallet') : t('Enter manually')}
</button>
)}
</>
);
};
+1 -1
View File
@@ -6,7 +6,7 @@ import { AssetDocument } from './__generated__/Asset';
export type Asset = AssetFieldsFragment;
const getData = (responseData: AssetQuery | null) => {
export const getData = (responseData: AssetQuery | null | undefined) => {
const foundAssets = responseData?.assetsConnection?.edges
?.filter((e) => Boolean(e?.node))
.map((e) => e?.node as Asset);
+21 -4
View File
@@ -1,6 +1,12 @@
import { t } from '@vegaprotocol/react-helpers';
import { useAssetsDataProvider } from './assets-data-provider';
import { Button, Dialog, Icon, Splash } from '@vegaprotocol/ui-toolkit';
import {
Button,
Dialog,
Icon,
Splash,
SyntaxHighlighter,
} from '@vegaprotocol/ui-toolkit';
import { create } from 'zustand';
import { AssetDetailsTable } from './asset-details-table';
import { AssetProposalNotification } from '@vegaprotocol/governance';
@@ -9,8 +15,9 @@ export type AssetDetailsDialogStore = {
isOpen: boolean;
id: string;
trigger: HTMLElement | null | undefined;
asJson: boolean;
setOpen: (isOpen: boolean) => void;
open: (id: string, trigger?: HTMLElement | null) => void;
open: (id: string, trigger?: HTMLElement | null, asJson?: boolean) => void;
};
export const useAssetDetailsDialogStore = create<AssetDetailsDialogStore>(
@@ -18,14 +25,16 @@ export const useAssetDetailsDialogStore = create<AssetDetailsDialogStore>(
isOpen: false,
id: '',
trigger: null,
asJson: false,
setOpen: (isOpen) => {
set({ isOpen: isOpen });
},
open: (id, trigger?) => {
open: (id, trigger?, asJson = false) => {
set({
isOpen: true,
id,
trigger,
asJson,
});
},
})
@@ -36,6 +45,7 @@ export interface AssetDetailsDialogProps {
trigger?: HTMLElement | null;
open: boolean;
onChange: (open: boolean) => void;
asJson?: boolean;
}
export const AssetDetailsDialog = ({
@@ -43,6 +53,7 @@ export const AssetDetailsDialog = ({
trigger,
open,
onChange,
asJson = false,
}: AssetDetailsDialogProps) => {
const { data } = useAssetsDataProvider();
@@ -51,7 +62,13 @@ export const AssetDetailsDialog = ({
const content = asset ? (
<div className="my-2">
<AssetProposalNotification assetId={asset.id} />
<AssetDetailsTable asset={asset} />
{asJson ? (
<div className="pr-8">
<SyntaxHighlighter size="smaller" data={asset} />
</div>
) : (
<AssetDetailsTable asset={asset} />
)}
</div>
) : (
<div className="py-12" data-testid="splash">
+14 -4
View File
@@ -2,6 +2,7 @@ import { useEtherscanLink } from '@vegaprotocol/environment';
import { addDecimalsFormatNumber, t } from '@vegaprotocol/react-helpers';
import type * as Schema from '@vegaprotocol/types';
import type { KeyValueTableRowProps } from '@vegaprotocol/ui-toolkit';
import { CopyWithTooltip, Icon } from '@vegaprotocol/ui-toolkit';
import { Link } from '@vegaprotocol/ui-toolkit';
import {
KeyValueTable,
@@ -105,7 +106,16 @@ export const rows: Rows = [
return;
}
return <ContractAddressLink address={asset.source.contractAddress} />;
return (
<>
<ContractAddressLink address={asset.source.contractAddress} />{' '}
<CopyWithTooltip text={asset.source.contractAddress}>
<button title={t('Copy address to clipboard')}>
<Icon size={3} name="duplicate" />
</button>
</CopyWithTooltip>
</>
);
},
},
{
@@ -180,7 +190,7 @@ export const rows: Rows = [
},
];
const AssetStatusMapping: Mapping = {
export const AssetStatusMapping: Mapping = {
STATUS_ENABLED: {
value: t('Enabled'),
tooltip: t('Asset can be used on the Vega network'),
@@ -199,7 +209,7 @@ const AssetStatusMapping: Mapping = {
},
};
const AssetTypeMapping: Mapping = {
export const AssetTypeMapping: Mapping = {
BuiltinAsset: {
value: 'Builtin asset',
tooltip: t('A Vega builtin asset'),
@@ -274,7 +284,7 @@ const ContractAddressLink = ({ address }: { address: string }) => {
const etherscanLink = useEtherscanLink();
const href = etherscanLink(`/address/${address}`);
return (
<Link href={href} target="_blank">
<Link href={href} target="_blank" title={t('View on etherscan')}>
{address}
</Link>
);
+2 -2
View File
@@ -5,7 +5,7 @@ import { addMockWalletCommand } from './lib/mock-rest';
import { addMockWeb3ProviderCommand } from './lib/commands/mock-web3-provider';
import { addSlackCommand } from './lib/commands/slack';
import { addHighlightLog } from './lib/commands/highlight-log';
import { addGetAssetInformation } from './lib/commands/get-asset-information';
import { addGetAssets } from './lib/commands/get-assets';
import { addVegaWalletReceiveFaucetedAsset } from './lib/commands/vega-wallet-receive-fauceted-asset';
import { addContainsExactly } from './lib/commands/contains-exactly';
import { addGetNetworkParameters } from './lib/commands/get-network-parameters';
@@ -26,7 +26,7 @@ addMockWalletCommand();
addMockWeb3ProviderCommand();
addHighlightLog();
addVegaWalletReceiveFaucetedAsset();
addGetAssetInformation();
addGetAssets();
addContainsExactly();
addGetNetworkParameters();
addUpdateCapsuleMultiSig();
@@ -1,38 +0,0 @@
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface Chainable<Subject> {
get_asset_information(): void;
}
}
}
export function addGetAssetInformation() {
// @ts-ignore - ignoring Cypress type error which gets resolved when Cypress uses the command
Cypress.Commands.add('get_asset_information', () => {
const mutation =
'{ assetsConnection{edges{node{name id symbol decimals source{__typename \
... on ERC20{contractAddress} \
... on BuiltinAsset{maxFaucetAmountMint}} \
infrastructureFeeAccount{__typename type balance} \
globalRewardPoolAccount {balance}}}}}';
cy.request({
method: 'POST',
url: `http://localhost:3028/query`,
body: {
query: mutation,
},
headers: { 'content-type': 'application/json' },
})
.its('body.data.assetsConnection.edges')
.then(function (response) {
// @ts-ignore - ignoring Cypress type error which gets resolved when Cypress uses the command
const object = response.reduce(function (assets, entry) {
assets[entry.node.name] = entry;
return assets;
}, {});
return object;
});
});
}
@@ -0,0 +1,83 @@
import { gql } from '@apollo/client';
import { print } from 'graphql';
import type { AssetFieldsFragment } from '@vegaprotocol/assets';
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface Chainable<Subject> {
getAssets(): Chainable<Record<string, AssetFieldsFragment>>;
}
}
}
export function addGetAssets() {
// @ts-ignore - ignoring Cypress type error which gets resolved when Cypress uses the command
Cypress.Commands.add('getAssets', () => {
// TODO: Investigate why importing here an actual AssetsDocument fails and
// causes cypress's webpack to go bonkers
const query = gql`
query Assets {
assetsConnection {
edges {
node {
id
name
symbol
decimals
quantum
source {
__typename
... on ERC20 {
contractAddress
lifetimeLimit
withdrawThreshold
}
... on BuiltinAsset {
maxFaucetAmountMint
}
}
status
infrastructureFeeAccount {
balance
}
globalRewardPoolAccount {
balance
}
takerFeeRewardAccount {
balance
}
makerFeeRewardAccount {
balance
}
lpFeeRewardAccount {
balance
}
marketProposerRewardAccount {
balance
}
}
}
}
}
`;
cy.request({
method: 'POST',
url: 'http://localhost:3028/query',
body: {
query: print(query),
},
headers: { 'content-type': 'application/json' },
})
.its('body.data.assetsConnection.edges')
.then((edges) => {
// @ts-ignore - ignoring Cypress type error which gets resolved when Cypress uses the command
return edges.reduce((list, edge) => {
list[edge.node.name] = edge.node;
return list;
}, {});
});
});
}
@@ -21,7 +21,7 @@ export function addVegaWalletReceiveFaucetedAsset() {
`Topping up vega wallet with ${assetName}, amount: ${amount}`
);
// @ts-ignore - ignoring Cypress type error which gets resolved when Cypress uses the command
cy.get_asset_information().then((assets) => {
cy.getAssets().then((assets) => {
console.log(assets);
const asset = assets[assetName];
if (assets[assetName] !== undefined) {
@@ -10,9 +10,9 @@ interface Props {
export const DealTicketButton = ({ disabled, variant }: Props) => {
const { pubKey } = useVegaWallet();
const { openVegaWalletDialog } = useVegaWalletDialogStore((store) => ({
openVegaWalletDialog: store.openVegaWalletDialog,
}));
const openVegaWalletDialog = useVegaWalletDialogStore(
(store) => store.openVegaWalletDialog
);
return pubKey ? (
<div className="mb-4">
<Button
@@ -31,8 +31,6 @@ import {
usePersistedOrderStoreSubscription,
} from '@vegaprotocol/orders';
export type TransactionStatus = 'default' | 'pending';
export interface DealTicketProps {
market: MarketDealTicket;
submit: (order: OrderSubmissionBody['orderSubmission']) => void;
@@ -4,6 +4,7 @@ import type { UpdateNetworkParameter } from '@vegaprotocol/types';
import { ProposalStateMapping } from '@vegaprotocol/types';
import { ProposalState } from '@vegaprotocol/types';
import type { Toast } from '@vegaprotocol/ui-toolkit';
import { ToastHeading } from '@vegaprotocol/ui-toolkit';
import { useToasts } from '@vegaprotocol/ui-toolkit';
import { ExternalLink, Intent } from '@vegaprotocol/ui-toolkit';
import compact from 'lodash/compact';
@@ -28,7 +29,7 @@ const UpdateNetworkParameterToastContent = ({
const enactment = Date.parse(proposal.terms.enactmentDatetime);
return (
<div>
<h3 className="font-bold">{title}</h3>
<ToastHeading>{title}</ToastHeading>
<p className="italic">
'
{t(
@@ -52,9 +53,8 @@ const UpdateNetworkParameterToastContent = ({
);
};
export const useUpdateNetworkParametersToasts = (): Toast[] => {
const { proposalToasts, setToast, remove } = useToasts((store) => ({
proposalToasts: store.toasts,
export const useUpdateNetworkParametersToasts = () => {
const { setToast, remove } = useToasts((store) => ({
setToast: store.setToast,
remove: store.remove,
}));
@@ -66,7 +66,9 @@ export const useUpdateNetworkParametersToasts = (): Toast[] => {
id: `update-network-param-proposal-${proposal.id}`,
intent: Intent.Warning,
content: <UpdateNetworkParameterToastContent proposal={proposal} />,
onClose: () => remove(id),
onClose: () => {
remove(id);
},
closeAfter: CLOSE_AFTER,
};
},
@@ -96,6 +98,4 @@ export const useUpdateNetworkParametersToasts = (): Toast[] => {
}
},
});
return proposalToasts;
};
@@ -1,6 +1,6 @@
import type { MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing';
import { act, renderHook } from '@testing-library/react-hooks';
import { renderHook } from '@testing-library/react-hooks';
import { ProposalState } from '@vegaprotocol/types';
import type { ReactNode } from 'react';
import { useUpdateNetworkParametersToasts } from './use-update-network-paramaters-toasts';
@@ -9,8 +9,8 @@ import type {
OnUpdateNetworkParametersSubscription,
} from './__generated__/Proposal';
import { OnUpdateNetworkParametersDocument } from './__generated__/Proposal';
import waitForNextTick from 'flush-promises';
import { useToasts } from '@vegaprotocol/ui-toolkit';
import { waitFor } from '@testing-library/react';
const render = (mocks?: MockedResponse[]) => {
const wrapper = ({ children }: { children: ReactNode }) => (
@@ -92,11 +92,10 @@ const mockedEvent: MockedResponse<OnUpdateNetworkParametersSubscription> = {
},
};
const INITIAL = useToasts.getState();
const clear = () => {
const { result: clearer } = renderHook(() =>
useToasts((store) => store.removeAll)
);
act(() => clearer.current());
useToasts.setState(INITIAL);
};
describe('useUpdateNetworkParametersToasts', () => {
@@ -104,29 +103,23 @@ describe('useUpdateNetworkParametersToasts', () => {
afterAll(clear);
it('returns toast for update network parameters bus event', async () => {
const { waitForNextUpdate, result } = render([mockedEvent]);
await act(async () => {
waitForNextUpdate();
await waitForNextTick();
render([mockedEvent]);
await waitFor(() => {
expect(useToasts.getState().count).toBe(1);
});
expect(result.current.length).toBe(1);
});
it('does not return toast for empty event', async () => {
const { waitForNextUpdate, result } = render([mockedEmptyEvent]);
await act(async () => {
waitForNextUpdate();
await waitForNextTick();
render([mockedEmptyEvent]);
await waitFor(() => {
expect(useToasts.getState().count).toBe(0);
});
expect(result.current.length).toBe(0);
});
it('does not return toast for wrong event', async () => {
const { waitForNextUpdate, result } = render([mockedWrongEvent]);
await act(async () => {
waitForNextUpdate();
await waitForNextTick();
render([mockedWrongEvent]);
await waitFor(() => {
expect(useToasts.getState().count).toBe(0);
});
expect(result.current.length).toBe(0);
});
});
@@ -2,6 +2,7 @@ import { renderHook, waitFor } from '@testing-library/react';
import type { MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing';
import type { NetworkParamsKey } from './use-network-params';
import { toRealKey } from './use-network-params';
import {
NetworkParams,
useNetworkParam,
@@ -18,7 +19,7 @@ describe('useNetworkParam', () => {
request: {
query: NetworkParamDocument,
variables: {
key: arg,
key: toRealKey(arg),
},
},
result: {
@@ -104,10 +104,11 @@ export const NetworkParams = {
market_liquidity_stakeToCcyVolume: 'market_liquidity_stakeToCcyVolume',
market_liquidity_targetstake_triggering_ratio:
'market_liquidity_targetstake_triggering_ratio',
transfer_fee_factor: 'transfer_fee_factor',
} as const;
type Params = typeof NetworkParams;
export type NetworkParamsKey = Params[keyof Params];
export type NetworkParamsKey = keyof Params;
type Result = {
[key in keyof Params]: string;
};
@@ -120,7 +121,7 @@ export const useNetworkParams = <T extends NetworkParamsKey[]>(params?: T) => {
return compact(data.networkParametersConnection.edges)
.map((p) => ({
...p.node,
key: p.node.key.split('.').join('_'),
key: toInternalKey(p.node.key),
}))
.filter((p) => {
if (params === undefined || params.length === 0) return true;
@@ -143,7 +144,7 @@ export const useNetworkParams = <T extends NetworkParamsKey[]>(params?: T) => {
export const useNetworkParam = (param: NetworkParamsKey) => {
const { data, loading, error } = useNetworkParamQuery({
variables: {
key: param,
key: toRealKey(param),
},
});
@@ -153,3 +154,11 @@ export const useNetworkParam = (param: NetworkParamsKey) => {
error,
};
};
export const toRealKey = (key: NetworkParamsKey) => {
return key.split('_').join('.');
};
export const toInternalKey = (key: string) => {
return key.split('.').join('_');
};
+5
View File
@@ -174,11 +174,16 @@ module.exports = {
'60%': { transform: 'rotate( 0.0deg)' },
'100%': { transform: 'rotate( 0.0deg)' },
},
progress: {
from: { width: '0' },
to: { width: '100%' },
},
},
animation: {
rotate: 'rotate 2s linear alternate infinite',
'rotate-back': 'rotate 2s linear reverse infinite',
wave: 'wave 2s linear infinite',
progress: 'progress 5s cubic-bezier(.39,.58,.57,1) 1',
},
data: {
selected: 'state~="checked"',
@@ -15,7 +15,7 @@ const vegaCustomClasses = plugin(function ({ addUtilities }) {
},
'.syntax-highlighter-wrapper .hljs': {
fontSize: '1rem',
fontFamily: "Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace",
fontFamily: "'Roboto Mono', monospace",
display: 'block',
overflowX: 'auto',
padding: '1em',
@@ -23,6 +23,10 @@ const vegaCustomClasses = plugin(function ({ addUtilities }) {
color: colors.neutral[700],
border: `1px solid ${colors.neutral[300]}`,
},
'.syntax-highlighter-wrapper-sm .hljs': {
fontSize: '0.875rem',
lineHeight: '1.25rem',
},
'.dark .syntax-highlighter-wrapper .hljs': {
background: colors.neutral[800],
color: theme.colors.vega.green.DEFAULT,
@@ -1,7 +1,6 @@
import { Splash } from '../splash';
import type { ReactNode } from 'react';
import { t } from '@vegaprotocol/react-helpers';
import * as Sentry from '@sentry/react';
interface AsyncRendererProps<T> {
loading: boolean;
@@ -27,7 +26,6 @@ export function AsyncRenderer<T = object>({
render,
}: AsyncRendererProps<T>) {
if (error) {
Sentry.captureException(`Error rendering data: ${error.message}`);
if (!data) {
return (
<Splash>
@@ -151,7 +151,7 @@ export const DropdownMenuSeparator = forwardRef<
{...separatorProps}
ref={forwardedRef}
className={classNames(
'h-px my-1 mx-2 bg-neutral-700 dark:bg-black',
'h-px my-1 mx-2 bg-neutral-400 dark:bg-neutral-300',
className
)}
/>
@@ -11,6 +11,7 @@ interface ProgressBarProps {
export const ProgressBar = ({ className, intent, value }: ProgressBarProps) => {
return (
<div
data-progress-bar
style={{ height: '6px' }}
className={classNames(
'bg-neutral-300 dark:bg-neutral-700 relative',
@@ -18,6 +19,7 @@ export const ProgressBar = ({ className, intent, value }: ProgressBarProps) => {
)}
>
<div
data-progress-bar-value
className={classNames(
'absolute left-0 top-0 bottom-0',
intent === undefined || intent === Intent.None
@@ -15,7 +15,7 @@ export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ className, hasError, ...props }, ref) => (
<div className="flex items-center relative">
<div className="relative">
<select
ref={ref}
{...props}
@@ -25,7 +25,10 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(
'appearance-none rounded-md'
)}
/>
<Icon name="chevron-down" className="absolute right-4 z-10" />
<Icon
name="chevron-down"
className="absolute top-3 right-4 z-10 pointer-events-none"
/>
</div>
)
);
@@ -1,8 +1,19 @@
import classNames from 'classnames';
import Highlighter from 'react-syntax-highlighter';
export const SyntaxHighlighter = ({ data }: { data: unknown }) => {
export const SyntaxHighlighter = ({
data,
size = 'default',
}: {
data: unknown;
size?: 'smaller' | 'default';
}) => {
return (
<div className="syntax-highlighter-wrapper">
<div
className={classNames('syntax-highlighter-wrapper', {
'syntax-highlighter-wrapper-sm': size === 'smaller',
})}
>
<Highlighter language="json" useInlineStyles={false}>
{JSON.stringify(data, null, ' ')}
</Highlighter>
@@ -1,21 +1,17 @@
.initial {
top: 20px;
opacity: 0;
max-height: 0;
border: 0;
transition: all 0.3s;
margin-bottom: 0;
}
.showing {
right: 0;
opacity: 1;
transition: all 0.3s;
max-height: 100vw;
}
.expired {
right: -375px;
opacity: 0;
transition: all 0.5s;
max-height: 0;
transition: all 0.75s;
}
@@ -1,7 +1,9 @@
/* eslint-disable jsx-a11y/accessible-emoji */
import { Toast } from './toast';
import { Panel, Toast, ToastHeading } from './toast';
import type { ComponentStory, ComponentMeta } from '@storybook/react';
import { Intent } from '../../utils/intent';
import { ExternalLink } from '../link';
import { ProgressBar } from '../progress-bar';
export default {
title: 'Toast',
@@ -9,14 +11,7 @@ export default {
} as ComponentMeta<typeof Toast>;
const Template: ComponentStory<typeof Toast> = (args) => {
const toastContent = (
<>
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit.</p>
<p>Eaque exercitationem saepe cupiditate sunt impedit.</p>
<p>I really like 🥪🥪🥪!</p>
</>
);
return <Toast {...args} content={toastContent} />;
return <Toast {...args} />;
};
export const Default = Template.bind({});
@@ -24,6 +19,16 @@ Default.args = {
id: 'def',
intent: Intent.None,
state: 'showing',
content: (
<>
<ToastHeading>Optional heading</ToastHeading>
<p>This is a message that can return over multiple lines.</p>
<p>
<ExternalLink>Optional link</ExternalLink>
</p>
</>
),
onClose: () => undefined,
};
export const Primary = Template.bind({});
@@ -31,6 +36,17 @@ Primary.args = {
id: 'pri',
intent: Intent.Primary,
state: 'showing',
content: (
<>
<ToastHeading>Optional heading</ToastHeading>
<p>This is a message that can return over multiple lines.</p>
<p>
<ExternalLink>Optional link</ExternalLink>
</p>
<Panel>Lorem ipsum dolor sit amet consectetur adipisicing elit</Panel>
</>
),
onClose: () => undefined,
};
export const Danger = Template.bind({});
@@ -38,6 +54,17 @@ Danger.args = {
id: 'dan',
intent: Intent.Danger,
state: 'showing',
content: (
<>
<ToastHeading>Optional heading</ToastHeading>
<p>This is a message that can return over multiple lines.</p>
<p>
<ExternalLink>Optional link</ExternalLink>
</p>
<Panel>Lorem ipsum dolor sit amet consectetur adipisicing elit</Panel>
</>
),
onClose: () => undefined,
};
export const Warning = Template.bind({});
@@ -45,6 +72,21 @@ Warning.args = {
id: 'war',
intent: Intent.Warning,
state: 'showing',
content: (
<>
<ToastHeading>Optional heading</ToastHeading>
<p>This is a message that can return over multiple lines.</p>
<p>
<ExternalLink>Optional link</ExternalLink>
</p>
<Panel>
<strong>Deposit 10.00 tUSDX</strong>
<p className="mt-[2px]">Awaiting confirmations (1/3)</p>
<ProgressBar value={33.33} />
</Panel>
</>
),
onClose: () => undefined,
};
export const Success = Template.bind({});
@@ -52,4 +94,15 @@ Success.args = {
id: 'suc',
intent: Intent.Success,
state: 'showing',
content: (
<>
<ToastHeading>Optional heading</ToastHeading>
<p>This is a message that can return over multiple lines.</p>
<p>
<ExternalLink>Optional link</ExternalLink>
</p>
<Panel>Lorem ipsum dolor sit amet consectetur adipisicing elit</Panel>
</>
),
onClose: () => undefined,
};
+175 -26
View File
@@ -3,7 +3,8 @@ import styles from './toast.module.css';
import type { IconName } from '@blueprintjs/icons';
import { IconNames } from '@blueprintjs/icons';
import classNames from 'classnames';
import { useEffect } from 'react';
import type { HTMLAttributes, HtmlHTMLAttributes } from 'react';
import { forwardRef, useEffect } from 'react';
import { useCallback } from 'react';
import { useLayoutEffect } from 'react';
import { useRef } from 'react';
@@ -33,20 +34,43 @@ const toastIconMapping: { [i in Intent]: IconName } = {
[Intent.None]: IconNames.HELP,
[Intent.Primary]: IconNames.INFO_SIGN,
[Intent.Success]: IconNames.TICK_CIRCLE,
[Intent.Warning]: IconNames.ERROR,
[Intent.Warning]: IconNames.WARNING_SIGN,
[Intent.Danger]: IconNames.ERROR,
};
const getToastAccent = (intent: Intent) => ({
// strip
'bg-gray-200 text-black text-opacity-70': intent === Intent.None,
'bg-vega-blue text-white text-opacity-70': intent === Intent.Primary,
'bg-success text-white text-opacity-70': intent === Intent.Success,
'bg-warning text-white text-opacity-70': intent === Intent.Warning,
'bg-vega-pink text-white text-opacity-70': intent === Intent.Danger,
});
export const CLOSE_DELAY = 500;
export const TICKER = 100;
export const CLOSE_AFTER = 5000;
export const CLOSE_DELAY = 750;
export const Panel = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ children, className, ...props }, ref) => {
return (
<div
data-panel
ref={ref}
data-testid="toast-panel"
className={classNames(
'p-2 rounded mt-[10px]',
'font-mono text-[12px] leading-[16px] font-normal',
'[&>h4]:font-bold',
className
)}
{...props}
>
{children}
</div>
);
}
);
export const ToastHeading = forwardRef<
HTMLHeadingElement,
HtmlHTMLAttributes<HTMLHeadingElement>
>(({ children, ...props }, ref) => (
<h3 ref={ref} className="text-sm uppercase mb-1" {...props}>
{children}
</h3>
));
export const Toast = ({
id,
@@ -59,6 +83,9 @@ export const Toast = ({
loader = false,
}: ToastProps) => {
const toastRef = useRef<HTMLDivElement>(null);
const progressRef = useRef<HTMLDivElement>(null);
const ticker = useRef<number>(0);
const lock = useRef<boolean>(false);
const closeToast = useCallback(() => {
requestAnimationFrame(() => {
@@ -80,16 +107,21 @@ export const Toast = ({
}
});
return () => cancelAnimationFrame(req);
}, [id]);
}, [id, intent, content]); // DO NOT REMOVE DEPS: intent, content
useEffect(() => {
let t: NodeJS.Timeout;
if (closeAfter && closeAfter > 0) {
t = setTimeout(() => {
const i = setInterval(() => {
if (!closeAfter || closeAfter === 0) return;
if (!lock.current) {
ticker.current += 100;
}
if (ticker.current >= closeAfter) {
closeToast();
}, closeAfter);
}
return () => clearTimeout(t);
}
}, 100);
return () => {
clearInterval(i);
};
}, [closeAfter, closeToast]);
useEffect(() => {
@@ -98,14 +130,76 @@ export const Toast = ({
}
}, [closeToast, signal]);
const withProgress = Boolean(closeAfter && closeAfter > 0);
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<div
data-testid="toast"
data-toast-id={id}
ref={toastRef}
role="dialog"
onMouseLeave={() => {
lock.current = false;
if (progressRef.current) {
progressRef.current.style.animationPlayState = 'running';
}
}}
onMouseEnter={() => {
lock.current = true;
if (progressRef.current) {
progressRef.current.style.animationPlayState = 'paused';
}
}}
className={classNames(
'relative w-[300px] top-0 rounded-md border overflow-hidden mb-2',
'text-black bg-white dark:border-zinc-700',
'w-[320px] rounded-md overflow-hidden',
'shadow-[8px_8px_16px_0_rgba(0,0,0,0.4)]',
'text-black dark:text-white',
'font-alpha liga-0-calt-0 text-[14px] leading-[19px]',
// background
{
'bg-vega-light-100 dark:bg-vega-dark-100 ': intent === Intent.None,
'bg-vega-blue-300 dark:bg-vega-blue-700': intent === Intent.Primary,
'bg-vega-green-300 dark:bg-vega-green-700': intent === Intent.Success,
'bg-vega-orange-300 dark:bg-vega-orange-700':
intent === Intent.Warning,
'bg-vega-pink-300 dark:bg-vega-pink-700': intent === Intent.Danger,
},
// panel's colours
{
'[&_[data-panel]]:bg-vega-light-150 [&_[data-panel]]:dark:bg-vega-dark-150 ':
intent === Intent.None,
'[&_[data-panel]]:bg-vega-blue-350 [&_[data-panel]]:dark:bg-vega-blue-650':
intent === Intent.Primary,
'[&_[data-panel]]:bg-vega-green-350 [&_[data-panel]]:dark:bg-vega-green-650':
intent === Intent.Success,
'[&_[data-panel]]:bg-vega-orange-350 [&_[data-panel]]:dark:bg-vega-orange-650':
intent === Intent.Warning,
'[&_[data-panel]]:bg-vega-pink-350 [&_[data-panel]]:dark:bg-vega-pink-650':
intent === Intent.Danger,
},
// panels's progress bar colours
'[&_[data-progress-bar]]:mt-[10px] [&_[data-progress-bar]]:mb-[4px]',
{
'[&_[data-progress-bar]]:bg-vega-light-200 [&_[data-progress-bar]]:dark:bg-vega-dark-200 ':
intent === Intent.None,
'[&_[data-progress-bar]]:bg-vega-blue-400 [&_[data-progress-bar]]:dark:bg-vega-blue-600':
intent === Intent.Primary,
'[&_[data-progress-bar-value]]:bg-vega-blue-500 [&_[data-progress-bar-value]]:dark:bg-vega-blue-500':
intent === Intent.Primary,
'[&_[data-progress-bar]]:bg-vega-green-400 [&_[data-progress-bar]]:dark:bg-vega-green-600':
intent === Intent.Success,
'[&_[data-progress-bar-value]]:bg-vega-green-600 [&_[data-progress-bar-value]]:dark:bg-vega-green-500':
intent === Intent.Success,
'[&_[data-progress-bar]]:bg-vega-orange-400 [&_[data-progress-bar]]:dark:bg-vega-orange-600':
intent === Intent.Warning,
'[&_[data-progress-bar-value]]:bg-vega-orange-500 [&_[data-progress-bar-value]]:dark:bg-vega-orange-500':
intent === Intent.Warning,
'[&_[data-progress-bar]]:bg-vega-pink-400 [&_[data-progress-bar]]:dark:bg-vega-pink-600':
intent === Intent.Danger,
'[&_[data-progress-bar-value]]:bg-vega-pink-500 [&_[data-progress-bar-value]]:dark:bg-vega-pink-500':
intent === Intent.Danger,
},
{
[styles['initial']]: state === 'initial',
[styles['showing']]: state === 'showing',
@@ -118,26 +212,81 @@ export const Toast = ({
type="button"
data-testid="toast-close"
onClick={closeToast}
className="absolute p-2 top-0 right-0"
className="absolute p-[8px] top-[3px] right-[3px] z-20"
>
<Icon name="cross" size={3} className="!block dark:text-white" />
<Icon
name="cross"
size={3}
className="!block dark:text-white !w-[11px] !h-[11px]"
/>
</button>
<div
className={classNames(getToastAccent(intent), 'p-2 pt-3 text-center')}
data-testid="toast-accent"
className={classNames(
{
// gray
'bg-vega-light-200 dark:bg-vega-dark-200 text-vega-light-400 dark:text-vega-dark-100':
intent === Intent.None,
// blue
'bg-vega-blue-500 text-vega-blue-600': intent === Intent.Primary,
// green
'bg-vega-green-500 text-vega-green-600':
intent === Intent.Success,
// orange
'bg-vega-orange-500 text-vega-orange-600':
intent === Intent.Warning,
// pink
'bg-vega-pink-500 text-vega-pink-600': intent === Intent.Danger,
},
'w-8 p-[9px]',
'flex justify-center'
)}
>
{loader ? (
<div className="w-4 h-4">
<div className="w-[15px] h-[15px]">
<Loader size="small" forceTheme="dark" />
</div>
) : (
<Icon name={toastIconMapping[intent]} size={4} className="!block" />
<Icon
name={toastIconMapping[intent]}
size={4}
className="!block !w-[14px] !h-[14px]"
/>
)}
</div>
<div
className="flex-1 p-2 pr-6 text-sm overflow-auto dark:bg-black dark:text-white"
className={classNames(
'relative',
'overflow-auto flex-1 p-4 pr-[40px] [&>p]:mb-[2.5px]'
)}
data-testid="toast-content"
>
{content}
{withProgress && (
<div
ref={progressRef}
data-testid="toast-progress-bar"
className={classNames(
{
'bg-vega-light-200 dark:bg-vega-dark-200 ':
intent === Intent.None,
'bg-vega-blue-400 dark:bg-vega-blue-600':
intent === Intent.Primary,
'bg-vega-green-400 dark:bg-vega-green-600':
intent === Intent.Success,
'bg-vega-orange-400 dark:bg-vega-orange-600':
intent === Intent.Warning,
'bg-vega-pink-400 dark:bg-vega-pink-600':
intent === Intent.Danger,
},
'absolute bottom-0 left-0 w-full h-[4px]',
'animate-progress'
)}
style={{
animationDuration: `${closeAfter}ms`,
}}
></div>
)}
</div>
</div>
</div>
@@ -1,5 +1,5 @@
import { act, render, renderHook, screen } from '@testing-library/react';
import { ToastsContainer, useToasts } from '..';
import { CLOSE_DELAY, ToastsContainer, useToasts } from '..';
import { Intent } from '../../utils/intent';
describe('ToastsContainer', () => {
@@ -108,7 +108,7 @@ describe('ToastsContainer', () => {
) as HTMLButtonElement;
act(() => {
closeBtn.click();
jest.runAllTimers();
jest.advanceTimersByTime(CLOSE_DELAY);
});
rerender(<ToastsContainer order="asc" toasts={result.current.toasts} />);
const toasts = [...screen.queryAllByTestId('toast-content')].map((t) =>
@@ -2,6 +2,7 @@
import type { ComponentStory, ComponentMeta } from '@storybook/react';
import { Intent } from '../../utils/intent';
import type { Toast } from './toast';
import { ToastHeading } from './toast';
import { ToastsContainer } from './toasts-container';
import random from 'lodash/random';
import sample from 'lodash/sample';
@@ -59,7 +60,8 @@ const randomWords = [
];
const randomToast = (): Toast => {
const content = sample(contents);
const now = new Date().toISOString();
const content = now + ' ' + sample(contents);
return {
id: String(uniqueId('toast_')),
intent: sample<Intent>([
@@ -83,7 +85,7 @@ const usePrice = create<PriceStore>((set) => ({
const Template: ComponentStory<typeof ToastsContainer> = (args) => {
const setPrice = usePrice((state) => state.setPrice);
const { add, close, closeAll, update, remove, toasts } = useToasts(
const { add, close, closeAll, update, remove, toasts, setToast } = useToasts(
(state) => ({
add: state.add,
close: state.close,
@@ -91,6 +93,7 @@ const Template: ComponentStory<typeof ToastsContainer> = (args) => {
update: state.update,
remove: state.remove,
toasts: state.toasts,
setToast: state.setToast,
})
);
@@ -195,6 +198,26 @@ const Template: ComponentStory<typeof ToastsContainer> = (args) => {
>
🧽
</button>
<button
onClick={() => {
const toasts = Object.values(useToasts.getState().toasts);
if (toasts.length > 0) {
const t = toasts[toasts.length - 1];
setToast({
...t,
intent: Intent.Danger,
content: (
<>
<ToastHeading>Error occurred</ToastHeading>
<p>Something went terribly wrong</p>
</>
),
});
}
}}
>
Set first as Error
</button>
<ToastsContainer {...args} toasts={toasts} />
</div>
);
@@ -202,5 +225,5 @@ const Template: ComponentStory<typeof ToastsContainer> = (args) => {
export const Default = Template.bind({});
Default.args = {
order: 'asc',
order: 'desc',
};
@@ -1,8 +1,16 @@
import { t, usePrevious } from '@vegaprotocol/react-helpers';
import classNames from 'classnames';
import type { Ref } from 'react';
import { useLayoutEffect, useRef } from 'react';
import { Button } from '../button';
import { Toast } from './toast';
import type { Toasts } from './use-toasts';
import { useToasts } from './use-toasts';
import { Portal } from '@radix-ui/react-portal';
type ToastsContainerProps = {
toasts: Toast[];
toasts: Toasts;
order: 'asc' | 'desc';
};
@@ -10,23 +18,75 @@ export const ToastsContainer = ({
toasts,
order = 'asc',
}: ToastsContainerProps) => {
const ref = useRef<HTMLDivElement>();
const closeAll = useToasts((store) => store.closeAll);
// Scroll to top for desc, bottom for asc when a toast is added.
const count = usePrevious(Object.keys(toasts).length) || 0;
useLayoutEffect(() => {
const t = setTimeout(
() => {
if (Object.keys(toasts).length > count) {
ref.current?.scrollTo({
top: order === 'desc' ? 0 : ref.current.scrollHeight,
behavior: 'smooth',
});
}
},
300 // need to delay scroll down in order for the toast to appear
);
return () => {
clearTimeout(t);
};
}, [count, order, toasts]);
return (
<ul
<Portal
ref={ref as Ref<HTMLDivElement>}
className={classNames(
'absolute top-0 right-0 pt-2 pr-2 max-w-full z-20 max-h-full overflow-x-hidden overflow-y-auto',
'group',
'absolute bottom-0 right-0 z-20 ',
'p-[8px_16px_16px_16px]',
'max-w-full max-h-full overflow-x-hidden overflow-y-auto',
{
'flex flex-col-reverse': order === 'desc',
hidden: Object.keys(toasts).length === 0,
}
)}
>
{toasts &&
toasts.map((toast) => {
return (
<li key={toast.id}>
<Toast {...toast} />
</li>
);
<ul
className={classNames('relative mt-[38px]', 'flex flex-col gap-[8px]', {
'flex-col-reverse': order === 'desc',
})}
</ul>
>
{toasts &&
Object.values(toasts).map((toast) => {
return (
<li key={toast.id}>
<Toast {...toast} />
</li>
);
})}
<Button
title={t('Dismiss all toasts')}
size="sm"
fill={true}
className={classNames(
'absolute top-[-38px] right-0 z-20',
'transition-opacity',
'opacity-0 group-hover:opacity-50 hover:!opacity-100',
'text-sm text-black dark:text-white bg-white dark:bg-black hover:!bg-white hover:dark:!bg-black',
{
hidden: Object.keys(toasts).length === 0,
}
)}
onClick={() => {
closeAll();
}}
variant={'default'}
>
{t('Dismiss all')}
</Button>
</ul>
</Portal>
);
};
@@ -0,0 +1,127 @@
import { renderHook } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { Intent } from '../../utils/intent';
import type { Toast } from './toast';
import { useToasts } from './use-toasts';
const T1: Toast = {
id: 'TEST-1',
intent: Intent.None,
content: undefined,
};
const T2: Toast = {
id: 'TEST-2',
intent: Intent.None,
content: undefined,
};
const T3: Toast = {
id: 'TEST-3',
intent: Intent.None,
content: undefined,
};
const INITIAL = useToasts.getState();
describe('useToasts', () => {
beforeEach(() => {
useToasts.setState(INITIAL, true);
});
afterAll(() => {
useToasts.setState(INITIAL, true);
});
it('adds toast', () => {
const { result } = renderHook(() => useToasts());
act(() => {
result.current.add(T1);
});
expect(result.current.toasts[T1.id]).toEqual(T1);
expect(result.current.count).toEqual(1);
});
it('removes toast', () => {
const { result } = renderHook(() => useToasts());
act(() => {
result.current.add(T1);
result.current.add(T2);
result.current.add(T3);
result.current.remove(T1.id);
result.current.remove(T1.id);
result.current.remove(T1.id);
});
expect(result.current.toasts[T1.id]).toBeUndefined();
expect(result.current.count).toEqual(2);
});
it('updates toast', () => {
const { result } = renderHook(() => useToasts());
const data = { content: <p>Burning hot toast</p> };
act(() => {
result.current.add(T1);
result.current.add(T2);
result.current.add(T3);
result.current.update(T2.id, data);
});
expect(result.current.toasts[T2.id]).toHaveProperty(
'content',
data.content
);
expect(result.current.count).toEqual(3);
});
it('removes all toasts', () => {
const { result } = renderHook(() => useToasts());
act(() => {
result.current.add(T1);
result.current.add(T2);
result.current.add(T3);
result.current.removeAll();
});
expect(result.current.toasts).toEqual({});
expect(result.current.count).toEqual(0);
});
it('sends close signal to toast', () => {
const { result } = renderHook(() => useToasts());
act(() => {
result.current.add(T1);
result.current.add(T2);
result.current.add(T3);
result.current.close(T2.id);
});
expect(result.current.toasts[T2.id]).toHaveProperty('signal', 'close');
});
it('sends close signal to all toasts', () => {
const { result } = renderHook(() => useToasts());
act(() => {
result.current.add(T1);
result.current.add(T2);
result.current.add(T3);
result.current.closeAll();
});
Object.values(result.current.toasts).forEach((t) => {
expect(t).toHaveProperty('signal', 'close');
});
});
it('sets toast (adds or update if exists)', () => {
const { result } = renderHook(() => useToasts());
const data = { content: <p>Burning hot toast</p> };
act(() => {
result.current.setToast(T1);
result.current.setToast(T1);
result.current.setToast(T2);
result.current.setToast(T3);
result.current.setToast(T3);
result.current.setToast({ ...T3, ...data });
});
expect(result.current.toasts[T3.id]).toHaveProperty(
'content',
data.content
);
expect(result.current.count).toEqual(3);
});
});
@@ -1,11 +1,20 @@
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import type { Toast } from './toast';
import omit from 'lodash/omit';
import isEqual from 'lodash/isEqual';
type ToastsStore = {
/**
* A list of active toasts
*/
toasts: Toast[];
export type Toasts = Record<string, Toast>;
const isUpdateable = (a: Toast, b: Toast) =>
isEqual(omit(a, 'onClose'), omit(b, 'onClose'));
type State = {
toasts: Toasts;
count: number;
};
type Actions = {
/**
* Adds/displays a new toast
*/
@@ -36,44 +45,54 @@ type ToastsStore = {
removeAll: () => void;
};
const add =
(toast: Toast) =>
(store: ToastsStore): Partial<ToastsStore> => ({
toasts: [...store.toasts, toast],
});
type ToastsStore = State & Actions;
const update =
(id: string, toastData: Partial<Toast>) =>
(store: ToastsStore): Partial<ToastsStore> => {
const toasts = [...store.toasts];
const toastIdx = toasts.findIndex((t) => t.id === id);
if (toastIdx > -1) toasts[toastIdx] = { ...toasts[toastIdx], ...toastData };
return { toasts };
};
export const useToasts = create<ToastsStore>((set) => ({
toasts: [],
add: (toast) => set(add(toast)),
update: (id, toastData) => set(update(id, toastData)),
setToast: (toast: Toast) =>
set((store) => {
if (store.toasts.find((t) => t.id === toast.id)) {
return update(toast.id, toast)(store);
} else {
return add(toast)(store);
}
}),
close: (id) => set(update(id, { signal: 'close' })),
closeAll: () =>
set((store) => ({
toasts: [...store.toasts].map((t) => ({ ...t, signal: 'close' })),
})),
remove: (id) =>
set((store) => ({
toasts: [...store.toasts].filter((t) => t.id !== id),
})),
removeAll: () =>
set(() => ({
toasts: [],
})),
}));
export const useToasts = create(
immer<ToastsStore>((set, get) => ({
toasts: {},
count: 0,
add: (toast) =>
set((state) => {
state.toasts[toast.id] = toast;
++state.count;
}),
update: (id, toastData) =>
set((state) => {
const found = state.toasts[id];
if (found) {
Object.assign(found, toastData);
}
}),
setToast: (toast: Toast) =>
set((state) => {
const found = state.toasts[toast.id];
if (found) {
if (!isUpdateable(found, toast)) {
Object.assign(found, toast);
}
} else {
state.toasts[toast.id] = toast;
++state.count;
}
}),
close: (id) =>
set((state) => {
const found = state.toasts[id];
if (found) {
found.signal = 'close';
}
}),
closeAll: () =>
set((state) => {
Object.values(state.toasts).forEach((t) => (t.signal = 'close'));
}),
remove: (id) =>
set((state) => {
if (state.toasts[id]) {
delete state.toasts[id];
--state.count;
}
}),
removeAll: () => set({ toasts: {}, count: 0 }),
}))
);
+1
View File
@@ -18,6 +18,7 @@ export const getIntentBorder = (intent = Intent.None) => {
export const getIntentBackground = (intent?: Intent) => {
return {
'bg-neutral-200 dark:bg-neutral-800': intent === undefined,
'bg-black dark:bg-white': intent === Intent.None,
'bg-vega-pink dark:bg-vega-yellow': intent === Intent.Primary,
'bg-danger': intent === Intent.Danger,
+40 -1
View File
@@ -292,6 +292,40 @@ export interface BatchMarketInstructionSubmissionBody {
};
}
interface TransferBase {
fromAccountType: Schema.AccountType;
to: string;
toAccountType: Schema.AccountType;
asset: string;
amount: string;
reference?: string;
}
export interface OneOffTransfer extends TransferBase {
oneOff: {
deliverOn?: number; // omit for immediate
};
}
export interface RecurringTransfer extends TransferBase {
recurring: {
factor: string;
startEpoch: number;
endEpoch?: number;
dispatchStrategy?: {
assetForMetric: string;
metric: Schema.DispatchMetric;
markets?: string[];
};
};
}
export type Transfer = OneOffTransfer | RecurringTransfer;
export interface TransferBody {
transfer: Transfer;
}
export type Transaction =
| OrderSubmissionBody
| OrderCancellationBody
@@ -301,7 +335,8 @@ export type Transaction =
| UndelegateSubmissionBody
| OrderAmendmentBody
| ProposalSubmissionBody
| BatchMarketInstructionSubmissionBody;
| BatchMarketInstructionSubmissionBody
| TransferBody;
export const isWithdrawTransaction = (
transaction: Transaction
@@ -324,6 +359,10 @@ export const isBatchMarketInstructionsTransaction = (
): transaction is BatchMarketInstructionSubmissionBody =>
'batchMarketInstructions' in transaction;
export const isTransferTransaction = (
transaction: Transaction
): transaction is TransferBody => 'transfer' in transaction;
export interface TransactionResponse {
transactionHash: string;
signature: string; // still to be added by core
@@ -13,10 +13,12 @@ const mockSendTx = jest.fn<Promise<Partial<TransactionResponse> | null>, []>();
const pubKey = 'pubKey';
const mockDisconnect = jest.fn();
jest.mock('./use-vega-wallet', () => ({
useVegaWallet: () => ({
sendTx: mockSendTx,
pubKey,
disconnect: mockDisconnect,
}),
}));
@@ -104,6 +106,17 @@ describe('useVegaTransactionManager', () => {
mockSendTx.mockRejectedValue(null);
renderHook(useVegaTransactionManager);
await waitForNextTick();
expect(mockDisconnect).not.toHaveBeenCalledWith();
expect(update).toBeCalled();
expect(update.mock.calls[0][1]?.status).toEqual(VegaTxStatus.Error);
});
it('call disconnect if detect no service error', async () => {
mockTransactionStoreState.mockReturnValue(defaultState);
mockSendTx.mockRejectedValue(new TypeError('Failed to fetch'));
renderHook(useVegaTransactionManager);
await waitForNextTick();
expect(mockDisconnect).toHaveBeenCalledWith();
expect(update).toBeCalled();
expect(update.mock.calls[0][1]?.status).toEqual(VegaTxStatus.Error);
});
@@ -1,12 +1,12 @@
import { useVegaWallet } from './use-vega-wallet';
import { useEffect, useRef } from 'react';
import type { WalletError } from './connectors';
import { ClientErrors } from './connectors';
import { VegaTxStatus } from './use-vega-transaction';
import { VegaTxStatus, orderErrorResolve } from './use-vega-transaction';
import { useVegaTransactionStore } from './use-vega-transaction-store';
import { WalletClientError } from '@vegaprotocol/wallet-client';
export const useVegaTransactionManager = () => {
const { sendTx, pubKey } = useVegaWallet();
const { sendTx, pubKey, disconnect } = useVegaWallet();
const processed = useRef<Set<number>>(new Set());
const transaction = useVegaTransactionStore((state) =>
state.transactions.find(
@@ -38,10 +38,14 @@ export const useVegaTransactionManager = () => {
}
})
.catch((err) => {
const error = orderErrorResolve(err);
if ((error as WalletError).code === ClientErrors.NO_SERVICE.code) {
disconnect();
}
update(transaction.id, {
error: err instanceof WalletClientError ? err : ClientErrors.UNKNOWN,
error,
status: VegaTxStatus.Error,
});
});
}, [transaction, pubKey, del, sendTx, update]);
}, [transaction, pubKey, del, sendTx, update, disconnect]);
};
+12 -5
View File
@@ -6,6 +6,7 @@ import {
isOrderCancellationTransaction,
isOrderAmendmentTransaction,
isBatchMarketInstructionsTransaction,
isTransferTransaction,
} from './connectors';
import { determineId } from './utils';
@@ -19,6 +20,7 @@ import type {
} from './__generated__/TransactionResult';
import type { WithdrawalApprovalQuery } from './__generated__/WithdrawalApproval';
import { subscribeWithSelector } from 'zustand/middleware';
export interface VegaStoredTxState extends VegaTxState {
id: number;
createdAt: Date;
@@ -50,8 +52,8 @@ export interface VegaTransactionStore {
) => void;
}
export const useVegaTransactionStore = create<VegaTransactionStore>(
(set, get) => ({
export const useVegaTransactionStore = create(
subscribeWithSelector<VegaTransactionStore>((set, get) => ({
transactions: [] as VegaStoredTxState[],
create: (body: Transaction) => {
const transactions = get().transactions;
@@ -184,9 +186,14 @@ export const useVegaTransactionStore = create<VegaTransactionStore>(
);
if (transaction) {
transaction.transactionResult = transactionResult;
if (
const isConfirmedOrderCancellation =
isOrderCancellationTransaction(transaction.body) &&
!transaction.body.orderCancellation.orderId &&
!transaction.body.orderCancellation.orderId;
const isConfirmedTransfer = isTransferTransaction(transaction.body);
if (
(isConfirmedOrderCancellation || isConfirmedTransfer) &&
!transactionResult.error &&
transactionResult.status
) {
@@ -198,5 +205,5 @@ export const useVegaTransactionStore = create<VegaTransactionStore>(
})
);
},
})
}))
);
@@ -6,17 +6,18 @@ import {
useTransactionEventSubscription,
} from './__generated__/TransactionResult';
import { useVegaTransactionStore } from './use-vega-transaction-store';
import { waitForWithdrawalApproval } from './wait-for-withdrawal-approval';
export const useVegaTransactionUpdater = () => {
const client = useApolloClient();
const { updateWithdrawal, updateOrder, updateTransaction } =
useVegaTransactionStore((state) => ({
updateWithdrawal: state.updateWithdrawal,
updateOrder: state.updateOrder,
updateTransaction: state.updateTransactionResult,
}));
const updateWithdrawal = useVegaTransactionStore(
(state) => state.updateWithdrawal
);
const updateOrder = useVegaTransactionStore((state) => state.updateOrder);
const updateTransaction = useVegaTransactionStore(
(state) => state.updateTransactionResult
);
const { pubKey } = useVegaWallet();
const variables = { partyId: pubKey || '' };
const skip = !pubKey;
@@ -87,7 +87,7 @@ describe('useVegaTransaction', () => {
);
});
it('handles an unkwown error', () => {
it('handles an unknown error', () => {
const unknownThrow = { foo: 'bar' };
const mockSendTx = jest.fn(() => {
throw unknownThrow;
+24 -3
View File
@@ -1,10 +1,15 @@
import type { ReactNode } from 'react';
import { useCallback, useMemo, useState } from 'react';
import {
WalletClientError,
WalletHttpError,
} from '@vegaprotocol/wallet-client';
import { useVegaWallet } from './use-vega-wallet';
import type { VegaTransactionContentMap } from './vega-transaction-dialog';
import { VegaTransactionDialog } from './vega-transaction-dialog';
import type { Intent } from '@vegaprotocol/ui-toolkit';
import type { Transaction } from './connectors';
import type { WalletError } from './connectors';
import { ClientErrors } from './connectors';
export interface DialogProps {
@@ -38,8 +43,21 @@ export const initialState = {
dialogOpen: false,
};
export const orderErrorResolve = (err: Error | unknown): Error => {
if (err instanceof WalletClientError) {
return err;
} else if (err instanceof WalletHttpError) {
return ClientErrors.UNKNOWN;
} else if (err instanceof TypeError) {
return ClientErrors.NO_SERVICE;
} else if (err instanceof Error) {
return err;
}
return ClientErrors.UNKNOWN;
};
export const useVegaTransaction = () => {
const { sendTx } = useVegaWallet();
const { sendTx, disconnect } = useVegaWallet();
const [transaction, _setTransaction] = useState<VegaTxState>(initialState);
const setTransaction = useCallback((update: Partial<VegaTxState>) => {
@@ -88,7 +106,10 @@ export const useVegaTransaction = () => {
return null;
} catch (err) {
const error = err instanceof Error ? err : ClientErrors.UNKNOWN;
const error = orderErrorResolve(err);
if ((error as WalletError).code === ClientErrors.NO_SERVICE.code) {
disconnect();
}
setTransaction({
error,
status: VegaTxStatus.Error,
@@ -96,7 +117,7 @@ export const useVegaTransaction = () => {
return null;
}
},
[sendTx, setTransaction, reset]
[sendTx, setTransaction, reset, disconnect]
);
const Dialog = useMemo(() => {
+22 -1
View File
@@ -1,6 +1,6 @@
import { removeDecimal, toNanoSeconds } from '@vegaprotocol/react-helpers';
import type { Market, Order } from '@vegaprotocol/types';
import { OrderTimeInForce, OrderType } from '@vegaprotocol/types';
import { OrderTimeInForce, OrderType, AccountType } from '@vegaprotocol/types';
import BigNumber from 'bignumber.js';
import { ethers } from 'ethers';
import { sha3_256 } from 'js-sha3';
@@ -8,6 +8,7 @@ import type {
OrderAmendmentBody,
OrderSubmissionBody,
Transaction,
Transfer,
} from './connectors';
/**
@@ -63,3 +64,23 @@ export const normalizeOrderAmendment = (
? toNanoSeconds(order.expiresAt) // Wallet expects timestamp in nanoseconds
: undefined,
});
export const normalizeTransfer = (
address: string,
amount: string,
asset: {
id: string;
decimals: number;
}
): Transfer => {
return {
fromAccountType: AccountType.ACCOUNT_TYPE_GENERAL,
to: address,
toAccountType: AccountType.ACCOUNT_TYPE_GENERAL,
asset: asset.id,
amount: removeDecimal(amount, asset.decimals),
// oneOff or recurring required otherwise wallet will error
// default oneOff is immediate transfer
oneOff: {},
};
};
@@ -9,6 +9,7 @@ import type { DepositBusEventFieldsFragment } from '@vegaprotocol/wallet';
import type { EthTxState } from './use-ethereum-transaction';
import { EthTxStatus } from './use-ethereum-transaction';
import { subscribeWithSelector } from 'zustand/middleware';
type Contract = MultisigControl | CollateralBridge | Token | TokenFaucetable;
type ContractMethod =
@@ -54,8 +55,8 @@ export interface EthTransactionStore {
delete: (index: number) => void;
}
export const useEthTransactionStore = create<EthTransactionStore>(
(set, get) => ({
export const useEthTransactionStore = create(
subscribeWithSelector<EthTransactionStore>((set, get) => ({
transactions: [] as EthStoredTxState[],
create: (
contract: Contract | null,
@@ -139,5 +140,5 @@ export const useEthTransactionStore = create<EthTransactionStore>(
})
);
},
})
}))
);
@@ -103,7 +103,7 @@ export const useEthWithdrawApprovalsManager = () => {
update(transaction.id, {
status: ApprovalStatus.Ready,
approval,
dialogOpen: false,
dialogOpen: true,
});
const signer = provider.getSigner();
createEthTransaction(
@@ -5,6 +5,7 @@ import type { WithdrawalBusEventFieldsFragment } from '@vegaprotocol/wallet';
import { useVegaTransactionStore } from '@vegaprotocol/wallet';
import type { WithdrawalApprovalQuery } from '@vegaprotocol/wallet';
import { subscribeWithSelector } from 'zustand/middleware';
export enum ApprovalStatus {
Idle = 'Idle',
@@ -45,10 +46,11 @@ export interface EthWithdrawApprovalStore {
>
) => void;
dismiss: (index: number) => void;
delete: (index: number) => void;
}
export const useEthWithdrawApprovalsStore = create<EthWithdrawApprovalStore>(
(set, get) => ({
export const useEthWithdrawApprovalsStore = create(
subscribeWithSelector<EthWithdrawApprovalStore>((set, get) => ({
transactions: [] as EthWithdrawalApprovalState[],
create: (
withdrawal: EthWithdrawalApprovalState['withdrawal'],
@@ -107,5 +109,12 @@ export const useEthWithdrawApprovalsStore = create<EthWithdrawApprovalStore>(
})
);
},
})
delete: (index: number) => {
set(
produce((state: EthWithdrawApprovalStore) => {
delete state.transactions[index];
})
);
},
}))
);
+5 -3
View File
@@ -229,13 +229,15 @@ export const VerificationStatus = ({ state }: { state: VerifyState }) => {
);
return (
<>
<p className="mb-2">
{t("The amount you're withdrawing has triggered a time delay")}
</p>
<p>{t("The amount you're withdrawing has triggered a time delay")}</p>
<p>{t(`Cannot be completed until ${formattedTime}`)}</p>
</>
);
}
if (state.status === ApprovalStatus.Ready) {
return <p>{t('The withdrawal has been approved.')}</p>;
}
return null;
};