Compare commits

...
Author SHA1 Message Date
Matthew Russell adcb63019a fix: flakely test had welcome dialog blocking trading interface 2023-02-15 19:19:45 -08:00
Matthew Russell b59e01c71a chore: rename wrapper classes 2023-02-15 13:26:56 -08:00
Matthew Russell c2ef40ba64 chore: add newlines to reduce diff 2023-02-15 13:22:03 -08:00
Matthew Russell 26658e56d8 chore: update mock 2023-02-15 11:48:50 -08:00
Matthew Russell 337c558806 chore: add fallbacks for etherscan url and ethereum provider 2023-02-15 11:48:50 -08:00
Matthew Russell 97c324c790 fix: env var name for token url 2023-02-15 11:48:50 -08:00
Matthew Russell 8d6ea15b29 feat: update lp dashboard and multisigner app 2023-02-15 11:48:50 -08:00
Matthew Russell 2e307b2bb9 chore: re add timeout for subscription test 2023-02-15 11:48:50 -08:00
Matthew Russell 0ae6bbcb09 chore: fix tests after readding sub test 2023-02-15 11:48:49 -08:00
Matthew Russell ba777b567b chore: remove unused files 2023-02-15 11:48:49 -08:00
Matthew Russell 09294c2029 chore: re-add app-loader, move isValidUrl to react-helpers 2023-02-15 11:48:49 -08:00
Matthew Russell 37651c0cb9 chore: remove unused files 2023-02-15 11:48:49 -08:00
Matthew Russell c28cef72a6 chore: make additional envs optional 2023-02-15 11:48:49 -08:00
Matthew Russell bed754ff7b chore: add storage key const 2023-02-15 11:48:49 -08:00
Matthew Russell 0ffcff2a4c chore: fix types for compile issue 2023-02-15 11:48:49 -08:00
Matthew Russell daccb88524 chore: add compile error helpers 2023-02-15 11:48:48 -08:00
Matthew Russell af8889c97f feat: add node guard component, tidy up app-loader 2023-02-15 11:48:48 -08:00
Matthew Russell 9f1dc0f483 chore: stop poll of query errors 2023-02-15 11:48:48 -08:00
Matthew Russell 8e63a4f653 test: add tests for row data and node switcher 2023-02-15 11:48:48 -08:00
Matthew Russell d78ee77c5b chore: use more basic apollo provider rather then render prop, add hook tests 2023-02-15 11:48:48 -08:00
Matthew Russell 072930372e wip: fix me 2023-02-15 11:48:48 -08:00
Matthew Russell 076c7b42a0 chore: update tests to not use environment provider, delete unused web3-container component 2023-02-15 11:48:48 -08:00
Matthew Russell 44c884bdaf test: update environment tests 2023-02-15 11:48:47 -08:00
Matthew Russell 5ac0b6bc35 chore: update useNodeHealth tests 2023-02-15 11:48:47 -08:00
Matthew Russell 9fe2c643c6 feat: fix response time 2023-02-15 11:48:47 -08:00
Matthew Russell 6a072a93ce chore: remove extra block column 2023-02-15 11:48:47 -08:00
Matthew Russell 2ac6bc72a1 feat: handle if node config fetch fails 2023-02-15 11:48:47 -08:00
Matthew Russell 8763a5bb93 chore: port validation for env vars 2023-02-15 11:48:47 -08:00
Matthew Russell 5c718a2e37 feat: re-add layout rows 2023-02-15 11:48:47 -08:00
Matthew Russell df393a4de8 chore: fix polling not working 2023-02-15 11:48:46 -08:00
Matthew Russell f35fda8f53 feat: switch apps to use new env provider and node switcher 2023-02-15 11:48:45 -08:00
Matthew Russell 609e7bdc4d chore: update use node health and query to include vega tme 2023-02-15 11:47:55 -08:00
Matthew Russell 0dcb026dea feat: add header store 2023-02-15 11:47:52 -08:00
Matthew Russell da66da170a refactor: env provider to use zustand 2023-02-15 11:47:13 -08:00
mattrussell36 b7426e73d8 chore: update tranches
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-02-15 18:07:58 +00:00
Art 969bfd6945 feat(explorer): markets and market details pages (#2914) 2023-02-15 14:20:01 +00:00
mattrussell36 200487a6fc chore: update tranches
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-02-15 12:07:55 +00:00
Daniel 3f330e78c5 chore(ci): print logs files in cypress pr workflow (#2906) 2023-02-15 09:11:01 +00:00
mattrussell36 05726c6a5a chore: update tranches
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-02-15 06:06:31 +00:00
mattrussell36 fcace22393 chore: update tranches
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-02-15 00:12:52 +00:00
mattrussell36 b95462ea38 chore: update tranches
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-02-14 18:08:30 +00:00
Joe Tsang 42cd32f376 test(governance): refactor proposal enactment tests (#2897) 2023-02-14 16:44:36 +00:00
m.ray 55d6dd4dce feat(trading): deposit flow deal ticket (#2874) 2023-02-14 15:43:52 +00:00
mattrussell36 7017e24adf chore: update tranches
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-02-14 12:06:40 +00:00
127 changed files with 4373 additions and 5345 deletions
-14
View File
@@ -50,20 +50,6 @@ jobs:
projects=[${projects// /,}]
echo PROJECTS=$projects >> $GITHUB_ENV
# Rename required because some of the files contains the colon character (in the dates)
- name: Rename files to allow archive
if: ${{ always() }}
run: |
while read -r file; do
mv "${file}" "$(echo ${file} | sed 's|:|-|g')"
done< <(find /home/runner/.vegacapsule/testnet/logs -type f)
- uses: actions/upload-artifact@v3
if: ${{ always() }}
with:
name: logs-${{ matrix.project }}
path: /home/runner/.vegacapsule/testnet/logs
outputs:
projects: ${{ env.PROJECTS }}
+4
View File
@@ -82,6 +82,10 @@ jobs:
mv "${file}" "$(echo ${file} | sed 's|:|-|g')"
done< <(find /home/runner/.vegacapsule/testnet/logs -type f)
- name: Print logs files
if: ${{ always() }}
run: ls -alsh /home/runner/.vegacapsule/testnet/logs/
- uses: actions/upload-artifact@v3
if: ${{ always() }}
with:
@@ -247,7 +247,7 @@ context('Network parameters page', { tags: '@smoke' }, function () {
.and('include', darkThemeSideMenuBackgroundColor);
});
it('should be able to see network parameters - on mobile', function () {
it.skip('should be able to see network parameters - on mobile', function () {
cy.common_switch_to_mobile_and_click_toggle();
cy.get(networkParametersNavigation).click();
cy.get_network_parameters().then((network_parameters) => {
+8 -31
View File
@@ -1,18 +1,16 @@
import classnames from 'classnames';
import { useState, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { EnvironmentProvider, NetworkLoader } from '@vegaprotocol/environment';
import { NetworkLoader, useInitializeEnv } from '@vegaprotocol/environment';
import { Nav } from './components/nav';
import { Header } from './components/header';
import { Main } from './components/main';
import { TendermintWebsocketProvider } from './contexts/websocket/tendermint-websocket-provider';
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';
import { DEFAULT_CACHE_CONFIG } from '@vegaprotocol/apollo-client';
const DialogsContainer = () => {
const { isOpen, id, trigger, asJson, setOpen } = useAssetDetailsDialogStore();
@@ -28,36 +26,18 @@ const DialogsContainer = () => {
};
function App() {
const [menuOpen, setMenuOpen] = useState(false);
const location = useLocation();
useEffect(() => {
setMenuOpen(false);
}, [location]);
const cacheConfig: InMemoryCacheConfig = {
typePolicies: {
statistics: {
keyFields: false,
},
},
};
const layoutClasses = classnames(
'grid grid-rows-[auto_1fr_auto] grid-cols-[1fr] md:grid-rows-[auto_minmax(700px,_1fr)_auto] md:grid-cols-[300px_1fr]',
'min-h-[100vh] mx-auto my-0',
'border-neutral-700 dark:border-neutral-300 lg:border-l lg:border-r',
'bg-white dark:bg-black',
'antialiased text-black dark:text-white',
{
'h-[100vh] min-h-auto overflow-hidden': menuOpen,
}
'overflow-hidden relative'
);
return (
<TendermintWebsocketProvider>
<NetworkLoader cache={cacheConfig}>
<NetworkLoader cache={DEFAULT_CACHE_CONFIG}>
<AnnouncementBanner>
<div className="font-alpha calt uppercase text-center text-lg text-white">
<span className="pr-4">Mainnet sim 2 coming in March!</span>
@@ -68,8 +48,8 @@ function App() {
</AnnouncementBanner>
<div className={layoutClasses}>
<Header menuOpen={menuOpen} setMenuOpen={setMenuOpen} />
<Nav menuOpen={menuOpen} />
<Header />
<Nav />
<Main />
<Footer />
</div>
@@ -81,11 +61,8 @@ function App() {
}
const Wrapper = () => {
return (
<EnvironmentProvider>
<App />
</EnvironmentProvider>
);
useInitializeEnv();
return <App />;
};
export default Wrapper;
@@ -0,0 +1,56 @@
import { t } from '@vegaprotocol/react-helpers';
import {
Button,
Dialog,
Icon,
SyntaxHighlighter,
} from '@vegaprotocol/ui-toolkit';
type JsonViewerDialogProps = {
title: string;
content: unknown;
open: boolean;
onChange: (isOpen: boolean) => void;
trigger?: HTMLElement;
};
export const JsonViewerDialog = ({
title,
content,
open,
onChange,
trigger,
}: JsonViewerDialogProps) => {
return (
<Dialog
size="medium"
title={title}
icon={<Icon name="info-sign"></Icon>}
open={open}
onChange={(isOpen) => onChange(isOpen)}
onCloseAutoFocus={(e) => {
/**
* This mimics radix's default behaviour that focuses the dialog's
* trigger after closing itself
*/
if (trigger) {
e.preventDefault();
trigger.focus();
}
}}
>
<div className="pr-8 mb-8 max-h-[70vh] overflow-y-scroll">
<SyntaxHighlighter size="smaller" data={content} />
</div>
<div className="w-1/4">
<Button
data-testid="close-asset-details-dialog"
fill={true}
size="sm"
onClick={() => onChange(false)}
>
{t('Close')}
</Button>
</div>
</Dialog>
);
};
@@ -1,39 +1,46 @@
import { useEnvironment } from '@vegaprotocol/environment';
import { NodeSwitcherDialog, useEnvironment } from '@vegaprotocol/environment';
import { t } from '@vegaprotocol/react-helpers';
import { Link } from '@vegaprotocol/ui-toolkit';
import { useState } from 'react';
export const Footer = () => {
const { VEGA_URL, GIT_COMMIT_HASH, GIT_ORIGIN_URL, setNodeSwitcherOpen } =
useEnvironment();
const { VEGA_URL, GIT_COMMIT_HASH, GIT_ORIGIN_URL } = useEnvironment();
const [nodeSwitcherOpen, setNodeSwitcherOpen] = useState(false);
return (
<footer className="grid grid-rows-2 grid-cols-[1fr_auto] text-xs md:text-md md:flex md:col-span-2 px-4 py-2 gap-4 border-t border-neutral-700 dark:border-neutral-300">
<div className="flex justify-between gap-2 align-middle">
<div className="content-center flex border-r border-neutral-700 dark:border-neutral-300 pr-4">
{GIT_COMMIT_HASH && (
<p data-testid="git-commit-hash">
{t('Version')}:{' '}
<Link
href={
GIT_ORIGIN_URL
? `${GIT_ORIGIN_URL}/commit/${GIT_COMMIT_HASH}`
: undefined
}
target={GIT_ORIGIN_URL ? '_blank' : undefined}
>
{GIT_COMMIT_HASH}
</Link>
</p>
)}
</div>
<>
<footer className="grid grid-rows-2 grid-cols-[1fr_auto] text-xs md:text-md md:flex md:col-span-2 px-4 py-2 gap-4 border-t border-neutral-700 dark:border-neutral-300">
<div className="flex justify-between gap-2 align-middle">
<div className="content-center flex border-r border-neutral-700 dark:border-neutral-300 pr-4">
{GIT_COMMIT_HASH && (
<p data-testid="git-commit-hash">
{t('Version')}:{' '}
<Link
href={
GIT_ORIGIN_URL
? `${GIT_ORIGIN_URL}/commit/${GIT_COMMIT_HASH}`
: undefined
}
target={GIT_ORIGIN_URL ? '_blank' : undefined}
>
{GIT_COMMIT_HASH}
</Link>
</p>
)}
</div>
<div className="flex pl-2 content-center">
{VEGA_URL && <NodeUrl url={VEGA_URL} />}
<Link className="ml-2" onClick={setNodeSwitcherOpen}>
{t('Change')}
</Link>
<div className="flex pl-2 content-center">
{VEGA_URL && <NodeUrl url={VEGA_URL} />}
<Link className="ml-2" onClick={() => setNodeSwitcherOpen(true)}>
{t('Change')}
</Link>
</div>
</div>
</div>
</footer>
</footer>
<NodeSwitcherDialog
open={nodeSwitcherOpen}
setOpen={setNodeSwitcherOpen}
/>
</>
);
};
@@ -14,7 +14,7 @@ jest.mock('../search', () => ({
const renderComponent = () => (
<MemoryRouter>
<Header menuOpen={false} setMenuOpen={jest.fn()} />
<Header />
</MemoryRouter>
);
@@ -4,15 +4,11 @@ import { ThemeSwitcher, Icon } from '@vegaprotocol/ui-toolkit';
import { t } from '@vegaprotocol/react-helpers';
import { Search } from '../search';
import { Routes } from '../../routes/route-names';
import type { Dispatch, SetStateAction } from 'react';
import { NetworkSwitcher } from '@vegaprotocol/environment';
import { useNavStore } from '../nav';
interface ThemeToggleProps {
menuOpen: boolean;
setMenuOpen: Dispatch<SetStateAction<boolean>>;
}
export const Header = ({ menuOpen, setMenuOpen }: ThemeToggleProps) => {
export const Header = () => {
const [open, toggle] = useNavStore((state) => [state.open, state.toggle]);
const headerClasses = classnames(
'md:col-span-2',
'grid grid-rows-2 md:grid-rows-1 grid-cols-[1fr_auto] md:grid-cols-[auto_1fr_auto] items-center',
@@ -36,9 +32,9 @@ export const Header = ({ menuOpen, setMenuOpen }: ThemeToggleProps) => {
<button
data-testid="open-menu"
className="md:hidden text-white"
onClick={() => setMenuOpen(!menuOpen)}
onClick={() => toggle()}
>
<Icon name={menuOpen ? 'cross' : 'menu'} />
<Icon name={open ? 'cross' : 'menu'} />
</button>
<Search />
<ThemeSwitcher className="-my-4" />
@@ -2,7 +2,7 @@ import { AppRouter } from '../../routes';
export const Main = () => {
return (
<main className="p-4 overflow-scroll">
<main className="p-4">
<AppRouter />
</main>
);
@@ -0,0 +1,246 @@
import {
addDecimalsFormatNumber,
formatNumberPercentage,
getMarketExpiryDateFormatted,
t,
} from '@vegaprotocol/react-helpers';
import type { MarketInfoNoCandlesQuery } from '@vegaprotocol/market-info';
import { MarketInfoTable } from '@vegaprotocol/market-info';
import pick from 'lodash/pick';
import {
MarketStateMapping,
MarketTradingModeMapping,
} from '@vegaprotocol/types';
import { AssetDetailsTable, useAssetDataProvider } from '@vegaprotocol/assets';
import { Splash } from '@vegaprotocol/ui-toolkit';
import BigNumber from 'bignumber.js';
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
export const MarketDetails = ({
market,
}: {
market: MarketInfoNoCandlesQuery['market'];
}) => {
const assetSymbol =
market?.tradableInstrument.instrument.product?.settlementAsset.symbol;
const assetId = useMemo(
() => market?.tradableInstrument.instrument.product?.settlementAsset.id,
[market]
);
const { data: asset } = useAssetDataProvider(assetId ?? '');
if (!market) return null;
const keyDetails = {
...pick(market, 'decimalPlaces', 'positionDecimalPlaces', 'tradingMode'),
state: MarketStateMapping[market.state],
};
const assetDecimals =
market.tradableInstrument.instrument.product.settlementAsset.decimals;
const panels = [
{
title: t('Key details'),
content: (
<MarketInfoTable
noBorder={false}
data={{
name: market.tradableInstrument.instrument.name,
marketID: market.id,
tradingMode:
keyDetails.tradingMode &&
MarketTradingModeMapping[keyDetails.tradingMode],
marketDecimalPlaces: market.decimalPlaces,
positionDecimalPlaces: market.positionDecimalPlaces,
settlementAssetDecimalPlaces: assetDecimals,
}}
/>
),
},
{
title: t('Instrument'),
content: (
<MarketInfoTable
noBorder={false}
data={{
marketName: market.tradableInstrument.instrument.name,
code: market.tradableInstrument.instrument.code,
productType:
market.tradableInstrument.instrument.product.__typename,
...market.tradableInstrument.instrument.product,
}}
/>
),
},
{
title: t('Settlement asset'),
content: asset ? (
<AssetDetailsTable
asset={asset}
inline={true}
noBorder={false}
dtClassName="text-black dark:text-white text-ui !px-0 !font-normal"
ddClassName="text-black dark:text-white text-ui !px-0 !font-normal max-w-full"
/>
) : (
<Splash>{t('No data')}</Splash>
),
},
{
title: t('Metadata'),
content: (
<MarketInfoTable
noBorder={false}
data={{
expiryDate: getMarketExpiryDateFormatted(
market.tradableInstrument.instrument.metadata.tags
),
...market.tradableInstrument.instrument.metadata.tags
?.map((tag) => {
const [key, value] = tag.split(':');
return { [key]: value };
})
.reduce((acc, curr) => ({ ...acc, ...curr }), {}),
}}
/>
),
},
{
title: t('Risk model'),
content: (
<MarketInfoTable
noBorder={false}
data={market.tradableInstrument.riskModel}
unformatted={true}
omits={[]}
/>
),
},
{
title: t('Risk parameters'),
content: (
<MarketInfoTable
noBorder={false}
data={market.tradableInstrument.riskModel.params}
unformatted={true}
omits={[]}
/>
),
},
{
title: t('Risk factors'),
content: (
<MarketInfoTable
noBorder={false}
data={market.riskFactors}
unformatted={true}
omits={['market', '__typename']}
/>
),
},
...(market.priceMonitoringSettings?.parameters?.triggers || []).map(
(trigger, i) => ({
title: t(`Price monitoring trigger ${i + 1}`),
content: <MarketInfoTable noBorder={false} data={trigger} />,
})
),
...(market.data?.priceMonitoringBounds || []).map((trigger, i) => ({
title: t(`Price monitoring bound ${i + 1}`),
content: (
<>
<MarketInfoTable
noBorder={false}
data={trigger}
decimalPlaces={market.decimalPlaces}
omits={['referencePrice', '__typename']}
/>
<MarketInfoTable
noBorder={false}
data={{ referencePrice: trigger.referencePrice }}
decimalPlaces={assetDecimals}
/>
</>
),
})),
{
title: t('Liquidity monitoring parameters'),
content: (
<MarketInfoTable
noBorder={false}
data={{
triggeringRatio:
market.liquidityMonitoringParameters.triggeringRatio,
...market.liquidityMonitoringParameters.targetStakeParameters,
}}
/>
),
},
{
title: t('Liquidity price range'),
content: (
<MarketInfoTable
noBorder={false}
data={{
liquidityPriceRange: formatNumberPercentage(
new BigNumber(market.lpPriceRange).times(100)
),
LPVolumeMin:
market.data?.midPrice &&
`${addDecimalsFormatNumber(
new BigNumber(1)
.minus(market.lpPriceRange)
.times(market.data.midPrice)
.toString(),
market.decimalPlaces
)} ${assetSymbol}`,
LPVolumeMax:
market.data?.midPrice &&
`${addDecimalsFormatNumber(
new BigNumber(1)
.plus(market.lpPriceRange)
.times(market.data.midPrice)
.toString(),
market.decimalPlaces
)} ${assetSymbol}`,
}}
></MarketInfoTable>
),
},
{
title: t('Oracle'),
content: (
<MarketInfoTable
noBorder={false}
data={
market.tradableInstrument.instrument.product.dataSourceSpecBinding
}
>
<Link
className="text-xs hover:underline"
to={`/oracles#${market.tradableInstrument.instrument.product.dataSourceSpecForSettlementData.id}`}
>
{t('View settlement data oracle specification')}
</Link>
<Link
className="text-xs hover:underline"
to={`/oracles#${market.tradableInstrument.instrument.product.dataSourceSpecForTradingTermination.id}`}
>
{t('View termination oracle specification')}
</Link>
</MarketInfoTable>
),
},
];
return (
<>
{panels.map((p) => (
<div className="mb-3">
<h2 className="font-alpha text-xl">{p.title}</h2>
{p.content}
</div>
))}
</>
);
};
@@ -0,0 +1,132 @@
import type { MarketFieldsFragment } from '@vegaprotocol/market-list';
import { t } from '@vegaprotocol/react-helpers';
import type {
VegaICellRendererParams,
VegaValueGetterParams,
} 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';
import { MarketStateMapping } from '@vegaprotocol/types';
import { useAssetDetailsDialogStore } from '@vegaprotocol/assets';
import type { RowClickedEvent } from 'ag-grid-community';
import { Link, useNavigate } from 'react-router-dom';
type MarketsTableProps = {
data: MarketFieldsFragment[] | null;
};
export const MarketsTable = ({ data }: MarketsTableProps) => {
const openAssetDetailsDialog = useAssetDetailsDialogStore(
(state) => state.open
);
const navigate = useNavigate();
const gridRef = useRef<AgGridReact>(null);
useLayoutEffect(() => {
const showColumnsOnDesktop = () => {
gridRef.current?.columnApi.setColumnsVisible(
['id', 'state', 'asset'],
window.innerWidth > BREAKPOINT_MD
);
};
window.addEventListener('resize', showColumnsOnDesktop);
return () => {
window.removeEventListener('resize', showColumnsOnDesktop);
};
}, []);
return (
<AgGrid
ref={gridRef}
rowData={data}
getRowId={({ data }: { data: MarketFieldsFragment }) => data.id}
overlayNoRowsTemplate={t('This chain has no markets')}
domLayout="autoHeight"
defaultColDef={{
flex: 1,
resizable: true,
sortable: true,
filter: true,
filterParams: { buttons: ['reset'] },
autoHeight: true,
}}
suppressCellFocus={true}
onRowClicked={({ data, event }: RowClickedEvent) => {
if ((event?.target as HTMLElement).tagName.toUpperCase() !== 'BUTTON') {
navigate(data.id);
}
}}
>
<AgGridColumn
colId="code"
headerName={t('Code')}
field="tradableInstrument.instrument.code"
/>
<AgGridColumn
colId="name"
headerName={t('Name')}
field="tradableInstrument.instrument.name"
/>
<AgGridColumn
headerName={t('Status')}
field="state"
hide={window.innerWidth <= BREAKPOINT_MD}
valueGetter={({
data,
}: VegaValueGetterParams<MarketFieldsFragment, 'state'>) => {
return data?.state ? MarketStateMapping[data?.state] : '-';
}}
/>
<AgGridColumn
colId="asset"
headerName={t('Settlement asset')}
field="tradableInstrument.instrument.product.settlementAsset"
hide={window.innerWidth <= BREAKPOINT_MD}
cellRenderer={({
value,
}: VegaICellRendererParams<
MarketFieldsFragment,
'tradableInstrument.instrument.product.settlementAsset'
>) =>
value ? (
<ButtonLink
onClick={(e) => {
openAssetDetailsDialog(value.id, e.target as HTMLElement);
}}
>
{value.symbol}
</ButtonLink>
) : (
''
)
}
/>
<AgGridColumn
flex={2}
headerName={t('Market ID')}
field="id"
hide={window.innerWidth <= BREAKPOINT_MD}
/>
<AgGridColumn
colId="actions"
headerName=""
field="id"
cellRenderer={({
value,
}: VegaICellRendererParams<MarketFieldsFragment, 'id'>) =>
value ? (
<Link className="underline" to={value}>
{t('View details')}
</Link>
) : (
''
)
}
/>
</AgGrid>
);
};
+1 -45
View File
@@ -1,45 +1 @@
import { NavLink } from 'react-router-dom';
import routerConfig from '../../routes/router-config';
import classnames from 'classnames';
interface NavProps {
menuOpen: boolean;
}
export const Nav = ({ menuOpen }: NavProps) => {
return (
<nav className="relative">
<div
className={classnames(
'absolute top-0 z-50 md:static',
'w-full p-4 md:border-r border-neutral-700 dark:border-neutral-300',
'bg-white dark:bg-black',
'transition-[right]',
{
'right-0 h-[100vh]': menuOpen,
'right-[200vw] h-full': !menuOpen,
}
)}
>
{routerConfig.map((r) => (
<NavLink
key={r.name}
to={r.path}
className={({ isActive }) =>
classnames(
'block mb-2 px-2',
'text-lg hover:bg-vega-pink dark:hover:bg-vega-yellow hover:text-white dark:hover:text-black',
{
'bg-vega-pink text-white dark:bg-vega-yellow dark:text-black':
isActive,
}
)
}
>
{r.text}
</NavLink>
))}
</div>
</nav>
);
};
export * from './nav';
@@ -0,0 +1,181 @@
import { NavLink, useLocation } from 'react-router-dom';
import type { Navigable } from '../../routes/router-config';
import routerConfig from '../../routes/router-config';
import classnames from 'classnames';
import { create } from 'zustand';
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from 'react';
import { Icon } from '@vegaprotocol/ui-toolkit';
import first from 'lodash/first';
import last from 'lodash/last';
import { BREAKPOINT_MD } from '../../config/breakpoints';
type NavStore = {
open: boolean;
toggle: () => void;
hide: () => void;
};
export const useNavStore = create<NavStore>((set, get) => ({
open: false,
toggle: () => set({ open: !get().open }),
hide: () => set({ open: false }),
}));
const NavLinks = ({ links }: { links: Navigable[] }) => {
const navLinks = links.map((r) => (
<li key={r.name}>
<NavLink
to={r.path}
className={({ isActive }) =>
classnames(
'block mb-2 px-2',
'text-lg hover:bg-vega-pink dark:hover:bg-vega-yellow hover:text-white dark:hover:text-black',
{
'bg-vega-pink text-white dark:bg-vega-yellow dark:text-black':
isActive,
}
)
}
>
{r.text}
</NavLink>
</li>
));
return <ul className="pr-8 md:pr-0">{navLinks}</ul>;
};
export const Nav = () => {
const [open, hide] = useNavStore((state) => [state.open, state.hide]);
const location = useLocation();
const navRef = useRef<HTMLElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const focusable = useMemo(
() =>
navRef.current
? [
...(navRef.current.querySelectorAll(
'a, button'
) as NodeListOf<HTMLElement>),
]
: [],
// eslint-disable-next-line react-hooks/exhaustive-deps
[navRef.current] // do not remove `navRef.current` from deps
);
const closeNav = useCallback(() => {
hide();
console.log(focusable);
focusable.forEach((fe) =>
fe.setAttribute(
'tabindex',
window.innerWidth > BREAKPOINT_MD ? '0' : '-1'
)
);
}, [focusable, hide]);
// close navigation when location changes
useEffect(() => {
closeNav();
}, [closeNav, location]);
useLayoutEffect(() => {
if (open) {
focusable.forEach((fe) => fe.setAttribute('tabindex', '0'));
}
document.body.style.overflow = open ? 'hidden' : '';
const offset =
document.querySelector('header')?.getBoundingClientRect().top || 0;
if (navRef.current) {
navRef.current.style.height = `calc(100vh - ${offset}px)`;
}
// focus current by default
if (navRef.current && open) {
(navRef.current.querySelector('a[aria-current]') as HTMLElement)?.focus();
}
const closeOnEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
closeNav();
}
};
// tabbing loop
const focusLast = (e: FocusEvent) => {
e.preventDefault();
const isNavElement =
e.relatedTarget && navRef.current?.contains(e.relatedTarget as Node);
if (!isNavElement && open) {
last(focusable)?.focus();
}
};
const focusFirst = (e: FocusEvent) => {
e.preventDefault();
const isNavElement =
e.relatedTarget && navRef.current?.contains(e.relatedTarget as Node);
if (!isNavElement && open) {
first(focusable)?.focus();
}
};
const resetOnDesktop = () => {
focusable.forEach((fe) =>
fe.setAttribute(
'tabindex',
window.innerWidth > BREAKPOINT_MD ? '0' : '-1'
)
);
};
window.addEventListener('resize', resetOnDesktop);
first(focusable)?.addEventListener('focusout', focusLast);
last(focusable)?.addEventListener('focusout', focusFirst);
document.addEventListener('keydown', closeOnEsc);
return () => {
window.removeEventListener('resize', resetOnDesktop);
document.removeEventListener('keydown', closeOnEsc);
first(focusable)?.removeEventListener('focusout', focusLast);
last(focusable)?.removeEventListener('focusout', focusFirst);
};
}, [closeNav, focusable, open]);
return (
<nav
ref={navRef}
className={classnames(
'absolute top-0 z-20 overflow-y-auto',
'transition-[right]',
{
'right-[-200vw] h-full': !open,
'right-0 h-[100vh]': open,
},
'w-full p-4 border-neutral-700 dark:border-neutral-300',
'bg-white dark:bg-black',
'md:static md:border-r'
)}
>
<NavLinks links={routerConfig} />
<button
ref={btnRef}
className="absolute top-0 right-0 p-4 md:hidden"
onClick={() => {
closeNav();
}}
>
<Icon name="cross" />
</button>
</nav>
);
};
@@ -1,140 +0,0 @@
query ExplorerMarkets {
marketsConnection {
edges {
node {
id
fees {
factors {
makerFee
infrastructureFee
liquidityFee
}
}
tradableInstrument {
instrument {
name
metadata {
tags
}
code
product {
... on Future {
settlementAsset {
id
name
decimals
globalRewardPoolAccount {
balance
}
}
}
}
}
riskModel {
... on LogNormalRiskModel {
tau
riskAversionParameter
params {
r
sigma
mu
}
}
... on SimpleRiskModel {
params {
factorLong
factorShort
}
}
}
marginCalculator {
scalingFactors {
searchLevel
initialMargin
collateralRelease
}
}
}
decimalPlaces
openingAuction {
durationSecs
volume
}
priceMonitoringSettings {
parameters {
triggers {
horizonSecs
probability
auctionExtensionSecs
}
}
}
liquidityMonitoringParameters {
triggeringRatio
targetStakeParameters {
timeWindow
scalingFactor
}
}
tradingMode
state
proposal {
id
}
state
accountsConnection {
edges {
node {
asset {
id
name
}
balance
type
}
}
}
data {
markPrice
bestBidPrice
bestBidVolume
bestOfferPrice
bestOfferVolume
bestStaticBidPrice
bestStaticBidVolume
bestStaticOfferPrice
bestStaticOfferVolume
midPrice
staticMidPrice
timestamp
openInterest
auctionEnd
auctionStart
indicativePrice
indicativeVolume
trigger
extensionTrigger
targetStake
suppliedStake
priceMonitoringBounds {
minValidPrice
maxValidPrice
trigger {
auctionExtensionSecs
probability
}
referencePrice
}
marketValueProxy
liquidityProviderFeeShare {
party {
id
}
equityLikeShare
averageEntryValuation
}
}
}
}
}
}
@@ -1,180 +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 ExplorerMarketsQueryVariables = Types.Exact<{ [key: string]: never; }>;
export type ExplorerMarketsQuery = { __typename?: 'Query', marketsConnection?: { __typename?: 'MarketConnection', edges: Array<{ __typename?: 'MarketEdge', node: { __typename?: 'Market', id: string, decimalPlaces: number, tradingMode: Types.MarketTradingMode, state: Types.MarketState, fees: { __typename?: 'Fees', factors: { __typename?: 'FeeFactors', makerFee: string, infrastructureFee: string, liquidityFee: string } }, tradableInstrument: { __typename?: 'TradableInstrument', instrument: { __typename?: 'Instrument', name: string, code: string, metadata: { __typename?: 'InstrumentMetadata', tags?: Array<string> | null }, product: { __typename?: 'Future', settlementAsset: { __typename?: 'Asset', id: string, name: string, decimals: number, globalRewardPoolAccount?: { __typename?: 'AccountBalance', balance: string } | null } } }, riskModel: { __typename?: 'LogNormalRiskModel', tau: number, riskAversionParameter: number, params: { __typename?: 'LogNormalModelParams', r: number, sigma: number, mu: number } } | { __typename?: 'SimpleRiskModel', params: { __typename?: 'SimpleRiskModelParams', factorLong: number, factorShort: number } }, marginCalculator?: { __typename?: 'MarginCalculator', scalingFactors: { __typename?: 'ScalingFactors', searchLevel: number, initialMargin: number, collateralRelease: number } } | null }, openingAuction: { __typename?: 'AuctionDuration', durationSecs: number, volume: number }, priceMonitoringSettings: { __typename?: 'PriceMonitoringSettings', parameters?: { __typename?: 'PriceMonitoringParameters', triggers?: Array<{ __typename?: 'PriceMonitoringTrigger', horizonSecs: number, probability: number, auctionExtensionSecs: number }> | null } | null }, liquidityMonitoringParameters: { __typename?: 'LiquidityMonitoringParameters', triggeringRatio: string, targetStakeParameters: { __typename?: 'TargetStakeParameters', timeWindow: number, scalingFactor: number } }, proposal?: { __typename?: 'Proposal', id?: string | null } | null, accountsConnection?: { __typename?: 'AccountsConnection', edges?: Array<{ __typename?: 'AccountEdge', node: { __typename?: 'AccountBalance', balance: string, type: Types.AccountType, asset: { __typename?: 'Asset', id: string, name: string } } } | null> | null } | null, data?: { __typename?: 'MarketData', markPrice: string, bestBidPrice: string, bestBidVolume: string, bestOfferPrice: string, bestOfferVolume: string, bestStaticBidPrice: string, bestStaticBidVolume: string, bestStaticOfferPrice: string, bestStaticOfferVolume: string, midPrice: string, staticMidPrice: string, timestamp: any, openInterest: string, auctionEnd?: string | null, auctionStart?: string | null, indicativePrice: string, indicativeVolume: string, trigger: Types.AuctionTrigger, extensionTrigger: Types.AuctionTrigger, targetStake?: string | null, suppliedStake?: string | null, marketValueProxy: string, priceMonitoringBounds?: Array<{ __typename?: 'PriceMonitoringBounds', minValidPrice: string, maxValidPrice: string, referencePrice: string, trigger: { __typename?: 'PriceMonitoringTrigger', auctionExtensionSecs: number, probability: number } }> | null, liquidityProviderFeeShare?: Array<{ __typename?: 'LiquidityProviderFeeShare', equityLikeShare: string, averageEntryValuation: string, party: { __typename?: 'Party', id: string } }> | null } | null } }> } | null };
export const ExplorerMarketsDocument = gql`
query ExplorerMarkets {
marketsConnection {
edges {
node {
id
fees {
factors {
makerFee
infrastructureFee
liquidityFee
}
}
tradableInstrument {
instrument {
name
metadata {
tags
}
code
product {
... on Future {
settlementAsset {
id
name
decimals
globalRewardPoolAccount {
balance
}
}
}
}
}
riskModel {
... on LogNormalRiskModel {
tau
riskAversionParameter
params {
r
sigma
mu
}
}
... on SimpleRiskModel {
params {
factorLong
factorShort
}
}
}
marginCalculator {
scalingFactors {
searchLevel
initialMargin
collateralRelease
}
}
}
decimalPlaces
openingAuction {
durationSecs
volume
}
priceMonitoringSettings {
parameters {
triggers {
horizonSecs
probability
auctionExtensionSecs
}
}
}
liquidityMonitoringParameters {
triggeringRatio
targetStakeParameters {
timeWindow
scalingFactor
}
}
tradingMode
state
proposal {
id
}
state
accountsConnection {
edges {
node {
asset {
id
name
}
balance
type
}
}
}
data {
markPrice
bestBidPrice
bestBidVolume
bestOfferPrice
bestOfferVolume
bestStaticBidPrice
bestStaticBidVolume
bestStaticOfferPrice
bestStaticOfferVolume
midPrice
staticMidPrice
timestamp
openInterest
auctionEnd
auctionStart
indicativePrice
indicativeVolume
trigger
extensionTrigger
targetStake
suppliedStake
priceMonitoringBounds {
minValidPrice
maxValidPrice
trigger {
auctionExtensionSecs
probability
}
referencePrice
}
marketValueProxy
liquidityProviderFeeShare {
party {
id
}
equityLikeShare
averageEntryValuation
}
}
}
}
}
}
`;
/**
* __useExplorerMarketsQuery__
*
* To run a query within a React component, call `useExplorerMarketsQuery` and pass it any options that fit your needs.
* When your component renders, `useExplorerMarketsQuery` 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 } = useExplorerMarketsQuery({
* variables: {
* },
* });
*/
export function useExplorerMarketsQuery(baseOptions?: Apollo.QueryHookOptions<ExplorerMarketsQuery, ExplorerMarketsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<ExplorerMarketsQuery, ExplorerMarketsQueryVariables>(ExplorerMarketsDocument, options);
}
export function useExplorerMarketsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ExplorerMarketsQuery, ExplorerMarketsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<ExplorerMarketsQuery, ExplorerMarketsQueryVariables>(ExplorerMarketsDocument, options);
}
export type ExplorerMarketsQueryHookResult = ReturnType<typeof useExplorerMarketsQuery>;
export type ExplorerMarketsLazyQueryHookResult = ReturnType<typeof useExplorerMarketsLazyQuery>;
export type ExplorerMarketsQueryResult = Apollo.QueryResult<ExplorerMarketsQuery, ExplorerMarketsQueryVariables>;
@@ -1,48 +0,0 @@
import { MockedProvider } from '@apollo/client/testing';
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import Markets from './index';
import type { MockedResponse } from '@apollo/client/testing';
import { ExplorerMarketsDocument } from './__generated__/Markets';
function renderComponent(mock: MockedResponse[]) {
return (
<MemoryRouter>
<MockedProvider mocks={mock}>
<Markets />
</MockedProvider>
</MemoryRouter>
);
}
describe('Markets index', () => {
it('Renders loader when loading', async () => {
const mock = {
request: {
query: ExplorerMarketsDocument,
},
result: {
data: {
marketsConnection: [],
},
},
};
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: ExplorerMarketsDocument,
},
result: {
data: {
marketsConnection: [],
},
},
};
const res = render(renderComponent([mock]));
expect(await res.findByTestId('emptylist')).toBeInTheDocument();
});
});
+2 -44
View File
@@ -1,44 +1,2 @@
import React from 'react';
import { Loader, SyntaxHighlighter } from '@vegaprotocol/ui-toolkit';
import { RouteTitle } from '../../components/route-title';
import { SubHeading } from '../../components/sub-heading';
import { t } from '@vegaprotocol/react-helpers';
import { useExplorerMarketsQuery } from './__generated__/Markets';
import { useScrollToLocation } from '../../hooks/scroll-to-location';
import { useDocumentTitle } from '../../hooks/use-document-title';
import EmptyList from '../../components/empty-list/empty-list';
const Markets = () => {
const { data, loading } = useExplorerMarketsQuery();
useScrollToLocation();
useDocumentTitle(['Markets']);
const m = data?.marketsConnection?.edges;
return (
<section key="markets">
<RouteTitle data-testid="markets-heading">{t('Markets')}</RouteTitle>
{m ? (
m.map((e) => (
<React.Fragment key={e.node.id}>
<SubHeading data-testid="markets-header" id={e.node.id}>
{e.node.tradableInstrument.instrument.name}
</SubHeading>
<SyntaxHighlighter data={e.node} />
</React.Fragment>
))
) : loading ? (
<Loader />
) : (
<EmptyList
heading={t('This chain has no markets')}
label={t('0 markets')}
/>
)}
</section>
);
};
export default Markets;
export * from './markets-page';
export * from './market-page';
@@ -0,0 +1,68 @@
import { t, useDataProvider } from '@vegaprotocol/react-helpers';
import { AsyncRenderer, Button } from '@vegaprotocol/ui-toolkit';
import { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { MarketDetails } from '../../components/markets/market-details';
import { RouteTitle } from '../../components/route-title';
import { useScrollToLocation } from '../../hooks/scroll-to-location';
import { useDocumentTitle } from '../../hooks/use-document-title';
import compact from 'lodash/compact';
import { JsonViewerDialog } from '../../components/dialogs/json-viewer-dialog';
import { marketInfoNoCandlesDataProvider } from '@vegaprotocol/market-info';
export const MarketPage = () => {
useScrollToLocation();
const { marketId } = useParams<{ marketId: string }>();
const variables = useMemo(
() => ({
marketId,
}),
[marketId]
);
const { data, loading, error } = useDataProvider({
dataProvider: marketInfoNoCandlesDataProvider,
skipUpdates: true,
variables,
});
useDocumentTitle(
compact([
'Market details',
data?.market?.tradableInstrument.instrument.name,
])
);
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
return (
<>
<section className="relative">
<RouteTitle data-testid="markets-heading">
{data?.market?.tradableInstrument.instrument.name}
</RouteTitle>
<AsyncRenderer
noDataMessage={t('This chain has no markets')}
data={data}
loading={loading}
error={error}
>
<div className="absolute top-0 right-0">
<Button size="xs" onClick={() => setDialogOpen(true)}>
{t('View JSON')}
</Button>
</div>
<MarketDetails market={data?.market} />
</AsyncRenderer>
</section>
<JsonViewerDialog
open={dialogOpen}
onChange={(isOpen) => setDialogOpen(isOpen)}
title={data?.market?.tradableInstrument.instrument.name || ''}
content={data?.market}
/>
</>
);
};
@@ -0,0 +1,31 @@
import { useScrollToLocation } from '../../hooks/scroll-to-location';
import { useDocumentTitle } from '../../hooks/use-document-title';
import { marketsProvider } from '@vegaprotocol/market-list';
import { RouteTitle } from '../../components/route-title';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { t, useDataProvider } from '@vegaprotocol/react-helpers';
import { MarketsTable } from '../../components/markets/markets-table';
export const MarketsPage = () => {
useDocumentTitle(['Markets']);
useScrollToLocation();
const { data, loading, error } = useDataProvider({
dataProvider: marketsProvider,
skipUpdates: true,
});
return (
<section>
<RouteTitle data-testid="markets-heading">{t('Markets')}</RouteTitle>
<AsyncRenderer
noDataMessage={t('This chain has no markets')}
data={data}
loading={loading}
error={error}
>
<MarketsTable data={data} />
</AsyncRenderer>
</section>
);
};
+23 -10
View File
@@ -2,7 +2,6 @@ import { Assets } from './assets';
import BlockPage from './blocks';
import Governance from './governance';
import Home from './home';
import Markets from './markets';
import OraclePage from './oracles';
import Oracles from './oracles/home';
import { Oracle } from './oracles/id';
@@ -21,8 +20,13 @@ import flags from '../config/flags';
import { t } from '@vegaprotocol/react-helpers';
import { Routes } from './route-names';
import { NetworkParameters } from './network-parameters';
import type { RouteObject } from 'react-router-dom';
import { MarketPage, MarketsPage } from './markets';
const partiesRoutes = flags.parties
export type Navigable = { path: string; name: string; text: string };
type Route = RouteObject & Navigable;
const partiesRoutes: Route[] = flags.parties
? [
{
path: Routes.PARTIES,
@@ -43,7 +47,7 @@ const partiesRoutes = flags.parties
]
: [];
const assetsRoutes = flags.assets
const assetsRoutes: Route[] = flags.assets
? [
{
path: Routes.ASSETS,
@@ -54,7 +58,7 @@ const assetsRoutes = flags.assets
]
: [];
const genesisRoutes = flags.genesis
const genesisRoutes: Route[] = flags.genesis
? [
{
path: Routes.GENESIS,
@@ -65,7 +69,7 @@ const genesisRoutes = flags.genesis
]
: [];
const governanceRoutes = flags.governance
const governanceRoutes: Route[] = flags.governance
? [
{
path: Routes.GOVERNANCE,
@@ -76,18 +80,27 @@ const governanceRoutes = flags.governance
]
: [];
const marketsRoutes = flags.markets
const marketsRoutes: Route[] = flags.markets
? [
{
path: Routes.MARKETS,
name: 'Markets',
text: t('Markets'),
element: <Markets />,
children: [
{
index: true,
element: <MarketsPage />,
},
{
path: ':marketId',
element: <MarketPage />,
},
],
},
]
: [];
const networkParametersRoutes = flags.networkParameters
const networkParametersRoutes: Route[] = flags.networkParameters
? [
{
path: Routes.NETWORK_PARAMETERS,
@@ -97,7 +110,7 @@ const networkParametersRoutes = flags.networkParameters
},
]
: [];
const validators = flags.validators
const validators: Route[] = flags.validators
? [
{
path: Routes.VALIDATORS,
@@ -108,7 +121,7 @@ const validators = flags.validators
]
: [];
const routerConfig = [
const routerConfig: Route[] = [
{
path: Routes.HOME,
name: 'Home',
@@ -1,3 +1,5 @@
import type { InMemoryCacheConfig } from '@apollo/client';
import { NetworkLoader, useInitializeEnv } from '@vegaprotocol/environment';
import { useRoutes } from 'react-router-dom';
import '../styles.scss';
@@ -5,14 +7,40 @@ import { Navbar } from './components/navbar';
import { routerConfig } from './routes/router-config';
const cache: InMemoryCacheConfig = {
typePolicies: {
Market: {
merge: true,
},
Party: {
merge: true,
},
Query: {},
Account: {
keyFields: false,
fields: {
balanceFormatted: {},
},
},
Node: {
keyFields: false,
},
Instrument: {
keyFields: false,
},
},
};
const AppRouter = () => useRoutes(routerConfig);
export function App() {
useInitializeEnv();
return (
<div className="max-h-full min-h-full bg-white">
<Navbar />
<AppRouter />
</div>
<NetworkLoader cache={cache}>
<div className="max-h-full min-h-full bg-white">
<Navbar />
<AppRouter />
</div>
</NetworkLoader>
);
}
@@ -1,44 +1,15 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { EnvironmentProvider, NetworkLoader } from '@vegaprotocol/environment';
import App from './app/app';
import type { InMemoryCacheConfig } from '@apollo/client';
const rootElement = document.getElementById('root');
const root = rootElement && createRoot(rootElement);
const cache: InMemoryCacheConfig = {
typePolicies: {
Market: {
merge: true,
},
Party: {
merge: true,
},
Query: {},
Account: {
keyFields: false,
fields: {
balanceFormatted: {},
},
},
Node: {
keyFields: false,
},
Instrument: {
keyFields: false,
},
},
};
root?.render(
<StrictMode>
<BrowserRouter>
<EnvironmentProvider>
<NetworkLoader cache={cache}>
<App />
</NetworkLoader>
</EnvironmentProvider>
<App />
</BrowserRouter>
</StrictMode>
);
+1
View File
@@ -2,3 +2,4 @@ NX_VEGA_URL=https://api.stagnet3.vega.xyz/graphql
NX_VEGA_CONFIG_URL=https://static.vega.xyz/assets/stagnet3-network.json
NX_VEGA_NETWORKS='{"TESTNET":"https://multisig-signer.fairground.wtf","MAINNET":"https://multisig-signer.vega.xyz"}'
NX_VEGA_ENV=STAGNET3
+6 -6
View File
@@ -3,9 +3,9 @@ import classnames from 'classnames';
import { useEffect, useMemo, useState } from 'react';
import { BrowserTracing } from '@sentry/tracing';
import {
EnvironmentProvider,
NetworkLoader,
useEnvironment,
useInitializeEnv,
} from '@vegaprotocol/environment';
import { AsyncRenderer, Button, Lozenge } from '@vegaprotocol/ui-toolkit';
import type { EthereumConfig } from '@vegaprotocol/web3';
@@ -64,6 +64,7 @@ function App() {
environment: VEGA_ENV,
});
}, [VEGA_ENV]);
const Connectors = useMemo(() => {
if (config?.chain_id) {
return createConnectors(ETHEREUM_PROVIDER_URL, Number(config.chain_id));
@@ -107,12 +108,11 @@ const Wrapper = () => {
},
},
};
useInitializeEnv();
return (
<EnvironmentProvider>
<NetworkLoader cache={cache}>
<App />
</NetworkLoader>
</EnvironmentProvider>
<NetworkLoader cache={cache}>
<App />
</NetworkLoader>
);
};
+459 -128
View File
@@ -2761,7 +2761,7 @@
"tranche_end": "2023-12-05T00:00:00.000Z",
"total_added": "86666.297",
"total_removed": "0",
"locked_amount": "69747.2088420178849030924",
"locked_amount": "69391.0872001880742983107",
"deposits": [
{
"amount": "86666.297",
@@ -2827,7 +2827,7 @@
"tranche_end": "2023-06-01T00:00:00.000Z",
"total_added": "2500",
"total_removed": "0",
"locked_amount": "1466.2679334554335",
"locked_amount": "1445.66592261904775",
"deposits": [
{
"amount": "2500",
@@ -3214,8 +3214,8 @@
"tranche_start": "2023-02-01T00:00:00.000Z",
"tranche_end": "2023-08-01T00:00:00.000Z",
"total_added": "37500",
"total_removed": "470.314897875",
"locked_amount": "34753.65446593002",
"total_removed": "2836.415649675",
"locked_amount": "34442.91695441989125",
"deposits": [
{
"amount": "7500",
@@ -3234,6 +3234,16 @@
"user": "0x20CD77B9FC2f1fEDfb6F184E25f7127BFE991C8b",
"tx": "0x7b40703e0fb0e1da154999d5d022df7b5dec42d14d9ff75298233227da2770c5"
},
{
"amount": "90.9875691",
"user": "0x20CD77B9FC2f1fEDfb6F184E25f7127BFE991C8b",
"tx": "0x5bfef799be13741d89872cc604df1f35b41069397e681577fc15c039866d8707"
},
{
"amount": "2275.1131827",
"user": "0x0B4e6fcE839B01ef43DA6F890FAC7B1Afb004600",
"tx": "0x78fd0cf1ca8de93d15eee1424146bcd70b84cc168b4c1ea1ac50020b8d67bcc1"
},
{
"amount": "183.137181525",
"user": "0x20CD77B9FC2f1fEDfb6F184E25f7127BFE991C8b",
@@ -3263,6 +3273,12 @@
"tranche_id": 34,
"tx": "0x7b40703e0fb0e1da154999d5d022df7b5dec42d14d9ff75298233227da2770c5"
},
{
"amount": "90.9875691",
"user": "0x20CD77B9FC2f1fEDfb6F184E25f7127BFE991C8b",
"tranche_id": 34,
"tx": "0x5bfef799be13741d89872cc604df1f35b41069397e681577fc15c039866d8707"
},
{
"amount": "183.137181525",
"user": "0x20CD77B9FC2f1fEDfb6F184E25f7127BFE991C8b",
@@ -3277,8 +3293,8 @@
}
],
"total_tokens": "7500",
"withdrawn_tokens": "470.314897875",
"remaining_tokens": "7029.685102125"
"withdrawn_tokens": "561.302466975",
"remaining_tokens": "6938.697533025"
},
{
"address": "0x0B4e6fcE839B01ef43DA6F890FAC7B1Afb004600",
@@ -3290,10 +3306,17 @@
"tx": "0x302591debd812f93121a17dd0413ae5084f3743a868b4325f81990eac58f8292"
}
],
"withdrawals": [],
"withdrawals": [
{
"amount": "2275.1131827",
"user": "0x0B4e6fcE839B01ef43DA6F890FAC7B1Afb004600",
"tranche_id": 34,
"tx": "0x78fd0cf1ca8de93d15eee1424146bcd70b84cc168b4c1ea1ac50020b8d67bcc1"
}
],
"total_tokens": "30000",
"withdrawn_tokens": "0",
"remaining_tokens": "30000"
"withdrawn_tokens": "2275.1131827",
"remaining_tokens": "27724.8868173"
}
]
},
@@ -3303,7 +3326,7 @@
"tranche_end": "2023-12-05T00:00:00.000Z",
"total_added": "129999.45",
"total_removed": "0",
"locked_amount": "69683.573289879860117625",
"locked_amount": "69327.776564239291943985",
"deposits": [
{
"amount": "129999.45",
@@ -3369,7 +3392,7 @@
"tranche_end": "2023-09-03T00:00:00.000Z",
"total_added": "62600",
"total_removed": "0",
"locked_amount": "34429.02336377474066",
"locked_amount": "34171.79286529680654",
"deposits": [
{
"amount": "10000",
@@ -3562,7 +3585,7 @@
"tranche_end": "2023-09-17T00:00:00.000Z",
"total_added": "5000",
"total_removed": "0",
"locked_amount": "2941.702815829528",
"locked_amount": "2921.1572488584475",
"deposits": [
{
"amount": "5000",
@@ -3773,7 +3796,7 @@
"tranche_end": "2023-04-05T00:00:00.000Z",
"total_added": "97499.58",
"total_removed": "0",
"locked_amount": "11575.295701809865721754",
"locked_amount": "11226.2922579206452066194",
"deposits": [
{
"amount": "97499.58",
@@ -3806,7 +3829,7 @@
"tranche_end": "2023-04-05T00:00:00.000Z",
"total_added": "135173.4239508",
"total_removed": "98230.390980249184455396",
"locked_amount": "15821.43083293940334189041604",
"locked_amount": "15344.40337807380869704113192",
"deposits": [
{
"amount": "135173.4239508",
@@ -3852,7 +3875,7 @@
"tranche_end": "2023-04-05T00:00:00.000Z",
"total_added": "32499.86",
"total_removed": "0",
"locked_amount": "4869.5270070866783780862",
"locked_amount": "4722.707285209589580316",
"deposits": [
{
"amount": "32499.86",
@@ -3885,7 +3908,7 @@
"tranche_end": "2023-04-05T00:00:00.000Z",
"total_added": "10833.29",
"total_removed": "0",
"locked_amount": "1584.9837880351312174272",
"locked_amount": "1537.1953932689953826786",
"deposits": [
{
"amount": "10833.29",
@@ -3918,7 +3941,7 @@
"tranche_end": "2023-04-05T00:00:00.000Z",
"total_added": "22749.93",
"total_removed": "4720.860935375",
"locked_amount": "5925.023399410994674404",
"locked_amount": "5746.3797064299737259078",
"deposits": [
{
"amount": "6500",
@@ -4069,8 +4092,8 @@
"tranche_start": "2022-11-01T00:00:00.000Z",
"tranche_end": "2023-05-01T00:00:00.000Z",
"total_added": "22500",
"total_removed": "4282.463838975",
"locked_amount": "9415.728591160220325",
"total_removed": "4373.451408",
"locked_amount": "9229.28608425414375",
"deposits": [
{
"amount": "7500",
@@ -4089,6 +4112,11 @@
"user": "0x20CD77B9FC2f1fEDfb6F184E25f7127BFE991C8b",
"tx": "0xf121e9810d454c5814a73b5b58e28c026bc53246bad6f6ba7836fb9e0adbd12a"
},
{
"amount": "90.987569025",
"user": "0x20CD77B9FC2f1fEDfb6F184E25f7127BFE991C8b",
"tx": "0x4a7c7462e3bb6abe2d33dfe5a1bf1e4211e52e5e03239c02554988f7d3635523"
},
{
"amount": "167.6680479",
"user": "0x20CD77B9FC2f1fEDfb6F184E25f7127BFE991C8b",
@@ -4178,6 +4206,12 @@
"tranche_id": 33,
"tx": "0xf121e9810d454c5814a73b5b58e28c026bc53246bad6f6ba7836fb9e0adbd12a"
},
{
"amount": "90.987569025",
"user": "0x20CD77B9FC2f1fEDfb6F184E25f7127BFE991C8b",
"tranche_id": 33,
"tx": "0x4a7c7462e3bb6abe2d33dfe5a1bf1e4211e52e5e03239c02554988f7d3635523"
},
{
"amount": "167.6680479",
"user": "0x20CD77B9FC2f1fEDfb6F184E25f7127BFE991C8b",
@@ -4264,8 +4298,8 @@
}
],
"total_tokens": "7500",
"withdrawn_tokens": "4282.463838975",
"remaining_tokens": "3217.536161025"
"withdrawn_tokens": "4373.451408",
"remaining_tokens": "3126.548592"
},
{
"address": "0x2539b51EbDE65a75672aBcfE9439a706a99D18D1",
@@ -4290,7 +4324,7 @@
"tranche_end": "2023-06-02T00:00:00.000Z",
"total_added": "1939928.38",
"total_removed": "928642.9598472029154",
"locked_amount": "572647.222275654524357944",
"locked_amount": "564675.8365855764677193232",
"deposits": [
{
"amount": "1852091.69",
@@ -9677,8 +9711,8 @@
"tranche_id": 11,
"tranche_start": "2021-09-03T00:00:00.000Z",
"tranche_end": "2022-09-03T00:00:00.000Z",
"total_added": "54241.000000000000000003",
"total_removed": "43047.21518131551",
"total_added": "54601.000000000000000003",
"total_removed": "43533.21518131551",
"locked_amount": "0",
"deposits": [
{
@@ -9716,6 +9750,81 @@
"user": "0x33Ce1D9E53AFb7367E34749517C086405a651a95",
"tx": "0xaf7a554c2b5b6690bf6798f31973ed34b351dd3d9c81335301d681c259c3f057"
},
{
"amount": "10",
"user": "0x33Ce1D9E53AFb7367E34749517C086405a651a95",
"tx": "0xdb1da0991e2a5a044fa1b6e146358e73cf4628762f6f66e01469e3a46d32860b"
},
{
"amount": "20",
"user": "0x33Ce1D9E53AFb7367E34749517C086405a651a95",
"tx": "0xd355ef37d32d1c619fcaeccb5d66bfaaecf0e8443f6c9b6b47716e63d31ae27e"
},
{
"amount": "20",
"user": "0x33Ce1D9E53AFb7367E34749517C086405a651a95",
"tx": "0x2db47f31526593c9c215db918aee1dccb6e1fcbd56fdef5e41f46fcbb0a34c18"
},
{
"amount": "10",
"user": "0x33Ce1D9E53AFb7367E34749517C086405a651a95",
"tx": "0x6d027468ba87f7347a7f8bc69613a60e3c204a5dffeffdc8479456c0d0a32f31"
},
{
"amount": "25",
"user": "0x33Ce1D9E53AFb7367E34749517C086405a651a95",
"tx": "0x2f485648f47a24fff0503cada896eca6fee655ca855a93388847ad16a1e71571"
},
{
"amount": "15",
"user": "0x33Ce1D9E53AFb7367E34749517C086405a651a95",
"tx": "0x9f8576c559ffa9bc1082c100250e6d3a967b110e34b944240588d69298f5c691"
},
{
"amount": "50",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tx": "0x4f2d5ffff22744dd8bca0cd18653e49d05a921e3b37e0ea8e5e8013338044674"
},
{
"amount": "50",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tx": "0x64870b69035b9bbc763e488bbcfafe74d7dc9b382a52e574986d99e6057695fd"
},
{
"amount": "20",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tx": "0xcc656bde93dc97848442b988966a72e9fb4a27236b999e43aa67ea71a205293a"
},
{
"amount": "60",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tx": "0x27088669b9ebcfbf0299f571cf839701b8fa0feb48493c375091ca731475411a"
},
{
"amount": "20",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tx": "0x65dfad0fb2e1bae848cd90e03a9ea591b3f64e1419d6c56c3c3eca312fae9d60"
},
{
"amount": "20",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tx": "0x652d4da270a1a5aab43538972a1a9f4922a09791201dac8c9a38386befda71ae"
},
{
"amount": "10",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tx": "0x6d2b8e1ece252bb50488134e9ded53f8f56aa797bb5b04a01253adaa808f1613"
},
{
"amount": "15",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tx": "0x90a80f78b6be0adaf9c8bdf1ab93b89841e1af1680e93af703a833ad3e488c3e"
},
{
"amount": "15",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tx": "0x942b78eab6683f71047e50bd3670399355538ca165c80441a901c200844c2f8d"
},
{
"amount": "45",
"user": "0xa7d3D2CC58e1E82e3ff2C2Cd3B750442522aBbfb",
@@ -19718,6 +19827,16 @@
"user": "0x1D92cb812FdeDF1a5aFE3c5080B2D3Ec102694c6",
"tx": "0xa948116b054b6975ede322752ceb85cc8e8769c88dc12db6bb4b649c8bd57b81"
},
{
"amount": "226",
"user": "0x33Ce1D9E53AFb7367E34749517C086405a651a95",
"tx": "0xa7649e2ff32d75b81dfff739fe3fd84cde827a3121c8d393f97b1078ee028afd"
},
{
"amount": "260",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tx": "0xfcfdc83be424a1379e7735e25f83f7fc9bc69a89607b71f0d6f79f84b18c30e8"
},
{
"amount": "311",
"user": "0xa7d3D2CC58e1E82e3ff2C2Cd3B750442522aBbfb",
@@ -21185,6 +21304,42 @@
"tranche_id": 11,
"tx": "0xaf7a554c2b5b6690bf6798f31973ed34b351dd3d9c81335301d681c259c3f057"
},
{
"amount": "10",
"user": "0x33Ce1D9E53AFb7367E34749517C086405a651a95",
"tranche_id": 11,
"tx": "0xdb1da0991e2a5a044fa1b6e146358e73cf4628762f6f66e01469e3a46d32860b"
},
{
"amount": "20",
"user": "0x33Ce1D9E53AFb7367E34749517C086405a651a95",
"tranche_id": 11,
"tx": "0xd355ef37d32d1c619fcaeccb5d66bfaaecf0e8443f6c9b6b47716e63d31ae27e"
},
{
"amount": "20",
"user": "0x33Ce1D9E53AFb7367E34749517C086405a651a95",
"tranche_id": 11,
"tx": "0x2db47f31526593c9c215db918aee1dccb6e1fcbd56fdef5e41f46fcbb0a34c18"
},
{
"amount": "10",
"user": "0x33Ce1D9E53AFb7367E34749517C086405a651a95",
"tranche_id": 11,
"tx": "0x6d027468ba87f7347a7f8bc69613a60e3c204a5dffeffdc8479456c0d0a32f31"
},
{
"amount": "25",
"user": "0x33Ce1D9E53AFb7367E34749517C086405a651a95",
"tranche_id": 11,
"tx": "0x2f485648f47a24fff0503cada896eca6fee655ca855a93388847ad16a1e71571"
},
{
"amount": "15",
"user": "0x33Ce1D9E53AFb7367E34749517C086405a651a95",
"tranche_id": 11,
"tx": "0x9f8576c559ffa9bc1082c100250e6d3a967b110e34b944240588d69298f5c691"
},
{
"amount": "35",
"user": "0x33Ce1D9E53AFb7367E34749517C086405a651a95",
@@ -21253,6 +21408,12 @@
}
],
"withdrawals": [
{
"amount": "226",
"user": "0x33Ce1D9E53AFb7367E34749517C086405a651a95",
"tranche_id": 11,
"tx": "0xa7649e2ff32d75b81dfff739fe3fd84cde827a3121c8d393f97b1078ee028afd"
},
{
"amount": "570",
"user": "0x33Ce1D9E53AFb7367E34749517C086405a651a95",
@@ -21260,9 +21421,145 @@
"tx": "0x818a1a6d0d22a088d468459803255b5f698843f7a89829991e2dc7920d8e8be6"
}
],
"total_tokens": "696",
"withdrawn_tokens": "570",
"remaining_tokens": "126"
"total_tokens": "796",
"withdrawn_tokens": "796",
"remaining_tokens": "0"
},
{
"address": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"deposits": [
{
"amount": "50",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0x4f2d5ffff22744dd8bca0cd18653e49d05a921e3b37e0ea8e5e8013338044674"
},
{
"amount": "50",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0x64870b69035b9bbc763e488bbcfafe74d7dc9b382a52e574986d99e6057695fd"
},
{
"amount": "20",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0xcc656bde93dc97848442b988966a72e9fb4a27236b999e43aa67ea71a205293a"
},
{
"amount": "60",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0x27088669b9ebcfbf0299f571cf839701b8fa0feb48493c375091ca731475411a"
},
{
"amount": "20",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0x65dfad0fb2e1bae848cd90e03a9ea591b3f64e1419d6c56c3c3eca312fae9d60"
},
{
"amount": "20",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0x652d4da270a1a5aab43538972a1a9f4922a09791201dac8c9a38386befda71ae"
},
{
"amount": "10",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0x6d2b8e1ece252bb50488134e9ded53f8f56aa797bb5b04a01253adaa808f1613"
},
{
"amount": "15",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0x90a80f78b6be0adaf9c8bdf1ab93b89841e1af1680e93af703a833ad3e488c3e"
},
{
"amount": "15",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0x942b78eab6683f71047e50bd3670399355538ca165c80441a901c200844c2f8d"
},
{
"amount": "25",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0xcb1bd76690836eaff41d027f34263a0aaef9f2e6a0dc02cf07a5dae9a2dcd409"
},
{
"amount": "20",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0xf8d79b7f4b99f5f924ec3747514ab1dc76123b9207cf8966a556b3ca99ac46d7"
},
{
"amount": "30",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0x60863c700b947135d53d80d6c76dda6cf9fc6d459040e0cad43bf8bcb14ebae0"
},
{
"amount": "30",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0xca81df945a705678e81d5acf6ff9da732986c394cb25afca898748a5f10f8b3b"
},
{
"amount": "15",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0x2b1077878eb68fdc4254054f2306e04dc35e8e6bb34bcc820a9e62a98078291e"
},
{
"amount": "20",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0xe381861fceca568dd5569246ca2d2a2d9e16af374434dc415d8be66574c44eb5"
},
{
"amount": "20",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0x3e7ccd74a5bece44dad8da2b408d3244312aa6f57ce76cf7ca3097ab637863da"
},
{
"amount": "250",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0xec131ba46616cd98500b4c781e0662938873cf861619a56f7f8deefe8f06c8b2"
},
{
"amount": "35",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0x1ba208cb36de855530b983d90ca432cb08ce9f0de2acb4c39ea3c5aa97191020"
}
],
"withdrawals": [
{
"amount": "260",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0xfcfdc83be424a1379e7735e25f83f7fc9bc69a89607b71f0d6f79f84b18c30e8"
},
{
"amount": "233.1966562025",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0xd72bc34666f08014c60a07fb1ea4fdeb10bf80668132e8b98248d91a613e6773"
},
{
"amount": "211.8033437975",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0x31901f42c25fbd4ee746b0a16cfad2461a361ec7b8367d28560cfd6d767c6d05"
}
],
"total_tokens": "705",
"withdrawn_tokens": "705",
"remaining_tokens": "0"
},
{
"address": "0xa7d3D2CC58e1E82e3ff2C2Cd3B750442522aBbfb",
@@ -30954,82 +31251,6 @@
"withdrawn_tokens": "12",
"remaining_tokens": "0"
},
{
"address": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"deposits": [
{
"amount": "25",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0xcb1bd76690836eaff41d027f34263a0aaef9f2e6a0dc02cf07a5dae9a2dcd409"
},
{
"amount": "20",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0xf8d79b7f4b99f5f924ec3747514ab1dc76123b9207cf8966a556b3ca99ac46d7"
},
{
"amount": "30",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0x60863c700b947135d53d80d6c76dda6cf9fc6d459040e0cad43bf8bcb14ebae0"
},
{
"amount": "30",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0xca81df945a705678e81d5acf6ff9da732986c394cb25afca898748a5f10f8b3b"
},
{
"amount": "15",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0x2b1077878eb68fdc4254054f2306e04dc35e8e6bb34bcc820a9e62a98078291e"
},
{
"amount": "20",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0xe381861fceca568dd5569246ca2d2a2d9e16af374434dc415d8be66574c44eb5"
},
{
"amount": "20",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0x3e7ccd74a5bece44dad8da2b408d3244312aa6f57ce76cf7ca3097ab637863da"
},
{
"amount": "250",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0xec131ba46616cd98500b4c781e0662938873cf861619a56f7f8deefe8f06c8b2"
},
{
"amount": "35",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0x1ba208cb36de855530b983d90ca432cb08ce9f0de2acb4c39ea3c5aa97191020"
}
],
"withdrawals": [
{
"amount": "233.1966562025",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0xd72bc34666f08014c60a07fb1ea4fdeb10bf80668132e8b98248d91a613e6773"
},
{
"amount": "211.8033437975",
"user": "0x89d0dfb58149fB1FF7471983191F6e0eF5c06dCd",
"tranche_id": 11,
"tx": "0x31901f42c25fbd4ee746b0a16cfad2461a361ec7b8367d28560cfd6d767c6d05"
}
],
"total_tokens": "445",
"withdrawn_tokens": "445",
"remaining_tokens": "0"
},
{
"address": "0xD6b97C294530ed250B64f01f26FF4f28dD6436c1",
"deposits": [
@@ -36373,7 +36594,7 @@
"tranche_end": "2023-06-05T00:00:00.000Z",
"total_added": "3732368.4671",
"total_removed": "592998.0503546212334",
"locked_amount": "904460.730779962649016435803",
"locked_amount": "892211.486316038327909139488",
"deposits": [
{
"amount": "1998.95815",
@@ -37698,8 +37919,8 @@
"tranche_start": "2022-06-05T00:00:00.000Z",
"tranche_end": "2023-12-05T00:00:00.000Z",
"total_added": "15870102.715470999700000001",
"total_removed": "565200.35868260229965452",
"locked_amount": "8506847.2650572351820796336345030046634225",
"total_removed": "565985.90378116129377952",
"locked_amount": "8463412.2306648275892962453531986551946473",
"deposits": [
{
"amount": "16249.93",
@@ -38218,6 +38439,16 @@
"user": "0x20CD77B9FC2f1fEDfb6F184E25f7127BFE991C8b",
"tx": "0x3d26957f37b25306381e1cd2f6d9efea7ec698e837c4b2bda76ca2e2ef8028ba"
},
{
"amount": "276.3015353431795",
"user": "0x20CD77B9FC2f1fEDfb6F184E25f7127BFE991C8b",
"tx": "0x71e874dd2277e6c5838399943e59bae3b006a06505fb3eacb81a4a6b6bf51a0b"
},
{
"amount": "509.243563215814625",
"user": "0x20CD77B9FC2f1fEDfb6F184E25f7127BFE991C8b",
"tx": "0x307e498402679f0f8ee11dcd3dfc684db5558203469f07ce58e98a4f87f0fa62"
},
{
"amount": "856.08784586478614",
"user": "0xe3eB4CF43C072658401996b71D43eE26E573562D",
@@ -39914,6 +40145,18 @@
"tranche_id": 2,
"tx": "0x3d26957f37b25306381e1cd2f6d9efea7ec698e837c4b2bda76ca2e2ef8028ba"
},
{
"amount": "276.3015353431795",
"user": "0x20CD77B9FC2f1fEDfb6F184E25f7127BFE991C8b",
"tranche_id": 2,
"tx": "0x71e874dd2277e6c5838399943e59bae3b006a06505fb3eacb81a4a6b6bf51a0b"
},
{
"amount": "509.243563215814625",
"user": "0x20CD77B9FC2f1fEDfb6F184E25f7127BFE991C8b",
"tranche_id": 2,
"tx": "0x307e498402679f0f8ee11dcd3dfc684db5558203469f07ce58e98a4f87f0fa62"
},
{
"amount": "966.75883976995675",
"user": "0x20CD77B9FC2f1fEDfb6F184E25f7127BFE991C8b",
@@ -41056,8 +41299,8 @@
}
],
"total_tokens": "259998.8875",
"withdrawn_tokens": "120493.09714116867225",
"remaining_tokens": "139505.79035883132775"
"withdrawn_tokens": "121278.642239727666375",
"remaining_tokens": "138720.245260272333625"
},
{
"address": "0x89051CAb67Bc7F8CC44F7e270c6EDaf1EC57676c",
@@ -43264,8 +43507,8 @@
"tranche_start": "2021-11-05T00:00:00.000Z",
"tranche_end": "2023-05-05T00:00:00.000Z",
"total_added": "14597706.0446472999",
"total_removed": "3925878.714389493431486282",
"locked_amount": "2132021.85207790252143347026472583",
"total_removed": "3926967.723059737571037032",
"locked_amount": "2091922.905662142960425555307396306",
"deposits": [
{
"amount": "129284.449",
@@ -43489,6 +43732,16 @@
"user": "0x4Aa3c35F6CC2d507E5C18205ee57099A4C80B19b",
"tx": "0xddcbb09fcdd4093749d76f4d5f882339989f87089ebe36ac64c4243b6f09b976"
},
{
"amount": "383.4048631759663005",
"user": "0x4Aa3c35F6CC2d507E5C18205ee57099A4C80B19b",
"tx": "0xf1c4c72d4c71eb26378b943efeafb5c1607a79d5163a2c8ae8c4ddce2afaf7ad"
},
{
"amount": "705.60380706817325025",
"user": "0x4Aa3c35F6CC2d507E5C18205ee57099A4C80B19b",
"tx": "0x532c270b8a50b8e2923a96b038129a203e01eba1b84faf238dd9b48aeceb1c91"
},
{
"amount": "1333.9237119810715295",
"user": "0x4Aa3c35F6CC2d507E5C18205ee57099A4C80B19b",
@@ -46373,6 +46626,18 @@
"tranche_id": 3,
"tx": "0xddcbb09fcdd4093749d76f4d5f882339989f87089ebe36ac64c4243b6f09b976"
},
{
"amount": "383.4048631759663005",
"user": "0x4Aa3c35F6CC2d507E5C18205ee57099A4C80B19b",
"tranche_id": 3,
"tx": "0xf1c4c72d4c71eb26378b943efeafb5c1607a79d5163a2c8ae8c4ddce2afaf7ad"
},
{
"amount": "705.60380706817325025",
"user": "0x4Aa3c35F6CC2d507E5C18205ee57099A4C80B19b",
"tranche_id": 3,
"tx": "0x532c270b8a50b8e2923a96b038129a203e01eba1b84faf238dd9b48aeceb1c91"
},
{
"amount": "1333.9237119810715295",
"user": "0x4Aa3c35F6CC2d507E5C18205ee57099A4C80B19b",
@@ -48817,8 +49082,8 @@
}
],
"total_tokens": "359123.469575",
"withdrawn_tokens": "306480.52161894473088575",
"remaining_tokens": "52642.94795605526911425"
"withdrawn_tokens": "307569.5302891888704365",
"remaining_tokens": "51553.9392858111295635"
},
{
"address": "0xBdd412797c1B78535Afc5F71503b91fAbD0160fB",
@@ -50155,8 +50420,8 @@
"tranche_start": "2021-10-05T00:00:00.000Z",
"tranche_end": "2023-04-05T00:00:00.000Z",
"total_added": "5778205.3912159303",
"total_removed": "2731059.487268130842706642",
"locked_amount": "525471.324576606379867043693736647",
"total_removed": "2749067.463242913023296295",
"locked_amount": "509627.9883313308999788596484477",
"deposits": [
{
"amount": "552496.6455",
@@ -50305,6 +50570,21 @@
"user": "0xBc934494675a6ceB639B9EfEe5b9C0f017D35a75",
"tx": "0x33da947571f71b4cd31f2cfc13cbcceba0bac110b7a975a4793afe40566d3741"
},
{
"amount": "4961.957448767374506",
"user": "0xafa64cCa337eFEE0AD827F6C2684e69275226e90",
"tx": "0x5ee84120105b5caa7e2d12e10bb2aeb234fcfaa416203d8b2b2fe68c06ed535e"
},
{
"amount": "10871.612867264090097103",
"user": "0xC24da173A250e9Ca5c54870639EbE5f88be5102d",
"tx": "0xf39eb46d1f973f6a0bb31a553dc087fa94933994d18b1e46a630490c2b7cbb04"
},
{
"amount": "2174.40565875071598655",
"user": "0x6ae83EAB68b7112BaD5AfD72d6B24546AbFF137D",
"tx": "0xb49529fae65600b960a8a6cebd02a4c8480983d0f399a14167b14462583cac47"
},
{
"amount": "19117.297184565582142",
"user": "0x1dD2718fd01d05C9F50Fce8Bb723A4C7483A1E15",
@@ -51376,6 +51656,12 @@
}
],
"withdrawals": [
{
"amount": "2174.40565875071598655",
"user": "0x6ae83EAB68b7112BaD5AfD72d6B24546AbFF137D",
"tranche_id": 4,
"tx": "0xb49529fae65600b960a8a6cebd02a4c8480983d0f399a14167b14462583cac47"
},
{
"amount": "3751.78499041934834165",
"user": "0x6ae83EAB68b7112BaD5AfD72d6B24546AbFF137D",
@@ -51462,8 +51748,8 @@
}
],
"total_tokens": "92082.572555",
"withdrawn_tokens": "81632.04967906306698935",
"remaining_tokens": "10450.52287593693301065"
"withdrawn_tokens": "83806.4553378137829759",
"remaining_tokens": "8276.1172171862170241"
},
{
"address": "0x1dC9B91DE003fd503F25cB5d114cf0fc68F7aFe6",
@@ -51491,6 +51777,12 @@
}
],
"withdrawals": [
{
"amount": "10871.612867264090097103",
"user": "0xC24da173A250e9Ca5c54870639EbE5f88be5102d",
"tranche_id": 4,
"tx": "0xf39eb46d1f973f6a0bb31a553dc087fa94933994d18b1e46a630490c2b7cbb04"
},
{
"amount": "18757.845956872719119701",
"user": "0xC24da173A250e9Ca5c54870639EbE5f88be5102d",
@@ -51601,8 +51893,8 @@
}
],
"total_tokens": "460415.072915097",
"withdrawn_tokens": "408161.740088926747515886",
"remaining_tokens": "52253.332826170252484114"
"withdrawn_tokens": "419033.352956190837612989",
"remaining_tokens": "41381.719958906162387011"
},
{
"address": "0xd66e4853c0880df150e7329974715BFC8d2da47D",
@@ -51811,6 +52103,12 @@
}
],
"withdrawals": [
{
"amount": "4961.957448767374506",
"user": "0xafa64cCa337eFEE0AD827F6C2684e69275226e90",
"tranche_id": 4,
"tx": "0x5ee84120105b5caa7e2d12e10bb2aeb234fcfaa416203d8b2b2fe68c06ed535e"
},
{
"amount": "4217.129347329502683",
"user": "0xafa64cCa337eFEE0AD827F6C2684e69275226e90",
@@ -52053,8 +52351,8 @@
}
],
"total_tokens": "331498.5873",
"withdrawn_tokens": "296737.775114654365887",
"remaining_tokens": "34760.812185345634113"
"withdrawn_tokens": "301699.732563421740393",
"remaining_tokens": "29798.854736578259607"
},
{
"address": "0x16da609341ed67750A8BCC5AAa2005471006Cd77",
@@ -52149,8 +52447,8 @@
"tranche_start": "2022-06-05T00:00:00.000Z",
"tranche_end": "2023-06-05T00:00:00.000Z",
"total_added": "472355.6199999996",
"total_removed": "33506.6676084772685",
"locked_amount": "143316.972910037919444212273363784",
"total_removed": "33643.6376997992685",
"locked_amount": "141376.01010506265186415083105024",
"deposits": [
{
"amount": "3000",
@@ -58819,6 +59117,21 @@
"user": "0xf016E520e70f12A366a34B24B621F83140f8Be68",
"tx": "0x331607e648b83bb53136291c741c792615a7ece15f2fe0acd6cfa4b33694fc2e"
},
{
"amount": "69.86133942",
"user": "0xb1EAD214506D0cD73785Dd09B3EfC2307133bf69",
"tx": "0x6b3f814e3018e59a0557e5e377ee2b8962db59171fe3bff57e887f58f732c1b5"
},
{
"amount": "48.073439878",
"user": "0xC91A06E92bFc696BCcFcDd564F376beDC40932cF",
"tx": "0x20750a137fa15fd06a1c7b0a40226fd2f78310bba9b2246b1b4ab23db72e5177"
},
{
"amount": "19.035312024",
"user": "0x4F4A140E9B4a8403792970C6b5535ed0AA32c8A4",
"tx": "0x181a9afe9b3592427f1ee10f205e01f34ae7be383294e8d93e397766092bf6df"
},
{
"amount": "13.1116203702",
"user": "0x4cBC0C88d8FE503f62823B42f30a4900292C13bE",
@@ -74021,6 +74334,12 @@
}
],
"withdrawals": [
{
"amount": "69.86133942",
"user": "0xb1EAD214506D0cD73785Dd09B3EfC2307133bf69",
"tranche_id": 5,
"tx": "0x6b3f814e3018e59a0557e5e377ee2b8962db59171fe3bff57e887f58f732c1b5"
},
{
"amount": "72.638508372",
"user": "0xb1EAD214506D0cD73785Dd09B3EfC2307133bf69",
@@ -74035,8 +74354,8 @@
}
],
"total_tokens": "400",
"withdrawn_tokens": "208.782635716",
"remaining_tokens": "191.217364284"
"withdrawn_tokens": "278.643975136",
"remaining_tokens": "121.356024864"
},
{
"address": "0xDcD1000CB62FAE013aEd379d9F1287D828bcEaA3",
@@ -74820,6 +75139,12 @@
}
],
"withdrawals": [
{
"amount": "48.073439878",
"user": "0xC91A06E92bFc696BCcFcDd564F376beDC40932cF",
"tranche_id": 5,
"tx": "0x20750a137fa15fd06a1c7b0a40226fd2f78310bba9b2246b1b4ab23db72e5177"
},
{
"amount": "91.388045408",
"user": "0xC91A06E92bFc696BCcFcDd564F376beDC40932cF",
@@ -74828,8 +75153,8 @@
}
],
"total_tokens": "200",
"withdrawn_tokens": "91.388045408",
"remaining_tokens": "108.611954592"
"withdrawn_tokens": "139.461485286",
"remaining_tokens": "60.538514714"
},
{
"address": "0xca680a0524F1440bA14afA3d6A57D73bBA7E138d",
@@ -75086,6 +75411,12 @@
}
],
"withdrawals": [
{
"amount": "19.035312024",
"user": "0x4F4A140E9B4a8403792970C6b5535ed0AA32c8A4",
"tranche_id": 5,
"tx": "0x181a9afe9b3592427f1ee10f205e01f34ae7be383294e8d93e397766092bf6df"
},
{
"amount": "19.78325723",
"user": "0x4F4A140E9B4a8403792970C6b5535ed0AA32c8A4",
@@ -75112,8 +75443,8 @@
}
],
"total_tokens": "200",
"withdrawn_tokens": "120.858288938",
"remaining_tokens": "79.141711062"
"withdrawn_tokens": "139.893600962",
"remaining_tokens": "60.106399038"
},
{
"address": "0x4DA5acEc788beF3d7F67cEA1c3CfeE1a41c1A21A",
@@ -0,0 +1,143 @@
/// <reference types="cypress" />
import {
createUpdateNetworkProposalTxBody,
createFreeFormProposalTxBody,
} from '../../support/proposal.functions';
const closedProposals = '[data-testid="closed-proposals"]';
const proposalStatus = '[data-testid="proposal-status"]';
const viewProposalButton = '[data-testid="view-proposal-btn"]';
const votesTable = '[data-testid="votes-table"]';
const openProposals = '[data-testid="open-proposals"]';
const proposalVoteProgressForPercentage =
'[data-testid="vote-progress-indicator-percentage-for"]';
const proposalTimeout = { timeout: 8000 };
context(
'Proposal flow - with proposals enacted or failed',
{ tags: '@slow' },
function () {
before('Connect wallets and set approval', function () {
cy.visit('/');
cy.vega_wallet_set_specified_approval_amount('1000');
cy.connectVegaWallet();
cy.ethereum_wallet_connect();
cy.ensure_specified_unstaked_tokens_are_associated(1);
cy.clearLocalStorage();
});
beforeEach('visit proposals', function () {
cy.reload();
cy.wait_for_spinner();
cy.connectVegaWallet();
cy.ethereum_wallet_connect();
cy.navigate_to('proposals');
});
// 3001-VOTE-006
it('Able to view enacted proposal', function () {
const proposalTitle = 'Add Lorem Ipsum market';
cy.createMarket();
cy.reload();
cy.wait_for_spinner();
cy.get(closedProposals).within(() => {
cy.contains(proposalTitle)
.parentsUntil('[data-testid="proposals-list-item"]')
.within(() => {
cy.get(proposalStatus).should('have.text', 'Enacted ');
cy.get(viewProposalButton).click();
});
});
cy.getByTestId('proposal-type').should('have.text', 'New market');
cy.get_proposal_information_from_table('State')
.contains('Enacted')
.and('be.visible');
cy.get(votesTable).within(() => {
cy.contains('Vote passed.').should('be.visible');
cy.contains('Voting has ended.').should('be.visible');
});
});
// 3001-VOTE-046 3001-VOTE-044 3001-VOTE-074 3001-VOTE-074
it('Able to enact proposal by voting', function () {
const proposalTitle = 'Add New proposal with short enactment';
const proposalTx = createUpdateNetworkProposalTxBody();
cy.VegaWalletSubmitProposal(proposalTx);
cy.navigate_to('proposals');
cy.reload();
cy.wait_for_spinner();
cy.get(openProposals).within(() => {
cy.contains(proposalTitle)
.parentsUntil('[data-testid="proposals-list-item"]')
.within(() => cy.get(viewProposalButton).click());
});
cy.get_proposal_information_from_table('State')
.contains('Open')
.and('be.visible');
cy.vote_for_proposal('for');
cy.get_proposal_information_from_table('State') // 3001-VOTE-047
.contains('Passed', proposalTimeout)
.and('be.visible');
cy.get_proposal_information_from_table('State')
.contains('Enacted', proposalTimeout)
.and('be.visible');
cy.get(votesTable).within(() => {
cy.contains('Vote passed.').should('be.visible');
cy.contains('Voting has ended.').should('be.visible');
});
cy.get(proposalVoteProgressForPercentage)
.contains('100.00%')
.and('be.visible');
});
// 3001-VOTE-047
it('Able to enact freeform proposal', function () {
const proposalTitle = 'Add New free form proposal with short enactment';
const proposalTx = createFreeFormProposalTxBody();
cy.VegaWalletSubmitProposal(proposalTx);
cy.navigate_to('proposals');
cy.reload();
cy.wait_for_spinner();
cy.get(openProposals).within(() => {
cy.contains(proposalTitle)
.parentsUntil('[data-testid="proposals-list-item"]')
.within(() => cy.get(viewProposalButton).click());
});
cy.get_proposal_information_from_table('State')
.contains('Open')
.and('be.visible');
cy.vote_for_proposal('for');
cy.get_proposal_information_from_table('State')
.contains('Enacted', proposalTimeout)
.and('be.visible');
});
// 3001-VOTE-048 3001-VOTE-049
it('Able to fail proposal due to lack of participation', function () {
const proposalTitle = 'Add New free form proposal with short enactment';
const proposalTx = createFreeFormProposalTxBody();
cy.VegaWalletSubmitProposal(proposalTx);
cy.navigate_to('proposals');
cy.reload();
cy.wait_for_spinner();
cy.get(openProposals).within(() => {
cy.contains(proposalTitle)
.parentsUntil('[data-testid="proposals-list-item"]')
.within(() => cy.get(viewProposalButton).click());
});
cy.get_proposal_information_from_table('State')
.contains('Open')
.and('be.visible');
cy.get_proposal_information_from_table('State') // 3001-VOTE-047
.contains('Declined', proposalTimeout)
.and('be.visible');
cy.get_proposal_information_from_table('Rejection reason')
.contains('PROPOSAL_ERROR_PARTICIPATION_THRESHOLD_NOT_REACHED')
.and('be.visible');
});
}
);
@@ -1,4 +1,5 @@
/// <reference types="cypress" />
const vegaWalletUnstakedBalance =
'[data-testid="vega-wallet-balance-unstaked"]';
const vegaWalletStakedBalances =
@@ -11,7 +12,6 @@ const newProposalSubmitButton = '[data-testid="proposal-submit"]';
const dialogCloseButton = '[data-testid="dialog-close"]';
const viewProposalButton = '[data-testid="view-proposal-btn"]';
const openProposals = '[data-testid="open-proposals"]';
const closedProposals = '[data-testid="closed-proposals"]';
const proposalVoteProgressForPercentage =
'[data-testid="vote-progress-indicator-percentage-for"]';
const proposalVoteProgressAgainstPercentage =
@@ -23,9 +23,7 @@ const proposalVoteProgressAgainstTokens =
const changeVoteButton = '[data-testid="change-vote-button"]';
const proposalDetailsTitle = '[data-testid="proposal-title"]';
const proposalDetailsDescription = '[data-testid="proposal-description"]';
const proposalStatus = '[data-testid="proposal-status"]';
const rawProposalData = '[data-testid="proposal-data"]';
const votesTable = '[data-testid="votes-table"]';
const minVoteButton = '[data-testid="min-vote"]';
const maxVoteButton = '[data-testid="max-vote"]';
const voteButtons = '[data-testid="vote-buttons"]';
@@ -787,107 +785,6 @@ context(
cy.contains('You voted: Against').should('be.visible');
});
// 3001-VOTE-006
it('Able to view enacted proposal', function () {
cy.createMarket();
cy.reload();
cy.wait_for_spinner();
cy.get(closedProposals).within(() => {
cy.get(proposalDetailsTitle).should(
'have.text',
'Add Lorem Ipsum market'
);
cy.get(proposalStatus).should('have.text', 'Enacted ');
cy.get(viewProposalButton).click();
});
cy.getByTestId('proposal-type').should('have.text', 'New market');
cy.get_proposal_information_from_table('State')
.contains('Enacted')
.and('be.visible');
cy.get(votesTable).within(() => {
cy.contains('Vote passed.').should('be.visible');
cy.contains('Voting has ended.').should('be.visible');
});
});
// 3001-VOTE-047
it('Able to enact freeform proposal', function () {
const proposalTitle = 'Add New free form proposal with short enactment';
cy.ensure_specified_unstaked_tokens_are_associated(
this.minProposerBalance
);
cy.sendWalletTxFreeFormProposal();
cy.navigate_to('proposals');
cy.reload();
cy.wait_for_spinner();
cy.contains(proposalTitle)
.parentsUntil('[data-testid="proposals-list-item"]')
.within(() => cy.get(viewProposalButton).click());
cy.get_proposal_information_from_table('State')
.contains('Open')
.and('be.visible');
cy.vote_for_proposal('for');
cy.get_proposal_information_from_table('State')
.contains('Enacted', epochTimeout)
.and('be.visible');
});
// 3001-VOTE-046 3001-VOTE-044 3001-VOTE-074 3001-VOTE-074
it('Able to enact proposal by voting', function () {
const proposalTitle = 'Add New proposal with short enactment';
cy.ensure_specified_unstaked_tokens_are_associated(
this.minProposerBalance
);
cy.sendWalletTxUpdateNetworkProposal();
cy.navigate_to('proposals');
cy.reload();
cy.wait_for_spinner();
cy.contains(proposalTitle)
.parentsUntil('[data-testid="proposals-list-item"]')
.within(() => cy.get(viewProposalButton).click());
cy.get_proposal_information_from_table('State')
.contains('Open')
.and('be.visible');
cy.vote_for_proposal('for');
cy.get_proposal_information_from_table('State') // 3001-VOTE-047
.contains('Passed', txTimeout)
.and('be.visible');
cy.get_proposal_information_from_table('State')
.contains('Enacted', epochTimeout)
.and('be.visible');
cy.get(votesTable).within(() => {
cy.contains('Vote passed.').should('be.visible');
cy.contains('Voting has ended.').should('be.visible');
});
cy.get(proposalVoteProgressForPercentage)
.contains('100.00%')
.and('be.visible');
});
// 3001-VOTE-048 3001-VOTE-049
it('Able to fail proposal due to lack of participation', function () {
const proposalTitle = 'Add New free form proposal with short enactment';
cy.ensure_specified_unstaked_tokens_are_associated(
this.minProposerBalance
);
cy.sendWalletTxFreeFormProposal();
cy.navigate_to('proposals');
cy.reload();
cy.wait_for_spinner();
cy.contains(proposalTitle)
.parentsUntil('[data-testid="proposals-list-item"]')
.within(() => cy.get(viewProposalButton).click());
cy.get_proposal_information_from_table('State')
.contains('Open')
.and('be.visible');
cy.get_proposal_information_from_table('State') // 3001-VOTE-047
.contains('Declined', txTimeout)
.and('be.visible');
cy.get_proposal_information_from_table('Rejection reason')
.contains('PROPOSAL_ERROR_PARTICIPATION_THRESHOLD_NOT_REACHED')
.and('be.visible');
});
function createRawProposal(proposerBalance) {
if (proposerBalance)
cy.ensure_specified_unstaked_tokens_are_associated(proposerBalance);
+1 -1
View File
@@ -7,7 +7,7 @@ import './governance.functions.js';
import './wallet-eth.functions.js';
import './wallet-teardown.functions.js';
import './wallet-vega.functions.js';
import './proposal.functions.js';
import './proposal.functions.ts';
import registerCypressGrep from '@cypress/grep';
import { aliasGQLQuery } from '@vegaprotocol/cypress';
import { chainIdQuery, statisticsQuery } from '@vegaprotocol/mock';
@@ -1,103 +0,0 @@
import { addSeconds, millisecondsToSeconds } from 'date-fns';
const walletName = Cypress.env('vegaWalletName');
const walletPubKey = Cypress.env('vegaWalletPublicKey');
const walletLocation = Cypress.env('vegaWalletLocation');
const walletPassphraseFile = './src/fixtures/wallet/passphrase';
Cypress.Commands.add('sendWalletTxUpdateNetworkProposal', () => {
const MIN_CLOSE_SEC = 8;
const MIN_ENACT_SEC = 5;
const closingDate = addSeconds(new Date(), MIN_CLOSE_SEC);
const enactmentDate = addSeconds(closingDate, MIN_ENACT_SEC);
const closingTimestamp = millisecondsToSeconds(closingDate.getTime());
const enactmentTimestamp = millisecondsToSeconds(enactmentDate.getTime());
cy.exec(
`vegawallet transaction send --wallet ${walletName} --pubkey ${walletPubKey} -p ${walletPassphraseFile} --network DV '{
"proposalSubmission": {
"rationale": {
"title": "Add New proposal with short enactment",
"description": "E2E enactment test"
},
"terms": {
"updateNetworkParameter": {
"changes": {
"key": "governance.proposal.updateNetParam.minProposerBalance",
"value": "2"
}
},
"closingTimestamp": ${closingTimestamp},
"enactmentTimestamp": ${enactmentTimestamp}
}
}
}' --home ${walletLocation}`,
{ failOnNonZeroExit: false }
)
.its('stderr')
.should('contain', '');
});
Cypress.Commands.add('sendWalletTxUpdateAssetProposal', () => {
const MIN_CLOSE_SEC = 8;
const MIN_ENACT_SEC = 5;
const closingDate = addSeconds(new Date(), MIN_CLOSE_SEC);
const enactmentDate = addSeconds(closingDate, MIN_ENACT_SEC);
const closingTimestamp = millisecondsToSeconds(closingDate.getTime());
const enactmentTimestamp = millisecondsToSeconds(enactmentDate.getTime());
cy.exec(
`vegawallet transaction send --wallet ${walletName} --pubkey ${walletPubKey} -p ${walletPassphraseFile} --network DV '{
"proposalSubmission": {
"rationale": {
"title": "Update Asset set to fail",
"description": "E2E fail test"
},
"terms": {
"updateAsset": {
"assetId": "ebcd94151ae1f0d39a4bde3b21a9c7ae81a80ea4352fb075a92e07608d9c953d",
"changes": {
"quantum": "1",
"erc20": {
"withdrawThreshold": "10",
"lifetimeLimit": "10"
}
}
},
"closingTimestamp": ${closingTimestamp},
"enactmentTimestamp": ${enactmentTimestamp}
}
}
}' --home ${walletLocation}`,
{ failOnNonZeroExit: false }
)
.its('stderr')
.should('contain', '');
});
Cypress.Commands.add('sendWalletTxFreeFormProposal', () => {
const MIN_CLOSE_SEC = 5;
const closingDate = addSeconds(new Date(), MIN_CLOSE_SEC);
const closingTimestamp = millisecondsToSeconds(closingDate.getTime());
cy.exec(
`vegawallet transaction send --wallet ${walletName} --pubkey ${walletPubKey} -p ${walletPassphraseFile} --network DV '{
"proposalSubmission": {
"rationale": {
"title": "Add New free form proposal with short enactment",
"description": "E2E enactment test"
},
"terms": {
"newFreeform": {},
"closingTimestamp": ${closingTimestamp}
}
}
}' --home ${walletLocation}`,
{ failOnNonZeroExit: false }
)
.its('stderr')
.should('contain', '');
});
@@ -0,0 +1,82 @@
import { addSeconds, millisecondsToSeconds } from 'date-fns';
import type { ProposalSubmissionBody } from '@vegaprotocol/wallet';
export function createUpdateNetworkProposalTxBody(): ProposalSubmissionBody {
const MIN_CLOSE_SEC = 5;
const MIN_ENACT_SEC = 7;
const closingDate = addSeconds(new Date(), MIN_CLOSE_SEC);
const enactmentDate = addSeconds(closingDate, MIN_ENACT_SEC);
const closingTimestamp = millisecondsToSeconds(closingDate.getTime());
const enactmentTimestamp = millisecondsToSeconds(enactmentDate.getTime());
return {
proposalSubmission: {
rationale: {
title: 'Add New proposal with short enactment',
description: 'E2E enactment test',
},
terms: {
updateNetworkParameter: {
changes: {
key: 'governance.proposal.updateNetParam.minProposerBalance',
value: '2',
},
},
closingTimestamp,
enactmentTimestamp,
},
},
};
}
export function createUpdateAssetProposalTxBody(): ProposalSubmissionBody {
const MIN_CLOSE_SEC = 5;
const MIN_ENACT_SEC = 7;
const closingDate = addSeconds(new Date(), MIN_CLOSE_SEC);
const enactmentDate = addSeconds(closingDate, MIN_ENACT_SEC);
const closingTimestamp = millisecondsToSeconds(closingDate.getTime());
const enactmentTimestamp = millisecondsToSeconds(enactmentDate.getTime());
return {
proposalSubmission: {
rationale: {
title: 'Update Asset set to fail',
description: 'E2E fail test',
},
terms: {
updateAsset: {
assetId:
'ebcd94151ae1f0d39a4bde3b21a9c7ae81a80ea4352fb075a92e07608d9c953d',
changes: {
quantum: '1',
erc20: {
withdrawThreshold: '10',
lifetimeLimit: '10',
},
},
},
closingTimestamp,
enactmentTimestamp,
},
},
};
}
export function createFreeFormProposalTxBody(): ProposalSubmissionBody {
const MIN_CLOSE_SEC = 7;
const closingDate = addSeconds(new Date(), MIN_CLOSE_SEC);
const closingTimestamp = millisecondsToSeconds(closingDate.getTime());
return {
proposalSubmission: {
rationale: {
title: 'Add New free form proposal with short enactment',
description: 'E2E enactment test',
},
terms: {
newFreeform: {},
closingTimestamp,
},
},
};
}
+6 -6
View File
@@ -24,8 +24,8 @@ import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { useEthereumConfig } from '@vegaprotocol/web3';
import {
useEnvironment,
EnvironmentProvider,
NetworkLoader,
useInitializeEnv,
} from '@vegaprotocol/environment';
import { createConnectors } from './lib/web3-connectors';
import { ENV } from './config/env';
@@ -166,12 +166,12 @@ const AppContainer = () => {
};
function App() {
useInitializeEnv();
return (
<EnvironmentProvider>
<NetworkLoader cache={cache}>
<AppContainer />
</NetworkLoader>
</EnvironmentProvider>
<NetworkLoader cache={cache}>
<AppContainer />
</NetworkLoader>
);
}
+4 -9
View File
@@ -1,23 +1,18 @@
import { render, screen, within } from '@testing-library/react';
import { EnvironmentProvider, Networks } from '@vegaprotocol/environment';
import { MemoryRouter } from 'react-router-dom';
import { Nav } from './nav';
jest.mock('@vegaprotocol/environment', () => ({
...jest.requireActual('@vegaprotocol/environment'),
NetworkSwitcher: () => <div data-testid="network-switcher" />,
useEnvironment: () => ({ VEGA_ENV: 'MAINNET' }),
}));
const renderComponent = (initialEntries?: string[]) => {
return render(
<EnvironmentProvider
definitions={{ VEGA_ENV: Networks.MAINNET }}
config={{ hosts: [] }}
>
<MemoryRouter initialEntries={initialEntries}>
<Nav />
</MemoryRouter>
</EnvironmentProvider>
<MemoryRouter initialEntries={initialEntries}>
<Nav />
</MemoryRouter>
);
};
@@ -17,6 +17,7 @@ const itemValue = 'item-value';
describe('Market proposal notification', { tags: '@smoke' }, () => {
before(() => {
cy.setVegaWallet();
cy.mockTradingPage(
Schema.MarketState.STATE_ACTIVE,
Schema.MarketTradingMode.TRADING_MODE_MONITORING_AUCTION,
@@ -239,7 +240,6 @@ describe('market states not accepting orders', { tags: '@smoke' }, function () {
cy.visit('/#/markets/market-0');
});
it('must display that market is not accepting orders', function () {
cy.getByTestId('place-order').click();
cy.getByTestId('dealticket-error-message-summary').should(
'have.text',
`This market is ${marketState
@@ -145,8 +145,9 @@ describe('markets table', { tags: '@smoke' }, () => {
proposal: { terms: { enactmentDatetime: '2023-01-31 12:00:01' } },
});
});
cy.visit('/');
cy.visit('#/markets/market-0');
cy.url().should('contain', 'market-0');
cy.getByTestId('dialog-close').click();
cy.getByTestId('item-value').contains('Opening auction').realHover();
cy.getByTestId('opening-auction-sub-status').should(
'contain.text',
@@ -155,8 +156,7 @@ describe('markets table', { tags: '@smoke' }, () => {
const now = new Date(Date.parse('2023-01-30 12:00:01')).getTime();
cy.clock(now, ['Date']); // Set "now" to BEFORE reservation
cy.visit('/');
cy.visit('#/markets/market-0');
cy.reload();
cy.getByTestId('item-value').contains('Opening auction').realHover();
cy.getByTestId('opening-auction-sub-status').should(
'contain.text',
@@ -13,7 +13,6 @@ const toggleShort = 'order-side-SIDE_SELL';
const toggleLong = 'order-side-SIDE_BUY';
const toggleLimit = 'order-type-TYPE_LIMIT';
const toggleMarket = 'order-type-TYPE_MARKET';
const errorMessage = 'dealticket-error-message';
const TIFlist = Object.values(Schema.OrderTimeInForce).map((value) => {
return {
@@ -363,11 +362,11 @@ describe('deal ticket validation', { tags: '@smoke' }, () => {
cy.wait('@Markets');
});
it('must not place an order if wallet is not connected', () => {
it('must show place order button and connect wallet if wallet is not connected', () => {
cy.getByTestId('connect-vega-wallet'); // Not connected
cy.getByTestId('order-connect-wallet').should('exist');
cy.getByTestId(placeOrderBtn).should('not.exist');
cy.getByTestId(errorMessage).should('not.exist');
cy.getByTestId(placeOrderBtn).should('exist');
cy.getByTestId('deal-ticket-connect-wallet').should('exist');
});
it('must be able to select order direction - long/short', function () {
@@ -664,13 +663,13 @@ describe('account validation', { tags: '@regression' }, () => {
});
it('should show an error if your balance is zero', () => {
cy.getByTestId('place-order').should('not.be.disabled');
cy.getByTestId('place-order').click();
cy.getByTestId('place-order').should('be.disabled');
//7002-SORD-003
cy.getByTestId('dealticket-error-message-zero-balance').should(
'have.text',
'Insufficient balance. Deposit ' + 'tDAI'
'You need ' +
'tDAI' +
' in your wallet to trade in this market. See all your collateral.Make a deposit'
);
cy.getByTestId('deal-ticket-deposit-dialog-button').should('exist');
});
@@ -708,7 +707,7 @@ describe('account validation', { tags: '@regression' }, () => {
);
cy.getByTestId('dealticket-warning-margin').should(
'contain.text',
'9,999.99 tDAI currently required, 1,000.00 tDAI available'
'9,999.99 tDAI is currently required. You have only 1,000.00 tDAI available.Deposit tDAI'
);
cy.getByTestId('deal-ticket-deposit-dialog-button').click();
cy.getByTestId('dialog-content')
+8 -2
View File
@@ -98,8 +98,14 @@ export const MarketPage = () => {
if (w > 960) {
return <TradeGrid market={data} onSelect={onSelect} />;
}
return <TradePanels market={data} onSelect={onSelect} />;
}, [w, data, onSelect]);
return (
<TradePanels
market={data}
onSelect={onSelect}
onClickCollateral={() => navigate('/portfolio')}
/>
);
}, [w, data, onSelect, navigate]);
if (!data && marketId) {
return (
<Splash>
@@ -111,7 +111,10 @@ const MainGrid = ({
<TradeGridChild>
<Tabs>
<Tab id="ticket" name={t('Ticket')}>
<TradingViews.Ticket marketId={marketId} />
<TradingViews.Ticket
marketId={marketId}
onClickCollateral={() => navigate('/portfolio')}
/>
</Tab>
<Tab id="info" name={t('Info')}>
<TradingViews.Info
@@ -210,15 +213,21 @@ interface TradePanelsProps {
market: Market | null;
onSelect: (marketId: string) => void;
onMarketClick?: (marketId: string) => void;
onClickCollateral: () => void;
}
export const TradePanels = ({ market, onSelect }: TradePanelsProps) => {
export const TradePanels = ({
market,
onSelect,
onClickCollateral,
}: TradePanelsProps) => {
const [view, setView] = useState<TradingView>('Candles');
const renderView = () => {
const Component = memo<{
marketId: string;
onSelect: (marketId: string) => void;
onMarketClick?: (marketId: string) => void;
onClickCollateral: () => void;
}>(TradingViews[view]);
if (!Component) {
@@ -227,7 +236,13 @@ export const TradePanels = ({ market, onSelect }: TradePanelsProps) => {
if (!market) return <Splash>{NO_MARKET}</Splash>;
return <Component marketId={market?.id} onSelect={onSelect} />;
return (
<Component
marketId={market?.id}
onSelect={onSelect}
onClickCollateral={onClickCollateral}
/>
);
};
return (
@@ -0,0 +1,30 @@
import { t } from '@vegaprotocol/react-helpers';
import { Button } from '@vegaprotocol/ui-toolkit';
import { useGlobalStore } from '../../stores';
export const AppFailure = ({
title,
error,
}: {
title: string;
error?: string | null;
}) => {
const { setNodeSwitcher } = useGlobalStore((store) => ({
nodeSwitcherOpen: store.nodeSwitcherDialog,
setNodeSwitcher: (open: boolean) =>
store.update({ nodeSwitcherDialog: open }),
}));
const nonIdealWrapperClasses =
'h-full min-h-screen flex items-center justify-center';
return (
<div className={nonIdealWrapperClasses}>
<div className="text-center">
<h1 className="text-xl mb-4">{title}</h1>
{error && <p className="text-sm mb-8">{error}</p>}
<Button onClick={() => setNodeSwitcher(true)}>
{t('Change node')}
</Button>
</div>
</div>
);
};
@@ -0,0 +1,96 @@
import type { InMemoryCacheConfig } from '@apollo/client';
import {
NetworkLoader,
NodeGuard,
useEnvironment,
} from '@vegaprotocol/environment';
import { t } from '@vegaprotocol/react-helpers';
import { MaintenancePage } from '@vegaprotocol/ui-toolkit';
import { VegaWalletProvider } from '@vegaprotocol/wallet';
import dynamic from 'next/dynamic';
import type { ReactNode } from 'react';
import { AppFailure } from './app-failure';
import { Web3Provider } from './web3-provider';
const DynamicLoader = dynamic(() => import('../preloader/preloader'), {
loading: () => <>Loading...</>,
});
export const AppLoader = ({ children }: { children: ReactNode }) => {
const { error, VEGA_URL, MAINTENANCE_PAGE } = useEnvironment((store) => ({
error: store.error,
VEGA_URL: store.VEGA_URL,
MAINTENANCE_PAGE: store.MAINTENANCE_PAGE,
}));
if (MAINTENANCE_PAGE) {
return <MaintenancePage />;
}
return (
<NetworkLoader
cache={cacheConfig}
skeleton={<DynamicLoader />}
failure={
<AppFailure title={t('Could not initialize app')} error={error} />
}
>
<NodeGuard
skeleton={<DynamicLoader />}
failure={<AppFailure title={t(`Node: ${VEGA_URL} is unsuitable`)} />}
>
<Web3Provider>
<VegaWalletProvider>{children}</VegaWalletProvider>
</Web3Provider>
</NodeGuard>
</NetworkLoader>
);
};
const cacheConfig: InMemoryCacheConfig = {
typePolicies: {
Account: {
keyFields: false,
fields: {
balanceFormatted: {},
},
},
Instrument: {
keyFields: false,
},
TradableInstrument: {
keyFields: ['instrument'],
},
Product: {
keyFields: ['settlementAsset', ['id']],
},
MarketData: {
keyFields: ['market', ['id']],
},
Node: {
keyFields: false,
},
Withdrawal: {
fields: {
pendingOnForeignChain: {
read: (isPending = false) => isPending,
},
},
},
ERC20: {
keyFields: ['contractAddress'],
},
PositionUpdate: {
keyFields: false,
},
AccountUpdate: {
keyFields: false,
},
Party: {
keyFields: false,
},
Fees: {
keyFields: false,
},
},
};
+3 -121
View File
@@ -1,121 +1,3 @@
import type { ReactNode } from 'react';
import { useEffect } from 'react';
import { NetworkLoader, useEnvironment } from '@vegaprotocol/environment';
import type { InMemoryCacheConfig } from '@apollo/client';
import {
useEthereumConfig,
createConnectors,
Web3Provider as Web3ProviderInternal,
useWeb3ConnectStore,
} from '@vegaprotocol/web3';
import { AsyncRenderer, Loader } from '@vegaprotocol/ui-toolkit';
interface AppLoaderProps {
children: ReactNode;
}
/**
* Component to handle any app initialization, startup queries and other things
* that must happen for it can be used
*/
export function AppLoader({ children }: AppLoaderProps) {
return (
<NetworkLoader skeleton={<Loader />} cache={cacheConfig}>
{children}
</NetworkLoader>
);
}
export const Web3Provider = ({ children }: { children: ReactNode }) => {
const { config, loading, error } = useEthereumConfig();
const { ETHEREUM_PROVIDER_URL, ETH_LOCAL_PROVIDER_URL, ETH_WALLET_MNEMONIC } =
useEnvironment();
const [connectors, initializeConnectors] = useWeb3ConnectStore((store) => [
store.connectors,
store.initialize,
]);
useEffect(() => {
if (config?.chain_id) {
return initializeConnectors(
createConnectors(
ETHEREUM_PROVIDER_URL,
Number(config?.chain_id),
ETH_LOCAL_PROVIDER_URL,
ETH_WALLET_MNEMONIC
),
Number(config.chain_id)
);
}
}, [
config?.chain_id,
ETHEREUM_PROVIDER_URL,
initializeConnectors,
ETH_LOCAL_PROVIDER_URL,
ETH_WALLET_MNEMONIC,
]);
return (
<AsyncRenderer
loading={loading}
error={error}
data={connectors}
noDataCondition={(d) => {
if (!d) return true;
return d.length < 1;
}}
>
<Web3ProviderInternal connectors={connectors}>
<>{children}</>
</Web3ProviderInternal>
</AsyncRenderer>
);
};
const cacheConfig: InMemoryCacheConfig = {
typePolicies: {
Account: {
keyFields: false,
fields: {
balanceFormatted: {},
},
},
Instrument: {
keyFields: false,
},
TradableInstrument: {
keyFields: ['instrument'],
},
Product: {
keyFields: ['settlementAsset', ['id']],
},
MarketData: {
keyFields: ['market', ['id']],
},
Node: {
keyFields: false,
},
Withdrawal: {
fields: {
pendingOnForeignChain: {
read: (isPending = false) => isPending,
},
},
},
ERC20: {
keyFields: ['contractAddress'],
},
PositionUpdate: {
keyFields: false,
},
AccountUpdate: {
keyFields: false,
},
Party: {
keyFields: false,
},
Fees: {
keyFields: false,
},
},
};
export * from './app-failure';
export * from './app-loader';
export * from './web3-provider';
@@ -0,0 +1,58 @@
import {
useEthereumConfig,
createConnectors,
Web3Provider as Web3ProviderInternal,
useWeb3ConnectStore,
} from '@vegaprotocol/web3';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { t } from '@vegaprotocol/react-helpers';
import { useEnvironment } from '@vegaprotocol/environment';
import type { ReactNode } from 'react';
import { useEffect } from 'react';
export const Web3Provider = ({ children }: { children: ReactNode }) => {
const { config, loading, error } = useEthereumConfig();
const { ETHEREUM_PROVIDER_URL, ETH_LOCAL_PROVIDER_URL, ETH_WALLET_MNEMONIC } =
useEnvironment();
const [connectors, initializeConnectors] = useWeb3ConnectStore((store) => [
store.connectors,
store.initialize,
]);
useEffect(() => {
if (config?.chain_id) {
return initializeConnectors(
createConnectors(
ETHEREUM_PROVIDER_URL,
Number(config?.chain_id),
ETH_LOCAL_PROVIDER_URL,
ETH_WALLET_MNEMONIC
),
Number(config.chain_id)
);
}
}, [
config?.chain_id,
ETHEREUM_PROVIDER_URL,
initializeConnectors,
ETH_LOCAL_PROVIDER_URL,
ETH_WALLET_MNEMONIC,
]);
return (
<AsyncRenderer
loading={loading}
error={error}
data={connectors}
noDataCondition={(d) => {
if (!d) return true;
return d.length < 1;
}}
noDataMessage={t('Could not fetch Ethereum configuration')}
>
<Web3ProviderInternal connectors={connectors}>
<>{children}</>
</Web3ProviderInternal>
</AsyncRenderer>
);
};
+16 -35
View File
@@ -1,57 +1,38 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { Footer, NodeHealth } from './footer';
import { useEnvironment } from '@vegaprotocol/environment';
import { NodeUrl, NodeHealth } from './footer';
jest.mock('@vegaprotocol/environment');
describe('Footer', () => {
describe('NodeUrl', () => {
it('can open node switcher by clicking the node url', () => {
const mockOpenNodeSwitcher = jest.fn();
const node = 'n99.somenetwork.vega.xyz';
const node = 'https://api.n99.somenetwork.vega.xyz';
// @ts-ignore mock env hook
useEnvironment.mockImplementation(() => ({
VEGA_URL: `https://api.${node}/graphql`,
blockDifference: 0,
setNodeSwitcherOpen: mockOpenNodeSwitcher,
}));
render(<NodeUrl url={node} openNodeSwitcher={mockOpenNodeSwitcher} />);
render(<Footer />);
fireEvent.click(screen.getByText(node));
expect(mockOpenNodeSwitcher).toHaveBeenCalled();
});
it('can open node switcher by clicking health', () => {
const mockOpenNodeSwitcher = jest.fn();
const node = 'n99.somenetwork.vega.xyz';
// @ts-ignore mock env hook
useEnvironment.mockImplementation(() => ({
VEGA_URL: `https://api.${node}/graphql`,
blockDifference: 0,
setNodeSwitcherOpen: mockOpenNodeSwitcher,
}));
render(<Footer />);
fireEvent.click(screen.getByText('Operational'));
fireEvent.click(screen.getByText(/n99/));
expect(mockOpenNodeSwitcher).toHaveBeenCalled();
});
});
describe('NodeHealth', () => {
const mockOpenNodeSwitcher = jest.fn();
const cases = [
{ diff: 0, classname: 'bg-success', text: 'Operational' },
{ diff: 0, classname: 'bg-vega-green-550', text: 'Operational' },
{ diff: 5, classname: 'bg-warning', text: '5 Blocks behind' },
{ diff: -1, classname: 'bg-danger', text: 'Non operational' },
{ diff: null, classname: 'bg-danger', text: 'Non operational' },
];
it.each(cases)(
'renders correct text and indicator color for $diff block difference',
(elem) => {
render(<NodeHealth blockDiff={elem.diff} openNodeSwitcher={jest.fn()} />);
render(
<NodeHealth
blockDiff={elem.diff}
openNodeSwitcher={mockOpenNodeSwitcher}
/>
);
expect(screen.getByTestId('indicator')).toHaveClass(elem.classname);
expect(screen.getByText(elem.text)).toBeInTheDocument();
fireEvent.click(screen.getByText(elem.text));
expect(mockOpenNodeSwitcher).toHaveBeenCalled();
}
);
});
+19 -10
View File
@@ -1,9 +1,15 @@
import { useEnvironment } from '@vegaprotocol/environment';
import { useEnvironment, useNodeHealth } from '@vegaprotocol/environment';
import { t, useNavigatorOnline } from '@vegaprotocol/react-helpers';
import { ButtonLink, Indicator, Intent } from '@vegaprotocol/ui-toolkit';
import { useGlobalStore } from '../../stores';
export const Footer = () => {
const { VEGA_URL, blockDifference, setNodeSwitcherOpen } = useEnvironment();
const { VEGA_URL } = useEnvironment();
const setNodeSwitcher = useGlobalStore(
(store) => (open: boolean) => store.update({ nodeSwitcherDialog: open })
);
const { blockDiff } = useNodeHealth();
return (
<footer className="px-4 py-1 text-xs border-t border-default">
<div className="flex justify-between">
@@ -11,11 +17,14 @@ export const Footer = () => {
{VEGA_URL && (
<>
<NodeHealth
blockDiff={blockDifference}
openNodeSwitcher={setNodeSwitcherOpen}
blockDiff={blockDiff}
openNodeSwitcher={() => setNodeSwitcher(true)}
/>
{' | '}
<NodeUrl url={VEGA_URL} openNodeSwitcher={setNodeSwitcherOpen} />
<NodeUrl
url={VEGA_URL}
openNodeSwitcher={() => setNodeSwitcher(true)}
/>
</>
)}
</div>
@@ -29,7 +38,7 @@ interface NodeUrlProps {
openNodeSwitcher: () => void;
}
const NodeUrl = ({ url, openNodeSwitcher }: NodeUrlProps) => {
export const NodeUrl = ({ url, openNodeSwitcher }: NodeUrlProps) => {
// get base url from api url, api sub domain
const urlObj = new URL(url);
const nodeUrl = urlObj.origin.replace(/^[^.]+\./g, '');
@@ -38,7 +47,7 @@ const NodeUrl = ({ url, openNodeSwitcher }: NodeUrlProps) => {
interface NodeHealthProps {
openNodeSwitcher: () => void;
blockDiff: number;
blockDiff: number | null;
}
// How many blocks behind the most advanced block that is
@@ -57,7 +66,7 @@ export const NodeHealth = ({
if (!online) {
text = t('Offline');
intent = Intent.Danger;
} else if (blockDiff < 0) {
} else if (blockDiff === null) {
// Block height query failed and null was returned
text = t('Non operational');
intent = Intent.Danger;
@@ -67,9 +76,9 @@ export const NodeHealth = ({
}
return (
<span>
<>
<Indicator variant={intent} />
<ButtonLink onClick={openNodeSwitcher}>{text}</ButtonLink>
</span>
</>
);
};
@@ -1,10 +1,12 @@
import { BrowserRouter } from 'react-router-dom';
import { render, screen, fireEvent } from '@testing-library/react';
import { MockedProvider } from '@apollo/client/testing';
import { Networks, EnvironmentProvider } from '@vegaprotocol/environment';
import { Networks, useEnvironment } from '@vegaprotocol/environment';
import { RiskNoticeDialog } from './risk-notice-dialog';
import { WelcomeDialog } from './welcome-dialog';
jest.mock('@vegaprotocol/environment');
const mockEnvDefinitions = {
VEGA_CONFIG_URL: 'https://config.url',
VEGA_URL: 'https://test.url',
@@ -28,15 +30,16 @@ describe('Risk notice dialog', () => {
`(
'$assertion the risk notice on $network',
async ({ assertion, network }) => {
// @ts-ignore ignore mock implementation
useEnvironment.mockImplementation(() => ({
...mockEnvDefinitions,
VEGA_ENV: network,
}));
render(
<EnvironmentProvider
definitions={{ ...mockEnvDefinitions, VEGA_ENV: network }}
config={{ hosts: [] }}
>
<MockedProvider>
<WelcomeDialog />
</MockedProvider>
</EnvironmentProvider>,
<MockedProvider>
<WelcomeDialog />
</MockedProvider>,
{ wrapper: BrowserRouter }
);
@@ -51,14 +54,13 @@ describe('Risk notice dialog', () => {
);
it("doesn't display the risk notice when previously acknowledged", () => {
render(
<EnvironmentProvider
definitions={{ ...mockEnvDefinitions, VEGA_ENV: Networks.MAINNET }}
config={{ hosts: [] }}
>
<RiskNoticeDialog onClose={mockOnClose} />
</EnvironmentProvider>
);
// @ts-ignore ignore mock implementation
useEnvironment.mockImplementation(() => ({
...mockEnvDefinitions,
VEGA_ENV: Networks.MAINNET,
}));
render(<RiskNoticeDialog onClose={mockOnClose} />);
expect(screen.queryByText(introText)).toBeInTheDocument();
+34 -45
View File
@@ -1,11 +1,9 @@
import Head from 'next/head';
import dynamic from 'next/dynamic';
import type { AppProps } from 'next/app';
import { Navbar } from '../components/navbar';
import { t } from '@vegaprotocol/react-helpers';
import {
useEagerConnect as useVegaEagerConnect,
VegaWalletProvider,
useVegaTransactionManager,
useVegaTransactionUpdater,
useVegaWallet,
@@ -17,17 +15,17 @@ import {
useEthWithdrawApprovalsManager,
} from '@vegaprotocol/web3';
import {
EnvironmentProvider,
envTriggerMapping,
Networks,
NodeSwitcherDialog,
useEnvironment,
useInitializeEnv,
} from '@vegaprotocol/environment';
import { AppLoader, Web3Provider } from '../components/app-loader';
import './styles.css';
import './gen-styles.scss';
import { usePageTitleStore } from '../stores';
import { useGlobalStore, usePageTitleStore } from '../stores';
import { Footer } from '../components/footer';
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import DialogsContainer from './dialogs-container';
import ToastsManager from './toasts-manager';
import { HashRouter, useLocation, useSearchParams } from 'react-router-dom';
@@ -35,6 +33,7 @@ import { Connectors } from '../lib/vega-connectors';
import { ViewingBanner } from '../components/viewing-banner';
import { Banner } from '../components/banner';
import classNames from 'classnames';
import { AppLoader } from '../components/app-loader';
const DEFAULT_TITLE = t('Welcome to Vega trading!');
@@ -84,57 +83,47 @@ function AppBody({ Component }: AppProps) {
<meta name="viewport" content="width=device-width, initial-scale=1" />
</Head>
<Title />
<VegaWalletProvider>
<AppLoader>
<Web3Provider>
<div className={gridClasses}>
<Navbar
navbarTheme={VEGA_ENV === Networks.TESTNET ? 'yellow' : 'dark'}
/>
<Banner />
<ViewingBanner />
<main data-testid={location.pathname}>
<Component />
</main>
<Footer />
</div>
<DialogsContainer />
<ToastsManager />
<TransactionsHandler />
<MaybeConnectEagerly />
</Web3Provider>
</AppLoader>
</VegaWalletProvider>
<div className={gridClasses}>
<Navbar
navbarTheme={VEGA_ENV === Networks.TESTNET ? 'yellow' : 'dark'}
/>
<Banner />
<ViewingBanner />
<main data-testid={location.pathname}>
<Component />
</main>
<Footer />
</div>
<DialogsContainer />
<ToastsManager />
<TransactionsHandler />
<MaybeConnectEagerly />
</div>
);
}
const DynamicLoader = dynamic(
() => import('../components/preloader/preloader'),
{
loading: () => <>Loading...</>,
}
);
function VegaTradingApp(props: AppProps) {
const [mounted, setMounted] = useState(false);
const status = useEnvironment((store) => store.status);
const { nodeSwitcherOpen, setNodeSwitcher } = useGlobalStore((store) => ({
nodeSwitcherOpen: store.nodeSwitcherDialog,
setNodeSwitcher: (open: boolean) =>
store.update({ nodeSwitcherDialog: open }),
}));
// Hash router requires access to the document object. At compile time that doesn't exist
// so we need to ensure client side rendering only from this point onwards in
// the component tree
useEffect(() => {
setMounted(true);
}, []);
useInitializeEnv();
if (!mounted) {
return <DynamicLoader />;
// Prevent HashRouter from being server side rendered as it
// relies on presence of document object
if (status === 'default') {
return null;
}
return (
<HashRouter>
<EnvironmentProvider>
<AppLoader>
<AppBody {...props} />
</EnvironmentProvider>
</AppLoader>
<NodeSwitcherDialog open={nodeSwitcherOpen} setOpen={setNodeSwitcher} />
</HashRouter>
);
}
+2 -2
View File
@@ -3,7 +3,7 @@ import { create } from 'zustand';
import produce from 'immer';
interface GlobalStore {
networkSwitcherDialog: boolean;
nodeSwitcherDialog: boolean;
marketId: string | null;
update: (store: Partial<Omit<GlobalStore, 'update'>>) => void;
shouldDisplayWelcomeDialog: boolean;
@@ -16,7 +16,7 @@ interface PageTitleStore {
}
export const useGlobalStore = create<GlobalStore>((set) => ({
networkSwitcherDialog: false,
nodeSwitcherDialog: false,
marketId: LocalStorage.getItem('marketId') || null,
shouldDisplayWelcomeDialog: false,
shouldDisplayAnnouncementBanner: true,
+52
View File
@@ -0,0 +1,52 @@
import type { InMemoryCacheConfig } from '@apollo/client';
export const DEFAULT_CACHE_CONFIG: InMemoryCacheConfig = {
typePolicies: {
Account: {
keyFields: false,
fields: {
balanceFormatted: {},
},
},
Instrument: {
keyFields: false,
},
TradableInstrument: {
keyFields: ['instrument'],
},
Product: {
keyFields: ['settlementAsset', ['id']],
},
MarketData: {
keyFields: ['market', ['id']],
},
Node: {
keyFields: false,
},
Withdrawal: {
fields: {
pendingOnForeignChain: {
read: (isPending = false) => isPending,
},
},
},
ERC20: {
keyFields: ['contractAddress'],
},
PositionUpdate: {
keyFields: false,
},
AccountUpdate: {
keyFields: false,
},
Party: {
keyFields: false,
},
Fees: {
keyFields: false,
},
statistics: {
keyFields: false,
},
},
};
+2
View File
@@ -1 +1,3 @@
export * from './lib/apollo-client';
export * from './cache-config';
export * from './lib/header-store';
+32 -1
View File
@@ -14,6 +14,7 @@ import { onError } from '@apollo/client/link/error';
import { RetryLink } from '@apollo/client/link/retry';
import ApolloLinkTimeout from 'apollo-link-timeout';
import { localLoggerFactory } from '@vegaprotocol/react-helpers';
import { useHeaderStore } from './header-store';
const isBrowser = typeof window !== 'undefined';
@@ -24,6 +25,7 @@ export type ClientOptions = {
cacheConfig?: InMemoryCacheConfig;
retry?: boolean;
connectToDevTools?: boolean;
connectToHeaderStore?: boolean;
};
export function createClient({
@@ -31,6 +33,7 @@ export function createClient({
cacheConfig,
retry = true,
connectToDevTools = true,
connectToHeaderStore = true,
}: ClientOptions) {
if (!url) {
throw new Error('url must be passed into createClient!');
@@ -47,6 +50,28 @@ export function createClient({
const timeoutLink = new ApolloLinkTimeout(10000);
const enlargedTimeoutLink = new ApolloLinkTimeout(100000);
const headerLink = connectToHeaderStore
? new ApolloLink((operation, forward) => {
return forward(operation).map((response) => {
const context = operation.getContext();
const r = context['response'];
const blockHeight = r?.headers.get('x-block-height');
const timestamp = r?.headers.get('x-block-timestamp');
if (blockHeight && timestamp) {
const state = useHeaderStore.getState();
useHeaderStore.setState({
...state,
[r.url]: {
blockHeight: Number(blockHeight),
timestamp: new Date(Number(timestamp.slice(0, -6))),
},
});
}
return response;
});
})
: noOpLink;
const retryLink = retry
? new RetryLink({
delay: {
@@ -104,7 +129,13 @@ export function createClient({
);
return new ApolloClient({
link: from([errorLink, composedTimeoutLink, retryLink, splitLink]),
link: from([
errorLink,
composedTimeoutLink,
retryLink,
headerLink,
splitLink,
]),
cache: new InMemoryCache(cacheConfig),
connectToDevTools,
});
@@ -0,0 +1,12 @@
import { create } from 'zustand';
export interface HeaderEntry {
blockHeight: number;
timestamp: Date;
}
type HeaderStore = {
[url: string]: HeaderEntry | undefined;
};
export const useHeaderStore = create<HeaderStore>(() => ({}));
+2
View File
@@ -17,6 +17,7 @@ import {
import { addMockTransactionResponse } from './lib/commands/mock-transaction-response';
import { addCreateMarket } from './lib/commands/create-market';
import { addConnectPublicKey } from './lib/commands/add-connect-public-key';
import { addVegaWalletSubmitProposal } from './lib/commands/vega-wallet-submit-proposal';
addGetTestIdcommand();
addSlackCommand();
@@ -35,6 +36,7 @@ addSetVegaWallet();
addMockTransactionResponse();
addCreateMarket();
addConnectPublicKey();
addVegaWalletSubmitProposal();
export { mockConnectWallet } from './lib/commands/vega-wallet-connect';
export type { onMessage } from './lib/mock-ws';
@@ -0,0 +1,25 @@
import { createWalletClient, sendVegaTx } from '../capsule/wallet-client';
import type { ProposalSubmissionBody } from '@vegaprotocol/wallet';
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface Chainable<Subject> {
VegaWalletSubmitProposal(proposalTx: ProposalSubmissionBody): void;
}
}
}
export const addVegaWalletSubmitProposal = () => {
Cypress.Commands.add('VegaWalletSubmitProposal', (proposalTx) => {
const vegaWalletUrl = Cypress.env('VEGA_WALLET_URL');
const token = Cypress.env('VEGA_WALLET_API_TOKEN');
const vegaPubKey = Cypress.env('VEGA_PUBLIC_KEY');
createWalletClient(vegaWalletUrl, token);
cy.highlight('Submitting proposal');
sendVegaTx(vegaPubKey, proposalTx);
});
};
@@ -1,5 +1,5 @@
import { formatNumber, t } from '@vegaprotocol/react-helpers';
import { ButtonLink } from '@vegaprotocol/ui-toolkit';
import { Notification, Intent } from '@vegaprotocol/ui-toolkit';
import { DepositDialog, useDepositDialog } from '@vegaprotocol/deposits';
interface Props {
@@ -16,27 +16,23 @@ export const MarginWarning = ({ margin, balance, asset }: Props) => {
const openDepositDialog = useDepositDialog((state) => state.open);
return (
<>
<div
className="text-xs text-warning mb-4"
data-testid="dealticket-warning-margin"
>
<p className="mb-2">
{t('You may not have enough margin available to open this position.')}{' '}
<ButtonLink
data-testid="deal-ticket-deposit-dialog-button"
onClick={() => openDepositDialog(asset.id)}
>
{t(`Deposit ${asset.symbol}`)}
</ButtonLink>
</p>
<p>
{`${formatNumber(margin, asset.decimals)} ${asset.symbol} ${t(
'currently required'
)}, ${formatNumber(balance, asset.decimals)} ${asset.symbol} ${t(
'available'
)}`}
</p>
</div>
<Notification
intent={Intent.Warning}
testId="dealticket-warning-margin"
message={`You may not have enough margin available to open this position. ${formatNumber(
margin,
asset.decimals
)} ${asset.symbol} ${t(
'is currently required. You have only'
)} ${formatNumber(balance, asset.decimals)} ${asset.symbol} ${t(
'available.'
)}`}
buttonProps={{
text: t(`Deposit ${asset.symbol}`),
action: () => openDepositDialog(asset.id),
dataTestId: 'deal-ticket-deposit-dialog-button',
}}
/>
<DepositDialog />
</>
);
@@ -1,26 +1,36 @@
import { t } from '@vegaprotocol/react-helpers';
import { ButtonLink, InputError } from '@vegaprotocol/ui-toolkit';
import { Intent, Notification, Link } from '@vegaprotocol/ui-toolkit';
import { useDepositDialog } from '@vegaprotocol/deposits';
import { t } from '@vegaprotocol/react-helpers';
interface ZeroBalanceErrorProps {
asset: {
id: string;
symbol: string;
};
onClickCollateral: () => void;
}
export const ZeroBalanceError = ({ asset }: ZeroBalanceErrorProps) => {
export const ZeroBalanceError = ({
asset,
onClickCollateral,
}: ZeroBalanceErrorProps) => {
const openDepositDialog = useDepositDialog((state) => state.open);
return (
<InputError data-testid="dealticket-error-message-zero-balance">
<p className="mb-2">
{t('Insufficient balance. ')}
<ButtonLink
data-testid="deal-ticket-deposit-dialog-button"
onClick={() => openDepositDialog(asset.id)}
>
{t(`Deposit ${asset.symbol}`)}
</ButtonLink>
</p>
</InputError>
<Notification
intent={Intent.Warning}
testId="dealticket-error-message-zero-balance"
message={
<>
You need {asset.symbol} in your wallet to trade in this market. See
all your <Link onClick={onClickCollateral}>collateral</Link>.
</>
}
buttonProps={{
text: t(`Make a deposit`),
action: () => openDepositDialog(asset.id),
dataTestId: 'deal-ticket-deposit-dialog-button',
size: 'md',
}}
/>
);
};
@@ -1,7 +1,7 @@
import { t } from '@vegaprotocol/react-helpers';
import type { ButtonVariant } from '@vegaprotocol/ui-toolkit';
import { Button } from '@vegaprotocol/ui-toolkit';
import { useVegaWallet, useVegaWalletDialogStore } from '@vegaprotocol/wallet';
import { useVegaWallet } from '@vegaprotocol/wallet';
interface Props {
disabled: boolean;
@@ -9,32 +9,19 @@ interface Props {
}
export const DealTicketButton = ({ disabled, variant }: Props) => {
const { pubKey } = useVegaWallet();
const openVegaWalletDialog = useVegaWalletDialogStore(
(store) => store.openVegaWalletDialog
);
return pubKey ? (
const { pubKey, isReadOnly } = useVegaWallet();
const isDisabled = !pubKey || isReadOnly || disabled;
return (
<div className="mb-4">
<Button
variant={variant}
fill
type="submit"
disabled={disabled}
disabled={isDisabled}
data-testid="place-order"
>
{t('Place order')}
</Button>
</div>
) : (
<Button
variant="default"
fill
type="button"
data-testid="order-connect-wallet"
onClick={openVegaWalletDialog}
className="mb-6"
>
{t('Connect wallet')}
</Button>
);
};
@@ -8,9 +8,13 @@ import { DealTicket } from './deal-ticket';
export interface DealTicketContainerProps {
marketId: string;
onClickCollateral?: () => void;
}
export const DealTicketContainer = ({ marketId }: DealTicketContainerProps) => {
export const DealTicketContainer = ({
marketId,
onClickCollateral,
}: DealTicketContainerProps) => {
const {
data: market,
error: marketError,
@@ -41,6 +45,7 @@ export const DealTicketContainer = ({ marketId }: DealTicketContainerProps) => {
market={market}
marketData={marketData}
submit={(orderSubmission) => create({ orderSubmission })}
onClickCollateral={onClickCollateral || (() => null)}
/>
) : (
<Splash>
@@ -1,4 +1,4 @@
import { FormGroup, Input, InputError } from '@vegaprotocol/ui-toolkit';
import { FormGroup, Input, NotificationError } from '@vegaprotocol/ui-toolkit';
import { t, toDecimal, validateAmount } from '@vegaprotocol/react-helpers';
import type { DealTicketAmountProps } from './deal-ticket-amount';
@@ -20,17 +20,17 @@ export const DealTicketLimitAmount = ({
const renderError = () => {
if (sizeError) {
return (
<InputError data-testid="dealticket-error-message-size-limit">
<NotificationError testId="dealticket-error-message-size-limit">
{sizeError}
</InputError>
</NotificationError>
);
}
if (priceError) {
return (
<InputError data-testid="dealticket-error-message-price-limit">
<NotificationError testId="dealticket-error-message-price-limit">
{priceError}
</InputError>
</NotificationError>
);
}
@@ -4,7 +4,7 @@ import {
toDecimal,
validateAmount,
} from '@vegaprotocol/react-helpers';
import { Input, InputError, Tooltip } from '@vegaprotocol/ui-toolkit';
import { Input, NotificationError, Tooltip } from '@vegaprotocol/ui-toolkit';
import { isMarketInAuction } from '../../utils';
import type { DealTicketAmountProps } from './deal-ticket-amount';
import { getMarketPrice } from '../../utils/get-price';
@@ -77,12 +77,12 @@ export const DealTicketMarketAmount = ({
</div>
</div>
{sizeError && (
<InputError
<NotificationError
intent="danger"
data-testid="dealticket-error-message-size-market"
testId="dealticket-error-message-size-market"
>
{sizeError}
</InputError>
</NotificationError>
)}
</div>
);
@@ -1,12 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { VegaWalletContext } from '@vegaprotocol/wallet';
import {
fireEvent,
render,
screen,
act,
waitFor,
} from '@testing-library/react';
import { fireEvent, render, screen, act } from '@testing-library/react';
import { generateMarket, generateMarketData } from '../../test-helpers';
import { DealTicket } from './deal-ticket';
import * as Schema from '@vegaprotocol/types';
@@ -15,14 +9,6 @@ import type { MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing';
import type { ChainIdQuery } from '@vegaprotocol/react-helpers';
import { ChainIdDocument, addDecimal } from '@vegaprotocol/react-helpers';
import * as utils from '../../utils';
let mockHasNoBalance = false;
jest.mock('../../hooks/use-has-no-balance', () => {
return {
useHasNoBalance: () => mockHasNoBalance,
};
});
const market = generateMarket();
const marketData = generateMarketData();
@@ -148,50 +134,6 @@ describe('DealTicket', () => {
);
});
it('validation should be reset', async () => {
mockHasNoBalance = true;
jest.spyOn(utils, 'validateMarketState').mockReturnValue('Wrong state');
jest
.spyOn(utils, 'validateMarketTradingMode')
.mockReturnValue('Wrong trading mode');
const { rerender } = render(generateJsx());
await act(async () => {
fireEvent.click(screen.getByTestId('place-order'));
});
await waitFor(async () => {
expect(
await screen.getByTestId('dealticket-error-message-summary')
).toHaveTextContent('Wrong state');
});
jest.spyOn(utils, 'validateMarketState').mockReturnValue(true);
await act(async () => {
rerender(generateJsx());
});
await act(async () => {
fireEvent.click(screen.getByTestId('place-order'));
});
await waitFor(async () => {
expect(
await screen.getByTestId('dealticket-error-message-zero-balance')
).toHaveTextContent('Insufficient balance.');
});
mockHasNoBalance = false;
await act(async () => {
rerender(generateJsx());
});
await act(async () => {
fireEvent.click(screen.getByTestId('place-order'));
});
await waitFor(async () => {
expect(
await screen.getByTestId('dealticket-error-message-summary')
).toHaveTextContent('Wrong trading mode');
});
});
it('can edit deal ticket', async () => {
render(generateJsx());
@@ -10,9 +10,15 @@ import { SideSelector } from './side-selector';
import { TimeInForceSelector } from './time-in-force-selector';
import { TypeSelector } from './type-selector';
import type { OrderSubmissionBody } from '@vegaprotocol/wallet';
import { useVegaWalletDialogStore } from '@vegaprotocol/wallet';
import { normalizeOrderSubmission } from '@vegaprotocol/wallet';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { InputError } from '@vegaprotocol/ui-toolkit';
import {
ExternalLink,
NotificationError,
Intent,
Notification,
} from '@vegaprotocol/ui-toolkit';
import { useOrderMarginValidation } from '../../hooks/use-order-margin-validation';
import { MarginWarning } from '../deal-ticket-validation/margin-warning';
import {
@@ -37,6 +43,7 @@ export interface DealTicketProps {
market: Market;
marketData: MarketData;
submit: (order: OrderSubmissionBody['orderSubmission']) => void;
onClickCollateral?: () => void;
}
export type DealTicketFormFields = OrderSubmissionBody['orderSubmission'] & {
@@ -45,7 +52,12 @@ export type DealTicketFormFields = OrderSubmissionBody['orderSubmission'] & {
summary: string;
};
export const DealTicket = ({ market, marketData, submit }: DealTicketProps) => {
export const DealTicket = ({
market,
marketData,
submit,
onClickCollateral,
}: DealTicketProps) => {
const { pubKey, isReadOnly } = useVegaWallet();
const { getPersistedOrder, setPersistedOrder } = usePersistedOrderStore(
(store) => ({
@@ -87,6 +99,44 @@ export const DealTicket = ({ market, marketData, submit }: DealTicketProps) => {
const marketTradingModeError = validateMarketTradingMode(
marketData.marketTradingMode
);
const checkForErrors = useCallback(() => {
if (!pubKey) {
setError('summary', { message: t('No public key selected') });
return;
}
if (marketStateError !== true) {
setError('summary', {
message: marketStateError,
type: SummaryValidationType.MarketState,
});
return;
}
if (hasNoBalance) {
setError('summary', {
message: SummaryValidationType.NoCollateral,
type: SummaryValidationType.NoCollateral,
});
return;
}
if (marketTradingModeError !== true) {
setError('summary', {
message: marketTradingModeError,
type: SummaryValidationType.TradingMode,
});
return;
}
}, [
hasNoBalance,
marketStateError,
marketTradingModeError,
pubKey,
setError,
]);
useEffect(() => {
if (
(!hasNoBalance &&
@@ -98,6 +148,7 @@ export const DealTicket = ({ market, marketData, submit }: DealTicketProps) => {
) {
clearErrors('summary');
}
checkForErrors();
}, [
hasNoBalance,
marketStateError,
@@ -105,39 +156,12 @@ export const DealTicket = ({ market, marketData, submit }: DealTicketProps) => {
clearErrors,
errors.summary?.message,
errors.summary?.type,
checkForErrors,
]);
const onSubmit = useCallback(
(order: OrderSubmissionBody['orderSubmission']) => {
if (!pubKey) {
setError('summary', { message: t('No public key selected') });
return;
}
if (marketStateError !== true) {
setError('summary', {
message: marketStateError,
type: SummaryValidationType.MarketState,
});
return;
}
if (hasNoBalance) {
setError('summary', {
message: SummaryValidationType.NoCollateral,
type: SummaryValidationType.NoCollateral,
});
return;
}
if (marketTradingModeError !== true) {
setError('summary', {
message: marketTradingModeError,
type: SummaryValidationType.TradingMode,
});
return;
}
checkForErrors();
submit(
normalizeOrderSubmission(
order,
@@ -146,16 +170,7 @@ export const DealTicket = ({ market, marketData, submit }: DealTicketProps) => {
)
);
},
[
submit,
pubKey,
hasNoBalance,
market.positionDecimalPlaces,
market.decimalPlaces,
marketStateError,
marketTradingModeError,
setError,
]
[checkForErrors, submit, market.decimalPlaces, market.positionDecimalPlaces]
);
return (
@@ -233,16 +248,18 @@ export const DealTicket = ({ market, marketData, submit }: DealTicketProps) => {
)}
/>
)}
<DealTicketButton
disabled={Object.keys(errors).length >= 1 || isReadOnly}
variant={order.side === Schema.Side.SIDE_BUY ? 'ternary' : 'secondary'}
/>
<SummaryMessage
errorMessage={errors.summary?.message}
market={market}
marketData={marketData}
order={order}
isReadOnly={isReadOnly}
pubKey={pubKey}
onClickCollateral={onClickCollateral || (() => null)}
/>
<DealTicketButton
disabled={Object.keys(errors).length >= 1 || isReadOnly}
variant={order.side === Schema.Side.SIDE_BUY ? 'ternary' : 'secondary'}
/>
<DealTicketFeeDetails
order={order}
@@ -263,6 +280,8 @@ interface SummaryMessageProps {
marketData: MarketData;
order: OrderSubmissionBody['orderSubmission'];
isReadOnly: boolean;
pubKey: string | null;
onClickCollateral: () => void;
}
const SummaryMessage = memo(
({
@@ -271,30 +290,60 @@ const SummaryMessage = memo(
marketData,
order,
isReadOnly,
pubKey,
onClickCollateral,
}: SummaryMessageProps) => {
// Specific error UI for if balance is so we can
// render a deposit dialog
const asset = market.tradableInstrument.instrument.product.settlementAsset;
const assetSymbol = asset.symbol;
const { balanceError, balance, margin } = useOrderMarginValidation({
market,
marketData,
order,
});
const openVegaWalletDialog = useVegaWalletDialogStore(
(store) => store.openVegaWalletDialog
);
if (isReadOnly) {
return (
<div className="mb-4">
<InputError data-testid="dealticket-error-message-summary">
<NotificationError testId="dealticket-error-message-summary">
{
'You need to connect your own wallet to start trading on this market'
}
</InputError>
</NotificationError>
</div>
);
}
if (!pubKey) {
return (
<Notification
testId={'deal-ticket-connect-wallet'}
intent={Intent.Warning}
message={
<p className="text-sm pb-2">
You need a{' '}
<ExternalLink href="https://vega.xyz/wallet">
Vega wallet
</ExternalLink>{' '}
with {assetSymbol} to start trading in this market.
</p>
}
buttonProps={{
text: t('Connect wallet'),
action: openVegaWalletDialog,
dataTestId: 'order-connect-wallet',
size: 'md',
}}
/>
);
}
if (errorMessage === SummaryValidationType.NoCollateral) {
return (
<ZeroBalanceError
asset={market.tradableInstrument.instrument.product.settlementAsset}
onClickCollateral={onClickCollateral}
/>
);
}
@@ -304,9 +353,9 @@ const SummaryMessage = memo(
if (errorMessage) {
return (
<div className="mb-4">
<InputError data-testid="dealticket-error-message-summary">
<NotificationError testId="dealticket-error-message-summary">
{errorMessage}
</InputError>
</NotificationError>
</div>
);
}
@@ -326,14 +375,13 @@ const SummaryMessage = memo(
].includes(marketData.marketTradingMode)
) {
return (
<div
className="text-sm text-warning mb-4"
data-testid="dealticket-warning-auction"
>
<p>
{t('Any orders placed now will not trade until the auction ends')}
</p>
</div>
<Notification
intent={Intent.Warning}
testId={'dealticket-warning-auction'}
message={t(
'Any orders placed now will not trade until the auction ends'
)}
/>
);
}
@@ -1,4 +1,4 @@
import { FormGroup, Input, InputError } from '@vegaprotocol/ui-toolkit';
import { FormGroup, Input, NotificationError } from '@vegaprotocol/ui-toolkit';
import { formatForInput } from '@vegaprotocol/react-helpers';
import { t } from '@vegaprotocol/react-helpers';
import type { UseFormRegister } from 'react-hook-form';
@@ -35,9 +35,9 @@ export const ExpirySelector = ({
})}
/>
{errorMessage && (
<InputError data-testid="dealticket-error-message-expiry">
<NotificationError testId="dealticket-error-message-expiry">
{errorMessage}
</InputError>
</NotificationError>
)}
</FormGroup>
);
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import {
FormGroup,
InputError,
NotificationError,
Select,
Tooltip,
} from '@vegaprotocol/ui-toolkit';
@@ -139,9 +139,9 @@ export const TimeInForceSelector = ({
))}
</Select>
{errorMessage && (
<InputError data-testid="dealticket-error-message-tif">
<NotificationError testId="dealticket-error-message-tif">
{renderError(errorMessage)}
</InputError>
</NotificationError>
)}
</FormGroup>
);
@@ -1,4 +1,8 @@
import { FormGroup, InputError, Tooltip } from '@vegaprotocol/ui-toolkit';
import {
FormGroup,
NotificationError,
Tooltip,
} from '@vegaprotocol/ui-toolkit';
import { DataGrid, t } from '@vegaprotocol/react-helpers';
import * as Schema from '@vegaprotocol/types';
import { Toggle } from '@vegaprotocol/ui-toolkit';
@@ -78,9 +82,9 @@ export const TypeSelector = ({
onChange={(e) => onSelect(e.target.value as Schema.OrderType)}
/>
{errorMessage && (
<InputError data-testid="dealticket-error-message-type">
<NotificationError testId="dealticket-error-message-type">
{renderError(errorMessage as MarketModeValidationType)}
</InputError>
</NotificationError>
)}
</FormGroup>
);
+18
View File
@@ -0,0 +1,18 @@
import { act } from 'react-dom/test-utils';
const zu = jest.requireActual('zustand'); // if using jest
// a variable to hold reset functions for all stores declared in the app
const storeResetFns = new Set();
// when creating a store, we get its initial state, create a reset function and add it in the set
export const create = (createState) => {
const store = zu.create(createState);
const initialState = store.getState();
storeResetFns.add(() => store.setState(initialState, true));
return store;
};
// Reset all stores after each test run
beforeEach(() => {
act(() => storeResetFns.forEach((resetFn) => resetFn()));
});
+1 -1
View File
@@ -1,4 +1,4 @@
export * from './network-loader';
export * from './network-switcher';
export * from './node-guard';
export * from './node-switcher';
export * from './node-switcher-dialog';
@@ -23,6 +23,7 @@ describe('Network loader', () => {
// @ts-ignore Typescript doesn't recognise mocked instances
useEnvironment.mockImplementation(() => ({
VEGA_URL: undefined,
status: 'success',
}));
render(
@@ -30,7 +31,7 @@ describe('Network loader', () => {
);
expect(screen.getByText(SKELETON_TEXT)).toBeInTheDocument();
expect(() => screen.getByText(SUCCESS_TEXT)).toThrow();
expect(screen.queryByText(SUCCESS_TEXT)).not.toBeInTheDocument();
expect(createClient).not.toHaveBeenCalled();
});
@@ -41,6 +42,7 @@ describe('Network loader', () => {
// @ts-ignore Typescript doesn't recognise mocked instances
useEnvironment.mockImplementation(() => ({
VEGA_URL: 'http://vega.node',
status: 'success',
}));
render(
@@ -50,6 +52,6 @@ describe('Network loader', () => {
url: 'http://vega.node',
cacheConfig: undefined,
});
expect(await screen.findByText(SUCCESS_TEXT)).toBeInTheDocument();
expect(screen.getByText(SUCCESS_TEXT)).toBeInTheDocument();
});
});
@@ -8,32 +8,40 @@ import { createClient } from '@vegaprotocol/apollo-client';
type NetworkLoaderProps = {
children?: ReactNode;
skeleton?: ReactNode;
failure?: ReactNode;
cache?: InMemoryCacheConfig;
};
export function NetworkLoader({
skeleton,
failure,
children,
cache,
}: NetworkLoaderProps) {
const { VEGA_URL } = useEnvironment();
const { status, VEGA_URL } = useEnvironment((store) => ({
status: store.status,
VEGA_URL: store.VEGA_URL,
}));
const client = useMemo(() => {
if (VEGA_URL) {
if (status === 'success' && VEGA_URL) {
return createClient({
url: VEGA_URL,
cacheConfig: cache,
});
}
return undefined;
}, [VEGA_URL, cache]);
}, [VEGA_URL, status, cache]);
if (!client) {
return (
<div className="h-full min-h-screen flex items-center justify-center">
{skeleton}
</div>
);
const nonIdealWrapperClasses =
'h-full min-h-screen flex items-center justify-center';
if (status === 'failed') {
return <div className={nonIdealWrapperClasses}>{failure}</div>;
}
if (status === 'default' || status === 'pending' || !client) {
return <div className={nonIdealWrapperClasses}>{skeleton}</div>;
}
return <ApolloProvider client={client}>{children}</ApolloProvider>;
@@ -0,0 +1 @@
export * from './node-guard';
@@ -0,0 +1,26 @@
import type { ReactNode } from 'react';
import { useStatisticsQuery } from '../../utils/__generated__/Node';
export const NodeGuard = ({
children,
failure,
skeleton,
}: {
children: ReactNode;
failure: ReactNode;
skeleton: ReactNode;
}) => {
const { error, loading } = useStatisticsQuery();
const wrapperClasses = 'h-full min-h-screen flex items-center justify-center';
if (loading) {
return <div className={wrapperClasses}>{skeleton}</div>;
}
if (error) {
return <div className={wrapperClasses}>{failure}</div>;
}
// eslint-disable-next-line
return <>{children}</>;
};
@@ -1 +0,0 @@
export * from './node-switcher-dialog';
@@ -1,59 +0,0 @@
import type { ComponentProps } from 'react';
import { Dialog, Loader } from '@vegaprotocol/ui-toolkit';
import { t } from '@vegaprotocol/react-helpers';
import { NodeSwitcher } from '../node-switcher';
import { useEnvironment } from '../../hooks/use-environment';
import type { Configuration } from '../../types';
type NodeSwitcherDialogProps = Pick<
ComponentProps<typeof NodeSwitcher>,
'initialErrorType' | 'onConnect'
> & {
loading: boolean;
config?: Configuration;
dialogOpen: boolean;
setDialogOpen: (dialogOpen: boolean) => void;
};
export const NodeSwitcherDialog = ({
config,
loading,
initialErrorType,
dialogOpen,
setDialogOpen,
onConnect,
}: NodeSwitcherDialogProps) => {
const { VEGA_ENV } = useEnvironment();
return (
<Dialog open={dialogOpen} onChange={setDialogOpen} size="medium">
<div className="uppercase text-xl text-center mb-2">
{t('Connected node')}
</div>
{!config && loading && (
<div className="py-8">
<p className="mb-4 text-center">{t('Loading configuration...')}</p>
<Loader size="large" />
</div>
)}
{config && dialogOpen && (
<>
<p className="mb-2 text-center">
{t(`This app will only work on a `)}
<span className="font-mono capitalize">
{VEGA_ENV.toLowerCase()}
</span>
{t(' chain ID')}
</p>
<NodeSwitcher
config={config}
initialErrorType={initialErrorType}
onConnect={(url) => {
onConnect(url);
setDialogOpen(false);
}}
/>
</>
)}
</Dialog>
);
};
@@ -0,0 +1,25 @@
import { ApolloProvider } from '@apollo/client';
import { createClient } from '@vegaprotocol/apollo-client';
import type { ReactNode } from 'react';
import { useMemo } from 'react';
export const ApolloWrapper = ({
url,
children,
}: {
url: string;
children: ReactNode;
}) => {
const client = useMemo(
() =>
createClient({
url,
cacheConfig: undefined,
retry: false,
connectToDevTools: false,
connectToHeaderStore: true,
}),
[url]
);
return <ApolloProvider client={client}>{children}</ApolloProvider>;
};
@@ -1 +1,2 @@
export * from './node-switcher-dialog';
export * from './node-switcher';
@@ -9,7 +9,7 @@ export const LayoutRow = ({ children, dataTestId }: LayoutRowProps) => {
return (
<div
data-testid={dataTestId}
className="lg:grid lg:gap-2 py-2 w-full lg:grid-cols-[minmax(200px,_1fr),_150px_125px_100px]"
className="lg:grid lg:gap-2 py-2 w-full lg:grid-cols-[minmax(200px,_1fr),_125px_125px_125px]"
>
{children}
</div>
@@ -1,34 +0,0 @@
import { useEffect } from 'react';
import { useStatisticsQuery } from '../../utils/__generated__/Node';
type NodeBlockHeightProps = {
value?: number;
setValue: (value: number) => void;
};
const POLL_INTERVAL = 3000;
export const NodeBlockHeight = ({ value, setValue }: NodeBlockHeightProps) => {
const { data, startPolling, stopPolling } = useStatisticsQuery({
pollInterval: POLL_INTERVAL,
});
useEffect(() => {
const handleStartPoll = () => startPolling(POLL_INTERVAL);
const handleStopPoll = () => stopPolling();
window.addEventListener('blur', handleStopPoll);
window.addEventListener('focus', handleStartPoll);
return () => {
window.removeEventListener('blur', handleStopPoll);
window.removeEventListener('focus', handleStartPoll);
};
}, [startPolling, stopPolling]);
useEffect(() => {
if (data?.statistics?.blockHeight) {
setValue(Number(data.statistics.blockHeight));
}
}, [setValue, data?.statistics?.blockHeight]);
return <span>{value ?? '-'}</span>;
};
@@ -1,137 +0,0 @@
import type { ReactNode } from 'react';
import { ApolloProvider } from '@apollo/client';
import { t } from '@vegaprotocol/react-helpers';
import type { NodeData } from '../../types';
import { LayoutRow } from './layout-row';
import { LayoutCell } from './layout-cell';
import { NodeBlockHeight } from './node-block-height';
import type { createClient } from '@vegaprotocol/apollo-client';
type NodeStatsContentProps = {
data?: NodeData;
highestBlock: number;
setBlock: (value: number) => void;
children?: ReactNode;
dataTestId?: string;
};
const getResponseTimeDisplayValue = (
responseTime?: NodeData['responseTime']
) => {
if (typeof responseTime?.value === 'number') {
return `${Number(responseTime.value).toFixed(2)}ms`;
}
if (responseTime?.hasError) {
return t('n/a');
}
return '-';
};
const getBlockDisplayValue = (
block: NodeData['block'] | undefined,
setBlock: (block: number) => void
) => {
if (block?.value) {
return <NodeBlockHeight value={block?.value} setValue={setBlock} />;
}
if (block?.hasError) {
return t('n/a');
}
return '-';
};
const getSubscriptionDisplayValue = (
subscription?: NodeData['subscription']
) => {
if (subscription?.value) {
return t('Yes');
}
if (subscription?.hasError) {
return t('No');
}
return '-';
};
const NodeStatsContent = ({
// @ts-ignore Allow defaulting to an empty object
data = {},
highestBlock,
setBlock,
children,
dataTestId,
}: NodeStatsContentProps) => {
return (
<LayoutRow dataTestId={dataTestId}>
{children}
<LayoutCell
label={t('Response time')}
isLoading={data.responseTime?.isLoading}
hasError={data.responseTime?.hasError}
dataTestId="response-time-cell"
>
{getResponseTimeDisplayValue(data.responseTime)}
</LayoutCell>
<LayoutCell
label={t('Block')}
isLoading={data.block?.isLoading}
hasError={
data.block?.hasError ||
(!!data.block?.value && highestBlock > data.block.value)
}
dataTestId="block-cell"
>
{getBlockDisplayValue(data.block, setBlock)}
</LayoutCell>
<LayoutCell
label={t('Subscription')}
isLoading={data.subscription?.isLoading}
hasError={data.subscription?.hasError}
dataTestId="subscription-cell"
>
{getSubscriptionDisplayValue(data.subscription)}
</LayoutCell>
</LayoutRow>
);
};
type WrapperProps = {
client?: ReturnType<typeof createClient>;
children: ReactNode;
};
const Wrapper = ({ client, children }: WrapperProps) => {
if (client) {
return <ApolloProvider client={client}>{children}</ApolloProvider>;
}
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{children}</>;
};
export type NodeStatsProps = {
data?: NodeData;
client?: ReturnType<typeof createClient>;
highestBlock: number;
setBlock: (value: number) => void;
children?: ReactNode;
};
export const NodeStats = ({
data,
client,
highestBlock,
children,
setBlock,
}: NodeStatsProps) => {
return (
<Wrapper client={client}>
<NodeStatsContent
data={data}
highestBlock={highestBlock}
setBlock={setBlock}
dataTestId="node-row"
>
{children}
</NodeStatsContent>
</Wrapper>
);
};
@@ -0,0 +1,16 @@
import { Dialog } from '@vegaprotocol/ui-toolkit';
import { NodeSwitcher } from './node-switcher';
export const NodeSwitcherDialog = ({
open,
setOpen,
}: {
open: boolean;
setOpen: (x: boolean) => void;
}) => {
return (
<Dialog open={open} onChange={setOpen} size="medium">
<NodeSwitcher closeDialog={() => setOpen(false)} />
</Dialog>
);
};
@@ -1,584 +0,0 @@
import { useState } from 'react';
import { MockedProvider } from '@apollo/react-testing';
import { render, screen, fireEvent } from '@testing-library/react';
import { useEnvironment } from '../../hooks/use-environment';
import { useNodes } from '../../hooks/use-nodes';
import createMockClient from '../../hooks/mocks/apollo-client';
import { StatisticsDocument } from '../../utils/__generated__/Node';
import { NodeSwitcher } from './node-switcher';
import { getErrorByType } from '../../utils/validate-node';
import type { Configuration, NodeData } from '../../types';
import { Networks, ErrorType, CUSTOM_NODE_KEY } from '../../types';
type NodeDataProp = 'responseTime' | 'block' | 'chain' | 'subscription';
jest.mock('../../hooks/use-environment');
jest.mock('../../hooks/use-nodes');
const mockNodesImplementation =
(
updateNodeUrlMock: jest.Mock,
getNodeState: typeof getValidNodeState = getValidNodeState
) =>
(config: Configuration) => {
const [{ state, clients }, setImplementation] = useState<{
state: Record<string, NodeData>;
clients: Record<string, ReturnType<typeof createMockClients>>;
}>({
state: createMockState(Networks.TESTNET, config.hosts),
clients: createMockClients(config.hosts),
});
return {
state,
clients,
updateNodeUrl: updateNodeUrlMock.mockImplementation(
(node: string, url: string) => {
setImplementation((prev) => ({
state: {
...prev.state,
[node]: getNodeState(Networks.TESTNET, url),
},
clients: {
...prev.clients,
[node]: createMockClient({ network: Networks.TESTNET }),
},
}));
}
),
updateNodeBlock: (node: string, value: number) => {
setImplementation((prev) => ({
state: {
...prev.state,
[node]: {
...prev.state[node],
block: {
...prev.state[node].block,
value,
},
},
},
clients: prev.clients,
}));
},
};
};
const statsQueryMock = {
request: {
query: StatisticsDocument,
},
result: {
data: {
statistics: {
blockHeight: 1234,
},
},
},
};
const onConnect = jest.fn();
const HOSTS = ['https://host1.com', 'https://host2.com'];
const enum STATES {
LOADING = 'is loading',
HAS_ERROR = 'has an error',
}
const getValidNodeState = (env: Networks, url: string) => ({
url,
initialized: true,
responseTime: {
isLoading: false,
hasError: false,
value: 10,
},
block: {
isLoading: false,
hasError: false,
value: 123,
},
subscription: {
isLoading: false,
hasError: false,
value: true,
},
chain: {
isLoading: false,
hasError: false,
value: `${env.toLowerCase()}-1234`,
},
});
const createMockState = (env: Networks, nodes: string[]) =>
nodes.reduce(
(acc, node) => ({
...acc,
[node]: getValidNodeState(env, node),
}),
{}
);
const createMockClients = (nodes: string[]) =>
nodes.reduce(
(acc, node) => ({
...acc,
[node]: createMockClient({ network: Networks.TESTNET }),
}),
{}
);
beforeEach(() => {
onConnect.mockReset();
// @ts-ignore Typescript doesn't recognise mocked instances
useEnvironment.mockImplementation(() => ({
VEGA_ENV: Networks.TESTNET,
VEGA_URL: undefined,
}));
// @ts-ignore Typescript doesn't recognise mocked instances
useNodes.mockImplementation((config: Configuration) => ({
state: createMockState(Networks.TESTNET, config.hosts),
clients: createMockClients(config.hosts),
updateNodeUrl: jest.fn(),
updateNodeBlock: jest.fn(),
}));
});
describe('Node switcher', () => {
it('renders with empty config', () => {
render(<NodeSwitcher config={{ hosts: [] }} onConnect={onConnect} />);
expect(() => screen.getAllByTestId('node')).toThrow();
expect(screen.getByRole('radio', { checked: false })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
'disabled'
);
});
it('renders with the provided config nodes', () => {
render(<NodeSwitcher config={{ hosts: HOSTS }} onConnect={onConnect} />);
HOSTS.forEach((host) => {
expect(
screen.getByRole('radio', { checked: false, name: host })
).toBeInTheDocument();
});
expect(
screen.getByRole('radio', { checked: false, name: 'Other' })
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
'disabled'
);
});
it('marks the node in the environment as selected', () => {
// @ts-ignore Typescript doesn't recognise mocked instances
useEnvironment.mockImplementation(() => ({
VEGA_ENV: Networks.TESTNET,
VEGA_URL: HOSTS[0],
}));
render(<NodeSwitcher config={{ hosts: HOSTS }} onConnect={onConnect} />);
HOSTS.forEach((host) => {
expect(
screen.getByRole('radio', { checked: host === HOSTS[0], name: host })
).toBeInTheDocument();
});
expect(
screen.getByRole('radio', { checked: false, name: 'Other' })
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Connect' })).not.toHaveAttribute(
'disabled'
);
});
it.each`
dataProp | state
${'responseTime'} | ${STATES.LOADING}
${'responseTime'} | ${STATES.HAS_ERROR}
${'block'} | ${STATES.LOADING}
${'block'} | ${STATES.HAS_ERROR}
${'chain'} | ${STATES.LOADING}
${'chain'} | ${STATES.HAS_ERROR}
${'subscription'} | ${STATES.LOADING}
${'subscription'} | ${STATES.HAS_ERROR}
`(
'disables selecting a node when the $dataProp $state',
({ dataProp, state }: { dataProp: NodeDataProp; state: STATES }) => {
const mockUrl = 'https://host.url';
const mockConfig = {
hosts: [mockUrl],
};
// @ts-ignore Typescript doesn't recognise mocked instances
useNodes.mockImplementation((config: Configuration) => {
const nodeState = getValidNodeState(Networks.TESTNET, mockUrl);
return {
state: {
[mockUrl]: {
...nodeState,
[dataProp]: {
...nodeState[dataProp],
isLoading:
state === STATES.LOADING
? true
: nodeState[dataProp].isLoading,
hasError:
state === STATES.HAS_ERROR
? true
: nodeState[dataProp].hasError,
value: undefined,
},
},
},
clients: createMockClients(config.hosts),
updateNodeUrl: jest.fn(),
updateNodeBlock: jest.fn(),
};
});
render(
<MockedProvider mocks={[statsQueryMock]}>
<NodeSwitcher config={mockConfig} onConnect={onConnect} />
</MockedProvider>
);
expect(screen.getByRole('radio', { name: mockUrl })).toHaveAttribute(
'disabled'
);
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
'disabled'
);
}
);
it('disables selecting a node when it has an invalid url', () => {
const mockUrl = 'not-valid-url';
const mockConfig = {
hosts: [mockUrl],
};
// @ts-ignore Typescript doesn't recognise mocked instances
useNodes.mockImplementation((config: Configuration) => ({
state: {
[mockUrl]: getValidNodeState(Networks.TESTNET, mockUrl),
},
clients: createMockClients(config.hosts),
updateNodeUrl: jest.fn(),
updateNodeBlock: jest.fn(),
}));
render(
<MockedProvider mocks={[statsQueryMock]}>
<NodeSwitcher config={mockConfig} onConnect={onConnect} />
</MockedProvider>
);
expect(screen.getByRole('radio', { name: mockUrl })).toHaveAttribute(
'disabled'
);
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
'disabled'
);
});
it('allows connecting to a valid node', () => {
render(<NodeSwitcher config={{ hosts: HOSTS }} onConnect={onConnect} />);
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
'disabled'
);
fireEvent.click(screen.getByRole('radio', { name: HOSTS[0] }));
expect(screen.getByRole('button', { name: 'Connect' })).not.toHaveAttribute(
'disabled'
);
fireEvent.click(screen.getByRole('button', { name: 'Connect' }));
expect(onConnect).toHaveBeenCalledWith(HOSTS[0]);
});
it('allows checking a custom node', () => {
const updateNodeUrlMock = jest.fn();
// @ts-ignore Typescript doesn't recognise mocked instances
useNodes.mockImplementation((config: Configuration) => ({
state: createMockState(Networks.TESTNET, config.hosts),
clients: createMockClients(config.hosts),
updateNodeUrl: updateNodeUrlMock,
updateNodeBlock: jest.fn(),
}));
const mockUrl = 'https://custom.url';
render(
<MockedProvider mocks={[statsQueryMock]}>
<NodeSwitcher config={{ hosts: [] }} onConnect={onConnect} />
</MockedProvider>
);
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
'disabled'
);
fireEvent.click(screen.getByRole('radio', { name: 'Other' }));
expect(screen.getByRole('link', { name: 'Check' })).toHaveAttribute(
'aria-disabled',
'true'
);
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
'disabled'
);
fireEvent.change(screen.getByRole('textbox'), {
target: {
value: mockUrl,
},
});
expect(screen.getByRole('link', { name: 'Check' })).toHaveAttribute(
'aria-disabled',
'false'
);
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
'disabled'
);
fireEvent.click(screen.getByRole('link', { name: 'Check' }));
expect(updateNodeUrlMock).toHaveBeenCalledWith(CUSTOM_NODE_KEY, mockUrl);
});
it('allows connecting to a custom node', () => {
const mockUrl = 'https://custom.url';
const updateNodeUrlMock = jest.fn();
// @ts-ignore Typescript doesn't recognise mocked instances
useNodes.mockImplementation(mockNodesImplementation(updateNodeUrlMock));
render(
<MockedProvider mocks={[statsQueryMock]}>
<NodeSwitcher config={{ hosts: [] }} onConnect={onConnect} />
</MockedProvider>
);
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
'disabled'
);
fireEvent.click(screen.getByRole('radio', { name: 'Other' }));
expect(screen.getByRole('link', { name: 'Check' })).toHaveAttribute(
'aria-disabled',
'true'
);
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
'disabled'
);
fireEvent.change(screen.getByRole('textbox'), {
target: {
value: mockUrl,
},
});
expect(screen.getByRole('link', { name: 'Check' })).toHaveAttribute(
'aria-disabled',
'false'
);
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
'disabled'
);
fireEvent.click(screen.getByRole('link', { name: 'Check' }));
expect(screen.getByRole('button', { name: 'Connect' })).not.toHaveAttribute(
'disabled'
);
fireEvent.click(screen.getByRole('button', { name: 'Connect' }));
expect(onConnect).toHaveBeenCalledWith(mockUrl);
});
it.each`
dataProp | state
${'responseTime'} | ${STATES.LOADING}
${'responseTime'} | ${STATES.HAS_ERROR}
${'block'} | ${STATES.LOADING}
${'block'} | ${STATES.HAS_ERROR}
${'chain'} | ${STATES.LOADING}
${'chain'} | ${STATES.HAS_ERROR}
${'subscription'} | ${STATES.LOADING}
${'subscription'} | ${STATES.HAS_ERROR}
`(
'disables selecting a custom node when the $dataProp $state',
({ dataProp, state }: { dataProp: NodeDataProp; state: STATES }) => {
const mockUrl = 'https://custom.url';
const updateNodeUrlMock = jest.fn();
// @ts-ignore Typescript doesn't recognise mocked instances
useNodes.mockImplementation(
mockNodesImplementation(updateNodeUrlMock, (env) => {
const nodeState = getValidNodeState(env, mockUrl);
return {
...nodeState,
[dataProp]: {
...nodeState[dataProp],
isLoading:
state === STATES.LOADING ? true : nodeState[dataProp].isLoading,
hasError:
state === STATES.HAS_ERROR
? true
: nodeState[dataProp].hasError,
value: undefined,
},
};
})
);
render(
<MockedProvider mocks={[statsQueryMock]}>
<NodeSwitcher config={{ hosts: [] }} onConnect={onConnect} />
</MockedProvider>
);
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
'disabled'
);
fireEvent.click(screen.getByRole('radio', { name: 'Other' }));
expect(screen.getByRole('link', { name: 'Check' })).toHaveAttribute(
'aria-disabled',
'true'
);
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
'disabled'
);
fireEvent.change(screen.getByRole('textbox'), {
target: {
value: mockUrl,
},
});
expect(screen.getByRole('link', { name: 'Check' })).toHaveAttribute(
'aria-disabled',
'false'
);
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
'disabled'
);
fireEvent.click(screen.getByRole('link', { name: 'Check' }));
if (state === STATES.LOADING) {
// eslint-disable-next-line jest/no-conditional-expect
expect(screen.getByRole('link', { name: 'Checking' })).toHaveAttribute(
'aria-disabled',
'true'
);
}
if (state === STATES.HAS_ERROR) {
// eslint-disable-next-line jest/no-conditional-expect
expect(screen.getByRole('link', { name: 'Check' })).toHaveAttribute(
'aria-disabled',
'false'
);
}
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
'disabled'
);
if (state === STATES.HAS_ERROR) {
const expectedErrorType =
dataProp === 'subscription'
? ErrorType.SUBSCRIPTION_ERROR
: ErrorType.CONNECTION_ERROR;
const error = getErrorByType(
expectedErrorType,
Networks.TESTNET,
mockUrl
);
// eslint-disable-next-line jest/no-conditional-expect
expect(error?.headline).not.toBeNull();
// eslint-disable-next-line jest/no-conditional-expect
expect(screen.getByText(error?.headline ?? '')).toBeInTheDocument();
}
}
);
it('disables selecting a custom node when it has an invalid url', () => {
const mockUrl = 'not-valid-url';
const updateNodeUrlMock = jest.fn();
// @ts-ignore Typescript doesn't recognise mocked instances
useNodes.mockImplementation(mockNodesImplementation(updateNodeUrlMock));
render(
<MockedProvider mocks={[statsQueryMock]}>
<NodeSwitcher config={{ hosts: [] }} onConnect={onConnect} />
</MockedProvider>
);
fireEvent.click(screen.getByRole('radio', { name: 'Other' }));
fireEvent.change(screen.getByRole('textbox'), {
target: {
value: mockUrl,
},
});
fireEvent.click(screen.getByRole('link', { name: 'Check' }));
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
'disabled'
);
const error = getErrorByType(
ErrorType.INVALID_URL,
Networks.TESTNET,
mockUrl
);
expect(error?.headline).not.toBeNull();
expect(screen.getByText(error?.headline ?? '')).toBeInTheDocument();
});
it.each`
description | errorType
${'the node has an invalid url'} | ${ErrorType.INVALID_URL}
${'the node has a subscription issue'} | ${ErrorType.SUBSCRIPTION_ERROR}
${'the node cannot be reached'} | ${ErrorType.CONNECTION_ERROR}
${'none of the config nodes can be connected to'} | ${ErrorType.CONNECTION_ERROR_ALL}
${'the config cannot be loaded'} | ${ErrorType.CONFIG_LOAD_ERROR}
${'the config is invalid'} | ${ErrorType.CONFIG_VALIDATION_ERROR}
`(
'displays initial error when $description',
({ errorType }: { errorType: ErrorType }) => {
const mockEnvUrl = 'https://mock.url';
// @ts-ignore Typescript doesn't recognise mocked instances
useEnvironment.mockImplementation(() => ({
VEGA_ENV: Networks.TESTNET,
VEGA_URL: mockEnvUrl,
}));
render(
<NodeSwitcher
config={{ hosts: HOSTS }}
initialErrorType={errorType}
onConnect={onConnect}
/>
);
const error = getErrorByType(errorType, Networks.TESTNET, mockEnvUrl);
expect(error?.headline).not.toBeNull();
expect(error?.message).not.toBeNull();
expect(screen.getByText(error?.headline ?? '')).toBeInTheDocument();
expect(screen.getByText(error?.message ?? '')).toBeInTheDocument();
}
);
});
@@ -1,197 +1,237 @@
import { useState } from 'react';
import { t } from '@vegaprotocol/react-helpers';
import { useCallback, useState } from 'react';
import { isValidUrl, t } from '@vegaprotocol/react-helpers';
import {
RadioGroup,
Button,
ButtonLink,
Input,
Link,
Loader,
Radio,
RadioGroup,
} from '@vegaprotocol/ui-toolkit';
import { useEnvironment } from '../../hooks/use-environment';
import { useNodes } from '../../hooks/use-nodes';
import {
getIsNodeLoading,
getIsNodeDisabled,
getIsFormDisabled,
getErrorType,
getErrorByType,
} from '../../utils/validate-node';
import { useEnvironment } from '../../hooks';
import { CUSTOM_NODE_KEY } from '../../types';
import type { Configuration, NodeData, ErrorType } from '../../types';
import { LayoutCell } from './layout-cell';
import { LayoutRow } from './layout-row';
import { NodeError } from './node-error';
import { NodeStats } from './node-stats';
import { ApolloWrapper } from './apollo-wrapper';
import { RowData } from './row-data';
type NodeSwitcherProps = {
error?: string;
config: Configuration;
initialErrorType?: ErrorType;
onConnect: (url: string) => void;
};
const getDefaultNode = (urls: string[], currentUrl?: string) => {
return currentUrl && urls.includes(currentUrl) ? currentUrl : undefined;
};
const getHighestBlock = (state: Record<string, NodeData>) => {
return Object.keys(state).reduce((acc, node) => {
const value = Number(state[node].block.value);
return value ? Math.max(acc, value) : acc;
}, 0);
};
export const NodeSwitcher = ({
config,
initialErrorType,
onConnect,
}: NodeSwitcherProps) => {
const { VEGA_ENV, VEGA_URL } = useEnvironment();
const [networkError, setNetworkError] = useState(
getErrorByType(initialErrorType, VEGA_ENV, VEGA_URL)
export const NodeSwitcher = ({ closeDialog }: { closeDialog: () => void }) => {
const { nodes, setUrl, status, VEGA_ENV, VEGA_URL } = useEnvironment(
(store) => ({
status: store.status,
nodes: store.nodes,
setUrl: store.setUrl,
VEGA_ENV: store.VEGA_ENV,
VEGA_URL: store.VEGA_URL,
})
);
const [customNodeText, setCustomNodeText] = useState('');
const [nodeRadio, setNodeRadio] = useState(
getDefaultNode(config.hosts, VEGA_URL)
);
const { state, clients, updateNodeUrl, updateNodeBlock } = useNodes(config);
const highestBlock = getHighestBlock(state);
const customUrl = state[CUSTOM_NODE_KEY]?.url;
const onSubmit = (node: ReturnType<typeof getDefaultNode>) => {
if (node && state[node]) {
onConnect(state[node].url);
const [nodeRadio, setNodeRadio] = useState<string>(() => {
if (VEGA_URL) {
return VEGA_URL;
}
};
return nodes.length > 0 ? '' : CUSTOM_NODE_KEY;
});
const [highestBlock, setHighestBlock] = useState<number | null>(null);
const [customUrlText, setCustomUrlText] = useState('');
const isSubmitDisabled = getIsFormDisabled(nodeRadio, VEGA_ENV, state);
const handleHighestBlock = useCallback((blockHeight: number) => {
setHighestBlock((curr) => {
if (curr === null) {
return blockHeight;
}
if (blockHeight > curr) {
return blockHeight;
}
return curr;
});
}, []);
const customNodeData =
nodeRadio &&
state[CUSTOM_NODE_KEY] &&
state[CUSTOM_NODE_KEY].url === customNodeText
? state[nodeRadio]
: undefined;
const customNodeError = getErrorByType(
getErrorType(VEGA_ENV, customNodeData),
VEGA_ENV,
customUrl
);
let isDisabled = false;
if (nodeRadio === '') {
isDisabled = true;
} else if (nodeRadio === VEGA_URL) {
isDisabled = true;
} else if (nodeRadio === CUSTOM_NODE_KEY) {
if (!isValidUrl(customUrlText)) {
isDisabled = true;
}
}
return (
<div className="text-black dark:text-white w-full lg:min-w-[800px]">
<NodeError {...(customNodeError || networkError)} />
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit(nodeRadio);
}}
>
<p className="text-lg mt-4">
{t('Select a GraphQL node to connect to:')}
</p>
<div className="mb-2">
<div className="hidden lg:block">
<LayoutRow>
<div />
<span className="text-right">{t('Response time')}</span>
<span className="text-right">{t('Block')}</span>
<span className="text-right">{t('Subscription')}</span>
</LayoutRow>
</div>
<div>
<h3 className="uppercase text-xl calt text-center mb-2">
{t('Connected node')}
</h3>
{status === 'pending' ? (
<div className="py-8">
<p className="mb-4 text-center">{t('Loading configuration...')}</p>
<Loader size="large" />
</div>
) : (
<div>
<p className="mb-2 text-sm text-center">
{t(
`This app will only work on ${VEGA_ENV}. Select a node to connect to.`
)}
</p>
<RadioGroup
value={nodeRadio}
onChange={(value) => {
setNodeRadio(value);
setNetworkError(null);
}}
onChange={(value) => setNodeRadio(value)}
>
<div className="w-full">
{config.hosts.map((node, index) => (
<NodeStats
key={index}
data={state[node]}
client={clients[node]}
<div className="hidden lg:block">
<LayoutRow>
<span>{t('Node')}</span>
<span className="text-right">{t('Response time')}</span>
<span className="text-right">{t('Block')}</span>
<span className="text-right">{t('Subscription')}</span>
</LayoutRow>
<div>
{nodes.map((node, index) => {
return (
<LayoutRow key={node} dataTestId="node-row">
<ApolloWrapper url={node}>
<RowData
id={index.toString()}
url={node}
highestBlock={highestBlock}
onBlockHeight={handleHighestBlock}
/>
</ApolloWrapper>
</LayoutRow>
);
})}
<CustomRowWrapper
inputText={customUrlText}
setInputText={setCustomUrlText}
nodes={nodes}
highestBlock={highestBlock}
setBlock={(block) => updateNodeBlock(node, block)}
>
<div className="break-all" data-testid="node">
<Radio
id={`node-url-${index}`}
value={node}
label={node}
disabled={getIsNodeDisabled(VEGA_ENV, state[node])}
/>
</div>
</NodeStats>
))}
<NodeStats
data={state[CUSTOM_NODE_KEY]}
client={customUrl ? clients[customUrl] : undefined}
highestBlock={highestBlock}
setBlock={(block) => updateNodeBlock(CUSTOM_NODE_KEY, block)}
>
<div className="flex w-full mb-2">
<Radio
id={`node-url-custom`}
value={CUSTOM_NODE_KEY}
label={
nodeRadio === CUSTOM_NODE_KEY || !!state[CUSTOM_NODE_KEY]
? ''
: t('Other')
}
/>
{(customNodeText || nodeRadio === CUSTOM_NODE_KEY) && (
<div
data-testid="custom-node"
className="flex items-center w-full gap-2"
>
<Input
placeholder="https://"
value={customNodeText}
hasError={
!!customNodeText &&
!!(
customNodeError?.headline ||
customNodeError?.message
)
}
onChange={(e) => setCustomNodeText(e.target.value)}
/>
<Link
aria-disabled={
!customNodeText ||
getIsNodeLoading(state[CUSTOM_NODE_KEY])
}
onClick={() => {
setNetworkError(null);
updateNodeUrl(CUSTOM_NODE_KEY, customNodeText);
}}
>
{state[CUSTOM_NODE_KEY] &&
getIsNodeLoading(state[CUSTOM_NODE_KEY])
? t('Checking')
: t('Check')}
</Link>
</div>
)}
</div>
</NodeStats>
onBlockHeight={handleHighestBlock}
nodeRadio={nodeRadio}
/>
</div>
</div>
</RadioGroup>
<div className="mt-4">
<Button
fill={true}
disabled={isDisabled}
onClick={() => {
if (nodeRadio === CUSTOM_NODE_KEY) {
setUrl(customUrlText);
} else {
setUrl(nodeRadio);
}
closeDialog();
}}
data-testid="connect"
>
{t('Connect to this node')}
</Button>
</div>
</div>
<div>
<Button
disabled={isSubmitDisabled}
fill={true}
type="submit"
data-testid="connect"
>
{t('Connect')}
</Button>
</div>
</form>
)}
</div>
);
};
interface CustomRowWrapperProps {
inputText: string;
setInputText: (text: string) => void;
nodes: string[];
highestBlock: number | null;
nodeRadio: string;
onBlockHeight: (blockHeight: number) => void;
}
const CustomRowWrapper = ({
inputText,
setInputText,
nodes,
highestBlock,
nodeRadio,
onBlockHeight,
}: CustomRowWrapperProps) => {
const [displayCustom, setDisplayCustom] = useState(false);
const [error, setError] = useState<string | null>(null);
const showInput = nodeRadio === CUSTOM_NODE_KEY || nodes.length <= 0;
return (
<LayoutRow dataTestId="custom-row">
<div className="flex w-full mb-2">
{nodes.length > 0 && (
<Radio
id="node-url-custom"
value={CUSTOM_NODE_KEY}
label={nodeRadio === CUSTOM_NODE_KEY ? '' : t('Other')}
/>
)}
{showInput && (
<div
data-testid="custom-node"
className="flex items-center w-full gap-2"
>
<Input
placeholder="https://"
value={inputText}
hasError={Boolean(error)}
onChange={(e) => {
setDisplayCustom(false);
setInputText(e.target.value);
}}
/>
<ButtonLink
onClick={() => {
if (!isValidUrl(inputText)) {
setError('Invalid url');
return;
}
setError(null);
setDisplayCustom(true);
}}
>
{t('Check')}
</ButtonLink>
</div>
)}
</div>
{displayCustom ? (
<ApolloWrapper url={inputText}>
<RowData
id={CUSTOM_NODE_KEY}
url={inputText}
onBlockHeight={onBlockHeight}
highestBlock={highestBlock}
/>
</ApolloWrapper>
) : (
<>
<LayoutCell
label={t('Response time')}
isLoading={false}
hasError={false}
dataTestId="response-time-cell"
>
{'-'}
</LayoutCell>
<LayoutCell
label={t('Block')}
isLoading={false}
hasError={false}
dataTestId="block-height-cell"
>
{'-'}
</LayoutCell>
<LayoutCell
label={t('Subscription')}
isLoading={false}
hasError={false}
dataTestId="subscription -cell"
>
{'-'}
</LayoutCell>
</>
)}
</LayoutRow>
);
};
@@ -0,0 +1,174 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { NodeSwitcher } from './node-switcher';
import type { EnvStore } from '../../hooks';
import { useEnvironment } from '../../hooks';
import { Networks } from '../../types';
import { MockedProvider } from '@apollo/react-testing';
import type { ReactNode } from 'react';
jest.mock('../../hooks/use-environment');
jest.mock('./apollo-wrapper', () => ({
ApolloWrapper: jest.fn(({ children }: { children: ReactNode }) => (
<MockedProvider>{children}</MockedProvider>
)),
}));
global.performance.getEntriesByName = jest.fn().mockReturnValue([]);
const mockEnv = (env: Partial<EnvStore>) => {
(useEnvironment as unknown as jest.Mock).mockImplementation(() => env);
};
describe('NodeSwitcher', () => {
it('renders with no nodes', () => {
mockEnv({
VEGA_ENV: Networks.TESTNET,
nodes: [],
});
render(<NodeSwitcher closeDialog={jest.fn()} />);
expect(
screen.getByText(new RegExp(Networks.TESTNET, 'i'))
).toBeInTheDocument();
expect(screen.queryAllByTestId('node')).toHaveLength(0);
expect(
screen.getByRole('button', { name: 'Connect to this node' })
).toHaveAttribute('disabled');
});
it('renders with nodes', async () => {
const nodes = [
'https://n00.api.vega.xyz',
'https://n01.api.vega.xyz',
'https://n02.api.vega.xyz',
];
mockEnv({
VEGA_ENV: Networks.TESTNET,
nodes,
});
render(<NodeSwitcher closeDialog={jest.fn()} />);
nodes.forEach((node) => {
expect(
screen.getByRole('radio', { checked: false, name: node })
).toBeInTheDocument();
});
expect(
screen.getByRole('radio', { checked: false, name: 'Other' })
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Connect to this node' })
).toHaveAttribute('disabled');
const rows = screen.getAllByTestId('node-row');
expect(rows).toHaveLength(nodes.length);
rows.forEach((r) => {
const row = within(r);
expect(row.getByTestId('response-time-cell')).toHaveTextContent(
'Checking'
);
expect(row.getByTestId('block-height-cell')).toHaveTextContent(
'Checking'
);
});
// Note actual requests tested in
});
it('marks current node as selected', () => {
const nodes = [
'https://n00.api.vega.xyz',
'https://n01.api.vega.xyz',
'https://n02.api.vega.xyz',
];
const selectedNode = nodes[0];
mockEnv({
VEGA_ENV: Networks.TESTNET,
VEGA_URL: selectedNode,
nodes,
});
render(<NodeSwitcher closeDialog={jest.fn()} />);
nodes.forEach((node) => {
expect(
screen.getByRole('radio', {
checked: node === selectedNode,
name: node,
})
).toBeInTheDocument();
});
expect(
screen.getByRole('radio', { checked: false, name: 'Other' })
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Connect to this node' })
).toHaveAttribute('disabled');
});
it('allows setting a custom node', () => {
const mockSetUrl = jest.fn();
const mockUrl = 'https://custom.url';
const nodes = ['https://n00.api.vega.xyz'];
mockEnv({
VEGA_ENV: Networks.TESTNET,
nodes,
setUrl: mockSetUrl,
});
render(<NodeSwitcher closeDialog={jest.fn()} />);
expect(
screen.getByRole('button', { name: 'Connect to this node' })
).toBeDisabled();
fireEvent.click(screen.getByRole('radio', { name: 'Other' }));
fireEvent.change(screen.getByRole('textbox'), {
target: {
value: mockUrl,
},
});
expect(screen.getByRole('textbox')).toHaveValue(mockUrl);
expect(screen.getByRole('button', { name: 'Check' })).not.toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: 'Check' }));
const customRow = within(screen.getByTestId('custom-row'));
expect(customRow.getByTestId('block-height-cell')).toBeInTheDocument();
fireEvent.click(
screen.getByRole('button', { name: 'Connect to this node' })
);
expect(mockSetUrl).toHaveBeenCalledWith(mockUrl);
});
it('disables a custom node with an invalid url', () => {
const mockSetUrl = jest.fn();
const mockUrl = 'invalid-url';
const nodes = [
'https://n00.api.vega.xyz',
'https://n01.api.vega.xyz',
'https://n02.api.vega.xyz',
];
mockEnv({
VEGA_ENV: Networks.TESTNET,
nodes,
setUrl: mockSetUrl,
});
render(<NodeSwitcher closeDialog={jest.fn()} />);
expect(
screen.getByRole('button', { name: 'Connect to this node' })
).toBeDisabled();
fireEvent.click(screen.getByRole('radio', { name: 'Other' }));
fireEvent.change(screen.getByRole('textbox'), {
target: {
value: mockUrl,
},
});
expect(
screen.getByRole('button', {
name: 'Connect to this node',
})
).toBeDisabled();
});
it.todo('displays errors');
});
@@ -0,0 +1,254 @@
import type { MockedResponse } from '@apollo/react-testing';
import { MockedProvider } from '@apollo/react-testing';
import { render, screen, waitFor } from '@testing-library/react';
import { RadioGroup } from '@vegaprotocol/ui-toolkit';
import type {
BlockTimeSubscription,
StatisticsQuery,
} from '../../utils/__generated__/Node';
import { BlockTimeDocument } from '../../utils/__generated__/Node';
import { StatisticsDocument } from '../../utils/__generated__/Node';
import type { RowDataProps } from './row-data';
import { BLOCK_THRESHOLD, RowData } from './row-data';
import type { HeaderEntry } from '@vegaprotocol/apollo-client';
import { useHeaderStore } from '@vegaprotocol/apollo-client';
import { CUSTOM_NODE_KEY } from '../../types';
jest.mock('@vegaprotocol/apollo-client', () => ({
useHeaderStore: jest.fn().mockReturnValue({}),
}));
const statsQueryMock: MockedResponse<StatisticsQuery> = {
request: {
query: StatisticsDocument,
},
result: {
data: {
statistics: {
blockHeight: '1234',
vegaTime: new Date().toISOString(),
chainId: 'test-chain-id',
},
},
},
};
const subMock: MockedResponse<BlockTimeSubscription> = {
request: {
query: BlockTimeDocument,
},
result: {
data: {
busEvents: [
{
__typename: 'BusEvent',
id: '123',
},
],
},
},
};
const mockResponseTime = 50;
global.performance.getEntriesByName = jest.fn().mockReturnValue([
{
duration: mockResponseTime,
},
]);
const mockHeaders = (
url: string,
headers: Partial<HeaderEntry> = {
blockHeight: 100,
timestamp: new Date(),
}
) => {
(useHeaderStore as unknown as jest.Mock).mockReturnValue({
[url]: headers,
});
};
const renderComponent = (
props: RowDataProps,
queryMock: MockedResponse<StatisticsQuery>,
subMock: MockedResponse<BlockTimeSubscription>
) => {
return (
<MockedProvider mocks={[queryMock, subMock, subMock, subMock]}>
<RadioGroup>
{/* Radio group required as radio is being render in isolation */}
<RowData {...props} />
</RadioGroup>
</MockedProvider>
);
};
describe('RowData', () => {
const props = {
id: '0',
url: 'https://foo.bar.com',
highestBlock: null,
onBlockHeight: jest.fn(),
};
it('radio button enabled after stats query successful', async () => {
mockHeaders(props.url);
render(renderComponent(props, statsQueryMock, subMock));
// radio should be disabled until query resolves
expect(
screen.getByRole('radio', {
checked: false,
name: props.url,
})
).toBeDisabled();
expect(screen.getByTestId('response-time-cell')).toHaveTextContent(
'Checking'
);
expect(screen.getByTestId('block-height-cell')).toHaveTextContent(
'Checking'
);
await waitFor(() => {
expect(screen.getByTestId('block-height-cell')).toHaveTextContent('100');
expect(screen.getByTestId('response-time-cell')).toHaveTextContent(
mockResponseTime.toFixed(2) + 'ms'
);
expect(screen.getByTestId('subscription-cell')).toHaveTextContent('Yes');
expect(
screen.getByRole('radio', {
checked: false,
name: props.url,
})
).toBeEnabled();
});
});
it('radio button disabled if query fails', async () => {
mockHeaders(props.url, {});
const failedQueryMock: MockedResponse<StatisticsQuery> = {
request: {
query: StatisticsDocument,
},
error: new Error('failed'),
};
const failedSubMock: MockedResponse<BlockTimeSubscription> = {
request: {
query: BlockTimeDocument,
},
error: new Error('failed'),
};
render(renderComponent(props, failedQueryMock, failedSubMock));
// radio should be disabled until query resolves
expect(
screen.getByRole('radio', {
checked: false,
name: props.url,
})
).toBeDisabled();
expect(screen.getByTestId('response-time-cell')).toHaveTextContent(
'Checking'
);
expect(screen.getByTestId('block-height-cell')).toHaveTextContent(
'Checking'
);
await waitFor(() => {
const responseCell = screen.getByTestId('response-time-cell');
const blockHeightCell = screen.getByTestId('block-height-cell');
const subscriptionCell = screen.getByTestId('subscription-cell');
expect(responseCell).toHaveTextContent('n/a');
expect(responseCell).toHaveClass('text-danger');
expect(blockHeightCell).toHaveTextContent('n/a');
expect(blockHeightCell).toHaveClass('text-danger');
expect(subscriptionCell).toHaveTextContent('No');
expect(
screen.getByRole('radio', {
checked: false,
name: props.url,
})
).toBeDisabled();
});
});
it('highlights rows with a slow block height', async () => {
const blockHeight = 100;
mockHeaders(props.url, { blockHeight });
const { rerender } = render(
renderComponent(
{ ...props, highestBlock: blockHeight + BLOCK_THRESHOLD },
statsQueryMock,
subMock
)
);
await waitFor(() => {
expect(screen.getByTestId('block-height-cell')).toHaveTextContent(
blockHeight.toString()
);
});
expect(screen.getByTestId('block-height-cell')).not.toHaveClass(
'text-danger'
);
rerender(
renderComponent(
{ ...props, highestBlock: blockHeight + BLOCK_THRESHOLD + 1 },
statsQueryMock,
subMock
)
);
expect(screen.getByTestId('block-height-cell')).toHaveClass('text-danger');
});
it('disables radio button if url is invalid', () => {
mockHeaders(props.url, { blockHeight: 100 });
render(renderComponent(props, statsQueryMock, subMock));
expect(
screen.getByRole('radio', {
checked: false,
name: props.url,
})
).toBeDisabled();
});
it('doesnt render the radio if its the custom row', () => {
render(
renderComponent(
{
...props,
id: CUSTOM_NODE_KEY,
},
statsQueryMock,
subMock
)
);
expect(
screen.queryByRole('radio', {
name: props.url,
})
).not.toBeInTheDocument();
});
it('updates highest block after new header received', async () => {
const mockOnBlockHeight = jest.fn();
const blockHeight = 200;
mockHeaders(props.url, { blockHeight });
render(
renderComponent(
{ ...props, onBlockHeight: mockOnBlockHeight },
statsQueryMock,
subMock
)
);
expect(mockOnBlockHeight).toHaveBeenCalledWith(blockHeight);
});
});
@@ -0,0 +1,207 @@
import type { ApolloError } from '@apollo/client';
import { useHeaderStore } from '@vegaprotocol/apollo-client';
import { isValidUrl, t } from '@vegaprotocol/react-helpers';
import { Radio } from '@vegaprotocol/ui-toolkit';
import { useEffect, useState } from 'react';
import { CUSTOM_NODE_KEY } from '../../types';
import {
useBlockTimeSubscription,
useStatisticsQuery,
} from '../../utils/__generated__/Node';
import { LayoutCell } from './layout-cell';
const POLL_INTERVAL = 1000;
export const BLOCK_THRESHOLD = 3;
export interface RowDataProps {
id: string;
url: string;
highestBlock: number | null;
onBlockHeight: (blockHeight: number) => void;
}
export const RowData = ({
id,
url,
highestBlock,
onBlockHeight,
}: RowDataProps) => {
const [time, setTime] = useState<number>();
// no use of data here as we need the data nodes reference to block height
const { data, error, loading, startPolling, stopPolling } =
useStatisticsQuery({
pollInterval: POLL_INTERVAL,
// fix for pollInterval
// https://github.com/apollographql/apollo-client/issues/9819
ssr: false,
});
const headerStore = useHeaderStore();
const headers = headerStore[url];
const {
data: subData,
error: subError,
loading: subLoading,
} = useBlockTimeSubscription();
useEffect(() => {
// stop polling if row has errored
}, [error, stopPolling]);
useEffect(() => {
const handleStartPoll = () => startPolling(POLL_INTERVAL);
const handleStopPoll = () => stopPolling();
window.addEventListener('blur', handleStopPoll);
window.addEventListener('focus', handleStartPoll);
handleStartPoll();
if (error) {
stopPolling();
}
return () => {
window.removeEventListener('blur', handleStopPoll);
window.removeEventListener('focus', handleStartPoll);
};
}, [startPolling, stopPolling, error]);
useEffect(() => {
if (!isValidUrl(url)) return;
// every time we get data measure response speed
const requestUrl = new URL(url);
const requests = window.performance.getEntriesByName(requestUrl.href);
const { duration } =
(requests.length && requests[requests.length - 1]) || {};
setTime(duration);
}, [url, data]);
useEffect(() => {
if (headers?.blockHeight) {
onBlockHeight(headers.blockHeight);
}
}, [headers?.blockHeight, onBlockHeight]);
const getHasError = () => {
// the stats query errored
if (error) {
return true;
}
// if we are still awaiting a header entry its not an error
// we are still waiting for the query to resolve
if (!headers) {
return false;
}
// highlight this node as 'error' if its more than BLOCK_THRESHOLD blocks behind the most
// advanced node
if (
highestBlock !== null &&
headers.blockHeight < highestBlock - BLOCK_THRESHOLD
) {
return true;
}
return false;
};
const getIsNodeDisabled = () => {
if (!isValidUrl(url)) {
return true;
}
// if still waiting or query errored disable node
if (loading || error) {
return true;
}
if (subLoading || subError) {
return true;
}
// if we are still waiting for a header entry for this
// url disable the node
if (!headers) {
return true;
}
return false;
};
return (
<>
{id !== CUSTOM_NODE_KEY && (
<div className="break-all" data-testid="node">
<Radio
id={`node-url-${id}`}
value={url}
label={url}
disabled={getIsNodeDisabled()}
/>
</div>
)}
<LayoutCell
label={t('Response time')}
isLoading={!error && loading}
hasError={Boolean(error)}
dataTestId="response-time-cell"
>
{getResponseTimeDisplayValue(time, error)}
</LayoutCell>
<LayoutCell
label={t('Block')}
isLoading={loading}
hasError={getHasError()}
dataTestId="block-height-cell"
>
{getBlockDisplayValue(headers?.blockHeight, error)}
</LayoutCell>
<LayoutCell
label={t('Subscription')}
isLoading={subLoading}
hasError={Boolean(subError)}
dataTestId="subscription-cell"
>
{getSubscriptionDisplayValue(subData?.busEvents, subError)}
</LayoutCell>
</>
);
};
const getResponseTimeDisplayValue = (
responseTime?: number,
error?: ApolloError
) => {
if (error) {
return t('n/a');
}
if (typeof responseTime === 'number') {
return `${Number(responseTime).toFixed(2)}ms`;
}
return '-';
};
const getBlockDisplayValue = (block?: number, error?: ApolloError) => {
if (error) {
return t('n/a');
}
if (block) {
return block;
}
return '-';
};
const getSubscriptionDisplayValue = (
events?: { id: string }[] | null,
error?: ApolloError
) => {
if (error) {
return t('No');
}
if (events?.length) {
return t('Yes');
}
return '-';
};
@@ -1,22 +0,0 @@
import { act, render, screen, waitFor } from '@testing-library/react';
import { Networks } from '../types';
import { EnvironmentProvider } from './use-environment';
describe('EnvironmentProvider', () => {
beforeAll(() => {
process.env['NX_MAINTENANCE_PAGE'] = 'true';
process.env['NX_VEGA_URL'] = 'https://vega.xyz';
process.env['NX_VEGA_ENV'] = Networks.TESTNET;
});
afterAll(() => {
process.env['NX_MAINTENANCE_PAGE'] = '';
});
it('EnvironmentProvider should return maintenance page', async () => {
await act(async () => {
render(<EnvironmentProvider />);
});
await waitFor(() => {
expect(screen.getByTestId('maintenance-page')).toBeInTheDocument();
});
});
});
@@ -1,140 +0,0 @@
import { renderHook, waitFor } from '@testing-library/react';
import type { EnvironmentWithOptionalUrl } from './use-config';
import { useConfig } from './use-config';
import { Networks, ErrorType } from '../types';
const mockConfig = {
hosts: [
'https://vega-host-1.com',
'https://vega-host-2.com',
'https://vega-host-3.com',
'https://vega-host-4.com',
],
};
const mockEnvironment: EnvironmentWithOptionalUrl = {
VEGA_ENV: Networks.TESTNET,
VEGA_CONFIG_URL: 'https://vega.url/config.json',
VEGA_NETWORKS: {},
ETHEREUM_PROVIDER_URL: 'https://ethereum.provider',
ETHERSCAN_URL: 'https://etherscan.url',
GIT_BRANCH: 'test',
GIT_ORIGIN_URL: 'https://github.com/test/repo',
GIT_COMMIT_HASH: 'abcde01234',
GITHUB_FEEDBACK_URL: 'https://github.com/test/feedback',
};
function setupFetch(configUrl: string) {
return (url: RequestInfo) => {
if (url === configUrl) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(mockConfig),
} as Response);
}
return Promise.resolve({
ok: true,
} as Response);
};
}
global.fetch = jest.fn();
const onError = jest.fn();
beforeAll(() => {
jest.useFakeTimers();
});
beforeEach(() => {
onError.mockClear();
window.localStorage.clear();
// @ts-ignore typescript doesn't recognise the mocked instance
global.fetch.mockReset();
// @ts-ignore typescript doesn't recognise the mocked instance
global.fetch.mockImplementation(
setupFetch(mockEnvironment.VEGA_CONFIG_URL ?? '')
);
});
afterAll(() => {
jest.clearAllMocks();
});
describe('useConfig hook', () => {
it("doesn't update when there is no VEGA_CONFIG_URL in the environment", async () => {
const mockEnvWithoutUrl = {
...mockEnvironment,
VEGA_CONFIG_URL: undefined,
};
const { result } = renderHook(() =>
useConfig({ environment: mockEnvWithoutUrl }, onError)
);
expect(result.current.config).toBe(undefined);
});
it('fetches configuration from the provided url', async () => {
const { result } = renderHook(() =>
useConfig({ environment: mockEnvironment }, onError)
);
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(mockEnvironment.VEGA_CONFIG_URL);
expect(result.current.config).toEqual(mockConfig);
});
});
it('executes the error callback when the config endpoint fails', async () => {
// @ts-ignore typescript doesn't recognise the mocked instance
global.fetch.mockImplementation(() => Promise.reject());
const { result } = renderHook(() =>
useConfig({ environment: mockEnvironment }, onError)
);
await waitFor(() => {
expect(result.current.config).toEqual({ hosts: [] });
expect(onError).toHaveBeenCalledWith(ErrorType.CONFIG_LOAD_ERROR);
});
});
it('executes the error callback when the config validation fails', async () => {
// @ts-ignore typescript doesn't recognise the mocked instance
global.fetch.mockImplementation(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ data: 'not-valid-config' }),
})
);
const { result } = renderHook(() =>
useConfig({ environment: mockEnvironment }, onError)
);
await waitFor(() => {
expect(result.current.config).toBe(undefined);
expect(onError).toHaveBeenCalledWith(ErrorType.CONFIG_VALIDATION_ERROR);
});
});
it('returns the default config without getting it from the network when provided', async () => {
const defaultConfig = { hosts: ['https://default.url'] };
const { result } = renderHook(() =>
useConfig(
{
environment: mockEnvironment,
defaultConfig,
},
onError
)
);
await waitFor(() => {
expect(result.current.config).toBe(defaultConfig);
expect(global.fetch).not.toHaveBeenCalled();
});
});
});
-72
View File
@@ -1,72 +0,0 @@
import { useState, useEffect } from 'react';
import { ErrorType } from '../types';
import type { Environment, Configuration } from '../types';
import { validateConfiguration } from '../utils/validate-configuration';
export type EnvironmentWithOptionalUrl = Partial<Environment> &
Omit<Environment, 'VEGA_URL'>;
const compileHosts = (hosts: string[], envUrl?: string) => {
if (envUrl && !hosts.includes(envUrl)) {
return [...hosts, envUrl];
}
return hosts;
};
type UseConfigOptions = {
environment: EnvironmentWithOptionalUrl;
defaultConfig?: Configuration;
};
/**
* Fetch list of hosts from the VEGA_CONFIG_URL
*/
export const useConfig = (
{ environment, defaultConfig }: UseConfigOptions,
onError: (errorType: ErrorType) => void
) => {
const [loading, setLoading] = useState(false);
const [config, setConfig] = useState<Configuration | undefined>(
defaultConfig
);
useEffect(() => {
let isMounted = true;
(async () => {
if (!config && environment.VEGA_CONFIG_URL) {
isMounted && setLoading(true);
try {
const response = await fetch(environment.VEGA_CONFIG_URL);
const configData: Configuration = await response.json();
if (validateConfiguration(configData)) {
onError(ErrorType.CONFIG_VALIDATION_ERROR);
isMounted && setLoading(false);
return;
}
const hosts = compileHosts(configData.hosts, environment.VEGA_URL);
isMounted && setConfig({ hosts });
isMounted && setLoading(false);
} catch (err) {
if (isMounted) {
setLoading(false);
setConfig({ hosts: [] });
}
onError(ErrorType.CONFIG_LOAD_ERROR);
}
}
})();
return () => {
isMounted = false;
};
// load config only once per runtime
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [environment.VEGA_CONFIG_URL, !!config, onError, setLoading]);
return {
loading,
config,
};
};
@@ -1,218 +0,0 @@
// having the node switcher dialog in the environment provider breaks the test renderer
// workaround based on: https://github.com/facebook/react/issues/11565
import type { ComponentProps, ReactNode } from 'react';
import { renderHook } from '@testing-library/react';
import { createClient } from '@vegaprotocol/apollo-client';
import { useEnvironment, EnvironmentProvider } from './use-environment';
import { Networks } from '../types';
import createMockClient from './mocks/apollo-client';
jest.mock('@vegaprotocol/apollo-client');
jest.mock('react-dom', () => ({
...jest.requireActual('react-dom'),
createPortal: (node: ReactNode) => node,
}));
global.fetch = jest.fn();
const MockWrapper = (props: ComponentProps<typeof EnvironmentProvider>) => {
return <EnvironmentProvider {...props} />;
};
const MOCK_HOST = 'https://vega.host/query';
// eslint-disable-next-line @typescript-eslint/no-empty-function
const noop = () => {};
const mockEnvironmentState = {
VEGA_URL: 'https://vega.xyz',
VEGA_ENV: Networks.TESTNET,
VEGA_CONFIG_URL: 'https://vega.xyz/testnet-config.json',
VEGA_NETWORKS: {
TESTNET: 'https://testnet.url',
STAGNET: 'https://stagnet.url',
MAINNET: 'https://mainnet.url',
},
ETHEREUM_PROVIDER_URL: 'https://ether.provider',
ETHERSCAN_URL: 'https://etherscan.url',
GIT_BRANCH: 'test',
GIT_ORIGIN_URL: 'https://github.com/test/repo',
GIT_COMMIT_HASH: 'abcde01234',
GITHUB_FEEDBACK_URL: 'https://github.com/test/feedback',
setNodeSwitcherOpen: noop,
networkError: undefined,
};
const MOCK_DURATION = 76;
window.performance.getEntriesByName = jest
.fn()
.mockImplementation((url: string) => [
{
entryType: 'resource',
name: url,
startTime: 0,
toJSON: () => ({}),
duration: MOCK_DURATION,
},
]);
function setupFetch(
configUrl: string = mockEnvironmentState.VEGA_CONFIG_URL,
hosts?: string[]
) {
return (url: RequestInfo) => {
if (url === configUrl) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ hosts: hosts || [MOCK_HOST] }),
} as Response);
}
return Promise.resolve({
ok: true,
} as Response);
};
}
beforeEach(() => {
// @ts-ignore: typescript doesn't recognize the mock implementation
global.fetch.mockImplementation(setupFetch());
window.localStorage.clear();
// @ts-ignore allow adding a mock return value to mocked module
createClient.mockImplementation(() => createMockClient());
process.env['NX_VEGA_URL'] = mockEnvironmentState.VEGA_URL;
process.env['NX_VEGA_ENV'] = mockEnvironmentState.VEGA_ENV;
process.env['NX_VEGA_CONFIG_URL'] = mockEnvironmentState.VEGA_CONFIG_URL;
process.env['NX_VEGA_NETWORKS'] = JSON.stringify(
mockEnvironmentState.VEGA_NETWORKS
);
process.env['NX_ETHEREUM_PROVIDER_URL'] =
mockEnvironmentState.ETHEREUM_PROVIDER_URL;
process.env['NX_ETHERSCAN_URL'] = mockEnvironmentState.ETHERSCAN_URL;
process.env['NX_GIT_BRANCH'] = mockEnvironmentState.GIT_BRANCH;
process.env['NX_GIT_ORIGIN_URL'] = mockEnvironmentState.GIT_ORIGIN_URL;
process.env['NX_GIT_COMMIT_HASH'] = mockEnvironmentState.GIT_COMMIT_HASH;
process.env['NX_GITHUB_FEEDBACK_URL'] =
mockEnvironmentState.GITHUB_FEEDBACK_URL;
});
describe('throws error', () => {
const consoleError = console.error;
beforeAll(() => {
console.error = jest.fn();
});
afterAll(() => {
console.error = consoleError;
});
beforeEach(() => {
// @ts-ignore: typescript doesn't recognize the mock implementation
global.fetch.mockImplementation(setupFetch());
window.localStorage.clear();
// @ts-ignore allow adding a mock return value to mocked module
createClient.mockImplementation(() => createMockClient());
process.env['NX_VEGA_URL'] = mockEnvironmentState.VEGA_URL;
process.env['NX_VEGA_ENV'] = mockEnvironmentState.VEGA_ENV;
process.env['NX_VEGA_CONFIG_URL'] = mockEnvironmentState.VEGA_CONFIG_URL;
process.env['NX_VEGA_NETWORKS'] = JSON.stringify(
mockEnvironmentState.VEGA_NETWORKS
);
process.env['NX_ETHEREUM_PROVIDER_URL'] =
mockEnvironmentState.ETHEREUM_PROVIDER_URL;
process.env['NX_ETHERSCAN_URL'] = mockEnvironmentState.ETHERSCAN_URL;
process.env['NX_GIT_BRANCH'] = mockEnvironmentState.GIT_BRANCH;
process.env['NX_GIT_ORIGIN_URL'] = mockEnvironmentState.GIT_ORIGIN_URL;
process.env['NX_GIT_COMMIT_HASH'] = mockEnvironmentState.GIT_COMMIT_HASH;
process.env['NX_GITHUB_FEEDBACK_URL'] =
mockEnvironmentState.GITHUB_FEEDBACK_URL;
});
beforeEach(() => jest.resetModules()); // clears the cache of the modules
it('throws a validation error when NX_ETHERSCAN_URL is not a valid url', () => {
process.env['NX_ETHERSCAN_URL'] = 'invalid-url';
const result = () =>
renderHook(() => useEnvironment(), {
wrapper: MockWrapper,
});
expect(result).toThrow(
`The NX_ETHERSCAN_URL environment variable must be a valid url`
);
});
it('throws a validation error when NX_ETHEREUM_PROVIDER_URL is not a valid url', () => {
process.env['NX_ETHEREUM_PROVIDER_URL'] = 'invalid-url';
const result = () =>
renderHook(() => useEnvironment(), {
wrapper: MockWrapper,
});
expect(result).toThrow(
`The NX_ETHEREUM_PROVIDER_URL environment variable must be a valid url`
);
});
it('throws a validation error when VEGA_NETWORKS has an invalid network as a key', () => {
process.env['NX_VEGA_NETWORKS'] = JSON.stringify({
NOT_A_NETWORK: 'https://somewhere.url',
});
const result = () =>
renderHook(() => useEnvironment(), {
wrapper: MockWrapper,
});
expect(result).toThrow(
`Error processing the vega app environment:
- All keys in NX_VEGA_NETWORKS must represent a valid environment: ${Object.keys(
Networks
).join(' | ')}`
);
});
it('throws a validation error when both VEGA_URL and VEGA_CONFIG_URL are missing in the environment', () => {
delete process.env['NX_VEGA_URL'];
delete process.env['NX_VEGA_CONFIG_URL'];
const result = () =>
renderHook(() => useEnvironment(), {
wrapper: MockWrapper,
});
expect(result).toThrow(
`Must provide either NX_VEGA_CONFIG_URL or NX_VEGA_URL in the environment.`
);
});
it('throws a validation error when NX_VEGA_ENV is not found in the environment', () => {
delete process.env['NX_VEGA_ENV'];
const result = () =>
renderHook(() => useEnvironment(), {
wrapper: MockWrapper,
});
expect(result).toThrow(
`Error processing the vega app environment:
- NX_VEGA_ENV is invalid, received "undefined" instead of: '${Object.keys(
Networks
).join("' | '")}'`
);
});
it('throws a validation error when VEGA_ENV is not a valid network', () => {
process.env['NX_VEGA_ENV'] = 'SOMETHING';
const result = () =>
renderHook(() => useEnvironment(), {
wrapper: MockWrapper,
});
expect(result).not.toThrow(
`Error processing the vega app environment:
- NX_VEGA_ENV is invalid, received "SOMETHING" instead of: '${Object.keys(
Networks
).join("' | '")}'`
);
});
});
@@ -0,0 +1,259 @@
import { renderHook, waitFor } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { Networks } from '../types';
import { useEnvironment } from './use-environment';
const noop = () => {
/* no op*/
};
jest.mock('@vegaprotocol/apollo-client', () => ({
createClient: () => ({
query: () =>
Promise.resolve({
data: {
statistics: {
chainId: 'chain-id',
blockHeight: '100',
vegaTime: new Date().toISOString(),
},
},
}),
subscribe: () => ({
// eslint-disable-next-line
subscribe: (obj: any) => {
obj.next();
},
}),
}),
}));
jest.mock('zustand');
global.fetch = jest.fn();
// eslint-disable-next-line
const setupFetch = (result: any) => {
return () => {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(result),
});
};
};
const mockEnvVars = {
VEGA_ENV: Networks.TESTNET,
VEGA_NETWORKS: {
DEVNET: 'https://devnet.url',
TESTNET: 'https://testnet.url',
STAGNET3: 'https://stagnet3.url',
MAINNET: 'https://mainnet.url',
},
VEGA_WALLET_URL: 'https://localhost:1234',
ETHEREUM_PROVIDER_URL: 'https://ether.provider',
ETHERSCAN_URL: 'https://etherscan.url',
};
describe('useEnvironment', () => {
const env = process.env;
beforeEach(() => {
jest.resetModules();
process.env = { ...env };
process.env['NX_VEGA_ENV'] = mockEnvVars.VEGA_ENV;
process.env['NX_VEGA_NETWORKS'] = JSON.stringify(mockEnvVars.VEGA_NETWORKS);
process.env['NX_ETHEREUM_PROVIDER_URL'] = mockEnvVars.ETHEREUM_PROVIDER_URL;
process.env['NX_VEGA_WALLET_URL'] = mockEnvVars.VEGA_WALLET_URL;
process.env['NX_ETHERSCAN_URL'] = mockEnvVars.ETHERSCAN_URL;
// if config is fetched resulting suitable node
// will be stored in localStorage
localStorage.clear();
// @ts-ignore clear mocked node config fetch
fetch.mockClear();
});
afterEach(() => {
process.env = env;
});
const setup = () => {
return renderHook(() => useEnvironment());
};
it('exposes env vars and sets VEGA_URL from config nodes', async () => {
const configUrl = 'https://vega.xyz/testnet-config.json';
process.env['NX_VEGA_CONFIG_URL'] = configUrl;
const nodes = [
'https://api.n00.foo.vega.xyz',
'https://api.n01.foo.vega.xyz',
];
// @ts-ignore: typscript doesn't recognise the mock implementation
global.fetch.mockImplementation(setupFetch({ hosts: nodes }));
const { result } = setup();
expect(result.current.status).toBe('default');
act(() => {
result.current.initialize();
});
expect(result.current.status).toBe('pending');
await waitFor(() => {
expect(result.current.status).toBe('success');
});
// resulting VEGA_URL should be one of the nodes from the config
expect(
result.current.VEGA_URL === nodes[0] ||
result.current.VEGA_URL === nodes[1]
).toBe(true);
expect(result.current).toMatchObject({
...mockEnvVars,
nodes,
});
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(configUrl);
});
it('sets error if environment is invalid', async () => {
const error = console.error;
console.error = noop;
process.env['NX_VEGA_ENV'] = undefined; // VEGA_ENV is required by zod schema
const { result } = setup();
await act(async () => {
result.current.initialize();
});
expect(result.current).toMatchObject({
status: 'failed',
error: 'Error processing the Vega environment',
});
console.error = error;
});
it('errors if neither VEGA_URL or VEGA_CONFIG_URL are set', async () => {
const error = console.error;
console.error = noop;
process.env['NX_VEGA_ENV'] = undefined;
process.env['NX_VEGA_URL'] = undefined;
const { result } = setup();
await act(async () => {
result.current.initialize();
});
expect(result.current).toMatchObject({
status: 'failed',
});
console.error = error;
});
it('allows for undefined VEGA_CONFIG_URL if VEGA_URL is set', async () => {
const url = 'https://my.vega.url';
process.env['NX_VEGA_URL'] = url;
process.env['NX_VEGA_CONFIG_URL'] = undefined;
const { result } = setup();
await act(async () => {
result.current.initialize();
});
expect(result.current).toMatchObject({
VEGA_URL: url,
VEGA_CONFIG_URL: undefined,
});
});
it('allows for undefined VEGA_URL if VEGA_CONFIG_URL is set', async () => {
const configUrl = 'https://vega.xyz/testnet-config.json';
process.env['NX_VEGA_CONFIG_URL'] = configUrl;
process.env['NX_VEGA_URL'] = undefined;
const nodes = [
'https://api.n00.foo.vega.xyz',
'https://api.n01.foo.vega.xyz',
];
// @ts-ignore setup mock fetch for config url
global.fetch.mockImplementation(setupFetch({ hosts: nodes }));
const { result } = setup();
await act(async () => {
result.current.initialize();
});
expect(typeof result.current.VEGA_URL).toEqual('string');
expect(result.current.VEGA_URL).not.toBeFalsy();
});
it('handles error if node config cannot be fetched', async () => {
const warn = console.warn;
console.warn = noop;
const configUrl = 'https://vega.xyz/testnet-config.json';
process.env['NX_VEGA_CONFIG_URL'] = configUrl;
process.env['NX_VEGA_URL'] = undefined;
// @ts-ignore setup mock fetch for config url
global.fetch.mockImplementation(() => {
throw new Error('failed to fetch');
});
const { result } = setup();
await act(async () => {
result.current.initialize();
});
expect(result.current.status).toEqual('failed');
expect(typeof result.current.error).toBe('string');
expect(result.current.error).toBeTruthy();
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(configUrl);
console.warn = warn;
});
it('handles an invalid node config', async () => {
const warn = console.warn;
console.warn = noop;
const configUrl = 'https://vega.xyz/testnet-config.json';
process.env['NX_VEGA_CONFIG_URL'] = configUrl;
process.env['NX_VEGA_URL'] = undefined;
// @ts-ignore: typscript doesn't recognise the mock implementation
global.fetch.mockImplementation(setupFetch({ invalid: 'invalid' }));
const { result } = setup();
await act(async () => {
result.current.initialize();
});
expect(result.current.status).toEqual('failed');
expect(typeof result.current.error).toBe('string');
expect(result.current.error).toBeTruthy();
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(configUrl);
console.warn = warn;
});
it('uses stored url', async () => {
const configUrl = 'https://vega.xyz/testnet-config.json';
process.env['NX_VEGA_CONFIG_URL'] = configUrl;
// @ts-ignore setup mock fetch for config url
global.fetch.mockImplementation(
setupFetch({ hosts: ['http://foo.bar.com'] })
);
const url = 'https://api.n00.foo.com';
localStorage.setItem('vega_url', url);
const { result } = setup();
await act(async () => {
result.current.initialize();
});
expect(result.current.VEGA_URL).toBe(url);
expect(result.current.status).toBe('success');
});
it('can update VEGA_URL', async () => {
const url = 'https://api.n00.foo.com';
const newUrl = 'http://foo.bar.com';
process.env['NX_VEGA_URL'] = url;
const { result } = setup();
await act(async () => {
result.current.initialize();
});
expect(result.current.VEGA_URL).toBe(url);
expect(result.current.status).toBe('success');
act(() => {
result.current.setUrl(newUrl);
});
expect(result.current.VEGA_URL).toBe(newUrl);
expect(localStorage.getItem('vega_url')).toBe(newUrl);
});
});
@@ -1,609 +0,0 @@
// having the node switcher dialog in the environment provider breaks the test renderer
// workaround based on: https://github.com/facebook/react/issues/11565
import type { ComponentProps, ReactNode } from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import type { ClientOptions } from '@vegaprotocol/apollo-client';
import { createClient } from '@vegaprotocol/apollo-client';
import { useEnvironment, EnvironmentProvider } from './use-environment';
import { Networks, ErrorType } from '../types';
import type { MockRequestConfig } from './mocks/apollo-client';
import createMockClient from './mocks/apollo-client';
import { getErrorByType } from '../utils/validate-node';
jest.mock('@vegaprotocol/apollo-client');
jest.mock('react-dom', () => ({
...jest.requireActual('react-dom'),
createPortal: (node: ReactNode) => node,
}));
global.fetch = jest.fn();
const MockWrapper = (props: ComponentProps<typeof EnvironmentProvider>) => {
return <EnvironmentProvider {...props} />;
};
const MOCK_HOST = 'https://vega.host/query';
// eslint-disable-next-line @typescript-eslint/no-empty-function
const noop = () => {};
const mockEnvironmentState = {
VEGA_URL: 'https://vega.xyz',
VEGA_ENV: Networks.TESTNET,
VEGA_CONFIG_URL: 'https://vega.xyz/testnet-config.json',
VEGA_NETWORKS: {
DEVNET: 'https://devnet.url',
TESTNET: 'https://testnet.url',
STAGNET3: 'https://stagnet3.url',
MAINNET: 'https://mainnet.url',
},
ETHEREUM_PROVIDER_URL: 'https://ether.provider',
ETHERSCAN_URL: 'https://etherscan.url',
GIT_BRANCH: 'test',
GIT_ORIGIN_URL: 'https://github.com/test/repo',
GIT_COMMIT_HASH: 'abcde01234',
GITHUB_FEEDBACK_URL: 'https://github.com/test/feedback',
MAINTENANCE_PAGE: false,
configLoading: false,
blockDifference: 0,
nodeSwitcherOpen: false,
setNodeSwitcherOpen: noop,
networkError: undefined,
};
const MOCK_DURATION = 76;
window.performance.getEntriesByName = jest
.fn()
.mockImplementation((url: string) => [
{
entryType: 'resource',
name: url,
startTime: 0,
toJSON: () => ({}),
duration: MOCK_DURATION,
},
]);
function setupFetch(
configUrl: string = mockEnvironmentState.VEGA_CONFIG_URL,
hosts?: string[]
) {
return (url: RequestInfo) => {
if (url === configUrl) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ hosts: hosts || [MOCK_HOST] }),
} as Response);
}
return Promise.resolve({
ok: true,
} as Response);
};
}
const getQuickestNode = (mockNodes: Record<string, MockRequestConfig>) => {
const { nodeUrl } = Object.keys(mockNodes).reduce<{
nodeUrl?: string;
delay: number;
}>(
(acc, url) => {
const { delay = 0, hasError = false } = mockNodes[url];
if (!hasError && delay < acc.delay) {
return { nodeUrl: url, delay };
}
return acc;
},
{ nodeUrl: undefined, delay: Infinity }
);
return nodeUrl;
};
beforeEach(() => {
// @ts-ignore: typscript doesn't recognise the mock implementation
global.fetch.mockImplementation(setupFetch());
window.localStorage.clear();
// @ts-ignore allow adding a mock return value to mocked module
createClient.mockImplementation(() => createMockClient());
process.env['NX_VEGA_URL'] = mockEnvironmentState.VEGA_URL;
process.env['NX_VEGA_ENV'] = mockEnvironmentState.VEGA_ENV;
process.env['NX_VEGA_CONFIG_URL'] = mockEnvironmentState.VEGA_CONFIG_URL;
process.env['NX_VEGA_NETWORKS'] = JSON.stringify(
mockEnvironmentState.VEGA_NETWORKS
);
process.env['NX_ETHEREUM_PROVIDER_URL'] =
mockEnvironmentState.ETHEREUM_PROVIDER_URL;
process.env['NX_ETHERSCAN_URL'] = mockEnvironmentState.ETHERSCAN_URL;
process.env['NX_GIT_BRANCH'] = mockEnvironmentState.GIT_BRANCH;
process.env['NX_GIT_ORIGIN_URL'] = mockEnvironmentState.GIT_ORIGIN_URL;
process.env['NX_GIT_COMMIT_HASH'] = mockEnvironmentState.GIT_COMMIT_HASH;
process.env['NX_GITHUB_FEEDBACK_URL'] =
mockEnvironmentState.GITHUB_FEEDBACK_URL;
});
afterAll(() => {
jest.clearAllMocks();
});
describe('useEnvironment hook', () => {
it('transforms and exposes values from the environment', async () => {
const { result } = renderHook(() => useEnvironment(), {
wrapper: MockWrapper,
});
await waitFor(() => {
expect(result.current).toEqual({
...mockEnvironmentState,
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
});
});
});
it('allows for the VEGA_CONFIG_URL to be missing when there is a VEGA_URL present', async () => {
delete process.env['NX_VEGA_CONFIG_URL'];
const { result } = renderHook(() => useEnvironment(), {
wrapper: MockWrapper,
});
await waitFor(() => {
expect(result.current).toEqual({
...mockEnvironmentState,
VEGA_CONFIG_URL: undefined,
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
});
});
});
it('allows for the VEGA_NETWORKS to be missing from the environment', async () => {
delete process.env['NX_VEGA_NETWORKS'];
const { result } = renderHook(() => useEnvironment(), {
wrapper: MockWrapper,
});
await waitFor(() => {
expect(result.current).toEqual({
...mockEnvironmentState,
VEGA_NETWORKS: {
TESTNET: window.location.origin,
},
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
});
});
});
it('throws a validation error when NX_VEGA_ENV is not found in the environment', async () => {
delete process.env['NX_VEGA_ENV'];
const consoleError = console.error;
console.error = noop;
expect(() => {
renderHook(() => useEnvironment(), {
wrapper: MockWrapper,
});
}).toThrowError(
`NX_VEGA_ENV is invalid, received "undefined" instead of: 'CUSTOM' | 'SANDBOX' | 'TESTNET' | 'STAGNET1' | 'STAGNET3' | 'DEVNET' | 'MAINNET' | 'MIRROR'`
);
console.error = consoleError;
});
it('throws a validation error when VEGA_ENV is not a valid network', async () => {
process.env['NX_VEGA_ENV'] = 'SOMETHING';
const consoleError = console.error;
console.error = noop;
expect(() => {
renderHook(() => useEnvironment(), {
wrapper: MockWrapper,
});
}).toThrowError(
`NX_VEGA_ENV is invalid, received "SOMETHING" instead of: CUSTOM | SANDBOX | TESTNET | STAGNET1 | STAGNET3 | DEVNET | MAINNET | MIRROR`
);
console.error = consoleError;
});
it('when VEGA_NETWORKS is not a valid json, prints a warning and continues without using the value from it', async () => {
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(noop);
process.env['NX_VEGA_NETWORKS'] = '{not:{valid:json';
const { result } = renderHook(() => useEnvironment(), {
wrapper: MockWrapper,
});
await waitFor(() => {
expect(result.current).toEqual({
...mockEnvironmentState,
VEGA_NETWORKS: {
TESTNET: window.location.origin,
},
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
});
});
expect(consoleWarnSpy).toHaveBeenCalled();
consoleWarnSpy.mockRestore();
});
it('throws a validation error when VEGA_NETWORKS has an invalid network as a key', async () => {
process.env['NX_VEGA_NETWORKS'] = JSON.stringify({
NOT_A_NETWORK: 'https://somewhere.url',
});
const consoleError = console.error;
console.error = noop;
expect(() => {
renderHook(() => useEnvironment(), {
wrapper: MockWrapper,
});
}).toThrowError(
`All keys in NX_VEGA_NETWORKS must represent a valid environment: CUSTOM | SANDBOX | TESTNET | STAGNET1 | STAGNET3 | DEVNET | MAINNET | MIRROR`
);
console.error = consoleError;
});
it('throws a validation error when both VEGA_URL and VEGA_CONFIG_URL are missing in the environment', async () => {
delete process.env['NX_VEGA_URL'];
delete process.env['NX_VEGA_CONFIG_URL'];
const consoleError = console.error;
console.error = noop;
expect(() => {
renderHook(() => useEnvironment(), {
wrapper: MockWrapper,
});
}).toThrowError(
`Must provide either NX_VEGA_CONFIG_URL or NX_VEGA_URL in the environment.`
);
console.error = consoleError;
});
it.each`
env | etherscanUrl | providerUrl
${Networks.DEVNET} | ${'https://sepolia.etherscan.io'} | ${'https://sepolia.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8'}
${Networks.TESTNET} | ${'https://sepolia.etherscan.io'} | ${'https://sepolia.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8'}
${Networks.STAGNET3} | ${'https://sepolia.etherscan.io'} | ${'https://sepolia.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8'}
${Networks.MAINNET} | ${'https://etherscan.io'} | ${'https://mainnet.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8'}
`(
'uses correct default ethereum connection variables in $env',
async ({ env, etherscanUrl, providerUrl }) => {
// @ts-ignore allow adding a mock return value to mocked module
createClient.mockImplementation(() => createMockClient({ network: env }));
process.env['NX_VEGA_ENV'] = env;
delete process.env['NX_ETHEREUM_PROVIDER_URL'];
delete process.env['NX_ETHERSCAN_URL'];
const { result } = renderHook(() => useEnvironment(), {
wrapper: MockWrapper,
});
await waitFor(() => {
expect(result.current).toEqual({
...mockEnvironmentState,
VEGA_ENV: env,
ETHEREUM_PROVIDER_URL: providerUrl,
ETHERSCAN_URL: etherscanUrl,
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
});
});
}
);
it('throws a validation error when NX_ETHERSCAN_URL is not a valid url', async () => {
process.env['NX_ETHERSCAN_URL'] = 'invalid-url';
const consoleError = console.error;
console.error = noop;
expect(() => {
renderHook(() => useEnvironment(), {
wrapper: MockWrapper,
});
}).toThrowError(
`The NX_ETHERSCAN_URL environment variable must be a valid url`
);
console.error = consoleError;
});
it('throws a validation error when NX_ETHEREUM_PROVIDER_URL is not a valid url', async () => {
process.env['NX_ETHEREUM_PROVIDER_URL'] = 'invalid-url';
const consoleError = console.error;
console.error = noop;
expect(() => {
renderHook(() => useEnvironment(), {
wrapper: MockWrapper,
});
}).toThrow(
`The NX_ETHEREUM_PROVIDER_URL environment variable must be a valid url`
);
console.error = consoleError;
});
describe('node selection', () => {
it('updates the VEGA_URL from the config when it is missing from the environment', async () => {
delete process.env['NX_VEGA_URL'];
const { result } = renderHook(() => useEnvironment(), {
wrapper: MockWrapper,
});
await waitFor(() => {
expect(result.current).toEqual({
...mockEnvironmentState,
VEGA_URL: MOCK_HOST,
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
});
});
});
// eslint-disable-next-line jest/no-disabled-tests
it.skip('updates the VEGA_URL with the quickest node to respond from the config urls', async () => {
delete process.env['NX_VEGA_URL'];
const mockNodes: Record<string, MockRequestConfig> = {
'https://mock-node-1.com': { hasError: false, delay: 4 },
'https://mock-node-2.com': { hasError: false, delay: 5 },
'https://mock-node-3.com': { hasError: false, delay: 8 },
'https://mock-node-4.com': { hasError: false, delay: 0 },
};
// @ts-ignore: typscript doesn't recognise the mock implementation
global.fetch.mockImplementation(
setupFetch(mockEnvironmentState.VEGA_CONFIG_URL, Object.keys(mockNodes))
);
// @ts-ignore allow adding a mock return value to mocked module
createClient.mockImplementation((cfg: ClientOptions) => {
// eslint-disable-next-line
return createMockClient({ statistics: mockNodes[cfg.url!] });
});
const nodeUrl = getQuickestNode(mockNodes);
const { result } = renderHook(() => useEnvironment(), {
wrapper: MockWrapper,
});
await waitFor(() => {
expect(result.current).toEqual({
...mockEnvironmentState,
VEGA_URL: nodeUrl,
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
});
});
});
it('ignores failing nodes and selects the first successful one to use', async () => {
delete process.env['NX_VEGA_URL'];
const mockNodes: Record<string, MockRequestConfig> = {
'https://mock-node-1.com': { hasError: true, delay: 4 },
'https://mock-node-2.com': { hasError: false, delay: 5 },
'https://mock-node-3.com': { hasError: false, delay: 8 },
'https://mock-node-4.com': { hasError: true, delay: 0 },
};
// @ts-ignore: typscript doesn't recognise the mock implementation
global.fetch.mockImplementation(
setupFetch(mockEnvironmentState.VEGA_CONFIG_URL, Object.keys(mockNodes))
);
// @ts-ignore allow adding a mock return value to mocked module
createClient.mockImplementation((cfg: ClientOptions) => {
// eslint-disable-next-line
return createMockClient({ statistics: mockNodes[cfg.url!] });
});
const nodeUrl = getQuickestNode(mockNodes);
const { result } = renderHook(() => useEnvironment(), {
wrapper: MockWrapper,
});
await waitFor(() => {
expect(result.current).toEqual({
...mockEnvironmentState,
VEGA_URL: nodeUrl,
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
});
});
});
it('has a network error when cannot connect to any nodes', async () => {
delete process.env['NX_VEGA_URL'];
const mockNodes: Record<string, MockRequestConfig> = {
'https://mock-node-1.com': { hasError: true, delay: 4 },
'https://mock-node-2.com': { hasError: true, delay: 5 },
'https://mock-node-3.com': { hasError: true, delay: 8 },
'https://mock-node-4.com': { hasError: true, delay: 0 },
};
// @ts-ignore: typscript doesn't recognise the mock implementation
global.fetch.mockImplementation(
setupFetch(mockEnvironmentState.VEGA_CONFIG_URL, Object.keys(mockNodes))
);
// @ts-ignore allow adding a mock return value to mocked module
createClient.mockImplementation((cfg: ClientOptions) => {
// eslint-disable-next-line
return createMockClient({ statistics: mockNodes[cfg.url!] });
});
const { result } = renderHook(() => useEnvironment(), {
wrapper: MockWrapper,
});
await waitFor(() => {
expect(result.current).toEqual({
...mockEnvironmentState,
VEGA_URL: undefined,
networkError: ErrorType.CONNECTION_ERROR_ALL,
nodeSwitcherOpen: true,
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
});
});
});
it('has a network error when it cannot fetch the network config and there is no VEGA_URL in the environment', async () => {
delete process.env['NX_VEGA_URL'];
// @ts-ignore: typscript doesn't recognise the mock implementation
global.fetch.mockImplementation(() => {
throw new Error('Cannot fetch');
});
const { result } = renderHook(() => useEnvironment(), {
wrapper: MockWrapper,
});
await waitFor(() => {
expect(result.current).toEqual({
...mockEnvironmentState,
VEGA_URL: undefined,
networkError: ErrorType.CONFIG_LOAD_ERROR,
nodeSwitcherOpen: true,
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
});
});
});
it('logs an error when it cannot fetch the network config and there is a VEGA_URL in the environment', async () => {
const consoleWarnSpy = jest
.spyOn(console, 'warn')
.mockImplementation(noop);
// @ts-ignore: typscript doesn't recognise the mock implementation
global.fetch.mockImplementation(() => {
throw new Error('Cannot fetch');
});
const { result } = renderHook(() => useEnvironment(), {
wrapper: MockWrapper,
});
await waitFor(() => {
expect(result.current).toEqual({
...mockEnvironmentState,
nodeSwitcherOpen: false,
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
});
expect(consoleWarnSpy).toHaveBeenCalledWith(
getErrorByType(
ErrorType.CONFIG_LOAD_ERROR,
mockEnvironmentState.VEGA_ENV
)?.headline
);
});
});
// SKIP due to https://github.com/facebook/jest/issues/12670
// eslint-disable-next-line jest/no-disabled-tests
it.skip('has a network error when the config is invalid and there is no VEGA_URL in the environment', async () => {
delete process.env['NX_VEGA_URL'];
// @ts-ignore: typscript doesn't recognise the mock implementation
global.fetch.mockImplementation(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ some: 'invalid-object' }),
})
);
const { result } = renderHook(() => useEnvironment(), {
wrapper: MockWrapper,
});
await waitFor(() => {
expect(result.current).toEqual({
...mockEnvironmentState,
VEGA_URL: undefined,
networkError: ErrorType.CONFIG_VALIDATION_ERROR,
nodeSwitcherOpen: true,
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
});
});
});
// SKIP due to https://github.com/facebook/jest/issues/12670
// eslint-disable-next-line jest/no-disabled-tests
it.skip('logs an error when the network config is invalid and there is a VEGA_URL in the environment', async () => {
const consoleWarnSpy = jest
.spyOn(console, 'warn')
.mockImplementation(noop);
// @ts-ignore: typscript doesn't recognise the mock implementation
global.fetch.mockImplementation(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ some: 'invalid-object' }),
})
);
const { result } = renderHook(() => useEnvironment(), {
wrapper: MockWrapper,
});
expect(result.current.configLoading).toBe(true);
await waitFor(() => {
expect(result.current).toEqual({
...mockEnvironmentState,
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
});
expect(consoleWarnSpy).toHaveBeenCalledWith(
getErrorByType(
ErrorType.CONFIG_VALIDATION_ERROR,
mockEnvironmentState.VEGA_ENV
)?.headline
);
});
});
it('has a network error when the selected node is not a valid url', async () => {
process.env['NX_VEGA_URL'] = 'not-url';
const { result } = renderHook(() => useEnvironment(), {
wrapper: MockWrapper,
});
await waitFor(() => {
expect(result.current).toEqual({
...mockEnvironmentState,
VEGA_URL: 'not-url',
nodeSwitcherOpen: true,
networkError: ErrorType.INVALID_URL,
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
});
});
});
it('has a network error when cannot connect to the selected node', async () => {
// @ts-ignore allow adding a mock return value to mocked module
createClient.mockImplementation(() => {
return createMockClient({ statistics: { hasError: true } });
});
const { result } = renderHook(() => useEnvironment(), {
wrapper: MockWrapper,
});
await waitFor(() => {
expect(result.current).toEqual({
...mockEnvironmentState,
nodeSwitcherOpen: true,
networkError: ErrorType.CONNECTION_ERROR,
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
});
});
});
it('has a network error when the selected node has no subscription available', async () => {
// @ts-ignore allow adding a mock return value to mocked module
createClient.mockImplementation(() => {
return createMockClient({ busEvents: { hasError: true } });
});
const { result } = renderHook(() => useEnvironment(), {
wrapper: MockWrapper,
});
await waitFor(() => {
expect(result.current).toEqual({
...mockEnvironmentState,
networkError: ErrorType.SUBSCRIPTION_ERROR,
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
nodeSwitcherOpen: true,
});
});
});
});
});
@@ -0,0 +1,315 @@
import { isValidUrl, LocalStorage, t } from '@vegaprotocol/react-helpers';
import { useEffect } from 'react';
import { create } from 'zustand';
import { createClient } from '@vegaprotocol/apollo-client';
import type {
BlockTimeSubscription,
StatisticsQuery,
} from '../utils/__generated__/Node';
import { BlockTimeDocument } from '../utils/__generated__/Node';
import { StatisticsDocument } from '../utils/__generated__/Node';
import type { Environment } from '../types';
import { Networks } from '../types';
import { compileErrors } from '../utils/compile-errors';
import { envSchema } from '../utils/validate-environment';
import { configSchema } from '../utils/validate-configuration';
type Client = ReturnType<typeof createClient>;
type ClientCollection = {
[node: string]: Client;
};
type EnvState = {
nodes: string[];
status: 'default' | 'pending' | 'success' | 'failed';
error: string | null;
};
type Actions = {
setUrl: (url: string) => void;
initialize: () => Promise<void>;
};
export type Env = Environment & EnvState;
export type EnvStore = Env & Actions;
const STORAGE_KEY = 'vega_url';
const SUBSCRIPTION_TIMEOUT = 3000;
export const useEnvironment = create<EnvStore>((set, get) => ({
...compileEnvVars(),
nodes: [],
status: 'default',
error: null,
setUrl: (url) => {
set({ VEGA_URL: url, status: 'success', error: null });
LocalStorage.setItem(STORAGE_KEY, url);
},
initialize: async () => {
set({ status: 'pending' });
// validate env vars
try {
const rawVars = compileEnvVars();
const safeVars = envSchema.parse(rawVars);
set({ ...safeVars });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
const headline = t('Error processing the Vega environment');
set({
status: 'failed',
error: headline,
});
console.error(compileErrors(headline, err));
return;
}
const state = get();
const storedUrl = LocalStorage.getItem(STORAGE_KEY);
let nodes: string[] | undefined;
try {
nodes = await fetchConfig(state.VEGA_CONFIG_URL);
set({ nodes });
} catch (err) {
console.warn(`Could not fetch node config from ${state.VEGA_CONFIG_URL}`);
}
// Node url found in localStorage, if its valid attempt to connect
if (storedUrl) {
if (isValidUrl(storedUrl)) {
set({ VEGA_URL: storedUrl, status: 'success' });
return;
} else {
LocalStorage.removeItem(STORAGE_KEY);
}
}
// VEGA_URL env var is set and is a valid url no need to proceed
if (state.VEGA_URL) {
set({ status: 'success' });
return;
}
// No url found in env vars or localStorage, AND no nodes were found in
// the config fetched from VEGA_CONFIG_URL, app initialization has failed
if (!nodes || !nodes.length) {
set({
status: 'failed',
error: t(`Failed to fetch node config from ${state.VEGA_CONFIG_URL}`),
});
return;
}
// Create a map of node urls to client instances
const clients: ClientCollection = {};
nodes.forEach((url) => {
clients[url] = createClient({
url,
cacheConfig: undefined,
retry: false,
connectToDevTools: false,
});
});
// Find a suitable node to connect to by attempting a query and a
// subscription, first to fulfill both will be the resulting url.
const url = await findNode(clients);
if (url !== null) {
set({
status: 'success',
VEGA_URL: url,
});
LocalStorage.setItem(STORAGE_KEY, url);
}
// Every node failed either to make a query or retrieve data from
// a subscription
else {
set({
status: 'failed',
error: t('No node found'),
});
console.warn(t('No suitable vega node was found'));
}
},
}));
/**
* Initialize Vega app to dynamically select a node from the
* VEGA_CONFIG_URL
*
* This can be ommitted if you intend to only use a single node,
* in those cases be sure to set NX_VEGA_URL
*/
export const useInitializeEnv = () => {
const { initialize, status } = useEnvironment((store) => ({
status: store.status,
initialize: store.initialize,
}));
useEffect(() => {
if (status === 'default') {
initialize();
}
}, [status, initialize]);
};
/**
* Fetch and validate a vega node configuration
*/
const fetchConfig = async (url?: string) => {
if (!url) return [];
const res = await fetch(url);
const cfg = await res.json();
const result = configSchema.parse(cfg);
return result.hosts;
};
/**
* Find a suitable node by running a test query and test
* subscription, against a list of clients, first to resolve wins
*/
const findNode = (clients: ClientCollection): Promise<string | null> => {
const tests = Object.entries(clients).map((args) => testNode(...args));
return Promise.race(tests);
};
/**
* Test a node for suitability for connection
*/
const testNode = async (
url: string,
client: Client
): Promise<string | null> => {
try {
const results = await Promise.all([
testQuery(client),
testSubscription(client),
]);
if (results[0] && results[1]) {
return url;
}
return null;
} catch (err) {
console.warn(`Tests failed for ${url}`);
return null;
}
};
/**
* Run a test query on a client
*/
const testQuery = async (client: Client) => {
try {
const result = await client.query<StatisticsQuery>({
query: StatisticsDocument,
});
if (!result || result.error) {
return false;
}
return true;
} catch (err) {
return false;
}
};
/**
* Run a test subscription on a client. A subscription
* that takes longer than SUBSCRIPTION_TIMEOUT ms to respond
* is deemed a failure
*/
const testSubscription = (client: Client) => {
return new Promise((resolve) => {
const sub = client
.subscribe<BlockTimeSubscription>({
query: BlockTimeDocument,
errorPolicy: 'all',
})
.subscribe({
next: () => {
resolve(true);
sub.unsubscribe();
},
error: () => {
resolve(false);
sub.unsubscribe();
},
});
setTimeout(() => {
resolve(false);
sub.unsubscribe();
}, SUBSCRIPTION_TIMEOUT);
});
};
/**
* Retrieve env vars, parsing where needed some type casting is needed
* here to appease the environment store interface
*/
function compileEnvVars() {
const VEGA_ENV = process.env['NX_VEGA_ENV'] as Networks;
const env: Environment = {
VEGA_URL: process.env['NX_VEGA_URL'],
VEGA_ENV,
VEGA_CONFIG_URL: process.env['NX_VEGA_CONFIG_URL'] as string,
VEGA_NETWORKS: parseNetworks(process.env['NX_VEGA_NETWORKS']),
VEGA_WALLET_URL: process.env['NX_VEGA_WALLET_URL'] as string,
HOSTED_WALLET_URL: process.env['NX_HOSTED_WALLET_URL'],
ETHERSCAN_URL: getEtherscanUrl(VEGA_ENV, process.env['NX_ETHERSCAN_URL']),
ETHEREUM_PROVIDER_URL: getEthereumProviderUrl(
VEGA_ENV,
process.env['NX_ETHEREUM_PROVIDER_URL']
),
ETH_LOCAL_PROVIDER_URL: process.env['NX_ETH_LOCAL_PROVIDER_URL'],
ETH_WALLET_MNEMONIC: process.env['NX_ETH_WALLET_MNEMONIC'],
VEGA_DOCS_URL: process.env['NX_VEGA_DOCS_URL'],
VEGA_EXPLORER_URL: process.env['NX_VEGA_EXPLORER_URL'],
VEGA_TOKEN_URL: process.env['NX_VEGA_TOKEN_URL'],
GITHUB_FEEDBACK_URL: process.env['NX_GITHUB_FEEDBACK_URL'],
MAINTENANCE_PAGE: parseBoolean(process.env['NX_MAINTENANCE_PAGE']),
GIT_BRANCH: process.env['GIT_COMMIT_BRANCH'],
GIT_COMMIT_HASH: process.env['GIT_COMMIT_HASH'],
GIT_ORIGIN_URL: process.env['GIT_ORIGIN_URL'],
};
return env;
}
function parseNetworks(value?: string) {
if (value) {
try {
return JSON.parse(value);
} catch (e) {
return {};
}
}
return {};
}
function parseBoolean(value?: string) {
return ['true', '1', 'yes'].includes(value?.toLowerCase() || '');
}
/**
* Provides a fallback ethereum provider url for test purposes in some apps
*/
function getEthereumProviderUrl(
network: Networks | undefined,
envvar: string | undefined
) {
if (envvar) return envvar;
return network === Networks.MAINNET
? 'https://mainnet.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8'
: 'https://sepolia.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8';
}
/**
* Provide a fallback etherscan url for test purposes in some apps
*/
function getEtherscanUrl(
network: Networks | undefined,
envvar: string | undefined
) {
if (envvar) return envvar;
return network === Networks.MAINNET
? 'https://etherscan.io'
: 'https://sepolia.etherscan.io';
}
@@ -1,181 +0,0 @@
import type { ReactNode } from 'react';
import {
useEffect,
useState,
createContext,
useContext,
useCallback,
} from 'react';
import { MaintenancePage } from '@vegaprotocol/ui-toolkit';
import { NodeSwitcherDialog } from '../components/node-switcher-dialog';
import { useConfig } from './use-config';
import { useNodes } from './use-nodes';
import { compileEnvironment } from '../utils/compile-environment';
import { validateEnvironment } from '../utils/validate-environment';
import {
getErrorType,
getErrorByType,
getIsNodeLoading,
} from '../utils/validate-node';
import { ErrorType } from '../types';
import type {
Environment,
Networks,
RawEnvironment,
NodeData,
Configuration,
} from '../types';
import { useNodeHealth } from './use-node-health';
type EnvironmentProviderProps = {
config?: Configuration;
definitions?: Partial<RawEnvironment>;
children?: ReactNode;
};
export type EnvironmentState = Environment & {
configLoading: boolean;
networkError?: ErrorType;
blockDifference: number;
nodeSwitcherOpen: boolean;
setNodeSwitcherOpen: () => void;
};
const EnvironmentContext = createContext({} as EnvironmentState);
const hasLoaded = (env: Networks, node: NodeData) =>
node.initialized &&
!getIsNodeLoading(node) &&
getErrorType(env, node) === null;
const hasFailedLoading = (env: Networks, node: NodeData) =>
node.initialized &&
!getIsNodeLoading(node) &&
getErrorType(env, node) !== null;
export const EnvironmentProvider = ({
config: defaultConfig,
definitions,
children,
}: EnvironmentProviderProps) => {
const [networkError, setNetworkError] = useState<undefined | ErrorType>();
const [isNodeSwitcherOpen, setNodeSwitcherIsOpen] = useState(false);
const [environment, updateEnvironment] = useState<Environment>(
compileEnvironment(definitions)
);
const setNodeSwitcherOpen = useCallback((isOpen: boolean) => {
if (!('Cypress' in window)) {
setNodeSwitcherIsOpen(isOpen);
}
}, []);
const { loading, config } = useConfig(
{ environment, defaultConfig },
(errorType) => {
if (!environment.VEGA_URL) {
setNetworkError(errorType);
setNodeSwitcherOpen(true);
} else {
const error = getErrorByType(errorType, environment.VEGA_ENV);
error && console.warn(error.headline);
}
}
);
const { state: nodes, clients } = useNodes(
config,
environment.MAINTENANCE_PAGE
);
const blockDifference = useNodeHealth(clients, environment.VEGA_URL);
const nodeKeys = Object.keys(nodes);
useEffect(() => {
if (!environment.VEGA_URL) {
const successfulNodeKey = nodeKeys.find((key) =>
hasLoaded(environment.VEGA_ENV, nodes[key])
);
if (successfulNodeKey && nodes[successfulNodeKey]) {
Object.keys(clients).forEach((node) => clients[node]?.stop());
const url = nodes[successfulNodeKey].url;
updateEnvironment((prevEnvironment) => ({
...prevEnvironment,
VEGA_URL: url,
}));
}
}
// if the selected node has errors
if (environment.VEGA_URL && nodes[environment.VEGA_URL]) {
const errorType = getErrorType(
environment.VEGA_ENV,
nodes[environment.VEGA_URL]
);
if (errorType !== null) {
Object.keys(clients).forEach((node) => clients[node]?.stop());
setNetworkError(errorType);
setNodeSwitcherOpen(true);
return;
}
}
// if the config doesn't contain nodes the app can connect to
if (
nodeKeys.length > 0 &&
nodeKeys.filter((key) =>
hasFailedLoading(environment.VEGA_ENV, nodes[key])
).length === nodeKeys.length
) {
Object.keys(clients).forEach((node) => clients[node]?.stop());
setNetworkError(ErrorType.CONNECTION_ERROR_ALL);
setNodeSwitcherOpen(true);
}
// prevent infinite render loop by skipping deps which will change as a result
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [environment.VEGA_URL, nodes]);
const errorMessage = validateEnvironment(environment);
if (errorMessage) {
throw new Error(errorMessage);
}
if (environment.MAINTENANCE_PAGE) {
return <MaintenancePage />;
}
return (
<EnvironmentContext.Provider
value={{
...environment,
configLoading: loading,
networkError,
blockDifference,
nodeSwitcherOpen: isNodeSwitcherOpen,
setNodeSwitcherOpen: () => setNodeSwitcherOpen(true),
}}
>
<NodeSwitcherDialog
dialogOpen={isNodeSwitcherOpen}
initialErrorType={networkError}
setDialogOpen={setNodeSwitcherOpen}
loading={loading}
config={config}
onConnect={(url) => {
updateEnvironment((env) => ({ ...env, VEGA_URL: url }));
}}
/>
{children}
</EnvironmentContext.Provider>
);
};
export const useEnvironment = () => {
const context = useContext(EnvironmentContext);
if (context === undefined) {
throw new Error(
'Error running "useEnvironment". No context found, make sure your component is wrapped in an <EnvironmentProvider />.'
);
}
return context;
};
@@ -1,117 +1,114 @@
import { act, renderHook } from '@testing-library/react';
import {
useNodeHealth,
NODE_SUBSET_COUNT,
INTERVAL_TIME,
} from './use-node-health';
import type { createClient } from '@vegaprotocol/apollo-client';
import type { ClientCollection } from './use-nodes';
import { renderHook, waitFor } from '@testing-library/react';
import { useNodeHealth } from './use-node-health';
import type { MockedResponse } from '@apollo/react-testing';
import { MockedProvider } from '@apollo/react-testing';
import type { StatisticsQuery } from '../utils/__generated__/Node';
import { StatisticsDocument } from '../utils/__generated__/Node';
import { useHeaderStore } from '@vegaprotocol/apollo-client';
function setup(...args: Parameters<typeof useNodeHealth>) {
return renderHook(() => useNodeHealth(...args));
}
const vegaUrl = 'https://foo.bar.com';
function createMockClient(blockHeight: number) {
jest.mock('./use-environment', () => ({
useEnvironment: () => vegaUrl,
}));
jest.mock('@vegaprotocol/apollo-client');
const createStatsMock = (
blockHeight: number
): MockedResponse<StatisticsQuery> => {
return {
query: jest.fn().mockResolvedValue({
request: {
query: StatisticsDocument,
},
result: {
data: {
statistics: {
chainId: 'chain-id',
blockHeight: blockHeight.toString(),
vegaTime: '12345',
},
},
}),
} as unknown as ReturnType<typeof createClient>;
}
},
};
};
function createRejectingClient() {
return {
query: () => Promise.reject(new Error('request failed')),
} as unknown as ReturnType<typeof createClient>;
}
function setup(
mock: MockedResponse<StatisticsQuery>,
headers:
| {
blockHeight: number;
timestamp: Date;
}
| undefined
) {
// @ts-ignore ignore mock implementation
useHeaderStore.mockImplementation(() => ({
[vegaUrl]: headers,
}));
function createErroringClient() {
return {
query: () =>
Promise.resolve({
error: new Error('failed'),
}),
} as unknown as ReturnType<typeof createClient>;
return renderHook(() => useNodeHealth(), {
wrapper: ({ children }) => (
<MockedProvider mocks={[mock]}>{children}</MockedProvider>
),
});
}
const CURRENT_URL = 'https://current.test.com';
describe('useNodeHealth', () => {
beforeAll(() => {
jest.useFakeTimers();
});
it.each([
{ core: 1, node: 1, expected: 0 },
{ core: 1, node: 5, expected: -4 },
{ core: 10, node: 5, expected: 5 },
])(
'provides difference core block $core and node block $node',
async (cases) => {
const { result } = setup(createStatsMock(cases.core), {
blockHeight: cases.node,
timestamp: new Date(),
});
expect(result.current.blockDiff).toEqual(null);
expect(result.current.coreBlockHeight).toEqual(undefined);
expect(result.current.datanodeBlockHeight).toEqual(cases.node);
await waitFor(() => {
expect(result.current.blockDiff).toEqual(cases.expected);
expect(result.current.coreBlockHeight).toEqual(cases.core);
expect(result.current.datanodeBlockHeight).toEqual(cases.node);
});
}
);
it('provides difference between the highest block and the current block', async () => {
const highest = 100;
const curr = 97;
const clientCollection: ClientCollection = {
[CURRENT_URL]: createMockClient(curr),
'https://n02.test.com': createMockClient(98),
'https://n03.test.com': createMockClient(highest),
it('block diff is null if query fails indicating non operational', async () => {
const failedQuery: MockedResponse<StatisticsQuery> = {
request: {
query: StatisticsDocument,
},
result: {
// @ts-ignore failed query with no result
data: {},
},
};
const { result } = setup(clientCollection, CURRENT_URL);
await act(async () => {
jest.advanceTimersByTime(INTERVAL_TIME);
const { result } = setup(failedQuery, {
blockHeight: 1,
timestamp: new Date(),
});
expect(result.current.blockDiff).toEqual(null);
expect(result.current.coreBlockHeight).toEqual(undefined);
expect(result.current.datanodeBlockHeight).toEqual(1);
await waitFor(() => {
expect(result.current.blockDiff).toEqual(null);
expect(result.current.coreBlockHeight).toEqual(undefined);
expect(result.current.datanodeBlockHeight).toEqual(1);
});
expect(result.current).toBe(highest - curr);
});
it('returns -1 if the current node query fails', async () => {
const clientCollection: ClientCollection = {
[CURRENT_URL]: createRejectingClient(),
'https://n02.test.com': createMockClient(200),
'https://n03.test.com': createMockClient(102),
};
const { result } = setup(clientCollection, CURRENT_URL);
await act(async () => {
jest.advanceTimersByTime(INTERVAL_TIME);
it('returns 0 if no headers are found (waits until stats query resolves)', async () => {
const { result } = setup(createStatsMock(1), undefined);
expect(result.current.blockDiff).toEqual(null);
expect(result.current.coreBlockHeight).toEqual(undefined);
expect(result.current.datanodeBlockHeight).toEqual(undefined);
await waitFor(() => {
expect(result.current.blockDiff).toEqual(0);
expect(result.current.coreBlockHeight).toEqual(1);
expect(result.current.datanodeBlockHeight).toEqual(undefined);
});
expect(result.current).toBe(-1);
});
it('returns -1 if the current node query returns an error', async () => {
const clientCollection: ClientCollection = {
[CURRENT_URL]: createErroringClient(),
'https://n02.test.com': createMockClient(200),
'https://n03.test.com': createMockClient(102),
};
const { result } = setup(clientCollection, CURRENT_URL);
await act(async () => {
jest.advanceTimersByTime(INTERVAL_TIME);
});
expect(result.current).toBe(-1);
});
it('queries against 5 random nodes along with the current url', async () => {
const clientCollection: ClientCollection = new Array(20)
.fill(null)
.reduce((obj, x, i) => {
obj[`https://n${i}.test.com`] = createMockClient(100);
return obj;
}, {} as ClientCollection);
clientCollection[CURRENT_URL] = createMockClient(100);
const spyOnCurrent = jest.spyOn(clientCollection[CURRENT_URL], 'query');
const { result } = setup(clientCollection, CURRENT_URL);
await act(async () => {
jest.advanceTimersByTime(INTERVAL_TIME);
});
let count = 0;
Object.values(clientCollection).forEach((client) => {
// @ts-ignore jest.fn() in client setup means mock will be present
if (client?.query.mock.calls.length) {
count++;
}
});
expect(count).toBe(NODE_SUBSET_COUNT + 1);
expect(spyOnCurrent).toHaveBeenCalledTimes(1);
expect(result.current).toBe(0);
});
});
+41 -82
View File
@@ -1,88 +1,47 @@
import compact from 'lodash/compact';
import shuffle from 'lodash/shuffle';
import type { createClient } from '@vegaprotocol/apollo-client';
import { useEffect, useState } from 'react';
import type { StatisticsQuery } from '../utils/__generated__/Node';
import { StatisticsDocument } from '../utils/__generated__/Node';
import type { ClientCollection } from './use-nodes';
import { useEffect, useMemo } from 'react';
import { useStatisticsQuery } from '../utils/__generated__/Node';
import { useHeaderStore } from '@vegaprotocol/apollo-client';
import { useEnvironment } from './use-environment';
import { fromNanoSeconds } from '@vegaprotocol/react-helpers';
// How often to query other nodes
export const INTERVAL_TIME = 30 * 1000;
// Number of nodes to query against
export const NODE_SUBSET_COUNT = 5;
export const useNodeHealth = () => {
const url = useEnvironment((store) => store.VEGA_URL);
const headerStore = useHeaderStore();
const headers = url ? headerStore[url] : undefined;
const { data, error, loading, stopPolling } = useStatisticsQuery({
pollInterval: 1000,
fetchPolicy: 'no-cache',
});
// Queries all nodes from the environment provider via an interval
// to calculate and return the difference between the most advanced block
// and the block height of the current node
export const useNodeHealth = (clients: ClientCollection, vegaUrl?: string) => {
const [blockDiff, setBlockDiff] = useState(0);
const blockDiff = useMemo(() => {
if (!data?.statistics.blockHeight) {
return null;
}
if (!headers) {
return 0;
}
return Number(data.statistics.blockHeight) - headers.blockHeight;
}, [data, headers]);
useEffect(() => {
if (!clients || !vegaUrl) return;
if (error) {
stopPolling();
}
}, [error, stopPolling]);
const fetchBlockHeight = async (
client?: ReturnType<typeof createClient>
) => {
try {
const result = await client?.query<StatisticsQuery>({
query: StatisticsDocument,
fetchPolicy: 'no-cache', // always fetch and never cache
});
if (!result) return null;
if (result.error) return null;
return result;
} catch {
return null;
}
};
const getBlockHeights = async () => {
const nodes = Object.keys(clients).filter((key) => key !== vegaUrl);
// make sure that your current vega url is always included
// so we can compare later
const testNodes = [vegaUrl, ...randomSubset(nodes, NODE_SUBSET_COUNT)];
const result = await Promise.all(
testNodes.map((node) => fetchBlockHeight(clients[node]))
);
const blockHeights: { [node: string]: number | null } = {};
testNodes.forEach((node, i) => {
const data = result[i];
const blockHeight = data
? Number(data?.data.statistics.blockHeight)
: null;
blockHeights[node] = blockHeight;
});
return blockHeights;
};
// Every INTERVAL_TIME get block heights of a random subset
// of nodes and determine if your current node is falling behind
const interval = setInterval(async () => {
const blockHeights = await getBlockHeights();
const highestBlock = Math.max.apply(
null,
compact(Object.values(blockHeights))
);
const currNodeBlock = blockHeights[vegaUrl];
if (!currNodeBlock) {
// Block height query failed and null was returned
setBlockDiff(-1);
} else {
setBlockDiff(highestBlock - currNodeBlock);
}
}, INTERVAL_TIME);
return () => {
clearInterval(interval);
};
}, [clients, vegaUrl]);
return blockDiff;
};
const randomSubset = (arr: string[], size: number) => {
const shuffled = shuffle(arr);
return shuffled.slice(0, size);
return {
error,
loading,
coreBlockHeight: data?.statistics
? Number(data.statistics.blockHeight)
: undefined,
coreVegaTime: data?.statistics
? fromNanoSeconds(data?.statistics.vegaTime)
: undefined,
datanodeBlockHeight: headers?.blockHeight,
datanodeVegaTime: headers?.timestamp,
blockDiff,
};
};
@@ -1,366 +0,0 @@
import { renderHook, act } from '@testing-library/react';
import { ApolloClient } from '@apollo/client';
import { createClient } from '@vegaprotocol/apollo-client';
import { useNodes } from './use-nodes';
import createMockClient, {
getMockStatisticsResult,
} from './mocks/apollo-client';
import { waitFor } from '@testing-library/react';
jest.mock('@vegaprotocol/apollo-client');
const MOCK_DURATION = 1073;
const initialState = {
url: '',
initialized: false,
responseTime: {
isLoading: false,
hasError: false,
value: undefined,
},
block: {
isLoading: false,
hasError: false,
value: undefined,
},
subscription: {
isLoading: false,
hasError: false,
value: undefined,
},
chain: {
isLoading: false,
hasError: false,
value: undefined,
},
};
window.performance.getEntriesByName = jest
.fn()
.mockImplementation((url: string) => [
{
entryType: 'resource',
name: url,
startTime: 0,
toJSON: () => ({}),
duration: MOCK_DURATION,
},
]);
beforeEach(() => {
// @ts-ignore allow adding a mock return value to mocked module
createClient.mockImplementation(() => createMockClient());
});
afterAll(() => {
jest.clearAllMocks();
});
describe('useNodes hook', () => {
it('returns the default state when empty config provided', () => {
const { result } = renderHook(() => useNodes({ hosts: [] }));
expect(result.current.state).toEqual({});
});
it('sets loading state while waiting for the results', async () => {
const node = 'https://some.url';
const { result } = renderHook(() => useNodes({ hosts: [node] }));
expect(result.current.state[node]).toEqual({
...initialState,
url: node,
initialized: true,
responseTime: {
...initialState.responseTime,
isLoading: true,
},
block: {
...initialState.block,
isLoading: true,
},
chain: {
...initialState.chain,
isLoading: true,
},
subscription: {
...initialState.subscription,
isLoading: true,
},
});
});
it('sets statistics results', async () => {
const mockResult = getMockStatisticsResult();
const node = 'https://some.url';
const { result } = renderHook(() => useNodes({ hosts: [node] }));
await waitFor(() => {
expect(result.current.state[node].block).toEqual({
isLoading: false,
hasError: false,
value: Number(mockResult.statistics.blockHeight),
});
expect(result.current.state[node].chain).toEqual({
isLoading: false,
hasError: false,
value: mockResult.statistics.chainId,
});
expect(result.current.state[node].responseTime).toEqual({
isLoading: false,
hasError: false,
value: MOCK_DURATION,
});
});
});
it('sets subscription result', async () => {
const node = 'https://some.url';
const { result } = renderHook(() => useNodes({ hosts: [node] }));
await waitFor(() => {
expect(result.current.state[node].subscription).toEqual({
isLoading: false,
hasError: false,
value: true,
});
});
});
it('sets error when host in not a valid url', async () => {
const node = 'not-url';
const { result } = renderHook(() => useNodes({ hosts: [node] }));
await waitFor(() => {
expect(result.current.state[node].block.hasError).toBe(true);
expect(result.current.state[node].chain.hasError).toBe(true);
expect(result.current.state[node].responseTime.hasError).toBe(true);
expect(result.current.state[node].responseTime.hasError).toBe(true);
});
});
it('sets error when statistics request fails', async () => {
// @ts-ignore allow adding a mock return value to mocked module
createClient.mockImplementation(() =>
createMockClient({ statistics: { hasError: true } })
);
const node = 'https://some.url';
const { result } = renderHook(() => useNodes({ hosts: [node] }));
await waitFor(() => {
expect(result.current.state[node].block).toEqual({
isLoading: false,
hasError: true,
value: undefined,
});
expect(result.current.state[node].chain).toEqual({
isLoading: false,
hasError: true,
value: undefined,
});
expect(result.current.state[node].responseTime).toEqual({
isLoading: false,
hasError: true,
value: undefined,
});
});
});
it('sets error when subscription request fails', async () => {
// @ts-ignore allow adding a mock return value to mocked module
createClient.mockImplementation(() =>
createMockClient({ busEvents: { hasError: true } })
);
const node = 'https://some.url';
const { result } = renderHook(() => useNodes({ hosts: [node] }));
await waitFor(() => {
expect(result.current.state[node].subscription).toEqual({
isLoading: false,
hasError: true,
value: undefined,
});
});
});
it('allows updating block values', async () => {
const mockResult = getMockStatisticsResult();
const node = 'https://some.url';
const { result } = renderHook(() => useNodes({ hosts: [node] }));
await waitFor(() => {
expect(result.current.state[node].block.value).toEqual(
Number(mockResult.statistics.blockHeight)
);
});
act(() => {
result.current.updateNodeBlock(node, 12);
});
await waitFor(() => {
expect(result.current.state[node].block.value).toEqual(12);
});
});
it('does nothing when calling the block update on a non-existing node', async () => {
const mockResult = getMockStatisticsResult();
const node = 'https://some.url';
const { result } = renderHook(() => useNodes({ hosts: [node] }));
await waitFor(() => {
expect(result.current.state[node].block.value).toEqual(
Number(mockResult.statistics.blockHeight)
);
});
act(() => {
result.current.updateNodeBlock('https://non-existing.url', 12);
});
expect(result.current.state['https://non-existing.url']).toBe(undefined);
});
it('adds new node', async () => {
const node = 'custom-node-key';
const { result } = renderHook(() => useNodes({ hosts: [] }));
expect(result.current.state[node]).toEqual(undefined);
act(() => {
result.current.addNode(node);
});
await waitFor(() => {
expect(result.current.state[node]).toEqual(initialState);
});
});
it('sets new url for node', async () => {
const node = 'https://some.url';
const newUrl = 'https://some-other.url';
const { result } = renderHook(() => useNodes({ hosts: [node] }));
act(() => {
result.current.updateNodeUrl(node, newUrl);
});
await waitFor(() => {
expect(result.current.state[node].url).toBe(newUrl);
});
});
it('sets error when custom node has an invalid url', async () => {
const node = 'node-key';
const url = 'not-url';
const { result } = renderHook(() => useNodes({ hosts: [] }));
expect(result.current.state[node]).toBe(undefined);
act(() => {
result.current.updateNodeUrl(node, url);
});
await waitFor(() => {
expect(result.current.state[node].url).toBe(url);
expect(result.current.state[node].block.hasError).toBe(true);
expect(result.current.state[node].chain.hasError).toBe(true);
expect(result.current.state[node].responseTime.hasError).toBe(true);
expect(result.current.state[node].subscription.hasError).toBe(true);
});
});
it('sets error when custom node statistics request fails', async () => {
// @ts-ignore allow adding a mock return value to mocked module
createClient.mockImplementation(() =>
createMockClient({ statistics: { hasError: true } })
);
const node = 'node-key';
const url = 'https://some.url';
const { result } = renderHook(() => useNodes({ hosts: [] }));
expect(result.current.state[node]).toBe(undefined);
act(() => {
result.current.updateNodeUrl(node, url);
});
await waitFor(() => {
expect(result.current.state[node].block).toEqual({
isLoading: false,
hasError: true,
value: undefined,
});
expect(result.current.state[node].chain).toEqual({
isLoading: false,
hasError: true,
value: undefined,
});
expect(result.current.state[node].responseTime).toEqual({
isLoading: false,
hasError: true,
value: undefined,
});
});
});
it('sets error when custom node subscription fails', async () => {
// @ts-ignore allow adding a mock return value to mocked module
createClient.mockImplementation(() =>
createMockClient({ busEvents: { hasError: true } })
);
const node = 'node-key';
const url = 'https://some.url';
const { result } = renderHook(() => useNodes({ hosts: [] }));
expect(result.current.state[node]).toBe(undefined);
act(() => {
result.current.updateNodeUrl(node, url);
});
await waitFor(() => {
expect(result.current.state[node].subscription).toEqual({
isLoading: false,
hasError: true,
value: undefined,
});
});
});
it('exposes a collection of clients', async () => {
const url1 = 'https://some.url';
const url2 = 'https://some-other.url';
const { result } = renderHook(() => useNodes({ hosts: [url1, url2] }));
await waitFor(() => {
expect(result.current.clients[url1]).toBeInstanceOf(ApolloClient);
expect(result.current.clients[url2]).toBeInstanceOf(ApolloClient);
});
});
it('exposes a client for the custom node', async () => {
const node = 'node-key';
const url = 'https://some.url';
const { result } = renderHook(() => useNodes({ hosts: [] }));
act(() => {
result.current.updateNodeUrl(node, url);
});
await waitFor(() => {
expect(result.current.clients[url]).toBeInstanceOf(ApolloClient);
});
});
});
-253
View File
@@ -1,253 +0,0 @@
import type { Dispatch } from 'react';
import { useState, useEffect, useReducer } from 'react';
import { produce } from 'immer';
import { initializeNode } from '../utils/initialize-node';
import type { NodeData, Configuration } from '../types';
import type { createClient } from '@vegaprotocol/apollo-client';
type StatisticsPayload = {
block: NodeData['block']['value'];
chain: NodeData['chain']['value'];
responseTime: NodeData['responseTime']['value'];
};
export enum ACTIONS {
GET_STATISTICS,
GET_STATISTICS_SUCCESS,
GET_STATISTICS_FAILURE,
CHECK_SUBSCRIPTION,
CHECK_SUBSCRIPTION_SUCCESS,
CHECK_SUBSCRIPTION_FAILURE,
ADD_NODE,
UPDATE_NODE_URL,
UPDATE_NODE_BLOCK,
}
type ActionType<T extends ACTIONS, P = undefined> = {
type: T;
node: string;
payload?: P;
};
export type Action =
| ActionType<ACTIONS.GET_STATISTICS, { url: string }>
| ActionType<ACTIONS.GET_STATISTICS_SUCCESS, StatisticsPayload>
| ActionType<ACTIONS.GET_STATISTICS_FAILURE>
| ActionType<ACTIONS.CHECK_SUBSCRIPTION, { url: string }>
| ActionType<ACTIONS.CHECK_SUBSCRIPTION_SUCCESS>
| ActionType<ACTIONS.CHECK_SUBSCRIPTION_FAILURE>
| ActionType<ACTIONS.ADD_NODE>
| ActionType<ACTIONS.UPDATE_NODE_URL, { url: string }>
| ActionType<ACTIONS.UPDATE_NODE_BLOCK, number>;
function withData<T>(value?: T) {
return {
isLoading: false,
hasError: false,
value,
};
}
function withError<T>(value?: T) {
return {
isLoading: false,
hasError: true,
value,
};
}
const getNodeData = (url?: string): NodeData => ({
url: url ?? '',
initialized: false,
responseTime: withData(),
block: withData(),
subscription: withData(),
chain: withData(),
});
const getInitialState = (config?: Configuration) =>
(config?.hosts ?? []).reduce<Record<string, NodeData>>(
(acc, url) => ({
...acc,
[url]: getNodeData(url),
}),
{}
);
export type ClientCollection = Record<
string,
undefined | ReturnType<typeof createClient>
>;
type ClientData = {
clients: ClientCollection;
subscriptions: ReturnType<typeof initializeNode>['unsubscribe'][];
};
const initializeNodes = (
dispatch: Dispatch<Action>,
nodes: Record<string, string>
) => {
return Object.keys(nodes).reduce<ClientData>(
(acc, node) => {
const { client, unsubscribe } = initializeNode(
dispatch,
node,
nodes[node]
);
Object.assign(acc.clients, { [nodes[node]]: client });
acc.subscriptions.push(unsubscribe);
return acc;
},
{
clients: {},
subscriptions: [],
}
);
};
const reducer = (state: Record<string, NodeData>, action: Action) => {
switch (action.type) {
case ACTIONS.GET_STATISTICS:
return produce(state, (state) => {
if (!state[action.node]) {
state[action.node] = getNodeData(action.payload?.url);
}
state[action.node].url = action.payload?.url ?? '';
state[action.node].initialized = true;
state[action.node].block.isLoading = true;
state[action.node].chain.isLoading = true;
state[action.node].responseTime.isLoading = true;
});
case ACTIONS.GET_STATISTICS_SUCCESS:
return produce(state, (state) => {
if (!state[action.node]) return;
state[action.node].block = withData(action.payload?.block);
state[action.node].chain = withData(action.payload?.chain);
state[action.node].responseTime = withData(
action.payload?.responseTime
);
});
case ACTIONS.GET_STATISTICS_FAILURE:
return produce(state, (state) => {
if (!state[action.node]) return;
state[action.node].block = withError();
state[action.node].chain = withError();
state[action.node].responseTime = withError();
});
case ACTIONS.CHECK_SUBSCRIPTION:
return produce(state, (state) => {
if (!state[action.node]) {
state[action.node] = getNodeData(action.payload?.url);
}
state[action.node].url = action.payload?.url ?? '';
state[action.node].subscription.isLoading = true;
state[action.node].initialized = true;
});
case ACTIONS.CHECK_SUBSCRIPTION_SUCCESS:
return produce(state, (state) => {
if (!state[action.node]) return;
state[action.node].subscription = withData(true);
});
case ACTIONS.CHECK_SUBSCRIPTION_FAILURE:
return produce(state, (state) => {
if (!state[action.node]) return;
state[action.node].subscription = withError();
});
case ACTIONS.ADD_NODE:
return produce(state, (state) => {
state[action.node] = getNodeData();
});
case ACTIONS.UPDATE_NODE_URL:
return produce(state, (state) => {
const existingNode = Object.keys(state).find(
(node) =>
action.node !== node && state[node].url === action.payload?.url
);
state[action.node] = existingNode
? state[existingNode]
: getNodeData(action.payload?.url);
});
case ACTIONS.UPDATE_NODE_BLOCK:
return produce(state, (state) => {
if (!state[action.node]) return;
state[action.node].block.value = action.payload;
});
default:
return state;
}
};
/**
* Tests each node to see if its suitable for connecting to and returns that data
* as a map of node urls to an object of that data
*/
export const useNodes = (config?: Configuration, skip?: boolean) => {
const [clients, setClients] = useState<ClientCollection>({});
const [state, dispatch] = useReducer(reducer, getInitialState(config));
const configCacheKey = config?.hosts.join(';');
const allUrls = Object.keys(state).map((node) => state[node].url);
useEffect(() => {
return () => {
Object.keys(clients).forEach((url) => clients[url]?.stop());
};
// stop all created clients on unmount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const hosts = skip ? [] : config?.hosts || [];
const nodeUrlMap = hosts.reduce((acc, url) => ({ ...acc, [url]: url }), {});
const { clients: newClients, subscriptions } = initializeNodes(
dispatch,
nodeUrlMap
);
setClients(newClients);
return () => {
subscriptions.forEach((unsubscribe) => unsubscribe());
};
// use primitive cache key to prevent infinite rerender loop
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [configCacheKey, skip]);
useEffect(() => {
const allNodes = Object.keys(state);
const initializedUrls = Object.keys(clients);
const nodeUrlMap = allUrls
.filter((node) => !initializedUrls.includes(node))
.reduce<Record<string, string>>((acc, url) => {
const node = allNodes.find((key) => state[key].url === url);
if (node) {
acc[node] = url;
}
return acc;
}, {});
const { clients: newClients, subscriptions } = initializeNodes(
dispatch,
nodeUrlMap
);
setClients((prevClients) => ({
...prevClients,
...newClients,
}));
return () => {
subscriptions.forEach((unsubscribe) => unsubscribe());
};
// use primitive cache key to prevent infinite rerender loop
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allUrls.join(';')]);
return {
state,
clients,
addNode: (node: string) => dispatch({ type: ACTIONS.ADD_NODE, node }),
updateNodeUrl: (node: string, url: string) =>
dispatch({ type: ACTIONS.UPDATE_NODE_URL, node, payload: { url } }),
updateNodeBlock: (node: string, value: number) =>
dispatch({ type: ACTIONS.UPDATE_NODE_BLOCK, node, payload: value }),
};
};
+11 -38
View File
@@ -1,44 +1,17 @@
import type z from 'zod';
import type { configSchema } from './utils/validate-configuration';
import type { envSchema } from './utils/validate-environment';
import { Networks, ENV_KEYS } from './utils/validate-environment';
export { ENV_KEYS, Networks };
export const CUSTOM_NODE_KEY = 'custom';
export enum ErrorType {
INVALID_URL,
SUBSCRIPTION_ERROR,
CONNECTION_ERROR,
CONNECTION_ERROR_ALL,
CONFIG_LOAD_ERROR,
CONFIG_VALIDATION_ERROR,
export enum Networks {
CUSTOM = 'CUSTOM',
SANDBOX = 'SANDBOX',
TESTNET = 'TESTNET',
STAGNET1 = 'STAGNET1',
STAGNET3 = 'STAGNET3',
DEVNET = 'DEVNET',
MAINNET = 'MAINNET',
MIRROR = 'MIRROR',
}
export type Environment = z.infer<typeof envSchema> & {
// provide this manually, zod fails to compile the correct type fot VEGA_NETWORKS
VEGA_NETWORKS: Partial<Record<Networks, string>>;
};
export type EnvKey = keyof Environment;
export type RawEnvironment = Record<EnvKey, string>;
export type Environment = z.infer<typeof envSchema>;
export type Configuration = z.infer<typeof configSchema>;
type NodeCheck<T> = {
isLoading: boolean;
hasError: boolean;
value?: T;
};
export type NodeData = {
url: string;
initialized: boolean;
subscription: NodeCheck<boolean>;
block: NodeCheck<number>;
responseTime: NodeCheck<number>;
chain: NodeCheck<string>;
};
export const CUSTOM_NODE_KEY = 'custom' as const;
+1
View File
@@ -2,6 +2,7 @@ query Statistics {
statistics {
chainId
blockHeight
vegaTime
}
}
+2 -1
View File
@@ -6,7 +6,7 @@ const defaultOptions = {} as const;
export type StatisticsQueryVariables = Types.Exact<{ [key: string]: never; }>;
export type StatisticsQuery = { __typename?: 'Query', statistics: { __typename?: 'Statistics', chainId: string, blockHeight: string } };
export type StatisticsQuery = { __typename?: 'Query', statistics: { __typename?: 'Statistics', chainId: string, blockHeight: string, vegaTime: any } };
export type BlockTimeSubscriptionVariables = Types.Exact<{ [key: string]: never; }>;
@@ -19,6 +19,7 @@ export const StatisticsDocument = gql`
statistics {
chainId
blockHeight
vegaTime
}
}
`;
@@ -1,140 +0,0 @@
import type { RawEnvironment, EnvKey, Environment } from '../types';
import { Networks, ENV_KEYS } from '../types';
declare global {
interface Window {
_env_?: Record<string, string>;
}
}
const isBrowser = typeof window !== 'undefined';
const getDefaultEtherumProviderUrl = (env: Networks) => {
return env === Networks.MAINNET
? 'https://mainnet.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8'
: 'https://sepolia.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8';
};
const getDefaultEtherscanUrl = (env: Networks) => {
return env === Networks.MAINNET
? 'https://etherscan.io'
: 'https://sepolia.etherscan.io';
};
const transformValue = (key: EnvKey, value?: string) => {
switch (key) {
case 'VEGA_ENV':
return value as Networks;
case 'VEGA_NETWORKS': {
if (value) {
try {
return JSON.parse(value);
} catch (e) {
console.warn(
'Error parsing the "NX_VEGA_NETWORKS" environment variable. Make sure it has a valid JSON format.'
);
return {};
}
}
return {};
}
case 'MAINTENANCE_PAGE':
return ['true', '1', 'yes'].includes(value?.toLowerCase() || '');
default:
return value;
}
};
const getBundledEnvironmentValue = (key: EnvKey) => {
switch (key) {
// need to have these hardcoded so on build time they can be replaced with the relevant environment variable
case 'VEGA_URL':
return process.env['NX_VEGA_URL'];
case 'VEGA_ENV':
return process.env['NX_VEGA_ENV'];
case 'VEGA_CONFIG_URL':
return process.env['NX_VEGA_CONFIG_URL'];
case 'ETHEREUM_PROVIDER_URL':
return process.env['NX_ETHEREUM_PROVIDER_URL'];
case 'ETHERSCAN_URL':
return process.env['NX_ETHERSCAN_URL'];
case 'VEGA_NETWORKS':
return process.env['NX_VEGA_NETWORKS'];
case 'GIT_BRANCH':
return process.env['NX_GIT_BRANCH'];
case 'GIT_COMMIT_HASH':
return process.env['NX_GIT_COMMIT_HASH'];
case 'GIT_ORIGIN_URL':
return process.env['NX_GIT_ORIGIN_URL'];
case 'GITHUB_FEEDBACK_URL':
return process.env['NX_GITHUB_FEEDBACK_URL'];
case 'VEGA_EXPLORER_URL':
return process.env['NX_VEGA_EXPLORER_URL'];
case 'VEGA_WALLET_URL':
return process.env['NX_VEGA_WALLET_URL'];
case 'VEGA_TOKEN_URL':
return process.env['NX_VEGA_TOKEN_URL'];
case 'VEGA_DOCS_URL':
return process.env['NX_VEGA_DOCS_URL'];
case 'HOSTED_WALLET_URL':
return process.env['NX_HOSTED_WALLET_URL'];
case 'ETH_LOCAL_PROVIDER_URL':
return process.env['NX_ETH_LOCAL_PROVIDER_URL'];
case 'ETH_WALLET_MNEMONIC':
return process.env['NX_ETH_WALLET_MNEMONIC'];
case 'MAINTENANCE_PAGE':
return process.env['NX_MAINTENANCE_PAGE'];
}
};
const getValue = (key: EnvKey, definitions: Partial<RawEnvironment> = {}) => {
if (!isBrowser) {
return transformValue(
key,
definitions[key] ?? getBundledEnvironmentValue(key)
);
}
return transformValue(
key,
definitions[key] ??
window._env_?.[`NX_${key}`] ??
getBundledEnvironmentValue(key)
);
};
export const compileEnvironment = (
definitions?: Partial<RawEnvironment>
): Environment => {
const environment = ENV_KEYS.reduce((acc, key) => {
const value = getValue(key, definitions);
if (value !== undefined && value !== null) {
return {
...acc,
[key]: value,
};
}
return acc;
}, {} as Environment);
const networkOverride = environment.VEGA_ENV
? {
[environment.VEGA_ENV]: isBrowser ? window.location.origin : undefined,
}
: {};
return {
// @ts-ignore enable using default object props
ETHERSCAN_URL: getDefaultEtherscanUrl(environment['VEGA_ENV']),
// @ts-ignore enable using default object props
ETHEREUM_PROVIDER_URL: getDefaultEtherumProviderUrl(
environment['VEGA_ENV']
),
...environment,
VEGA_NETWORKS: {
...networkOverride,
...environment.VEGA_NETWORKS,
},
};
};
+19 -6
View File
@@ -1,19 +1,32 @@
import type { ZodIssue } from 'zod';
import { ZodError } from 'zod';
/**
* Makes a nice error string to be printed to the console
*/
export const compileErrors = (
headline: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: any,
compileIssue?: (issue: ZodIssue) => string
error: any
) => {
if (error instanceof ZodError) {
return error.issues.reduce((acc, issue) => {
return (
acc + `\n - ${compileIssue ? compileIssue(issue) : issue.message}`
);
}, `${headline}:`);
return acc + `\n - ${compileIssue(issue)}`;
}, headline);
}
return `${headline}${error?.message ? `: ${error.message}` : ''}`;
};
const compileIssue = (issue: ZodIssue) => {
switch (issue.code) {
case 'invalid_type':
return `NX_${issue.path[0]}: Received "${issue.received}" instead of: ${issue.expected}`;
case 'invalid_enum_value':
return `NX_${issue.path[0]}: Received "${
issue.received
}" instead of: ${issue.options.join(' | ')}`;
default:
return `${issue.path.join('.')} ${issue.message}`;
}
};
@@ -1,55 +0,0 @@
import type { Dispatch } from 'react';
import { ACTIONS } from '../hooks/use-nodes';
import type { Action } from '../hooks/use-nodes';
import { requestNode } from './request-node';
const getResponseTime = (url: string) => {
const requestUrl = new URL(url);
const requests = window.performance.getEntriesByName(requestUrl.href);
const { duration } = (requests.length && requests[requests.length - 1]) || {};
return duration;
};
export const initializeNode = (
dispatch: Dispatch<Action>,
node: string,
nodeUrl?: string
) => {
let isMounted = true;
const url = nodeUrl ?? node;
dispatch({ type: ACTIONS.GET_STATISTICS, node, payload: { url } });
dispatch({ type: ACTIONS.CHECK_SUBSCRIPTION, node, payload: { url } });
const client = requestNode(url, {
onStatsSuccess: (data) => {
isMounted &&
dispatch({
type: ACTIONS.GET_STATISTICS_SUCCESS,
node,
payload: {
chain: data.statistics.chainId,
block: Number(data.statistics.blockHeight),
responseTime: getResponseTime(url),
},
});
},
onStatsFailure: () => {
isMounted && dispatch({ type: ACTIONS.GET_STATISTICS_FAILURE, node });
},
onSubscriptionSuccess: () => {
isMounted && dispatch({ type: ACTIONS.CHECK_SUBSCRIPTION_SUCCESS, node });
},
onSubscriptionFailure: () => {
isMounted && dispatch({ type: ACTIONS.CHECK_SUBSCRIPTION_FAILURE, node });
},
});
return {
client,
unsubscribe: () => {
client?.stop();
isMounted = false;
},
};
};

Some files were not shown because too many files have changed in this diff Show More