Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb4aeb6d8d | ||
|
|
e5bcc1dc96 | ||
|
|
2a8094ed6d | ||
|
|
9dfed0a211 | ||
|
|
5283676be7 | ||
|
|
a82509f0e0 | ||
|
|
64c92ce91d | ||
|
|
b7426e73d8 | ||
|
|
969bfd6945 | ||
|
|
200487a6fc | ||
|
|
3f330e78c5 | ||
|
|
05726c6a5a | ||
|
|
fcace22393 | ||
|
|
b95462ea38 | ||
|
|
42cd32f376 | ||
|
|
55d6dd4dce | ||
|
|
7017e24adf | ||
|
|
a4d597d426 | ||
|
|
b51376ba0d | ||
|
|
c72b5d7912 |
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 +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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -23,6 +23,7 @@ describe('Desktop view', { tags: '@smoke' }, () => {
|
||||
cy.wrap(element)
|
||||
.get('span.absolute.md\\:h-1.w-full')
|
||||
.should('exist');
|
||||
cy.contains('No market').should('not.exist');
|
||||
cy.location('hash').should('equal', hashes[index]);
|
||||
});
|
||||
});
|
||||
@@ -68,6 +69,7 @@ describe('Mobile view', { tags: '@smoke' }, () => {
|
||||
cy.getByTestId('button-menu-drawer').click();
|
||||
cy.getByTestId('menu-drawer').within((el) => {
|
||||
cy.wrap(el).getByTestId('Trading').click();
|
||||
cy.contains('No market').should('not.exist');
|
||||
cy.location('hash').should('equal', '#/markets/market-1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1 +1,3 @@
|
||||
export * from './lib/apollo-client';
|
||||
export * from './cache-config';
|
||||
export * from './lib/header-store';
|
||||
|
||||
@@ -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>(() => ({}));
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -2,6 +2,7 @@ query Statistics {
|
||||
statistics {
|
||||
chainId
|
||||
blockHeight
|
||||
vegaTime
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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}`;
|
||||
}
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user